diff --git a/.github/workflows/_build-and-test.yml b/.github/workflows/_build-and-test.yml index cde30eb..1482eb0 100644 --- a/.github/workflows/_build-and-test.yml +++ b/.github/workflows/_build-and-test.yml @@ -68,7 +68,7 @@ jobs: fail-fast: false matrix: framework: [net8.0, net10.0] - name: integration (${{ matrix.framework }}) + name: integration-linux (${{ matrix.framework }}) steps: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-dotnet-build @@ -85,3 +85,20 @@ jobs: - name: Integration Tests run: dotnet test tests/CosmosDB.InMemoryEmulator.Tests.Integration --configuration Release --no-build --verbosity normal --framework ${{ matrix.framework }} + + test-integration-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + framework: [net8.0, net10.0] + name: integration-windows (${{ matrix.framework }}) + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-dotnet-build + with: + build-args: --framework ${{ matrix.framework }} + + - name: Integration Tests + shell: bash + run: dotnet test tests/CosmosDB.InMemoryEmulator.Tests.Integration --configuration Release --no-build --verbosity normal --framework ${{ matrix.framework }} diff --git a/AGENTS.md b/AGENTS.md index 047847f..2d719de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,43 @@ Tests are split into two projects. When creating or moving tests, follow these r ### Key constraint The Integration project does **not** have `InternalsVisibleTo` access. If a test needs internal APIs, it belongs in Unit. +## Publishing & Releases + +Packages are published to NuGet via the `release.yml` GitHub Actions workflow, triggered by pushing a `v*` tag. The workflow runs the full test suite before publishing — if tests fail, nothing is published. + +### Beta / Prerelease + +To publish a prerelease package for testing before merging: + +1. Ensure your fix is committed and pushed to a branch. +2. Create and push a tag with a prerelease suffix: + ```bash + git tag v4.0.18-beta.1 + git push origin v4.0.18-beta.1 + ``` +3. The workflow extracts the version from the tag (strips the `v` prefix), passes it to `dotnet pack -p:Version=...`, and publishes to NuGet as a prerelease package. +4. Consumers install with: `dotnet add package CosmosDB.InMemoryEmulator --version 4.0.18-beta.1` + +The tag can be on any branch — the workflow checks out the tagged commit. + +### Stable Release + +After merging to `main`: + +1. Ensure `src/Directory.Build.props` has the correct `` (e.g. `4.0.18`). +2. Commit, create a tag, and push: + ```bash + git tag v4.0.18 + git push origin v4.0.18 + ``` +3. The workflow publishes stable packages and creates a GitHub Release with auto-generated release notes. + +### Version Conventions + +- `Directory.Build.props` `` is the target stable version — increment the patch after each release. +- The CI workflow **overrides** the version from the tag, so the `.props` value doesn't need to match beta suffixes. +- Prerelease format: `X.Y.Z-beta.N` (increment N for successive betas of the same version). + ## Documentation After any changes are made that might effect the public API or functionality, documentation must be updated to reflect those changes. The documentation should be clear and comprehensive, covering all new features, changes to existing features, and any deprecations or removals. This includes updating README file (if relevant), but mainly the wiki which can be found in a sister folder to the main repository - ../CosmosDB.InMemoryEmulator.wiki. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da297f..cba28ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.20] - 2026-05-20 + +### Fixed +- `COUNT(expr)` with nested ternary expressions no longer miscounts documents (Issue #64). `ExprToString` in `CosmosSqlParser` did not parenthesise ternary/coalesce sub-expressions when they appeared as operands of higher-precedence binary operators. When the SDK's transformed query was round-tripped through `SimplifySdkQuery`, the missing parentheses caused re-parsing to produce a different AST — e.g. `(innerTernary > 0) ? 1 : undefined` became `innerTernary ? val : (otherVal > 0 ? 1 : undefined)` — making `COUNT` evaluate the wrong condition. The fix wraps ternary and coalesce expressions in parentheses whenever they appear inside binary, unary, BETWEEN, IN, or LIKE operators. +- String literal aliases in aggregate queries (e.g. `SELECT 'Settlement' AS Label, COUNT(1) AS ItemCount FROM c`) no longer return `null` on Linux (Issue #67). `ProjectAggregateFields` now evaluates literal/expression fields via `EvaluateSqlExpression` instead of treating them as property paths. `DefaultQueryPlanStrategy` also bypasses the SDK aggregate pipeline when literals appear alongside aggregates. +- Patch operations with filter predicates referencing non-existent properties no longer throw (Issue #70). Missing properties are now treated as `null` in `FilterPredicate` evaluation, matching real Cosmos DB behavior. +- Queries without `ORDER BY` now return documents in insertion order, matching real Cosmos DB behavior (Issue #72). Previously documents were returned in hash-map order due to `ConcurrentDictionary` enumeration. Added insertion-order tracking to `InMemoryContainer` that maintains document position across create, replace, upsert, and delete operations. + +### Changed +- Removed `EmulatorFlaky` trait. The single flaky test (`ConcurrentReadsOfNonExistent`) now uses adaptive concurrency — 50 parallel reads for in-memory, 10 for emulator targets — eliminating the need for blanket test exclusions. + +### CI +- Integration tests now run on **both** `ubuntu-latest` and `windows-latest` to catch platform-specific divergences (e.g. ServiceInterop vs non-ServiceInterop code paths). + ## [4.0.17] - 2026-05-14 ### Fixed diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 index eb3b828..47aa50c 100644 --- a/scripts/run-tests.ps1 +++ b/scripts/run-tests.ps1 @@ -44,13 +44,10 @@ if ($Target -ne 'inmemory') { Remove-Item Env:COSMOS_EMULATOR_ENDPOINT -ErrorAction SilentlyContinue } -# Build filter: exclude InMemoryOnly and EmulatorFlaky tests when targeting an emulator. -# EmulatorFlaky tags tests that pass on in-memory but fail reproducibly on the Linux -# Docker / Windows Cosmos DB emulators due to emulator-side instability — the behaviour -# is still validated on the inmemory target, so excluding here just keeps CI signal clean. +# Build filter: exclude InMemoryOnly tests when targeting an emulator. $filterExpr = '' if ($Target -ne 'inmemory') { - $filterExpr = 'Target!=InMemoryOnly&Target!=EmulatorFlaky' + $filterExpr = 'Target!=InMemoryOnly' } if ($Filter) { if ($filterExpr -and $Filter -match '\|') { diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/JintSprocEngine.cs b/src/CosmosDB.InMemoryEmulator.JsTriggers/JintSprocEngine.cs index c4af384..61a2e66 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/JintSprocEngine.cs +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/JintSprocEngine.cs @@ -15,48 +15,48 @@ namespace CosmosDB.InMemoryEmulator.JsTriggers; /// public class JintSprocEngine : ISprocEngine { - private List _capturedLogs = new(); - - public IReadOnlyList CapturedLogs => _capturedLogs; - - public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args) - => Execute(jsBody, partitionKey, args, null!); - - public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context) - { - string? result = null; - _capturedLogs = new List(); - - var engine = new Engine(options => - { - options.TimeoutInterval(TimeSpan.FromSeconds(5)); - options.MaxStatements(10_000); - }); - - // Wire up the Cosmos DB server-side stored procedure API - engine.SetValue("__setBody", new Action(val => - { - engine.SetValue("__toSerialize", val); - result = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); - // Unwrap JSON string wrapping for simple string values - if (result is not null && result.StartsWith('"') && result.EndsWith('"')) - { - result = result[1..^1] - .Replace("\\\"", "\"") - .Replace("\\\\", "\\") - .Replace("\\n", "\n") - .Replace("\\r", "\r") - .Replace("\\t", "\t"); - } - })); - engine.SetValue("__log", new Action(msg => - { - _capturedLogs.Add(msg.ToString()); - })); - - WireCollectionContext(engine, context); - - engine.Execute(""" + private List _capturedLogs = new(); + + public IReadOnlyList CapturedLogs => _capturedLogs; + + public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args) + => Execute(jsBody, partitionKey, args, null!); + + public string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context) + { + string? result = null; + _capturedLogs = new List(); + + var engine = new Engine(options => + { + options.TimeoutInterval(TimeSpan.FromSeconds(5)); + options.MaxStatements(10_000); + }); + + // Wire up the Cosmos DB server-side stored procedure API + engine.SetValue("__setBody", new Action(val => + { + engine.SetValue("__toSerialize", val); + result = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); + // Unwrap JSON string wrapping for simple string values + if (result is not null && result.StartsWith('"') && result.EndsWith('"')) + { + result = result[1..^1] + .Replace("\\\"", "\"") + .Replace("\\\\", "\\") + .Replace("\\n", "\n") + .Replace("\\r", "\r") + .Replace("\\t", "\t"); + } + })); + engine.SetValue("__log", new Action(msg => + { + _capturedLogs.Add(msg.ToString()); + })); + + WireCollectionContext(engine, context); + + engine.Execute(""" var console = { log: function(msg) { __log(msg); } }; function getContext() { return { @@ -70,94 +70,94 @@ function getContext() { } """); - // Convert C# args to JS values - var jsArgs = new JsValue[args?.Length ?? 0]; - for (var i = 0; i < jsArgs.Length; i++) - { - var arg = args![i]; - if (arg is null) - jsArgs[i] = JsValue.Null; - else if (arg is string s) - jsArgs[i] = new JsString(s); - else if (arg is int intVal) - jsArgs[i] = new JsNumber(intVal); - else if (arg is long longVal) - jsArgs[i] = new JsNumber(longVal); - else if (arg is double dblVal) - jsArgs[i] = new JsNumber(dblVal); - else if (arg is bool bVal) - jsArgs[i] = bVal ? JsBoolean.True : JsBoolean.False; - else - jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})"); - } - - try - { - // Check if the body starts with a named function declaration - var match = System.Text.RegularExpressions.Regex.Match(jsBody.TrimStart(), @"^function\s+(\w+)\s*\("); - if (match.Success) - { - engine.Execute(jsBody); - engine.Invoke(match.Groups[1].Value, jsArgs); - } - else - { - // Anonymous function: wrap as expression and invoke - var func = engine.Evaluate($"({jsBody})"); - engine.Invoke(func, jsArgs); - } - } - catch (JavaScriptException ex) - { - throw InMemoryCosmosException.Create( - $"Stored procedure failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - - return result; - } - - internal static void WireCollectionContext(Engine engine, ICollectionContext context) - { - if (context == null) - { - engine.Execute(""" + // Convert C# args to JS values + var jsArgs = new JsValue[args?.Length ?? 0]; + for (var i = 0; i < jsArgs.Length; i++) + { + var arg = args![i]; + if (arg is null) + jsArgs[i] = JsValue.Null; + else if (arg is string s) + jsArgs[i] = new JsString(s); + else if (arg is int intVal) + jsArgs[i] = new JsNumber(intVal); + else if (arg is long longVal) + jsArgs[i] = new JsNumber(longVal); + else if (arg is double dblVal) + jsArgs[i] = new JsNumber(dblVal); + else if (arg is bool bVal) + jsArgs[i] = bVal ? JsBoolean.True : JsBoolean.False; + else + jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})"); + } + + try + { + // Check if the body starts with a named function declaration + var match = System.Text.RegularExpressions.Regex.Match(jsBody.TrimStart(), @"^function\s+(\w+)\s*\("); + if (match.Success) + { + engine.Execute(jsBody); + engine.Invoke(match.Groups[1].Value, jsArgs); + } + else + { + // Anonymous function: wrap as expression and invoke + var func = engine.Evaluate($"({jsBody})"); + engine.Invoke(func, jsArgs); + } + } + catch (JavaScriptException ex) + { + throw InMemoryCosmosException.Create( + $"Stored procedure failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + + return result; + } + + internal static void WireCollectionContext(Engine engine, ICollectionContext context) + { + if (context == null) + { + engine.Execute(""" var __collection = { getSelfLink: function() { return ""; } }; """); - return; - } - - var selfLink = context.SelfLink ?? ""; - - engine.SetValue("__selfLink", selfLink); - engine.SetValue("__createDocument", new Func(jsonDoc => - { - var doc = JObject.Parse(jsonDoc); - var created = context.CreateDocument(doc); - return created.ToString(Formatting.None); - })); - engine.SetValue("__readDocument", new Func(docId => - { - var doc = context.ReadDocument(docId); - return doc.ToString(Formatting.None); - })); - engine.SetValue("__queryDocuments", new Func(sql => - { - var docs = context.QueryDocuments(sql); - return JsonConvert.SerializeObject(docs, Formatting.None); - })); - engine.SetValue("__replaceDocument", new Func((docId, jsonDoc) => - { - var doc = JObject.Parse(jsonDoc); - var replaced = context.ReplaceDocument(docId, doc); - return replaced.ToString(Formatting.None); - })); - engine.SetValue("__deleteDocument", new Action(docId => - { - context.DeleteDocument(docId); - })); - - engine.Execute(""" + return; + } + + var selfLink = context.SelfLink ?? ""; + + engine.SetValue("__selfLink", selfLink); + engine.SetValue("__createDocument", new Func(jsonDoc => + { + var doc = JObject.Parse(jsonDoc); + var created = context.CreateDocument(doc); + return created.ToString(Formatting.None); + })); + engine.SetValue("__readDocument", new Func(docId => + { + var doc = context.ReadDocument(docId); + return doc.ToString(Formatting.None); + })); + engine.SetValue("__queryDocuments", new Func(sql => + { + var docs = context.QueryDocuments(sql); + return JsonConvert.SerializeObject(docs, Formatting.None); + })); + engine.SetValue("__replaceDocument", new Func((docId, jsonDoc) => + { + var doc = JObject.Parse(jsonDoc); + var replaced = context.ReplaceDocument(docId, doc); + return replaced.ToString(Formatting.None); + })); + engine.SetValue("__deleteDocument", new Action(docId => + { + context.DeleteDocument(docId); + })); + + engine.Execute(""" var __collection = { getSelfLink: function() { return __selfLink; }, createDocument: function(link, doc, opts, cb) { @@ -196,5 +196,5 @@ internal static void WireCollectionContext(Engine engine, ICollectionContext con } }; """); - } + } } diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/JintTriggerEngine.cs b/src/CosmosDB.InMemoryEmulator.JsTriggers/JintTriggerEngine.cs index 167ff1f..b2ab660 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/JintTriggerEngine.cs +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/JintTriggerEngine.cs @@ -14,30 +14,30 @@ namespace CosmosDB.InMemoryEmulator.JsTriggers; /// public class JintTriggerEngine : IJsTriggerEngine, IJsUdfEngine { - public JObject ExecutePreTrigger(string jsBody, JObject document) - => ExecutePreTrigger(jsBody, document, null!); + public JObject ExecutePreTrigger(string jsBody, JObject document) + => ExecutePreTrigger(jsBody, document, null!); - public JObject ExecutePreTrigger(string jsBody, JObject document, ICollectionContext context) - { - var bodyJson = document.ToString(Newtonsoft.Json.Formatting.None); - JsValue? updatedBody = null; + public JObject ExecutePreTrigger(string jsBody, JObject document, ICollectionContext context) + { + var bodyJson = document.ToString(Newtonsoft.Json.Formatting.None); + JsValue? updatedBody = null; - var engine = new Engine(options => - { - options.TimeoutInterval(TimeSpan.FromSeconds(5)); - options.MaxStatements(10_000); - }); + var engine = new Engine(options => + { + options.TimeoutInterval(TimeSpan.FromSeconds(5)); + options.MaxStatements(10_000); + }); - // Parse the document into JS land once - var jsDoc = engine.Evaluate($"({bodyJson})"); + // Parse the document into JS land once + var jsDoc = engine.Evaluate($"({bodyJson})"); - // Wire up the Cosmos DB server-side pre-trigger API - engine.SetValue("__getBody", new Func(() => jsDoc)); - engine.SetValue("__setBody", new Action(val => updatedBody = val)); + // Wire up the Cosmos DB server-side pre-trigger API + engine.SetValue("__getBody", new Func(() => jsDoc)); + engine.SetValue("__setBody", new Action(val => updatedBody = val)); - JintSprocEngine.WireCollectionContext(engine, context); + JintSprocEngine.WireCollectionContext(engine, context); - engine.Execute(""" + engine.Execute(""" function getContext() { return { getRequest: function() { @@ -54,53 +54,53 @@ function getContext() { } """); - try - { - engine.Execute(jsBody); - InvokeFirstFunction(engine, jsBody); - } - catch (JavaScriptException ex) - { - throw InMemoryCosmosException.Create( - $"Pre-trigger failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - - if (updatedBody is not null) - { - engine.SetValue("__toSerialize", updatedBody); - var json = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); - return JObject.Parse(json); - } - - return document; - } - - public JObject? ExecutePostTrigger(string jsBody, JObject document) - => ExecutePostTrigger(jsBody, document, null!); - - public JObject? ExecutePostTrigger(string jsBody, JObject document, ICollectionContext context) - { - var bodyJson = document.ToString(Newtonsoft.Json.Formatting.None); - JsValue? updatedBody = null; - - var engine = new Engine(options => - { - options.TimeoutInterval(TimeSpan.FromSeconds(5)); - options.MaxStatements(10_000); - }); - - // Parse the document into JS land once - var jsDoc = engine.Evaluate($"({bodyJson})"); - - // Wire up the Cosmos DB server-side post-trigger API - // Post-triggers have access to both getResponse() and getRequest() - engine.SetValue("__getBody", new Func(() => jsDoc)); - engine.SetValue("__setBody", new Action(val => updatedBody = val)); - - JintSprocEngine.WireCollectionContext(engine, context); - - engine.Execute(""" + try + { + engine.Execute(jsBody); + InvokeFirstFunction(engine, jsBody); + } + catch (JavaScriptException ex) + { + throw InMemoryCosmosException.Create( + $"Pre-trigger failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + + if (updatedBody is not null) + { + engine.SetValue("__toSerialize", updatedBody); + var json = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); + return JObject.Parse(json); + } + + return document; + } + + public JObject? ExecutePostTrigger(string jsBody, JObject document) + => ExecutePostTrigger(jsBody, document, null!); + + public JObject? ExecutePostTrigger(string jsBody, JObject document, ICollectionContext context) + { + var bodyJson = document.ToString(Newtonsoft.Json.Formatting.None); + JsValue? updatedBody = null; + + var engine = new Engine(options => + { + options.TimeoutInterval(TimeSpan.FromSeconds(5)); + options.MaxStatements(10_000); + }); + + // Parse the document into JS land once + var jsDoc = engine.Evaluate($"({bodyJson})"); + + // Wire up the Cosmos DB server-side post-trigger API + // Post-triggers have access to both getResponse() and getRequest() + engine.SetValue("__getBody", new Func(() => jsDoc)); + engine.SetValue("__setBody", new Action(val => updatedBody = val)); + + JintSprocEngine.WireCollectionContext(engine, context); + + engine.Execute(""" function getContext() { return { getResponse: function() { @@ -119,99 +119,99 @@ function getContext() { } """); - try - { - engine.Execute(jsBody); - InvokeFirstFunction(engine, jsBody); - } - catch (JavaScriptException ex) - { - throw InMemoryCosmosException.Create( - $"Post-trigger failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - - if (updatedBody is not null) - { - engine.SetValue("__toSerialize", updatedBody); - var json = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); - return JObject.Parse(json); - } - - return null; - } - - private static void InvokeFirstFunction(Engine engine, string jsBody) - { - var matches = System.Text.RegularExpressions.Regex.Matches(jsBody, @"\bfunction\s+(\w+)\s*\("); - if (matches.Count > 0) - { - engine.Invoke(matches[0].Groups[1].Value); - } - } - - public object? ExecuteUdf(string jsBody, object[] args) - { - var engine = new Engine(options => - { - options.TimeoutInterval(TimeSpan.FromSeconds(5)); - options.MaxStatements(10_000); - }); - - try - { - // Find the function name - var matches = System.Text.RegularExpressions.Regex.Matches(jsBody, @"\bfunction\s+(\w+)\s*\("); - if (matches.Count > 0) - { - engine.Execute(jsBody); - var jsArgs = ConvertArgsToJsValues(engine, args); - var result = engine.Invoke(matches[0].Groups[1].Value, jsArgs); - return ConvertJsResult(result); - } - else - { - var func = engine.Evaluate($"({jsBody})"); - var jsArgs = ConvertArgsToJsValues(engine, args); - var result = engine.Invoke(func, jsArgs); - return ConvertJsResult(result); - } - } - catch (JavaScriptException ex) - { - throw InMemoryCosmosException.Create( - $"UDF execution failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - } - - private static JsValue[] ConvertArgsToJsValues(Engine engine, object[] args) - { - var jsArgs = new JsValue[args?.Length ?? 0]; - for (var i = 0; i < jsArgs.Length; i++) - { - var arg = args![i]; - if (arg is null) jsArgs[i] = JsValue.Null; - else if (arg is string s) jsArgs[i] = new JsString(s); - else if (arg is int iv) jsArgs[i] = new JsNumber(iv); - else if (arg is long lv) jsArgs[i] = new JsNumber(lv); - else if (arg is double dv) jsArgs[i] = new JsNumber(dv); - else if (arg is bool bv) jsArgs[i] = bv ? JsBoolean.True : JsBoolean.False; - else jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})"); - } - return jsArgs; - } - - private static object? ConvertJsResult(JsValue result) - { - if (result.IsNull() || result.IsUndefined()) return null; - if (result.IsBoolean()) return result.AsBoolean(); - if (result.IsNumber()) return result.AsNumber(); - if (result.IsString()) return result.AsString(); - // For objects/arrays, serialize to JSON and parse as JToken for proper deserialization - var engine = new Engine(); - engine.SetValue("__val", result); - var json = engine.Evaluate("JSON.stringify(__val)").AsString(); - return Newtonsoft.Json.Linq.JToken.Parse(json); - } + try + { + engine.Execute(jsBody); + InvokeFirstFunction(engine, jsBody); + } + catch (JavaScriptException ex) + { + throw InMemoryCosmosException.Create( + $"Post-trigger failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + + if (updatedBody is not null) + { + engine.SetValue("__toSerialize", updatedBody); + var json = engine.Evaluate("JSON.stringify(__toSerialize)").AsString(); + return JObject.Parse(json); + } + + return null; + } + + private static void InvokeFirstFunction(Engine engine, string jsBody) + { + var matches = System.Text.RegularExpressions.Regex.Matches(jsBody, @"\bfunction\s+(\w+)\s*\("); + if (matches.Count > 0) + { + engine.Invoke(matches[0].Groups[1].Value); + } + } + + public object? ExecuteUdf(string jsBody, object[] args) + { + var engine = new Engine(options => + { + options.TimeoutInterval(TimeSpan.FromSeconds(5)); + options.MaxStatements(10_000); + }); + + try + { + // Find the function name + var matches = System.Text.RegularExpressions.Regex.Matches(jsBody, @"\bfunction\s+(\w+)\s*\("); + if (matches.Count > 0) + { + engine.Execute(jsBody); + var jsArgs = ConvertArgsToJsValues(engine, args); + var result = engine.Invoke(matches[0].Groups[1].Value, jsArgs); + return ConvertJsResult(result); + } + else + { + var func = engine.Evaluate($"({jsBody})"); + var jsArgs = ConvertArgsToJsValues(engine, args); + var result = engine.Invoke(func, jsArgs); + return ConvertJsResult(result); + } + } + catch (JavaScriptException ex) + { + throw InMemoryCosmosException.Create( + $"UDF execution failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + } + + private static JsValue[] ConvertArgsToJsValues(Engine engine, object[] args) + { + var jsArgs = new JsValue[args?.Length ?? 0]; + for (var i = 0; i < jsArgs.Length; i++) + { + var arg = args![i]; + if (arg is null) jsArgs[i] = JsValue.Null; + else if (arg is string s) jsArgs[i] = new JsString(s); + else if (arg is int iv) jsArgs[i] = new JsNumber(iv); + else if (arg is long lv) jsArgs[i] = new JsNumber(lv); + else if (arg is double dv) jsArgs[i] = new JsNumber(dv); + else if (arg is bool bv) jsArgs[i] = bv ? JsBoolean.True : JsBoolean.False; + else jsArgs[i] = engine.Evaluate($"({Newtonsoft.Json.JsonConvert.SerializeObject(arg)})"); + } + return jsArgs; + } + + private static object? ConvertJsResult(JsValue result) + { + if (result.IsNull() || result.IsUndefined()) return null; + if (result.IsBoolean()) return result.AsBoolean(); + if (result.IsNumber()) return result.AsNumber(); + if (result.IsString()) return result.AsString(); + // For objects/arrays, serialize to JSON and parse as JToken for proper deserialization + var engine = new Engine(); + engine.SetValue("__val", result); + var json = engine.Evaluate("JSON.stringify(__val)").AsString(); + return Newtonsoft.Json.Linq.JToken.Parse(json); + } } diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/JsTriggerExtensions.cs b/src/CosmosDB.InMemoryEmulator.JsTriggers/JsTriggerExtensions.cs index fb1641f..279fed0 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/JsTriggerExtensions.cs +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/JsTriggerExtensions.cs @@ -5,25 +5,25 @@ namespace CosmosDB.InMemoryEmulator.JsTriggers; /// public static class JsTriggerExtensions { - /// - /// Enables JavaScript trigger body interpretation using the Jint engine. - /// Call this on an to allow triggers registered - /// via CreateTriggerAsync (with a JS body) to execute. - /// - public static IContainerTestSetup UseJsTriggers(this IContainerTestSetup container) - { - container.JsTriggerEngine = new JintTriggerEngine(); - return container; - } + /// + /// Enables JavaScript trigger body interpretation using the Jint engine. + /// Call this on an to allow triggers registered + /// via CreateTriggerAsync (with a JS body) to execute. + /// + public static IContainerTestSetup UseJsTriggers(this IContainerTestSetup container) + { + container.JsTriggerEngine = new JintTriggerEngine(); + return container; + } - /// - /// Enables JavaScript stored procedure execution using the Jint engine. - /// Call this on an to allow stored procedures created - /// via CreateStoredProcedureAsync (with a JS body) to execute when no C# handler is registered. - /// - public static IContainerTestSetup UseJsStoredProcedures(this IContainerTestSetup container) - { - container.SprocEngine = new JintSprocEngine(); - return container; - } + /// + /// Enables JavaScript stored procedure execution using the Jint engine. + /// Call this on an to allow stored procedures created + /// via CreateStoredProcedureAsync (with a JS body) to execute when no C# handler is registered. + /// + public static IContainerTestSetup UseJsStoredProcedures(this IContainerTestSetup container) + { + container.SprocEngine = new JintSprocEngine(); + return container; + } } diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosOverridableFeedIteratorExtensions.cs b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosOverridableFeedIteratorExtensions.cs index e5d68b5..b2aacad 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosOverridableFeedIteratorExtensions.cs +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosOverridableFeedIteratorExtensions.cs @@ -85,123 +85,123 @@ namespace CosmosDB.InMemoryEmulator.ProductionExtensions; [Obsolete("No longer needed since 4.0. All recommended approaches (InMemoryCosmos, UseInMemoryCosmosDB, UseInMemoryCosmosContainers) use FakeCosmosHandler which handles .ToFeedIterator() natively. Use .ToFeedIterator() instead of .ToFeedIteratorOverridable().")] public static class CosmosOverridableFeedIteratorExtensions { - // ────────────────────────────────────────────────────────────────────── - // Factory 1: AsyncLocal (per-async-flow, test-isolated) - // ────────────────────────────────────────────────────────────────────── - // - // AsyncLocal stores a value that flows with ExecutionContext. Every - // async continuation (await, Task.Run, etc.) inherits the calling flow's - // value. This means parallel xUnit tests — each running in their own - // async flow — see their own factory without cross-talk. - // - // Limitation: `new Thread()` does NOT capture ExecutionContext, so the - // AsyncLocal value will be null on a bare thread. That's what the static - // fallback below is for. - // ────────────────────────────────────────────────────────────────────── - private static readonly AsyncLocal?> _factory = new(); + // ────────────────────────────────────────────────────────────────────── + // Factory 1: AsyncLocal (per-async-flow, test-isolated) + // ────────────────────────────────────────────────────────────────────── + // + // AsyncLocal stores a value that flows with ExecutionContext. Every + // async continuation (await, Task.Run, etc.) inherits the calling flow's + // value. This means parallel xUnit tests — each running in their own + // async flow — see their own factory without cross-talk. + // + // Limitation: `new Thread()` does NOT capture ExecutionContext, so the + // AsyncLocal value will be null on a bare thread. That's what the static + // fallback below is for. + // ────────────────────────────────────────────────────────────────────── + private static readonly AsyncLocal?> _factory = new(); - // ────────────────────────────────────────────────────────────────────── - // Factory 2: Static fallback (global, catches new Thread()) - // ────────────────────────────────────────────────────────────────────── - // - // A plain static field marked volatile for safe cross-thread reads. - // Only consulted when the AsyncLocal factory is null — i.e. when we're - // on a thread that didn't inherit ExecutionContext. - // - // Why is this safe? The factory delegate is stateless: it receives an - // IQueryable and materialises it into an InMemoryFeedIterator. - // Each test's queryable points at that test's in-memory data, so even - // though this field is globally shared, there's no data cross-talk. - // - // Edge case: if test A calls Deregister() while test B's new Thread() - // is mid-flight, B's thread could hit .ToFeedIterator() and throw. - // This is a pre-existing test isolation issue — not caused by the - // fallback mechanism. - // ────────────────────────────────────────────────────────────────────── - private static volatile Func? _staticFallbackFactory; + // ────────────────────────────────────────────────────────────────────── + // Factory 2: Static fallback (global, catches new Thread()) + // ────────────────────────────────────────────────────────────────────── + // + // A plain static field marked volatile for safe cross-thread reads. + // Only consulted when the AsyncLocal factory is null — i.e. when we're + // on a thread that didn't inherit ExecutionContext. + // + // Why is this safe? The factory delegate is stateless: it receives an + // IQueryable and materialises it into an InMemoryFeedIterator. + // Each test's queryable points at that test's in-memory data, so even + // though this field is globally shared, there's no data cross-talk. + // + // Edge case: if test A calls Deregister() while test B's new Thread() + // is mid-flight, B's thread could hit .ToFeedIterator() and throw. + // This is a pre-existing test isolation issue — not caused by the + // fallback mechanism. + // ────────────────────────────────────────────────────────────────────── + private static volatile Func? _staticFallbackFactory; - /// - /// Per-async-flow factory delegate for creating feed iterators in tests. - /// - /// Backed by : each async flow (i.e. each test) gets its own - /// value, enabling safe parallel test execution. Flows through await, - /// Task.Run, and anything that captures . - /// - /// Does not flow to new Thread() — see . - /// - /// When null, checks - /// before falling through to the real SDK's .ToFeedIterator(). - /// - /// Set by InMemoryFeedIteratorSetup.Register(). You should not need to set this directly. - /// - public static Func? FeedIteratorFactory - { - get => _factory.Value; - set => _factory.Value = value; - } + /// + /// Per-async-flow factory delegate for creating feed iterators in tests. + /// + /// Backed by : each async flow (i.e. each test) gets its own + /// value, enabling safe parallel test execution. Flows through await, + /// Task.Run, and anything that captures . + /// + /// Does not flow to new Thread() — see . + /// + /// When null, checks + /// before falling through to the real SDK's .ToFeedIterator(). + /// + /// Set by InMemoryFeedIteratorSetup.Register(). You should not need to set this directly. + /// + public static Func? FeedIteratorFactory + { + get => _factory.Value; + set => _factory.Value = value; + } - /// - /// Global fallback factory for threads where does not flow. - /// - /// The primary scenario: production code that uses new Thread(() => { ... }).Start(). - /// new Thread() does not capture ExecutionContext, so the - /// value () will be null on that thread. This static fallback - /// catches that case. - /// - /// Checked only when is null. If both are null, we're in - /// production and .ToFeedIterator() is called normally. - /// - /// This field is volatile for safe cross-thread visibility — no lock needed because - /// reads and writes of reference types are atomic on .NET. - /// - /// Set by InMemoryFeedIteratorSetup.Register(). You should not need to set this directly. - /// - public static Func? StaticFallbackFactory - { - get => _staticFallbackFactory; - set => _staticFallbackFactory = value; - } + /// + /// Global fallback factory for threads where does not flow. + /// + /// The primary scenario: production code that uses new Thread(() => { ... }).Start(). + /// new Thread() does not capture ExecutionContext, so the + /// value () will be null on that thread. This static fallback + /// catches that case. + /// + /// Checked only when is null. If both are null, we're in + /// production and .ToFeedIterator() is called normally. + /// + /// This field is volatile for safe cross-thread visibility — no lock needed because + /// reads and writes of reference types are atomic on .NET. + /// + /// Set by InMemoryFeedIteratorSetup.Register(). You should not need to set this directly. + /// + public static Func? StaticFallbackFactory + { + get => _staticFallbackFactory; + set => _staticFallbackFactory = value; + } - /// - /// Drop-in replacement for .ToFeedIterator() that supports in-memory interception. - /// - /// Resolution order: - /// - /// - /// (AsyncLocal, per-async-flow) — handles the vast - /// majority of test scenarios where ExecutionContext flows normally. - /// - /// - /// (static, global) — catches new Thread() - /// and other contexts where AsyncLocal doesn't flow. - /// - /// - /// queryable.ToFeedIterator() — real Cosmos SDK. This is the production path, - /// taken when neither factory is set. - /// - /// - /// - /// The document type being queried. - /// - /// The LINQ queryable obtained from container.GetItemLinqQueryable<T>(), - /// optionally with further LINQ operators (.Where(), .OrderBy(), etc.). - /// - /// - /// A that pages through the query results. In tests this is - /// an InMemoryFeedIterator<T>; in production it's the real SDK iterator. - /// - public static FeedIterator ToFeedIteratorOverridable(this IQueryable queryable) - { - // Check AsyncLocal first (test-isolated, per-async-flow), then static fallback - // (catches new Thread() where AsyncLocal doesn't flow). If both are null, we're - // in production — delegate to the real Cosmos SDK. - var factory = FeedIteratorFactory ?? StaticFallbackFactory; + /// + /// Drop-in replacement for .ToFeedIterator() that supports in-memory interception. + /// + /// Resolution order: + /// + /// + /// (AsyncLocal, per-async-flow) — handles the vast + /// majority of test scenarios where ExecutionContext flows normally. + /// + /// + /// (static, global) — catches new Thread() + /// and other contexts where AsyncLocal doesn't flow. + /// + /// + /// queryable.ToFeedIterator() — real Cosmos SDK. This is the production path, + /// taken when neither factory is set. + /// + /// + /// + /// The document type being queried. + /// + /// The LINQ queryable obtained from container.GetItemLinqQueryable<T>(), + /// optionally with further LINQ operators (.Where(), .OrderBy(), etc.). + /// + /// + /// A that pages through the query results. In tests this is + /// an InMemoryFeedIterator<T>; in production it's the real SDK iterator. + /// + public static FeedIterator ToFeedIteratorOverridable(this IQueryable queryable) + { + // Check AsyncLocal first (test-isolated, per-async-flow), then static fallback + // (catches new Thread() where AsyncLocal doesn't flow). If both are null, we're + // in production — delegate to the real Cosmos SDK. + var factory = FeedIteratorFactory ?? StaticFallbackFactory; - if (factory is not null) - { - return (FeedIterator)factory(queryable); - } + if (factory is not null) + { + return (FeedIterator)factory(queryable); + } - return queryable.ToFeedIterator(); - } + return queryable.ToFeedIterator(); + } } diff --git a/src/CosmosDB.InMemoryEmulator/ContainerConfig.cs b/src/CosmosDB.InMemoryEmulator/ContainerConfig.cs index 1247a0f..4bf84a8 100644 --- a/src/CosmosDB.InMemoryEmulator/ContainerConfig.cs +++ b/src/CosmosDB.InMemoryEmulator/ContainerConfig.cs @@ -6,7 +6,7 @@ namespace CosmosDB.InMemoryEmulator; /// Configuration for a single in-memory Cosmos container. /// public record ContainerConfig( - string ContainerName, - string PartitionKeyPath = "/id", - string? DatabaseName = null, - ContainerProperties? ContainerProperties = null); + string ContainerName, + string PartitionKeyPath = "/id", + string? DatabaseName = null, + ContainerProperties? ContainerProperties = null); diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs index 5647ac2..9625e67 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs +++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs @@ -13,74 +13,74 @@ namespace CosmosDB.InMemoryEmulator; public enum CosmosSqlToken { - // Keywords - Select, - Distinct, - Top, - Value, - As, - From, - Join, - In, - Where, - And, - Or, - Not, - Between, - Like, - Is, - Null, - Defined, - Undefined, - True, - False, - Exists, - Order, - By, - Asc, - Desc, - Group, - Having, - Offset, - Limit, - Array, - Escape, - - // Literals & identifiers - Identifier, - StringLiteral, - DoubleQuotedString, - NumberLiteral, - Parameter, - - // Operators & punctuation - Star, - Comma, - Dot, - OpenParen, - CloseParen, - OpenBracket, - CloseBracket, - Equals, - NotEquals, - LessThanOrEqual, - GreaterThanOrEqual, - LessThan, - GreaterThan, - Plus, - Minus, - Slash, - Percent, - Ampersand, - Pipe, - Caret, - Tilde, - QuestionQuestion, - Question, - Colon, - DoublePipe, - OpenBrace, - CloseBrace, + // Keywords + Select, + Distinct, + Top, + Value, + As, + From, + Join, + In, + Where, + And, + Or, + Not, + Between, + Like, + Is, + Null, + Defined, + Undefined, + True, + False, + Exists, + Order, + By, + Asc, + Desc, + Group, + Having, + Offset, + Limit, + Array, + Escape, + + // Literals & identifiers + Identifier, + StringLiteral, + DoubleQuotedString, + NumberLiteral, + Parameter, + + // Operators & punctuation + Star, + Comma, + Dot, + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Equals, + NotEquals, + LessThanOrEqual, + GreaterThanOrEqual, + LessThan, + GreaterThan, + Plus, + Minus, + Slash, + Percent, + Ampersand, + Pipe, + Caret, + Tilde, + QuestionQuestion, + Question, + Colon, + DoublePipe, + OpenBrace, + CloseBrace, } // ────────────────────────────────────────────── @@ -89,13 +89,13 @@ public enum CosmosSqlToken public enum ComparisonOp { - Equal, - NotEqual, - LessThan, - GreaterThan, - LessThanOrEqual, - GreaterThanOrEqual, - Like, + Equal, + NotEqual, + LessThan, + GreaterThan, + LessThanOrEqual, + GreaterThanOrEqual, + Like, } public sealed record SelectField(string Expression, string Alias, SqlExpression SqlExpr = null); @@ -151,17 +151,17 @@ public sealed record ArrayLiteralExpression(SqlExpression[] Elements) : SqlExpre public enum BinaryOp { - Equal, NotEqual, LessThan, GreaterThan, LessThanOrEqual, GreaterThanOrEqual, - And, Or, - Add, Subtract, Multiply, Divide, Modulo, - BitwiseAnd, BitwiseOr, BitwiseXor, - Like, - StringConcat, + Equal, NotEqual, LessThan, GreaterThan, LessThanOrEqual, GreaterThanOrEqual, + And, Or, + Add, Subtract, Multiply, Divide, Modulo, + BitwiseAnd, BitwiseOr, BitwiseXor, + Like, + StringConcat, } public enum UnaryOp { - Not, Negate, BitwiseNot, + Not, Negate, BitwiseNot, } // ── Backward-compatible WhereExpression wrappers ── @@ -185,26 +185,26 @@ public sealed record SqlExpressionCondition(SqlExpression Expression) : WhereExp // ── Query ── public sealed record CosmosSqlQuery( - SelectField[] SelectFields, - bool IsSelectAll, - int? TopCount, - string FromAlias, - JoinClause Join, - WhereExpression Where, - int? Offset, - int? Limit, - OrderByClause OrderBy = null, - bool IsDistinct = false, - bool IsValueSelect = false, - JoinClause[] Joins = null, - OrderByField[] OrderByFields = null, - string[] GroupByFields = null, - SqlExpression[] GroupByExpressions = null, - WhereExpression Having = null, - SqlExpression WhereExpr = null, - SqlExpression HavingExpr = null, - string FromSource = null, - SqlExpression RankExpression = null); + SelectField[] SelectFields, + bool IsSelectAll, + int? TopCount, + string FromAlias, + JoinClause Join, + WhereExpression Where, + int? Offset, + int? Limit, + OrderByClause OrderBy = null, + bool IsDistinct = false, + bool IsValueSelect = false, + JoinClause[] Joins = null, + OrderByField[] OrderByFields = null, + string[] GroupByFields = null, + SqlExpression[] GroupByExpressions = null, + WhereExpression Having = null, + SqlExpression WhereExpr = null, + SqlExpression HavingExpr = null, + string FromSource = null, + SqlExpression RankExpression = null); public sealed record OrderByClause(string Field, bool Ascending); @@ -214,133 +214,133 @@ public sealed record OrderByClause(string Field, bool Ascending); public static class CosmosSqlTokenizer { - private static readonly TextParser StringLiteralToken = - from open in Character.EqualTo('\'') - from content in Character.EqualTo('\'').Then(_ => Character.EqualTo('\'')).Try() - .Or(Character.Except('\'')) - .Many() - from close in Character.EqualTo('\'') - select Unit.Value; - - private static readonly TextParser DoubleQuotedStringToken = - from open in Character.EqualTo('"') - from content in Character.Except('"').Many() - from close in Character.EqualTo('"') - select Unit.Value; - - private static readonly TextParser ParameterToken = - from at in Character.EqualTo('@') - from rest in Character.LetterOrDigit.Or(Character.EqualTo('_')).AtLeastOnce() - select Unit.Value; - - private static readonly TextParser NumberToken = - (from number in Numerics.Decimal - from exp in ( - from e in Character.EqualTo('e').Or(Character.EqualTo('E')) - from sign in Character.EqualTo('+').Or(Character.EqualTo('-')).OptionalOrDefault() - from digits in Character.Digit.AtLeastOnce() - select Unit.Value - ).OptionalOrDefault() - select Unit.Value); - - // Ref: EF Core Cosmos provider uses $type as a discriminator column. - // Allow $ as a valid identifier character to support queries like c.$type. - private static readonly TextParser IdentOrKeyword = - from first in Character.Letter.Or(Character.EqualTo('_')).Or(Character.EqualTo('$')) - from rest in Character.LetterOrDigit.Or(Character.EqualTo('_')).Or(Character.EqualTo('$')).Many() - select Unit.Value; - - // Quoted identifiers like [My Column] are not used by the Cosmos SDK's LINQ provider - // (it uses root["name"] with double-quoted strings instead). Removing this tokenizer rule - // allows [expr, expr] array literals and [0] numeric indexers to tokenize correctly. - // If needed in the future, require the content to contain a space to disambiguate. - - private static readonly Dictionary Keywords = new(StringComparer.OrdinalIgnoreCase) - { - ["SELECT"] = CosmosSqlToken.Select, - ["DISTINCT"] = CosmosSqlToken.Distinct, - ["TOP"] = CosmosSqlToken.Top, - ["VALUE"] = CosmosSqlToken.Value, - ["AS"] = CosmosSqlToken.As, - ["FROM"] = CosmosSqlToken.From, - ["JOIN"] = CosmosSqlToken.Join, - ["IN"] = CosmosSqlToken.In, - ["WHERE"] = CosmosSqlToken.Where, - ["AND"] = CosmosSqlToken.And, - ["OR"] = CosmosSqlToken.Or, - ["NOT"] = CosmosSqlToken.Not, - ["BETWEEN"] = CosmosSqlToken.Between, - ["LIKE"] = CosmosSqlToken.Like, - ["IS"] = CosmosSqlToken.Is, - ["NULL"] = CosmosSqlToken.Null, - ["DEFINED"] = CosmosSqlToken.Defined, - ["UNDEFINED"] = CosmosSqlToken.Undefined, - ["TRUE"] = CosmosSqlToken.True, - ["FALSE"] = CosmosSqlToken.False, - ["EXISTS"] = CosmosSqlToken.Exists, - ["ORDER"] = CosmosSqlToken.Order, - ["BY"] = CosmosSqlToken.By, - ["ASC"] = CosmosSqlToken.Asc, - ["DESC"] = CosmosSqlToken.Desc, - ["GROUP"] = CosmosSqlToken.Group, - ["HAVING"] = CosmosSqlToken.Having, - ["OFFSET"] = CosmosSqlToken.Offset, - ["LIMIT"] = CosmosSqlToken.Limit, - ["ARRAY"] = CosmosSqlToken.Array, - ["ESCAPE"] = CosmosSqlToken.Escape, - }; - - private static readonly Tokenizer Inner = new TokenizerBuilder() - .Ignore(Span.WhiteSpace) - .Match(StringLiteralToken, CosmosSqlToken.StringLiteral) - .Match(DoubleQuotedStringToken, CosmosSqlToken.DoubleQuotedString) - .Match(ParameterToken, CosmosSqlToken.Parameter) - .Match(NumberToken, CosmosSqlToken.NumberLiteral) - .Match(IdentOrKeyword, CosmosSqlToken.Identifier) - .Match(Character.EqualTo('*'), CosmosSqlToken.Star) - .Match(Character.EqualTo(','), CosmosSqlToken.Comma) - .Match(Character.EqualTo('.'), CosmosSqlToken.Dot) - .Match(Character.EqualTo('('), CosmosSqlToken.OpenParen) - .Match(Character.EqualTo(')'), CosmosSqlToken.CloseParen) - .Match(Character.EqualTo('['), CosmosSqlToken.OpenBracket) - .Match(Character.EqualTo(']'), CosmosSqlToken.CloseBracket) - .Match(Span.EqualTo("!="), CosmosSqlToken.NotEquals) - .Match(Span.EqualTo("<>"), CosmosSqlToken.NotEquals) - .Match(Span.EqualTo("<="), CosmosSqlToken.LessThanOrEqual) - .Match(Span.EqualTo(">="), CosmosSqlToken.GreaterThanOrEqual) - .Match(Span.EqualTo("??"), CosmosSqlToken.QuestionQuestion) - .Match(Span.EqualTo("||"), CosmosSqlToken.DoublePipe) - .Match(Character.EqualTo('='), CosmosSqlToken.Equals) - .Match(Character.EqualTo('<'), CosmosSqlToken.LessThan) - .Match(Character.EqualTo('>'), CosmosSqlToken.GreaterThan) - .Match(Character.EqualTo('+'), CosmosSqlToken.Plus) - .Match(Character.EqualTo('-'), CosmosSqlToken.Minus) - .Match(Character.EqualTo('/'), CosmosSqlToken.Slash) - .Match(Character.EqualTo('%'), CosmosSqlToken.Percent) - .Match(Character.EqualTo('&'), CosmosSqlToken.Ampersand) - .Match(Character.EqualTo('|'), CosmosSqlToken.Pipe) - .Match(Character.EqualTo('^'), CosmosSqlToken.Caret) - .Match(Character.EqualTo('~'), CosmosSqlToken.Tilde) - .Match(Character.EqualTo('?'), CosmosSqlToken.Question) - .Match(Character.EqualTo(':'), CosmosSqlToken.Colon) - .Match(Character.EqualTo('{'), CosmosSqlToken.OpenBrace) - .Match(Character.EqualTo('}'), CosmosSqlToken.CloseBrace) - .Build(); - - public static TokenList Tokenize(string input) - { - var tokens = Inner.Tokenize(input); - var remapped = tokens.Select(token => - { - if (token.Kind == CosmosSqlToken.Identifier && - Keywords.TryGetValue(token.Span.ToStringValue(), out var keyword)) - { - return new Token(keyword, token.Span); - } - return token; - }).ToArray(); - return new TokenList(remapped); - } + private static readonly TextParser StringLiteralToken = + from open in Character.EqualTo('\'') + from content in Character.EqualTo('\'').Then(_ => Character.EqualTo('\'')).Try() + .Or(Character.Except('\'')) + .Many() + from close in Character.EqualTo('\'') + select Unit.Value; + + private static readonly TextParser DoubleQuotedStringToken = + from open in Character.EqualTo('"') + from content in Character.Except('"').Many() + from close in Character.EqualTo('"') + select Unit.Value; + + private static readonly TextParser ParameterToken = + from at in Character.EqualTo('@') + from rest in Character.LetterOrDigit.Or(Character.EqualTo('_')).AtLeastOnce() + select Unit.Value; + + private static readonly TextParser NumberToken = + (from number in Numerics.Decimal + from exp in ( + from e in Character.EqualTo('e').Or(Character.EqualTo('E')) + from sign in Character.EqualTo('+').Or(Character.EqualTo('-')).OptionalOrDefault() + from digits in Character.Digit.AtLeastOnce() + select Unit.Value + ).OptionalOrDefault() + select Unit.Value); + + // Ref: EF Core Cosmos provider uses $type as a discriminator column. + // Allow $ as a valid identifier character to support queries like c.$type. + private static readonly TextParser IdentOrKeyword = + from first in Character.Letter.Or(Character.EqualTo('_')).Or(Character.EqualTo('$')) + from rest in Character.LetterOrDigit.Or(Character.EqualTo('_')).Or(Character.EqualTo('$')).Many() + select Unit.Value; + + // Quoted identifiers like [My Column] are not used by the Cosmos SDK's LINQ provider + // (it uses root["name"] with double-quoted strings instead). Removing this tokenizer rule + // allows [expr, expr] array literals and [0] numeric indexers to tokenize correctly. + // If needed in the future, require the content to contain a space to disambiguate. + + private static readonly Dictionary Keywords = new(StringComparer.OrdinalIgnoreCase) + { + ["SELECT"] = CosmosSqlToken.Select, + ["DISTINCT"] = CosmosSqlToken.Distinct, + ["TOP"] = CosmosSqlToken.Top, + ["VALUE"] = CosmosSqlToken.Value, + ["AS"] = CosmosSqlToken.As, + ["FROM"] = CosmosSqlToken.From, + ["JOIN"] = CosmosSqlToken.Join, + ["IN"] = CosmosSqlToken.In, + ["WHERE"] = CosmosSqlToken.Where, + ["AND"] = CosmosSqlToken.And, + ["OR"] = CosmosSqlToken.Or, + ["NOT"] = CosmosSqlToken.Not, + ["BETWEEN"] = CosmosSqlToken.Between, + ["LIKE"] = CosmosSqlToken.Like, + ["IS"] = CosmosSqlToken.Is, + ["NULL"] = CosmosSqlToken.Null, + ["DEFINED"] = CosmosSqlToken.Defined, + ["UNDEFINED"] = CosmosSqlToken.Undefined, + ["TRUE"] = CosmosSqlToken.True, + ["FALSE"] = CosmosSqlToken.False, + ["EXISTS"] = CosmosSqlToken.Exists, + ["ORDER"] = CosmosSqlToken.Order, + ["BY"] = CosmosSqlToken.By, + ["ASC"] = CosmosSqlToken.Asc, + ["DESC"] = CosmosSqlToken.Desc, + ["GROUP"] = CosmosSqlToken.Group, + ["HAVING"] = CosmosSqlToken.Having, + ["OFFSET"] = CosmosSqlToken.Offset, + ["LIMIT"] = CosmosSqlToken.Limit, + ["ARRAY"] = CosmosSqlToken.Array, + ["ESCAPE"] = CosmosSqlToken.Escape, + }; + + private static readonly Tokenizer Inner = new TokenizerBuilder() + .Ignore(Span.WhiteSpace) + .Match(StringLiteralToken, CosmosSqlToken.StringLiteral) + .Match(DoubleQuotedStringToken, CosmosSqlToken.DoubleQuotedString) + .Match(ParameterToken, CosmosSqlToken.Parameter) + .Match(NumberToken, CosmosSqlToken.NumberLiteral) + .Match(IdentOrKeyword, CosmosSqlToken.Identifier) + .Match(Character.EqualTo('*'), CosmosSqlToken.Star) + .Match(Character.EqualTo(','), CosmosSqlToken.Comma) + .Match(Character.EqualTo('.'), CosmosSqlToken.Dot) + .Match(Character.EqualTo('('), CosmosSqlToken.OpenParen) + .Match(Character.EqualTo(')'), CosmosSqlToken.CloseParen) + .Match(Character.EqualTo('['), CosmosSqlToken.OpenBracket) + .Match(Character.EqualTo(']'), CosmosSqlToken.CloseBracket) + .Match(Span.EqualTo("!="), CosmosSqlToken.NotEquals) + .Match(Span.EqualTo("<>"), CosmosSqlToken.NotEquals) + .Match(Span.EqualTo("<="), CosmosSqlToken.LessThanOrEqual) + .Match(Span.EqualTo(">="), CosmosSqlToken.GreaterThanOrEqual) + .Match(Span.EqualTo("??"), CosmosSqlToken.QuestionQuestion) + .Match(Span.EqualTo("||"), CosmosSqlToken.DoublePipe) + .Match(Character.EqualTo('='), CosmosSqlToken.Equals) + .Match(Character.EqualTo('<'), CosmosSqlToken.LessThan) + .Match(Character.EqualTo('>'), CosmosSqlToken.GreaterThan) + .Match(Character.EqualTo('+'), CosmosSqlToken.Plus) + .Match(Character.EqualTo('-'), CosmosSqlToken.Minus) + .Match(Character.EqualTo('/'), CosmosSqlToken.Slash) + .Match(Character.EqualTo('%'), CosmosSqlToken.Percent) + .Match(Character.EqualTo('&'), CosmosSqlToken.Ampersand) + .Match(Character.EqualTo('|'), CosmosSqlToken.Pipe) + .Match(Character.EqualTo('^'), CosmosSqlToken.Caret) + .Match(Character.EqualTo('~'), CosmosSqlToken.Tilde) + .Match(Character.EqualTo('?'), CosmosSqlToken.Question) + .Match(Character.EqualTo(':'), CosmosSqlToken.Colon) + .Match(Character.EqualTo('{'), CosmosSqlToken.OpenBrace) + .Match(Character.EqualTo('}'), CosmosSqlToken.CloseBrace) + .Build(); + + public static TokenList Tokenize(string input) + { + var tokens = Inner.Tokenize(input); + var remapped = tokens.Select(token => + { + if (token.Kind == CosmosSqlToken.Identifier && + Keywords.TryGetValue(token.Span.ToStringValue(), out var keyword)) + { + return new Token(keyword, token.Span); + } + return token; + }).ToArray(); + return new TokenList(remapped); + } } // ────────────────────────────────────────────── @@ -349,1152 +349,1164 @@ public static TokenList Tokenize(string input) public static class CosmosSqlParser { - private static readonly HashSet LegacyFunctionNames = new(StringComparer.OrdinalIgnoreCase) - { - "STARTSWITH", "ENDSWITH", "CONTAINS", "ARRAY_CONTAINS", "IS_DEFINED", "IS_NULL", - }; - - // ── Helpers ── - - private static string TokenSpanToString(Token token) => - token.Span.ToStringValue(); - - private static TokenListParser AnyIdentifierOrKeyword => - Token.EqualTo(CosmosSqlToken.Identifier).Select(TokenSpanToString) - .Or(Token.EqualTo(CosmosSqlToken.Value).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Array).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Defined).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Asc).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Top).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Limit).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Offset).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.Null).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.True).Select(TokenSpanToString)) - .Or(Token.EqualTo(CosmosSqlToken.False).Select(TokenSpanToString)); - - // ── Dotted path: ident.ident.ident ── - - private static readonly TokenListParser DottedPath = - from first in AnyIdentifierOrKeyword - from rest in ( - from dot in Token.EqualTo(CosmosSqlToken.Dot) - from next in AnyIdentifierOrKeyword - select "." + next - ).Many() - select first + string.Concat(rest); - - // ── Dotted path with optional array indexing: ident.ident[0].ident ── - - // Helper: a bracketed string inside [ ] can be single- or double-quoted - private static readonly TokenListParser BracketedStringContent = - Token.EqualTo(CosmosSqlToken.StringLiteral).Select(t => t.Span.ToStringValue()[1..^1]) - .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString).Select(t => t.Span.ToStringValue()[1..^1])); - - private static readonly TokenListParser DottedPathWithIndex = - from first in AnyIdentifierOrKeyword - from rest in ( - from dot in Token.EqualTo(CosmosSqlToken.Dot) - from next in AnyIdentifierOrKeyword - select "." + next - ).Or( - (from open in Token.EqualTo(CosmosSqlToken.OpenBracket) - from idx in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(TokenSpanToString) - from close in Token.EqualTo(CosmosSqlToken.CloseBracket) - select "[" + idx + "]").Try() - ).Or( - (from open in Token.EqualTo(CosmosSqlToken.OpenBracket) - from str in BracketedStringContent - from close in Token.EqualTo(CosmosSqlToken.CloseBracket) - select "." + str).Try() - ).Many() - select first + string.Concat(rest); - - // ── Primary expression ── - - // String literal expression: accepts both single-quoted (Cosmos SQL standard) and - // double-quoted strings (used by the SDK's LINQ provider in generated queries). - private static readonly TokenListParser StringLiteral = - Token.EqualTo(CosmosSqlToken.StringLiteral) - .Select(t => - { - var raw = t.Span.ToStringValue(); - var unquoted = raw[1..^1].Replace("''", "'"); - return (SqlExpression)new LiteralExpression(unquoted); - }) - .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString) - .Select(t => - { - var raw = t.Span.ToStringValue(); - var unquoted = raw[1..^1]; - return (SqlExpression)new LiteralExpression(unquoted); - })); - - private static readonly TokenListParser NumberLiteral = - Token.EqualTo(CosmosSqlToken.NumberLiteral) - .Select(t => - { - var raw = t.Span.ToStringValue(); - if (long.TryParse(raw, out var longVal)) - { - return (SqlExpression)new LiteralExpression(longVal); - } - - return (SqlExpression)new LiteralExpression(double.Parse(raw, System.Globalization.CultureInfo.InvariantCulture)); - }); - - private static readonly TokenListParser TrueLiteral = - Token.EqualTo(CosmosSqlToken.True).Select(_ => (SqlExpression)new LiteralExpression(true)); - - private static readonly TokenListParser FalseLiteral = - Token.EqualTo(CosmosSqlToken.False).Select(_ => (SqlExpression)new LiteralExpression(false)); - - private static readonly TokenListParser NullLiteral = - Token.EqualTo(CosmosSqlToken.Null).Select(_ => (SqlExpression)new LiteralExpression(null)); - - private static readonly TokenListParser UndefinedLiteral = - Token.EqualTo(CosmosSqlToken.Undefined).Select(_ => (SqlExpression)new UndefinedLiteralExpression()); - - private static readonly TokenListParser ParameterExpr = - Token.EqualTo(CosmosSqlToken.Parameter) - .Select(t => (SqlExpression)new ParameterExpression(t.Span.ToStringValue())); - - // Function call: FUNC_NAME(args...) - // Supports * as a function argument (e.g. COUNT(*)) - private static readonly TokenListParser FunctionCall = - from name in AnyIdentifierOrKeyword - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from args in Token.EqualTo(CosmosSqlToken.Star).Select(_ => (SqlExpression)new IdentifierExpression("*")) - .Or(Superpower.Parse.Ref(() => Expr)) - .ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (SqlExpression)new FunctionCallExpression(name.ToUpperInvariant(), args); - - // Dotted function call: namespace.FUNC_NAME(args...) — supports udf.xxx() - // UDF names preserve original casing (Cosmos DB UDFs are case-sensitive); - // built-in dotted functions (e.g. ST_DISTANCE) are uppercased. - private static readonly TokenListParser DottedFunctionCall = - from first in AnyIdentifierOrKeyword - from dot in Token.EqualTo(CosmosSqlToken.Dot) - from second in AnyIdentifierOrKeyword - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from args in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (SqlExpression)new FunctionCallExpression( - first.Equals("udf", StringComparison.OrdinalIgnoreCase) - ? "UDF." + second // preserve UDF name casing - : (first + "." + second).ToUpperInvariant(), args); - - // EXISTS( subquery-text ) - private static readonly TokenListParser ExistsExpr = - from kw in Token.EqualTo(CosmosSqlToken.Exists) - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from inner in CaptureBalancedParens() - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (SqlExpression)new ExistsExpression(inner); - - // Subquery expression: (SELECT ...) — a full SELECT statement inside parentheses - private static readonly TokenListParser SubqueryParens = - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from subquery in Superpower.Parse.Ref(() => QueryParser) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (SqlExpression)new SubqueryExpression(subquery); - - // Parenthesized expression (try subquery first, fall back to regular expression) - private static readonly TokenListParser Parens = - SubqueryParens.Try() - .Or( - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from expr in Superpower.Parse.Ref(() => Expr) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select expr); - - // Object literal: { key: expr, key: expr, ... } - // Keys can be identifiers or double-quoted strings (for SDK-generated queries like {"item": root.value}) - private static readonly TokenListParser ObjectLiteral = - from open in Token.EqualTo(CosmosSqlToken.OpenBrace) - from props in ( - from key in AnyIdentifierOrKeyword - .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString).Select(t => t.Span.ToStringValue()[1..^1])) - .Or(Token.EqualTo(CosmosSqlToken.StringLiteral).Select(t => t.Span.ToStringValue()[1..^1])) - from colon in Token.EqualTo(CosmosSqlToken.Colon) - from value in Superpower.Parse.Ref(() => Expr) - select new KeyValuePair(key, value) - ).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseBrace) - select (SqlExpression)new ObjectLiteralExpression(props); - - // Array literal: [ expr, expr, ... ] - private static readonly TokenListParser ArrayLiteral = - from open in Token.EqualTo(CosmosSqlToken.OpenBracket) - from elements in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseBracket) - select (SqlExpression)new ArrayLiteralExpression(elements); - - // Identifier or dotted path - private static readonly TokenListParser IdentExpr = - DottedPathWithIndex.Select(path => (SqlExpression)new IdentifierExpression(path)); - - // ARRAY(subquery): ARRAY keyword followed by a parenthesised SELECT - private static readonly TokenListParser ArraySubqueryCall = - from name in Token.EqualTo(CosmosSqlToken.Array) - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from subquery in Superpower.Parse.Ref(() => QueryParser) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (SqlExpression)new FunctionCallExpression("ARRAY", [new SubqueryExpression(subquery)]); - - // Primary: literal | parameter | function call | exists | parens | object | array | ident - private static readonly TokenListParser Primary = - StringLiteral - .Or(NumberLiteral) - .Or(TrueLiteral) - .Or(FalseLiteral) - .Or(NullLiteral) - .Or(UndefinedLiteral) - .Or(ParameterExpr) - .Or(ExistsExpr.Try()) - .Or(ArraySubqueryCall.Try()) - .Or(DottedFunctionCall.Try()) - .Or(FunctionCall.Try()) - .Or(Parens) - .Or(ObjectLiteral.Try()) - .Or(ArrayLiteral.Try()) - .Or(IdentExpr); - - // ── Unary ── - - private static readonly TokenListParser UnaryExpr = - (from op in Token.EqualTo(CosmosSqlToken.Not) - from operand in Superpower.Parse.Ref(() => UnaryExpr) - select (SqlExpression)new UnaryExpression(UnaryOp.Not, operand)) - .Or( - from op in Token.EqualTo(CosmosSqlToken.Minus) - from operand in Primary - select (SqlExpression)new UnaryExpression(UnaryOp.Negate, operand)) - .Or( - from op in Token.EqualTo(CosmosSqlToken.Tilde) - from operand in Primary - select (SqlExpression)new UnaryExpression(UnaryOp.BitwiseNot, operand)) - .Or(Primary); - - // ── Multiplicative: *, /, % ── - - private static readonly TokenListParser Multiplicative = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Star).Select(_ => BinaryOp.Multiply) - .Or(Token.EqualTo(CosmosSqlToken.Slash).Select(_ => BinaryOp.Divide)) - .Or(Token.EqualTo(CosmosSqlToken.Percent).Select(_ => BinaryOp.Modulo)), - UnaryExpr, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Additive: +, - ── - - private static readonly TokenListParser Additive = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Plus).Select(_ => BinaryOp.Add) - .Or(Token.EqualTo(CosmosSqlToken.Minus).Select(_ => BinaryOp.Subtract)), - Multiplicative, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Bitwise AND: & ── - - private static readonly TokenListParser BitwiseAndExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Ampersand).Select(_ => BinaryOp.BitwiseAnd), - Additive, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Bitwise XOR: ^ ── - - private static readonly TokenListParser BitwiseXorExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Caret).Select(_ => BinaryOp.BitwiseXor), - BitwiseAndExpr, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Bitwise OR: | ── - - private static readonly TokenListParser BitwiseOrExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Pipe).Select(_ => BinaryOp.BitwiseOr), - BitwiseXorExpr, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── String concat: || ── - - private static readonly TokenListParser StringConcatExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.DoublePipe).Select(_ => BinaryOp.StringConcat), - BitwiseOrExpr, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Comparison: =, !=, <, >, <=, >=, LIKE ── - - private static readonly TokenListParser CompOp = - Token.EqualTo(CosmosSqlToken.Equals).Select(_ => BinaryOp.Equal) - .Or(Token.EqualTo(CosmosSqlToken.NotEquals).Select(_ => BinaryOp.NotEqual)) - .Or(Token.EqualTo(CosmosSqlToken.LessThanOrEqual).Select(_ => BinaryOp.LessThanOrEqual)) - .Or(Token.EqualTo(CosmosSqlToken.GreaterThanOrEqual).Select(_ => BinaryOp.GreaterThanOrEqual)) - .Or(Token.EqualTo(CosmosSqlToken.LessThan).Select(_ => BinaryOp.LessThan)) - .Or(Token.EqualTo(CosmosSqlToken.GreaterThan).Select(_ => BinaryOp.GreaterThan)) - .Or(Token.EqualTo(CosmosSqlToken.Like).Select(_ => BinaryOp.Like)); - - private static readonly TokenListParser Comparison = - from left in StringConcatExpr - from rest in ( - // BETWEEN low AND high - (from kw in Token.EqualTo(CosmosSqlToken.Between) - from low in StringConcatExpr - from and in Token.EqualTo(CosmosSqlToken.And) - from high in StringConcatExpr - select (Func)(l => new BetweenExpression(l, low, high))) - // NOT BETWEEN low AND high - .Or( - (from not in Token.EqualTo(CosmosSqlToken.Not) - from kw in Token.EqualTo(CosmosSqlToken.Between) - from low in StringConcatExpr - from and in Token.EqualTo(CosmosSqlToken.And) - from high in StringConcatExpr - select (Func)(l => new UnaryExpression(UnaryOp.Not, new BetweenExpression(l, low, high)))) - .Try()) - // IN (val, val, ...) - .Or( - from kw in Token.EqualTo(CosmosSqlToken.In) - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from vals in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (Func)(l => new InExpression(l, vals))) - // NOT IN (val, val, ...) - .Or( - (from not in Token.EqualTo(CosmosSqlToken.Not) - from kw in Token.EqualTo(CosmosSqlToken.In) - from open in Token.EqualTo(CosmosSqlToken.OpenParen) - from vals in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from close in Token.EqualTo(CosmosSqlToken.CloseParen) - select (Func)(l => new UnaryExpression(UnaryOp.Not, new InExpression(l, vals)))) - .Try()) - // LIKE pattern ESCAPE char - .Or( - (from kw in Token.EqualTo(CosmosSqlToken.Like) - from pattern in StringConcatExpr - from esc in Token.EqualTo(CosmosSqlToken.Escape) - from escChar in Token.EqualTo(CosmosSqlToken.StringLiteral) - select (Func)(l => new LikeExpression(l, pattern, escChar.Span.ToStringValue()[1..^1]))) - .Try()) - // NOT LIKE pattern ESCAPE char - .Or( - (from not in Token.EqualTo(CosmosSqlToken.Not) - from kw in Token.EqualTo(CosmosSqlToken.Like) - from pattern in StringConcatExpr - from esc in Token.EqualTo(CosmosSqlToken.Escape) - from escChar in Token.EqualTo(CosmosSqlToken.StringLiteral) - select (Func)(l => new UnaryExpression(UnaryOp.Not, new LikeExpression(l, pattern, escChar.Span.ToStringValue()[1..^1])))) - .Try()) - // NOT LIKE pattern (without ESCAPE) - .Or( - (from not in Token.EqualTo(CosmosSqlToken.Not) - from kw in Token.EqualTo(CosmosSqlToken.Like) - from pattern in StringConcatExpr - select (Func)(l => new UnaryExpression(UnaryOp.Not, new BinaryExpression(l, BinaryOp.Like, pattern)))) - .Try()) - // IS NOT NULL (must come before IS NULL to avoid consuming IS and failing on NOT) - .Or( - (from is_ in Token.EqualTo(CosmosSqlToken.Is) - from not_ in Token.EqualTo(CosmosSqlToken.Not) - from null_ in Token.EqualTo(CosmosSqlToken.Null) - select (Func)(l => new BinaryExpression(l, BinaryOp.NotEqual, new LiteralExpression(null)))) - .Try()) - // IS NULL - .Or( - from is_ in Token.EqualTo(CosmosSqlToken.Is) - from null_ in Token.EqualTo(CosmosSqlToken.Null) - select (Func)(l => new BinaryExpression(l, BinaryOp.Equal, new LiteralExpression(null)))) - // op right - .Or( - from op in CompOp - from right in StringConcatExpr - select (Func)(l => new BinaryExpression(l, op, right))) - ).OptionalOrDefault(null) - select rest != null ? rest(left) : left; - - // ── Logical AND ── - - private static readonly TokenListParser AndExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.And).Select(_ => BinaryOp.And), - Comparison, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Logical OR ── - - private static readonly TokenListParser OrExpr = - Superpower.Parse.Chain( - Token.EqualTo(CosmosSqlToken.Or).Select(_ => BinaryOp.Or), - AndExpr, - (op, left, right) => new BinaryExpression(left, op, right)); - - // ── Null coalesce: ?? ── - - private static readonly TokenListParser CoalesceExpr = - from left in OrExpr - from rest in ( - from op in Token.EqualTo(CosmosSqlToken.QuestionQuestion) - from right in Superpower.Parse.Ref(() => CoalesceExpr) - select right - ).OptionalOrDefault(null) - select rest != null ? new CoalesceExpression(left, rest) : left; - - // ── Ternary: cond ? then : else ── - - private static readonly TokenListParser TernaryExpr = - from cond in CoalesceExpr - from rest in ( - from q in Token.EqualTo(CosmosSqlToken.Question) - from ifTrue in Superpower.Parse.Ref(() => Expr) - from colon in Token.EqualTo(CosmosSqlToken.Colon) - from ifFalse in Superpower.Parse.Ref(() => Expr) - select new { ifTrue, ifFalse } - ).OptionalOrDefault(null) - select rest != null ? (SqlExpression)new TernaryExpression(cond, rest.ifTrue, rest.ifFalse) : cond; - - // ── Top-level expression ── - - private static readonly TokenListParser Expr = TernaryExpr; - - // ────────────────────────────────────────────── - // SELECT field parsing - // ────────────────────────────────────────────── - - private static readonly TokenListParser StarField = - Token.EqualTo(CosmosSqlToken.Star).Select(_ => new SelectField("*", null)); - - // Handle "c.*" (alias DOT STAR) as equivalent to "*" - private static readonly TokenListParser AliasDotStarField = - from ident in AnyIdentifierOrKeyword - from dot in Token.EqualTo(CosmosSqlToken.Dot) - from star in Token.EqualTo(CosmosSqlToken.Star) - select new SelectField("*", null); - - private static readonly TokenListParser ExpressionField = - from expr in Expr - from alias in ( - from as_ in Token.EqualTo(CosmosSqlToken.As) - from name in AnyIdentifierOrKeyword - select name - ).OptionalOrDefault(null) - select new SelectField(ExprToString(expr), alias, expr); - - private static readonly TokenListParser SingleSelectField = - StarField.Try().Or(AliasDotStarField.Try()).Or(ExpressionField); - - // ────────────────────────────────────────────── - // JOIN clause parsing - // ────────────────────────────────────────────── - - private static readonly TokenListParser JoinParser = - from kw in Token.EqualTo(CosmosSqlToken.Join) - from alias in AnyIdentifierOrKeyword - from in_ in Token.EqualTo(CosmosSqlToken.In) - from source in DottedPathWithIndex - select ParseJoinSource(alias, source); - - // ────────────────────────────────────────────── - // ORDER BY parsing - // ────────────────────────────────────────────── - - private static readonly TokenListParser OrderByFieldParser = - (from expr in Superpower.Parse.Ref(() => Expr) - from dir in Token.EqualTo(CosmosSqlToken.Asc).Select(_ => true) - .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(_ => false)) - .OptionalOrDefault(true) - select expr is IdentifierExpression ident - ? new OrderByField(ident.Name, dir) - : new OrderByField(null, dir, expr)).Try() - .Or( - from field in DottedPathWithIndex - from dir in Token.EqualTo(CosmosSqlToken.Asc).Select(_ => true) - .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(_ => false)) - .OptionalOrDefault(true) - select new OrderByField(field, dir)); - - // ────────────────────────────────────────────── - // Full query parsing - // ────────────────────────────────────────────── - - private static readonly TokenListParser QueryParser = - from select_ in Token.EqualTo(CosmosSqlToken.Select) - from distinct in Token.EqualTo(CosmosSqlToken.Distinct).OptionalOrDefault() - from top in ( - from topKw in Token.EqualTo(CosmosSqlToken.Top) - from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) - select (int?)n - ).OptionalOrDefault(null) - from value_ in Token.EqualTo(CosmosSqlToken.Value).OptionalOrDefault() - from fields in SingleSelectField.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - from fromKw in Token.EqualTo(CosmosSqlToken.From) - from fromFirstIdent in AnyIdentifierOrKeyword - from fromClause in ( - // FROM alias IN source.path - from in_ in Token.EqualTo(CosmosSqlToken.In) - from source in DottedPath - select (Alias: fromFirstIdent, Source: (string)source) - ).Try().Or( - // FROM source AS alias - from as_ in Token.EqualTo(CosmosSqlToken.As) - from alias in AnyIdentifierOrKeyword - select (Alias: alias, Source: (string)null) - ).Try().Or( - // FROM source alias (implicit alias without AS keyword — only plain identifiers) - from alias in Token.EqualTo(CosmosSqlToken.Identifier).Select(TokenSpanToString) - select (Alias: alias, Source: (string)null) - ).Try().OptionalOrDefault((Alias: fromFirstIdent, Source: (string)null)) - from joins in JoinParser.Many() - from where_ in ( - from whereKw in Token.EqualTo(CosmosSqlToken.Where) - from expr in Expr - select expr - ).OptionalOrDefault(null) - from groupBy in ( - from groupKw in Token.EqualTo(CosmosSqlToken.Group) - from byKw in Token.EqualTo(CosmosSqlToken.By) - from groupFields in Expr.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - select groupFields - ).OptionalOrDefault(null) - from having in ( - from havingKw in Token.EqualTo(CosmosSqlToken.Having) - from expr in Expr - select expr - ).OptionalOrDefault(null) - from orderByResult in ( - from orderKw in Token.EqualTo(CosmosSqlToken.Order) - from byKw in Token.EqualTo(CosmosSqlToken.By) - from result in ( - from rankKw in Token.EqualTo(CosmosSqlToken.Identifier) - .Where(t => string.Equals(t.Span.ToStringValue(), "RANK", StringComparison.OrdinalIgnoreCase)) - from expr in Expr - select (Fields: (OrderByField[])null, RankExpr: (SqlExpression)expr) - ).Try().Or( - from orderFields in OrderByFieldParser.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) - select (Fields: orderFields, RankExpr: (SqlExpression)null) - ) - select result - ).OptionalOrDefault(default) - from offset in ( - from offsetKw in Token.EqualTo(CosmosSqlToken.Offset) - from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) - select (int?)n - ).OptionalOrDefault(null) - from limit in ( - from limitKw in Token.EqualTo(CosmosSqlToken.Limit) - from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) - select (int?)n - ).OptionalOrDefault(null) - select BuildQuery( - distinct.HasValue, top, value_.HasValue, fields, - fromClause.Alias, fromClause.Source, joins, where_, - groupBy?.Select(ExprToString).ToArray(), - groupBy?.ToArray(), - having, orderByResult.Fields, offset, limit, orderByResult.RankExpr); - - // ────────────────────────────────────────────── - // Public API - // ────────────────────────────────────────────── - - private const int ParseCacheMaxSize = 5000; - private static readonly ConcurrentDictionary ParseCache = new(); - - public static CosmosSqlQuery Parse(string sql) - { - if (ParseCache.TryGetValue(sql, out var cached)) - { - return cached; - } - - CosmosSqlQuery parsed; - try - { - var tokens = CosmosSqlTokenizer.Tokenize(sql); - parsed = QueryParser.Parse(tokens); - } - catch (Exception ex) - { - throw new NotSupportedException($"Failed to parse Cosmos SQL query: {sql}", ex); - } - - if (ParseCache.Count < ParseCacheMaxSize) - { - ParseCache.TryAdd(sql, parsed); - } - - return parsed; - } - - public static bool TryParse(string sql, out CosmosSqlQuery result) - { - try - { - result = Parse(sql); - return true; - } - catch - { - result = null; - return false; - } - } - - // Backward-compatible: convert SqlExpression tree to WhereExpression tree - public static WhereExpression ToWhereExpression(SqlExpression expr) - { - if (expr is null) - { - return null; - } - - switch (expr) - { - case BinaryExpression bin when bin.Operator == BinaryOp.And: - return new AndCondition(ToWhereExpression(bin.Left), ToWhereExpression(bin.Right)); - - case BinaryExpression bin when bin.Operator == BinaryOp.Or: - return new OrCondition(ToWhereExpression(bin.Left), ToWhereExpression(bin.Right)); - - case UnaryExpression { Operator: UnaryOp.Not } unary: - return new NotCondition(ToWhereExpression(unary.Operand)); - - case ExistsExpression exists: - return new ExistsCondition(exists.RawSubquery); - - case FunctionCallExpression func: - if (func.FunctionName.StartsWith("UDF.", StringComparison.OrdinalIgnoreCase) || - func.FunctionName.StartsWith("ST_", StringComparison.OrdinalIgnoreCase)) - { - return new SqlExpressionCondition(func); - } - - var hasComplexArgs = func.Arguments.Any(a => a is ObjectLiteralExpression or ArrayLiteralExpression or FunctionCallExpression); - if (hasComplexArgs) - { - return new SqlExpressionCondition(func); - } - - if (LegacyFunctionNames.Contains(func.FunctionName)) - { - var args = func.Arguments.Select(ExprToString).ToArray(); - return new FunctionCondition(func.FunctionName, args); - } - - return new SqlExpressionCondition(func); - - case BinaryExpression bin when IsComparisonOp(bin.Operator): - if (ContainsFunctionCall(bin.Left) || ContainsFunctionCall(bin.Right) || - ContainsArithmetic(bin.Left) || ContainsArithmetic(bin.Right) || - ContainsComplexExpression(bin.Left) || ContainsComplexExpression(bin.Right)) - { - return new SqlExpressionCondition(bin); - } - - var compOp = bin.Operator switch - { - BinaryOp.Equal => ComparisonOp.Equal, - BinaryOp.NotEqual => ComparisonOp.NotEqual, - BinaryOp.LessThan => ComparisonOp.LessThan, - BinaryOp.GreaterThan => ComparisonOp.GreaterThan, - BinaryOp.LessThanOrEqual => ComparisonOp.LessThanOrEqual, - BinaryOp.GreaterThanOrEqual => ComparisonOp.GreaterThanOrEqual, - BinaryOp.Like => ComparisonOp.Like, - _ => ComparisonOp.Equal, - }; - return new ComparisonCondition(ExprToString(bin.Left), compOp, ExprToString(bin.Right)); - - default: - return new SqlExpressionCondition(expr); - } - } - - /// - /// Removes SDK-injected IS_DEFINED(alias) and literal true nodes from - /// AND chains in a WHERE expression AST, returning the simplified user condition. - /// Only strips IS_DEFINED() calls whose argument is exactly the FROM alias - /// (the SDK injects IS_DEFINED(root) for ORDER BY, never IS_DEFINED(root.field)), - /// preserving any legitimate IS_DEFINED() from user code. - /// Returns null when the entire expression reduces to nothing. - /// - public static SqlExpression SimplifySdkWhereExpression( - SqlExpression expr, string fromAlias = null) - { - return SimplifyCore(expr, fromAlias); - } - - private static SqlExpression SimplifyCore(SqlExpression expr, string fromAlias) - { - if (expr is null) - { - return null; - } - - if (expr is LiteralExpression { Value: true }) - { - return null; - } - - if (expr is FunctionCallExpression func && - string.Equals(func.FunctionName, "IS_DEFINED", StringComparison.OrdinalIgnoreCase) && - func.Arguments.Length == 1) - { - var argStr = ExprToString(func.Arguments[0]); - // Only strip when: no alias specified (backward-compat), or arg IS the FROM alias exactly. - // The SDK injects IS_DEFINED(root) — never IS_DEFINED(root.field) — so this - // preserves any user-written IS_DEFINED on specific field paths. - if (fromAlias is null || string.Equals(argStr, fromAlias, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - } - - if (expr is BinaryExpression { Operator: BinaryOp.And } and) - { - var left = SimplifyCore(and.Left, fromAlias); - var right = SimplifyCore(and.Right, fromAlias); - if (left is null && right is null) - { - return null; - } - - if (left is null) - { - return right; - } - - if (right is null) - { - return left; - } - - return new BinaryExpression(left, BinaryOp.And, right); - } - - return expr; - } - - /// - /// Rebuilds a Superpower-parsed into clean SQL that - /// can execute. Strips SDK-injected WHERE clauses - /// (IS_DEFINED(alias), literal true), normalises bracket notation - /// (root["name"]) to dot notation (root.name) via the AST, and - /// reconstructs SELECT, WHERE, ORDER BY, TOP, OFFSET/LIMIT, DISTINCT, and GROUP BY - /// clauses from the parsed structure. - /// - /// For ORDER BY queries where the SDK rewrites the SELECT to include orderByItems - /// and payload, this emits SELECT VALUE alias to return full documents. - /// For all other queries, the original SELECT expressions are emitted from the AST. - /// - /// - public static string SimplifySdkQuery(CosmosSqlQuery parsed) - { - var fromAlias = parsed.FromAlias; - - var isOrderByQuery = parsed.SelectFields.Any(field => - string.Equals(field.Alias, "orderByItems", StringComparison.OrdinalIgnoreCase)); - - // SELECT clause - var sb = new System.Text.StringBuilder("SELECT "); - if (parsed.IsDistinct) - { - sb.Append("DISTINCT "); - } - - if (parsed.TopCount.HasValue) - { - sb.Append($"TOP {parsed.TopCount.Value} "); - } - - if (isOrderByQuery) - { - sb.Append($"VALUE {fromAlias}"); - } - else if (parsed.IsValueSelect) - { - var selectExprs = parsed.SelectFields - .Select(field => ExprToString(field.SqlExpr ?? new IdentifierExpression(field.Expression))) - .ToArray(); - sb.Append("VALUE "); - sb.Append(string.Join(", ", selectExprs)); - } - else if (parsed.IsSelectAll) - { - sb.Append('*'); - } - else - { - var selectParts = parsed.SelectFields.Select(field => - { - var expr = field.SqlExpr is not null ? ExprToString(field.SqlExpr) : field.Expression; - return field.Alias is not null ? $"{expr} AS {field.Alias}" : expr; - }); - sb.Append(string.Join(", ", selectParts)); - } - - // FROM - if (parsed.FromSource is not null) - { - sb.Append($" FROM {fromAlias} IN {parsed.FromSource}"); - } - else - { - sb.Append($" FROM {fromAlias}"); - } - - // JOINs - if (parsed.Joins is { Length: > 0 }) - { - foreach (var join in parsed.Joins) - { - sb.Append($" JOIN {join.Alias} IN {join.SourceAlias}.{join.ArrayField}"); - } - } - - // WHERE — strip SDK-injected nodes - var simplifiedWhere = SimplifySdkWhereExpression(parsed.WhereExpr, fromAlias); - if (simplifiedWhere is not null) - { - sb.Append($" WHERE {ExprToString(simplifiedWhere)}"); - } - - // GROUP BY - if (parsed.GroupByFields is { Length: > 0 }) - { - sb.Append($" GROUP BY {string.Join(", ", parsed.GroupByFields)}"); - } - - // HAVING - if (parsed.HavingExpr is not null) - { - sb.Append($" HAVING {ExprToString(parsed.HavingExpr)}"); - } - - // ORDER BY - if (parsed.OrderByFields is { Length: > 0 }) - { - var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => - $"{field.Field ?? ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); - sb.Append($" ORDER BY {orderByStr}"); - } - else if (parsed.RankExpression is not null) - { - sb.Append($" ORDER BY RANK {ExprToString(parsed.RankExpression)}"); - } - - // OFFSET / LIMIT - if (parsed.Offset.HasValue) - { - sb.Append($" OFFSET {parsed.Offset.Value}"); - } - - if (parsed.Limit.HasValue) - { - sb.Append($" LIMIT {parsed.Limit.Value}"); - } - - return sb.ToString(); - } - - // ────────────────────────────────────────────── - // Internal helpers - // ────────────────────────────────────────────── - - internal static WhereExpression ParseWhereExpression(string expression) - { - var wrappedSql = $"SELECT * FROM c WHERE {expression}"; - var query = Parse(wrappedSql); - return query.Where; - } - - private static bool IsComparisonOp(BinaryOp op) => - op is BinaryOp.Equal or BinaryOp.NotEqual or BinaryOp.LessThan or BinaryOp.GreaterThan - or BinaryOp.LessThanOrEqual or BinaryOp.GreaterThanOrEqual or BinaryOp.Like; - - private static bool ContainsFunctionCall(SqlExpression expr) => - expr switch - { - FunctionCallExpression => true, - BinaryExpression bin => ContainsFunctionCall(bin.Left) || ContainsFunctionCall(bin.Right), - UnaryExpression unary => ContainsFunctionCall(unary.Operand), - _ => false - }; - - private static bool ContainsArithmetic(SqlExpression expr) => - expr switch - { - BinaryExpression bin when !IsComparisonOp(bin.Operator) => true, - BinaryExpression bin => ContainsArithmetic(bin.Left) || ContainsArithmetic(bin.Right), - UnaryExpression unary => ContainsArithmetic(unary.Operand), - _ => false - }; - - /// - /// Returns true when the expression contains a subquery, coalesce, or ternary — - /// any of which require full expression evaluation rather than string-based ResolveValue. - /// - private static bool ContainsComplexExpression(SqlExpression expr) => - expr switch - { - SubqueryExpression => true, - CoalesceExpression => true, - TernaryExpression => true, - BinaryExpression bin => ContainsComplexExpression(bin.Left) || ContainsComplexExpression(bin.Right), - UnaryExpression unary => ContainsComplexExpression(unary.Operand), - _ => false - }; - - public static string ExprToString(SqlExpression expr) - { - return expr switch - { - LiteralExpression { Value: null } => "null", - LiteralExpression { Value: string s } => $"'{s}'", - LiteralExpression { Value: bool b } => b ? "true" : "false", - LiteralExpression lit => lit.Value?.ToString() ?? "null", - UndefinedLiteralExpression => "undefined", - IdentifierExpression ident => ident.Name, - ParameterExpression param => param.Name, - PropertyAccessExpression prop => $"{ExprToString(prop.Object)}.{prop.Property}", - IndexAccessExpression idx => $"{ExprToString(idx.Object)}[{ExprToString(idx.Index)}]", - FunctionCallExpression func => $"{func.FunctionName}({string.Join(", ", func.Arguments.Select(ExprToString))})", - BinaryExpression { Operator: BinaryOp.And or BinaryOp.Or } bin => - $"({ExprToString(bin.Left)} {BinaryOpToString(bin.Operator)} {ExprToString(bin.Right)})", - BinaryExpression bin => $"{ExprToString(bin.Left)} {BinaryOpToString(bin.Operator)} {ExprToString(bin.Right)}", - UnaryExpression unary => $"{UnaryOpToString(unary.Operator)} {ExprToString(unary.Operand)}", - BetweenExpression betw => $"{ExprToString(betw.Value)} BETWEEN {ExprToString(betw.Low)} AND {ExprToString(betw.High)}", - InExpression inExpr => $"{ExprToString(inExpr.Value)} IN ({string.Join(", ", inExpr.List.Select(ExprToString))})", - LikeExpression like => like.EscapeChar is not null - ? $"{ExprToString(like.Value)} LIKE {ExprToString(like.Pattern)} ESCAPE '{like.EscapeChar}'" - : $"{ExprToString(like.Value)} LIKE {ExprToString(like.Pattern)}", - ExistsExpression exists => $"EXISTS({exists.RawSubquery})", - SubqueryExpression sub => $"({SubqueryToString(sub.Subquery)})", - TernaryExpression tern => $"{ExprToString(tern.Condition)} ? {ExprToString(tern.IfTrue)} : {ExprToString(tern.IfFalse)}", - CoalesceExpression coal => $"{ExprToString(coal.Left)} ?? {ExprToString(coal.Right)}", - ObjectLiteralExpression obj => "{" + string.Join(", ", obj.Properties.Select(p => $"{p.Key}: {ExprToString(p.Value)}")) + "}", - ArrayLiteralExpression arr => "[" + string.Join(", ", arr.Elements.Select(ExprToString)) + "]", - _ => expr.ToString(), - }; - } - - private static string SubqueryToString(CosmosSqlQuery sub) - { - var sb = new System.Text.StringBuilder("SELECT "); - if (sub.IsDistinct) - { - sb.Append("DISTINCT "); - } - - if (sub.TopCount.HasValue) - { - sb.Append($"TOP {sub.TopCount.Value} "); - } - - if (sub.IsValueSelect) - { - sb.Append("VALUE "); - } - - if (sub.IsSelectAll) - { - sb.Append('*'); - } - else - { - var selectParts = sub.SelectFields.Select(field => - { - var expr = field.SqlExpr is not null ? ExprToString(field.SqlExpr) : field.Expression; - return field.Alias is not null ? $"{expr} AS {field.Alias}" : expr; - }); - sb.Append(string.Join(", ", selectParts)); - } - - if (sub.FromSource is not null) - { - sb.Append($" FROM {sub.FromAlias} IN {sub.FromSource}"); - } - else - { - sb.Append($" FROM {sub.FromAlias}"); - } - - if (sub.Joins is { Length: > 0 }) - { - foreach (var join in sub.Joins) - { - sb.Append($" JOIN {join.Alias} IN {join.SourceAlias}.{join.ArrayField}"); - } - } - - if (sub.WhereExpr is not null) - { - sb.Append($" WHERE {ExprToString(sub.WhereExpr)}"); - } - - if (sub.GroupByFields is { Length: > 0 }) - { - sb.Append($" GROUP BY {string.Join(", ", sub.GroupByFields)}"); - } - - if (sub.HavingExpr is not null) - { - sb.Append($" HAVING {ExprToString(sub.HavingExpr)}"); - } - - if (sub.OrderByFields is { Length: > 0 }) - { - var orderByStr = string.Join(", ", sub.OrderByFields.Select(field => - $"{field.Field} {(field.Ascending ? "ASC" : "DESC")}")); - sb.Append($" ORDER BY {orderByStr}"); - } - - if (sub.Offset.HasValue) - { - sb.Append($" OFFSET {sub.Offset.Value}"); - } - - if (sub.Limit.HasValue) - { - sb.Append($" LIMIT {sub.Limit.Value}"); - } - - return sb.ToString(); - } - - private static string BinaryOpToString(BinaryOp op) => op switch - { - BinaryOp.Equal => "=", - BinaryOp.NotEqual => "!=", - BinaryOp.LessThan => "<", - BinaryOp.GreaterThan => ">", - BinaryOp.LessThanOrEqual => "<=", - BinaryOp.GreaterThanOrEqual => ">=", - BinaryOp.And => "AND", - BinaryOp.Or => "OR", - BinaryOp.Add => "+", - BinaryOp.Subtract => "-", - BinaryOp.Multiply => "*", - BinaryOp.Divide => "/", - BinaryOp.Modulo => "%", - BinaryOp.Like => "LIKE", - BinaryOp.StringConcat => "||", - BinaryOp.BitwiseAnd => "&", - BinaryOp.BitwiseOr => "|", - BinaryOp.BitwiseXor => "^", - _ => op.ToString(), - }; - - private static string UnaryOpToString(UnaryOp op) => op switch - { - UnaryOp.Not => "NOT", - UnaryOp.Negate => "-", - UnaryOp.BitwiseNot => "~", - _ => op.ToString(), - }; - - private static JoinClause ParseJoinSource(string alias, string source) - { - var dotIdx = source.IndexOf('.'); - if (dotIdx < 0) - { - return new JoinClause(alias, source, source); - } - - return new JoinClause(alias, source[..dotIdx], source[(dotIdx + 1)..]); - } - - private static CosmosSqlQuery BuildQuery( - bool isDistinct, int? top, bool isValue, - SelectField[] fields, string fromAlias, string fromSource, - JoinClause[] joins, SqlExpression whereExpr, - string[] groupBy, SqlExpression[] groupByExpressions, SqlExpression havingExpr, - OrderByField[] orderByFields, - int? offset, int? limit, SqlExpression rankExpr = null) - { - var isSelectAll = fields.Length == 1 && fields[0].Expression == "*"; - var where = whereExpr != null ? ToWhereExpression(whereExpr) : null; - // HAVING always uses SqlExpressionCondition to preserve aggregate function expressions - // (ToWhereExpression would decompose AND/OR into AndCondition/OrCondition with string-based - // comparison operands, losing the ability to evaluate aggregate functions like COUNT/SUM). - var having = havingExpr != null ? new SqlExpressionCondition(havingExpr) : null; - var rawHavingExpr = havingExpr; - - // Backward compat: first join, first order by - var firstJoin = joins.Length > 0 ? joins[0] : null; - OrderByClause legacyOrderBy = null; - if (orderByFields is { Length: > 0 }) - { - legacyOrderBy = new OrderByClause(orderByFields[0].Field, orderByFields[0].Ascending); - } - - return new CosmosSqlQuery( - SelectFields: fields, - IsSelectAll: isSelectAll, - TopCount: top, - FromAlias: fromAlias, - Join: firstJoin, - Where: where, - Offset: offset, - Limit: limit, - OrderBy: legacyOrderBy, - IsDistinct: isDistinct, - IsValueSelect: isValue, - Joins: joins, - OrderByFields: orderByFields, - GroupByFields: groupBy, - GroupByExpressions: groupByExpressions, - Having: having, - WhereExpr: whereExpr, - HavingExpr: rawHavingExpr, - FromSource: fromSource, - RankExpression: rankExpr); - } - - // Captures tokens inside balanced parentheses as a raw string - private static TokenListParser CaptureBalancedParens() - { - return input => - { - var depth = 0; - var startPos = input.Position; - var current = input; - var parts = new List(); - - while (!current.IsAtEnd) - { - var token = current.ConsumeToken(); - if (!token.HasValue) - { - break; - } - - if (token.Value.Kind == CosmosSqlToken.OpenParen) - { - depth++; - parts.Add("("); - current = token.Remainder; - } - else if (token.Value.Kind == CosmosSqlToken.CloseParen) - { - if (depth == 0) - { - return TokenListParserResult.Value(string.Join(" ", parts), input, current); - } - depth--; - parts.Add(")"); - current = token.Remainder; - } - else - { - parts.Add(token.Value.Span.ToStringValue()); - current = token.Remainder; - } - } - - return TokenListParserResult.Value(string.Join(" ", parts), input, current); - }; - } + private static readonly HashSet LegacyFunctionNames = new(StringComparer.OrdinalIgnoreCase) + { + "STARTSWITH", "ENDSWITH", "CONTAINS", "ARRAY_CONTAINS", "IS_DEFINED", "IS_NULL", + }; + + // ── Helpers ── + + private static string TokenSpanToString(Token token) => + token.Span.ToStringValue(); + + private static TokenListParser AnyIdentifierOrKeyword => + Token.EqualTo(CosmosSqlToken.Identifier).Select(TokenSpanToString) + .Or(Token.EqualTo(CosmosSqlToken.Value).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Array).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Defined).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Asc).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Top).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Limit).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Offset).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.Null).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.True).Select(TokenSpanToString)) + .Or(Token.EqualTo(CosmosSqlToken.False).Select(TokenSpanToString)); + + // ── Dotted path: ident.ident.ident ── + + private static readonly TokenListParser DottedPath = + from first in AnyIdentifierOrKeyword + from rest in ( + from dot in Token.EqualTo(CosmosSqlToken.Dot) + from next in AnyIdentifierOrKeyword + select "." + next + ).Many() + select first + string.Concat(rest); + + // ── Dotted path with optional array indexing: ident.ident[0].ident ── + + // Helper: a bracketed string inside [ ] can be single- or double-quoted + private static readonly TokenListParser BracketedStringContent = + Token.EqualTo(CosmosSqlToken.StringLiteral).Select(t => t.Span.ToStringValue()[1..^1]) + .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString).Select(t => t.Span.ToStringValue()[1..^1])); + + private static readonly TokenListParser DottedPathWithIndex = + from first in AnyIdentifierOrKeyword + from rest in ( + from dot in Token.EqualTo(CosmosSqlToken.Dot) + from next in AnyIdentifierOrKeyword + select "." + next + ).Or( + (from open in Token.EqualTo(CosmosSqlToken.OpenBracket) + from idx in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(TokenSpanToString) + from close in Token.EqualTo(CosmosSqlToken.CloseBracket) + select "[" + idx + "]").Try() + ).Or( + (from open in Token.EqualTo(CosmosSqlToken.OpenBracket) + from str in BracketedStringContent + from close in Token.EqualTo(CosmosSqlToken.CloseBracket) + select "." + str).Try() + ).Many() + select first + string.Concat(rest); + + // ── Primary expression ── + + // String literal expression: accepts both single-quoted (Cosmos SQL standard) and + // double-quoted strings (used by the SDK's LINQ provider in generated queries). + private static readonly TokenListParser StringLiteral = + Token.EqualTo(CosmosSqlToken.StringLiteral) + .Select(t => + { + var raw = t.Span.ToStringValue(); + var unquoted = raw[1..^1].Replace("''", "'"); + return (SqlExpression)new LiteralExpression(unquoted); + }) + .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString) + .Select(t => + { + var raw = t.Span.ToStringValue(); + var unquoted = raw[1..^1]; + return (SqlExpression)new LiteralExpression(unquoted); + })); + + private static readonly TokenListParser NumberLiteral = + Token.EqualTo(CosmosSqlToken.NumberLiteral) + .Select(t => + { + var raw = t.Span.ToStringValue(); + if (long.TryParse(raw, out var longVal)) + { + return (SqlExpression)new LiteralExpression(longVal); + } + + return (SqlExpression)new LiteralExpression(double.Parse(raw, System.Globalization.CultureInfo.InvariantCulture)); + }); + + private static readonly TokenListParser TrueLiteral = + Token.EqualTo(CosmosSqlToken.True).Select(_ => (SqlExpression)new LiteralExpression(true)); + + private static readonly TokenListParser FalseLiteral = + Token.EqualTo(CosmosSqlToken.False).Select(_ => (SqlExpression)new LiteralExpression(false)); + + private static readonly TokenListParser NullLiteral = + Token.EqualTo(CosmosSqlToken.Null).Select(_ => (SqlExpression)new LiteralExpression(null)); + + private static readonly TokenListParser UndefinedLiteral = + Token.EqualTo(CosmosSqlToken.Undefined).Select(_ => (SqlExpression)new UndefinedLiteralExpression()); + + private static readonly TokenListParser ParameterExpr = + Token.EqualTo(CosmosSqlToken.Parameter) + .Select(t => (SqlExpression)new ParameterExpression(t.Span.ToStringValue())); + + // Function call: FUNC_NAME(args...) + // Supports * as a function argument (e.g. COUNT(*)) + private static readonly TokenListParser FunctionCall = + from name in AnyIdentifierOrKeyword + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from args in Token.EqualTo(CosmosSqlToken.Star).Select(_ => (SqlExpression)new IdentifierExpression("*")) + .Or(Superpower.Parse.Ref(() => Expr)) + .ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (SqlExpression)new FunctionCallExpression(name.ToUpperInvariant(), args); + + // Dotted function call: namespace.FUNC_NAME(args...) — supports udf.xxx() + // UDF names preserve original casing (Cosmos DB UDFs are case-sensitive); + // built-in dotted functions (e.g. ST_DISTANCE) are uppercased. + private static readonly TokenListParser DottedFunctionCall = + from first in AnyIdentifierOrKeyword + from dot in Token.EqualTo(CosmosSqlToken.Dot) + from second in AnyIdentifierOrKeyword + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from args in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (SqlExpression)new FunctionCallExpression( + first.Equals("udf", StringComparison.OrdinalIgnoreCase) + ? "UDF." + second // preserve UDF name casing + : (first + "." + second).ToUpperInvariant(), args); + + // EXISTS( subquery-text ) + private static readonly TokenListParser ExistsExpr = + from kw in Token.EqualTo(CosmosSqlToken.Exists) + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from inner in CaptureBalancedParens() + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (SqlExpression)new ExistsExpression(inner); + + // Subquery expression: (SELECT ...) — a full SELECT statement inside parentheses + private static readonly TokenListParser SubqueryParens = + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from subquery in Superpower.Parse.Ref(() => QueryParser) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (SqlExpression)new SubqueryExpression(subquery); + + // Parenthesized expression (try subquery first, fall back to regular expression) + private static readonly TokenListParser Parens = + SubqueryParens.Try() + .Or( + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from expr in Superpower.Parse.Ref(() => Expr) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select expr); + + // Object literal: { key: expr, key: expr, ... } + // Keys can be identifiers or double-quoted strings (for SDK-generated queries like {"item": root.value}) + private static readonly TokenListParser ObjectLiteral = + from open in Token.EqualTo(CosmosSqlToken.OpenBrace) + from props in ( + from key in AnyIdentifierOrKeyword + .Or(Token.EqualTo(CosmosSqlToken.DoubleQuotedString).Select(t => t.Span.ToStringValue()[1..^1])) + .Or(Token.EqualTo(CosmosSqlToken.StringLiteral).Select(t => t.Span.ToStringValue()[1..^1])) + from colon in Token.EqualTo(CosmosSqlToken.Colon) + from value in Superpower.Parse.Ref(() => Expr) + select new KeyValuePair(key, value) + ).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseBrace) + select (SqlExpression)new ObjectLiteralExpression(props); + + // Array literal: [ expr, expr, ... ] + private static readonly TokenListParser ArrayLiteral = + from open in Token.EqualTo(CosmosSqlToken.OpenBracket) + from elements in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseBracket) + select (SqlExpression)new ArrayLiteralExpression(elements); + + // Identifier or dotted path + private static readonly TokenListParser IdentExpr = + DottedPathWithIndex.Select(path => (SqlExpression)new IdentifierExpression(path)); + + // ARRAY(subquery): ARRAY keyword followed by a parenthesised SELECT + private static readonly TokenListParser ArraySubqueryCall = + from name in Token.EqualTo(CosmosSqlToken.Array) + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from subquery in Superpower.Parse.Ref(() => QueryParser) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (SqlExpression)new FunctionCallExpression("ARRAY", [new SubqueryExpression(subquery)]); + + // Primary: literal | parameter | function call | exists | parens | object | array | ident + private static readonly TokenListParser Primary = + StringLiteral + .Or(NumberLiteral) + .Or(TrueLiteral) + .Or(FalseLiteral) + .Or(NullLiteral) + .Or(UndefinedLiteral) + .Or(ParameterExpr) + .Or(ExistsExpr.Try()) + .Or(ArraySubqueryCall.Try()) + .Or(DottedFunctionCall.Try()) + .Or(FunctionCall.Try()) + .Or(Parens) + .Or(ObjectLiteral.Try()) + .Or(ArrayLiteral.Try()) + .Or(IdentExpr); + + // ── Unary ── + + private static readonly TokenListParser UnaryExpr = + (from op in Token.EqualTo(CosmosSqlToken.Not) + from operand in Superpower.Parse.Ref(() => UnaryExpr) + select (SqlExpression)new UnaryExpression(UnaryOp.Not, operand)) + .Or( + from op in Token.EqualTo(CosmosSqlToken.Minus) + from operand in Primary + select (SqlExpression)new UnaryExpression(UnaryOp.Negate, operand)) + .Or( + from op in Token.EqualTo(CosmosSqlToken.Tilde) + from operand in Primary + select (SqlExpression)new UnaryExpression(UnaryOp.BitwiseNot, operand)) + .Or(Primary); + + // ── Multiplicative: *, /, % ── + + private static readonly TokenListParser Multiplicative = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Star).Select(_ => BinaryOp.Multiply) + .Or(Token.EqualTo(CosmosSqlToken.Slash).Select(_ => BinaryOp.Divide)) + .Or(Token.EqualTo(CosmosSqlToken.Percent).Select(_ => BinaryOp.Modulo)), + UnaryExpr, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Additive: +, - ── + + private static readonly TokenListParser Additive = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Plus).Select(_ => BinaryOp.Add) + .Or(Token.EqualTo(CosmosSqlToken.Minus).Select(_ => BinaryOp.Subtract)), + Multiplicative, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Bitwise AND: & ── + + private static readonly TokenListParser BitwiseAndExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Ampersand).Select(_ => BinaryOp.BitwiseAnd), + Additive, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Bitwise XOR: ^ ── + + private static readonly TokenListParser BitwiseXorExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Caret).Select(_ => BinaryOp.BitwiseXor), + BitwiseAndExpr, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Bitwise OR: | ── + + private static readonly TokenListParser BitwiseOrExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Pipe).Select(_ => BinaryOp.BitwiseOr), + BitwiseXorExpr, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── String concat: || ── + + private static readonly TokenListParser StringConcatExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.DoublePipe).Select(_ => BinaryOp.StringConcat), + BitwiseOrExpr, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Comparison: =, !=, <, >, <=, >=, LIKE ── + + private static readonly TokenListParser CompOp = + Token.EqualTo(CosmosSqlToken.Equals).Select(_ => BinaryOp.Equal) + .Or(Token.EqualTo(CosmosSqlToken.NotEquals).Select(_ => BinaryOp.NotEqual)) + .Or(Token.EqualTo(CosmosSqlToken.LessThanOrEqual).Select(_ => BinaryOp.LessThanOrEqual)) + .Or(Token.EqualTo(CosmosSqlToken.GreaterThanOrEqual).Select(_ => BinaryOp.GreaterThanOrEqual)) + .Or(Token.EqualTo(CosmosSqlToken.LessThan).Select(_ => BinaryOp.LessThan)) + .Or(Token.EqualTo(CosmosSqlToken.GreaterThan).Select(_ => BinaryOp.GreaterThan)) + .Or(Token.EqualTo(CosmosSqlToken.Like).Select(_ => BinaryOp.Like)); + + private static readonly TokenListParser Comparison = + from left in StringConcatExpr + from rest in ( + // BETWEEN low AND high + (from kw in Token.EqualTo(CosmosSqlToken.Between) + from low in StringConcatExpr + from and in Token.EqualTo(CosmosSqlToken.And) + from high in StringConcatExpr + select (Func)(l => new BetweenExpression(l, low, high))) + // NOT BETWEEN low AND high + .Or( + (from not in Token.EqualTo(CosmosSqlToken.Not) + from kw in Token.EqualTo(CosmosSqlToken.Between) + from low in StringConcatExpr + from and in Token.EqualTo(CosmosSqlToken.And) + from high in StringConcatExpr + select (Func)(l => new UnaryExpression(UnaryOp.Not, new BetweenExpression(l, low, high)))) + .Try()) + // IN (val, val, ...) + .Or( + from kw in Token.EqualTo(CosmosSqlToken.In) + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from vals in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (Func)(l => new InExpression(l, vals))) + // NOT IN (val, val, ...) + .Or( + (from not in Token.EqualTo(CosmosSqlToken.Not) + from kw in Token.EqualTo(CosmosSqlToken.In) + from open in Token.EqualTo(CosmosSqlToken.OpenParen) + from vals in Superpower.Parse.Ref(() => Expr).ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from close in Token.EqualTo(CosmosSqlToken.CloseParen) + select (Func)(l => new UnaryExpression(UnaryOp.Not, new InExpression(l, vals)))) + .Try()) + // LIKE pattern ESCAPE char + .Or( + (from kw in Token.EqualTo(CosmosSqlToken.Like) + from pattern in StringConcatExpr + from esc in Token.EqualTo(CosmosSqlToken.Escape) + from escChar in Token.EqualTo(CosmosSqlToken.StringLiteral) + select (Func)(l => new LikeExpression(l, pattern, escChar.Span.ToStringValue()[1..^1]))) + .Try()) + // NOT LIKE pattern ESCAPE char + .Or( + (from not in Token.EqualTo(CosmosSqlToken.Not) + from kw in Token.EqualTo(CosmosSqlToken.Like) + from pattern in StringConcatExpr + from esc in Token.EqualTo(CosmosSqlToken.Escape) + from escChar in Token.EqualTo(CosmosSqlToken.StringLiteral) + select (Func)(l => new UnaryExpression(UnaryOp.Not, new LikeExpression(l, pattern, escChar.Span.ToStringValue()[1..^1])))) + .Try()) + // NOT LIKE pattern (without ESCAPE) + .Or( + (from not in Token.EqualTo(CosmosSqlToken.Not) + from kw in Token.EqualTo(CosmosSqlToken.Like) + from pattern in StringConcatExpr + select (Func)(l => new UnaryExpression(UnaryOp.Not, new BinaryExpression(l, BinaryOp.Like, pattern)))) + .Try()) + // IS NOT NULL (must come before IS NULL to avoid consuming IS and failing on NOT) + .Or( + (from is_ in Token.EqualTo(CosmosSqlToken.Is) + from not_ in Token.EqualTo(CosmosSqlToken.Not) + from null_ in Token.EqualTo(CosmosSqlToken.Null) + select (Func)(l => new BinaryExpression(l, BinaryOp.NotEqual, new LiteralExpression(null)))) + .Try()) + // IS NULL + .Or( + from is_ in Token.EqualTo(CosmosSqlToken.Is) + from null_ in Token.EqualTo(CosmosSqlToken.Null) + select (Func)(l => new BinaryExpression(l, BinaryOp.Equal, new LiteralExpression(null)))) + // op right + .Or( + from op in CompOp + from right in StringConcatExpr + select (Func)(l => new BinaryExpression(l, op, right))) + ).OptionalOrDefault(null) + select rest != null ? rest(left) : left; + + // ── Logical AND ── + + private static readonly TokenListParser AndExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.And).Select(_ => BinaryOp.And), + Comparison, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Logical OR ── + + private static readonly TokenListParser OrExpr = + Superpower.Parse.Chain( + Token.EqualTo(CosmosSqlToken.Or).Select(_ => BinaryOp.Or), + AndExpr, + (op, left, right) => new BinaryExpression(left, op, right)); + + // ── Null coalesce: ?? ── + + private static readonly TokenListParser CoalesceExpr = + from left in OrExpr + from rest in ( + from op in Token.EqualTo(CosmosSqlToken.QuestionQuestion) + from right in Superpower.Parse.Ref(() => CoalesceExpr) + select right + ).OptionalOrDefault(null) + select rest != null ? new CoalesceExpression(left, rest) : left; + + // ── Ternary: cond ? then : else ── + + private static readonly TokenListParser TernaryExpr = + from cond in CoalesceExpr + from rest in ( + from q in Token.EqualTo(CosmosSqlToken.Question) + from ifTrue in Superpower.Parse.Ref(() => Expr) + from colon in Token.EqualTo(CosmosSqlToken.Colon) + from ifFalse in Superpower.Parse.Ref(() => Expr) + select new { ifTrue, ifFalse } + ).OptionalOrDefault(null) + select rest != null ? (SqlExpression)new TernaryExpression(cond, rest.ifTrue, rest.ifFalse) : cond; + + // ── Top-level expression ── + + private static readonly TokenListParser Expr = TernaryExpr; + + // ────────────────────────────────────────────── + // SELECT field parsing + // ────────────────────────────────────────────── + + private static readonly TokenListParser StarField = + Token.EqualTo(CosmosSqlToken.Star).Select(_ => new SelectField("*", null)); + + // Handle "c.*" (alias DOT STAR) as equivalent to "*" + private static readonly TokenListParser AliasDotStarField = + from ident in AnyIdentifierOrKeyword + from dot in Token.EqualTo(CosmosSqlToken.Dot) + from star in Token.EqualTo(CosmosSqlToken.Star) + select new SelectField("*", null); + + private static readonly TokenListParser ExpressionField = + from expr in Expr + from alias in ( + from as_ in Token.EqualTo(CosmosSqlToken.As) + from name in AnyIdentifierOrKeyword + select name + ).OptionalOrDefault(null) + select new SelectField(ExprToString(expr), alias, expr); + + private static readonly TokenListParser SingleSelectField = + StarField.Try().Or(AliasDotStarField.Try()).Or(ExpressionField); + + // ────────────────────────────────────────────── + // JOIN clause parsing + // ────────────────────────────────────────────── + + private static readonly TokenListParser JoinParser = + from kw in Token.EqualTo(CosmosSqlToken.Join) + from alias in AnyIdentifierOrKeyword + from in_ in Token.EqualTo(CosmosSqlToken.In) + from source in DottedPathWithIndex + select ParseJoinSource(alias, source); + + // ────────────────────────────────────────────── + // ORDER BY parsing + // ────────────────────────────────────────────── + + private static readonly TokenListParser OrderByFieldParser = + (from expr in Superpower.Parse.Ref(() => Expr) + from dir in Token.EqualTo(CosmosSqlToken.Asc).Select(_ => true) + .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(_ => false)) + .OptionalOrDefault(true) + select expr is IdentifierExpression ident + ? new OrderByField(ident.Name, dir) + : new OrderByField(null, dir, expr)).Try() + .Or( + from field in DottedPathWithIndex + from dir in Token.EqualTo(CosmosSqlToken.Asc).Select(_ => true) + .Or(Token.EqualTo(CosmosSqlToken.Desc).Select(_ => false)) + .OptionalOrDefault(true) + select new OrderByField(field, dir)); + + // ────────────────────────────────────────────── + // Full query parsing + // ────────────────────────────────────────────── + + private static readonly TokenListParser QueryParser = + from select_ in Token.EqualTo(CosmosSqlToken.Select) + from distinct in Token.EqualTo(CosmosSqlToken.Distinct).OptionalOrDefault() + from top in ( + from topKw in Token.EqualTo(CosmosSqlToken.Top) + from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) + select (int?)n + ).OptionalOrDefault(null) + from value_ in Token.EqualTo(CosmosSqlToken.Value).OptionalOrDefault() + from fields in SingleSelectField.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + from fromKw in Token.EqualTo(CosmosSqlToken.From) + from fromFirstIdent in AnyIdentifierOrKeyword + from fromClause in ( + // FROM alias IN source.path + from in_ in Token.EqualTo(CosmosSqlToken.In) + from source in DottedPath + select (Alias: fromFirstIdent, Source: (string)source) + ).Try().Or( + // FROM source AS alias + from as_ in Token.EqualTo(CosmosSqlToken.As) + from alias in AnyIdentifierOrKeyword + select (Alias: alias, Source: (string)null) + ).Try().Or( + // FROM source alias (implicit alias without AS keyword — only plain identifiers) + from alias in Token.EqualTo(CosmosSqlToken.Identifier).Select(TokenSpanToString) + select (Alias: alias, Source: (string)null) + ).Try().OptionalOrDefault((Alias: fromFirstIdent, Source: (string)null)) + from joins in JoinParser.Many() + from where_ in ( + from whereKw in Token.EqualTo(CosmosSqlToken.Where) + from expr in Expr + select expr + ).OptionalOrDefault(null) + from groupBy in ( + from groupKw in Token.EqualTo(CosmosSqlToken.Group) + from byKw in Token.EqualTo(CosmosSqlToken.By) + from groupFields in Expr.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + select groupFields + ).OptionalOrDefault(null) + from having in ( + from havingKw in Token.EqualTo(CosmosSqlToken.Having) + from expr in Expr + select expr + ).OptionalOrDefault(null) + from orderByResult in ( + from orderKw in Token.EqualTo(CosmosSqlToken.Order) + from byKw in Token.EqualTo(CosmosSqlToken.By) + from result in ( + from rankKw in Token.EqualTo(CosmosSqlToken.Identifier) + .Where(t => string.Equals(t.Span.ToStringValue(), "RANK", StringComparison.OrdinalIgnoreCase)) + from expr in Expr + select (Fields: (OrderByField[])null, RankExpr: (SqlExpression)expr) + ).Try().Or( + from orderFields in OrderByFieldParser.ManyDelimitedBy(Token.EqualTo(CosmosSqlToken.Comma)) + select (Fields: orderFields, RankExpr: (SqlExpression)null) + ) + select result + ).OptionalOrDefault(default) + from offset in ( + from offsetKw in Token.EqualTo(CosmosSqlToken.Offset) + from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) + select (int?)n + ).OptionalOrDefault(null) + from limit in ( + from limitKw in Token.EqualTo(CosmosSqlToken.Limit) + from n in Token.EqualTo(CosmosSqlToken.NumberLiteral).Select(t => int.Parse(t.Span.ToStringValue())) + select (int?)n + ).OptionalOrDefault(null) + select BuildQuery( + distinct.HasValue, top, value_.HasValue, fields, + fromClause.Alias, fromClause.Source, joins, where_, + groupBy?.Select(ExprToString).ToArray(), + groupBy?.ToArray(), + having, orderByResult.Fields, offset, limit, orderByResult.RankExpr); + + // ────────────────────────────────────────────── + // Public API + // ────────────────────────────────────────────── + + private const int ParseCacheMaxSize = 5000; + private static readonly ConcurrentDictionary ParseCache = new(); + + public static CosmosSqlQuery Parse(string sql) + { + if (ParseCache.TryGetValue(sql, out var cached)) + { + return cached; + } + + CosmosSqlQuery parsed; + try + { + var tokens = CosmosSqlTokenizer.Tokenize(sql); + parsed = QueryParser.Parse(tokens); + } + catch (Exception ex) + { + throw new NotSupportedException($"Failed to parse Cosmos SQL query: {sql}", ex); + } + + if (ParseCache.Count < ParseCacheMaxSize) + { + ParseCache.TryAdd(sql, parsed); + } + + return parsed; + } + + public static bool TryParse(string sql, out CosmosSqlQuery result) + { + try + { + result = Parse(sql); + return true; + } + catch + { + result = null; + return false; + } + } + + // Backward-compatible: convert SqlExpression tree to WhereExpression tree + public static WhereExpression ToWhereExpression(SqlExpression expr) + { + if (expr is null) + { + return null; + } + + switch (expr) + { + case BinaryExpression bin when bin.Operator == BinaryOp.And: + return new AndCondition(ToWhereExpression(bin.Left), ToWhereExpression(bin.Right)); + + case BinaryExpression bin when bin.Operator == BinaryOp.Or: + return new OrCondition(ToWhereExpression(bin.Left), ToWhereExpression(bin.Right)); + + case UnaryExpression { Operator: UnaryOp.Not } unary: + return new NotCondition(ToWhereExpression(unary.Operand)); + + case ExistsExpression exists: + return new ExistsCondition(exists.RawSubquery); + + case FunctionCallExpression func: + if (func.FunctionName.StartsWith("UDF.", StringComparison.OrdinalIgnoreCase) || + func.FunctionName.StartsWith("ST_", StringComparison.OrdinalIgnoreCase)) + { + return new SqlExpressionCondition(func); + } + + var hasComplexArgs = func.Arguments.Any(a => a is ObjectLiteralExpression or ArrayLiteralExpression or FunctionCallExpression); + if (hasComplexArgs) + { + return new SqlExpressionCondition(func); + } + + if (LegacyFunctionNames.Contains(func.FunctionName)) + { + var args = func.Arguments.Select(ExprToString).ToArray(); + return new FunctionCondition(func.FunctionName, args); + } + + return new SqlExpressionCondition(func); + + case BinaryExpression bin when IsComparisonOp(bin.Operator): + if (ContainsFunctionCall(bin.Left) || ContainsFunctionCall(bin.Right) || + ContainsArithmetic(bin.Left) || ContainsArithmetic(bin.Right) || + ContainsComplexExpression(bin.Left) || ContainsComplexExpression(bin.Right)) + { + return new SqlExpressionCondition(bin); + } + + var compOp = bin.Operator switch + { + BinaryOp.Equal => ComparisonOp.Equal, + BinaryOp.NotEqual => ComparisonOp.NotEqual, + BinaryOp.LessThan => ComparisonOp.LessThan, + BinaryOp.GreaterThan => ComparisonOp.GreaterThan, + BinaryOp.LessThanOrEqual => ComparisonOp.LessThanOrEqual, + BinaryOp.GreaterThanOrEqual => ComparisonOp.GreaterThanOrEqual, + BinaryOp.Like => ComparisonOp.Like, + _ => ComparisonOp.Equal, + }; + return new ComparisonCondition(ExprToString(bin.Left), compOp, ExprToString(bin.Right)); + + default: + return new SqlExpressionCondition(expr); + } + } + + /// + /// Removes SDK-injected IS_DEFINED(alias) and literal true nodes from + /// AND chains in a WHERE expression AST, returning the simplified user condition. + /// Only strips IS_DEFINED() calls whose argument is exactly the FROM alias + /// (the SDK injects IS_DEFINED(root) for ORDER BY, never IS_DEFINED(root.field)), + /// preserving any legitimate IS_DEFINED() from user code. + /// Returns null when the entire expression reduces to nothing. + /// + public static SqlExpression SimplifySdkWhereExpression( + SqlExpression expr, string fromAlias = null) + { + return SimplifyCore(expr, fromAlias); + } + + private static SqlExpression SimplifyCore(SqlExpression expr, string fromAlias) + { + if (expr is null) + { + return null; + } + + if (expr is LiteralExpression { Value: true }) + { + return null; + } + + if (expr is FunctionCallExpression func && + string.Equals(func.FunctionName, "IS_DEFINED", StringComparison.OrdinalIgnoreCase) && + func.Arguments.Length == 1) + { + var argStr = ExprToString(func.Arguments[0]); + // Only strip when: no alias specified (backward-compat), or arg IS the FROM alias exactly. + // The SDK injects IS_DEFINED(root) — never IS_DEFINED(root.field) — so this + // preserves any user-written IS_DEFINED on specific field paths. + if (fromAlias is null || string.Equals(argStr, fromAlias, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + if (expr is BinaryExpression { Operator: BinaryOp.And } and) + { + var left = SimplifyCore(and.Left, fromAlias); + var right = SimplifyCore(and.Right, fromAlias); + if (left is null && right is null) + { + return null; + } + + if (left is null) + { + return right; + } + + if (right is null) + { + return left; + } + + return new BinaryExpression(left, BinaryOp.And, right); + } + + return expr; + } + + /// + /// Rebuilds a Superpower-parsed into clean SQL that + /// can execute. Strips SDK-injected WHERE clauses + /// (IS_DEFINED(alias), literal true), normalises bracket notation + /// (root["name"]) to dot notation (root.name) via the AST, and + /// reconstructs SELECT, WHERE, ORDER BY, TOP, OFFSET/LIMIT, DISTINCT, and GROUP BY + /// clauses from the parsed structure. + /// + /// For ORDER BY queries where the SDK rewrites the SELECT to include orderByItems + /// and payload, this emits SELECT VALUE alias to return full documents. + /// For all other queries, the original SELECT expressions are emitted from the AST. + /// + /// + public static string SimplifySdkQuery(CosmosSqlQuery parsed) + { + var fromAlias = parsed.FromAlias; + + var isOrderByQuery = parsed.SelectFields.Any(field => + string.Equals(field.Alias, "orderByItems", StringComparison.OrdinalIgnoreCase)); + + // SELECT clause + var sb = new System.Text.StringBuilder("SELECT "); + if (parsed.IsDistinct) + { + sb.Append("DISTINCT "); + } + + if (parsed.TopCount.HasValue) + { + sb.Append($"TOP {parsed.TopCount.Value} "); + } + + if (isOrderByQuery) + { + sb.Append($"VALUE {fromAlias}"); + } + else if (parsed.IsValueSelect) + { + var selectExprs = parsed.SelectFields + .Select(field => ExprToString(field.SqlExpr ?? new IdentifierExpression(field.Expression))) + .ToArray(); + sb.Append("VALUE "); + sb.Append(string.Join(", ", selectExprs)); + } + else if (parsed.IsSelectAll) + { + sb.Append('*'); + } + else + { + var selectParts = parsed.SelectFields.Select(field => + { + var expr = field.SqlExpr is not null ? ExprToString(field.SqlExpr) : field.Expression; + return field.Alias is not null ? $"{expr} AS {field.Alias}" : expr; + }); + sb.Append(string.Join(", ", selectParts)); + } + + // FROM + if (parsed.FromSource is not null) + { + sb.Append($" FROM {fromAlias} IN {parsed.FromSource}"); + } + else + { + sb.Append($" FROM {fromAlias}"); + } + + // JOINs + if (parsed.Joins is { Length: > 0 }) + { + foreach (var join in parsed.Joins) + { + sb.Append($" JOIN {join.Alias} IN {join.SourceAlias}.{join.ArrayField}"); + } + } + + // WHERE — strip SDK-injected nodes + var simplifiedWhere = SimplifySdkWhereExpression(parsed.WhereExpr, fromAlias); + if (simplifiedWhere is not null) + { + sb.Append($" WHERE {ExprToString(simplifiedWhere)}"); + } + + // GROUP BY + if (parsed.GroupByFields is { Length: > 0 }) + { + sb.Append($" GROUP BY {string.Join(", ", parsed.GroupByFields)}"); + } + + // HAVING + if (parsed.HavingExpr is not null) + { + sb.Append($" HAVING {ExprToString(parsed.HavingExpr)}"); + } + + // ORDER BY + if (parsed.OrderByFields is { Length: > 0 }) + { + var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => + $"{field.Field ?? ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); + sb.Append($" ORDER BY {orderByStr}"); + } + else if (parsed.RankExpression is not null) + { + sb.Append($" ORDER BY RANK {ExprToString(parsed.RankExpression)}"); + } + + // OFFSET / LIMIT + if (parsed.Offset.HasValue) + { + sb.Append($" OFFSET {parsed.Offset.Value}"); + } + + if (parsed.Limit.HasValue) + { + sb.Append($" LIMIT {parsed.Limit.Value}"); + } + + return sb.ToString(); + } + + // ────────────────────────────────────────────── + // Internal helpers + // ────────────────────────────────────────────── + + internal static WhereExpression ParseWhereExpression(string expression) + { + var wrappedSql = $"SELECT * FROM c WHERE {expression}"; + var query = Parse(wrappedSql); + return query.Where; + } + + private static bool IsComparisonOp(BinaryOp op) => + op is BinaryOp.Equal or BinaryOp.NotEqual or BinaryOp.LessThan or BinaryOp.GreaterThan + or BinaryOp.LessThanOrEqual or BinaryOp.GreaterThanOrEqual or BinaryOp.Like; + + private static bool ContainsFunctionCall(SqlExpression expr) => + expr switch + { + FunctionCallExpression => true, + BinaryExpression bin => ContainsFunctionCall(bin.Left) || ContainsFunctionCall(bin.Right), + UnaryExpression unary => ContainsFunctionCall(unary.Operand), + _ => false + }; + + private static bool ContainsArithmetic(SqlExpression expr) => + expr switch + { + BinaryExpression bin when !IsComparisonOp(bin.Operator) => true, + BinaryExpression bin => ContainsArithmetic(bin.Left) || ContainsArithmetic(bin.Right), + UnaryExpression unary => ContainsArithmetic(unary.Operand), + _ => false + }; + + /// + /// Returns true when the expression contains a subquery, coalesce, or ternary — + /// any of which require full expression evaluation rather than string-based ResolveValue. + /// + private static bool ContainsComplexExpression(SqlExpression expr) => + expr switch + { + SubqueryExpression => true, + CoalesceExpression => true, + TernaryExpression => true, + BinaryExpression bin => ContainsComplexExpression(bin.Left) || ContainsComplexExpression(bin.Right), + UnaryExpression unary => ContainsComplexExpression(unary.Operand), + _ => false + }; + + public static string ExprToString(SqlExpression expr) + { + return expr switch + { + LiteralExpression { Value: null } => "null", + LiteralExpression { Value: string s } => $"'{s}'", + LiteralExpression { Value: bool b } => b ? "true" : "false", + LiteralExpression lit => lit.Value?.ToString() ?? "null", + UndefinedLiteralExpression => "undefined", + IdentifierExpression ident => ident.Name, + ParameterExpression param => param.Name, + PropertyAccessExpression prop => $"{ExprToString(prop.Object)}.{prop.Property}", + IndexAccessExpression idx => $"{ExprToString(idx.Object)}[{ExprToString(idx.Index)}]", + FunctionCallExpression func => $"{func.FunctionName}({string.Join(", ", func.Arguments.Select(ExprToString))})", + BinaryExpression { Operator: BinaryOp.And or BinaryOp.Or } bin => + $"({WrapIfLowPrecedence(bin.Left)} {BinaryOpToString(bin.Operator)} {WrapIfLowPrecedence(bin.Right)})", + BinaryExpression bin => $"{WrapIfLowPrecedence(bin.Left)} {BinaryOpToString(bin.Operator)} {WrapIfLowPrecedence(bin.Right)}", + UnaryExpression unary => $"{UnaryOpToString(unary.Operator)} {WrapIfLowPrecedence(unary.Operand)}", + BetweenExpression betw => $"{WrapIfLowPrecedence(betw.Value)} BETWEEN {ExprToString(betw.Low)} AND {ExprToString(betw.High)}", + InExpression inExpr => $"{WrapIfLowPrecedence(inExpr.Value)} IN ({string.Join(", ", inExpr.List.Select(ExprToString))})", + LikeExpression like => like.EscapeChar is not null + ? $"{WrapIfLowPrecedence(like.Value)} LIKE {ExprToString(like.Pattern)} ESCAPE '{like.EscapeChar}'" + : $"{WrapIfLowPrecedence(like.Value)} LIKE {ExprToString(like.Pattern)}", + ExistsExpression exists => $"EXISTS({exists.RawSubquery})", + SubqueryExpression sub => $"({SubqueryToString(sub.Subquery)})", + TernaryExpression tern => $"{ExprToString(tern.Condition)} ? {ExprToString(tern.IfTrue)} : {ExprToString(tern.IfFalse)}", + CoalesceExpression coal => $"{ExprToString(coal.Left)} ?? {ExprToString(coal.Right)}", + ObjectLiteralExpression obj => "{" + string.Join(", ", obj.Properties.Select(p => $"{p.Key}: {ExprToString(p.Value)}")) + "}", + ArrayLiteralExpression arr => "[" + string.Join(", ", arr.Elements.Select(ExprToString)) + "]", + _ => expr.ToString(), + }; + } + + /// + /// Wraps the expression in parentheses if it has lower precedence than binary/unary operators + /// (i.e., ternary or coalesce expressions). This ensures that re-parsing the serialized string + /// produces the same AST. + /// + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/ternary-coalesce-operators + // Ternary (?) and Coalesce (??) have the lowest operator precedence in Cosmos DB SQL. + private static string WrapIfLowPrecedence(SqlExpression expr) => + expr is TernaryExpression or CoalesceExpression + ? $"({ExprToString(expr)})" + : ExprToString(expr); + + private static string SubqueryToString(CosmosSqlQuery sub) + { + var sb = new System.Text.StringBuilder("SELECT "); + if (sub.IsDistinct) + { + sb.Append("DISTINCT "); + } + + if (sub.TopCount.HasValue) + { + sb.Append($"TOP {sub.TopCount.Value} "); + } + + if (sub.IsValueSelect) + { + sb.Append("VALUE "); + } + + if (sub.IsSelectAll) + { + sb.Append('*'); + } + else + { + var selectParts = sub.SelectFields.Select(field => + { + var expr = field.SqlExpr is not null ? ExprToString(field.SqlExpr) : field.Expression; + return field.Alias is not null ? $"{expr} AS {field.Alias}" : expr; + }); + sb.Append(string.Join(", ", selectParts)); + } + + if (sub.FromSource is not null) + { + sb.Append($" FROM {sub.FromAlias} IN {sub.FromSource}"); + } + else + { + sb.Append($" FROM {sub.FromAlias}"); + } + + if (sub.Joins is { Length: > 0 }) + { + foreach (var join in sub.Joins) + { + sb.Append($" JOIN {join.Alias} IN {join.SourceAlias}.{join.ArrayField}"); + } + } + + if (sub.WhereExpr is not null) + { + sb.Append($" WHERE {ExprToString(sub.WhereExpr)}"); + } + + if (sub.GroupByFields is { Length: > 0 }) + { + sb.Append($" GROUP BY {string.Join(", ", sub.GroupByFields)}"); + } + + if (sub.HavingExpr is not null) + { + sb.Append($" HAVING {ExprToString(sub.HavingExpr)}"); + } + + if (sub.OrderByFields is { Length: > 0 }) + { + var orderByStr = string.Join(", ", sub.OrderByFields.Select(field => + $"{field.Field} {(field.Ascending ? "ASC" : "DESC")}")); + sb.Append($" ORDER BY {orderByStr}"); + } + + if (sub.Offset.HasValue) + { + sb.Append($" OFFSET {sub.Offset.Value}"); + } + + if (sub.Limit.HasValue) + { + sb.Append($" LIMIT {sub.Limit.Value}"); + } + + return sb.ToString(); + } + + private static string BinaryOpToString(BinaryOp op) => op switch + { + BinaryOp.Equal => "=", + BinaryOp.NotEqual => "!=", + BinaryOp.LessThan => "<", + BinaryOp.GreaterThan => ">", + BinaryOp.LessThanOrEqual => "<=", + BinaryOp.GreaterThanOrEqual => ">=", + BinaryOp.And => "AND", + BinaryOp.Or => "OR", + BinaryOp.Add => "+", + BinaryOp.Subtract => "-", + BinaryOp.Multiply => "*", + BinaryOp.Divide => "/", + BinaryOp.Modulo => "%", + BinaryOp.Like => "LIKE", + BinaryOp.StringConcat => "||", + BinaryOp.BitwiseAnd => "&", + BinaryOp.BitwiseOr => "|", + BinaryOp.BitwiseXor => "^", + _ => op.ToString(), + }; + + private static string UnaryOpToString(UnaryOp op) => op switch + { + UnaryOp.Not => "NOT", + UnaryOp.Negate => "-", + UnaryOp.BitwiseNot => "~", + _ => op.ToString(), + }; + + private static JoinClause ParseJoinSource(string alias, string source) + { + var dotIdx = source.IndexOf('.'); + if (dotIdx < 0) + { + return new JoinClause(alias, source, source); + } + + return new JoinClause(alias, source[..dotIdx], source[(dotIdx + 1)..]); + } + + private static CosmosSqlQuery BuildQuery( + bool isDistinct, int? top, bool isValue, + SelectField[] fields, string fromAlias, string fromSource, + JoinClause[] joins, SqlExpression whereExpr, + string[] groupBy, SqlExpression[] groupByExpressions, SqlExpression havingExpr, + OrderByField[] orderByFields, + int? offset, int? limit, SqlExpression rankExpr = null) + { + var isSelectAll = fields.Length == 1 && fields[0].Expression == "*"; + var where = whereExpr != null ? ToWhereExpression(whereExpr) : null; + // HAVING always uses SqlExpressionCondition to preserve aggregate function expressions + // (ToWhereExpression would decompose AND/OR into AndCondition/OrCondition with string-based + // comparison operands, losing the ability to evaluate aggregate functions like COUNT/SUM). + var having = havingExpr != null ? new SqlExpressionCondition(havingExpr) : null; + var rawHavingExpr = havingExpr; + + // Backward compat: first join, first order by + var firstJoin = joins.Length > 0 ? joins[0] : null; + OrderByClause legacyOrderBy = null; + if (orderByFields is { Length: > 0 }) + { + legacyOrderBy = new OrderByClause(orderByFields[0].Field, orderByFields[0].Ascending); + } + + return new CosmosSqlQuery( + SelectFields: fields, + IsSelectAll: isSelectAll, + TopCount: top, + FromAlias: fromAlias, + Join: firstJoin, + Where: where, + Offset: offset, + Limit: limit, + OrderBy: legacyOrderBy, + IsDistinct: isDistinct, + IsValueSelect: isValue, + Joins: joins, + OrderByFields: orderByFields, + GroupByFields: groupBy, + GroupByExpressions: groupByExpressions, + Having: having, + WhereExpr: whereExpr, + HavingExpr: rawHavingExpr, + FromSource: fromSource, + RankExpression: rankExpr); + } + + // Captures tokens inside balanced parentheses as a raw string + private static TokenListParser CaptureBalancedParens() + { + return input => + { + var depth = 0; + var startPos = input.Position; + var current = input; + var parts = new List(); + + while (!current.IsAtEnd) + { + var token = current.ConsumeToken(); + if (!token.HasValue) + { + break; + } + + if (token.Value.Kind == CosmosSqlToken.OpenParen) + { + depth++; + parts.Add("("); + current = token.Remainder; + } + else if (token.Value.Kind == CosmosSqlToken.CloseParen) + { + if (depth == 0) + { + return TokenListParserResult.Value(string.Join(" ", parts), input, current); + } + depth--; + parts.Add(")"); + current = token.Remainder; + } + else + { + parts.Add(token.Value.Span.ToStringValue()); + current = token.Remainder; + } + } + + return TokenListParserResult.Value(string.Join(" ", parts), input, current); + }; + } } diff --git a/src/CosmosDB.InMemoryEmulator/DefaultBatchSchemaStrategy.cs b/src/CosmosDB.InMemoryEmulator/DefaultBatchSchemaStrategy.cs index c818bbd..e9d49d8 100644 --- a/src/CosmosDB.InMemoryEmulator/DefaultBatchSchemaStrategy.cs +++ b/src/CosmosDB.InMemoryEmulator/DefaultBatchSchemaStrategy.cs @@ -10,28 +10,28 @@ namespace CosmosDB.InMemoryEmulator; /// public sealed class DefaultBatchSchemaStrategy : IBatchSchemaStrategy { - private readonly Lazy<(bool Available, string? Reason)> _state = new(Probe); + private readonly Lazy<(bool Available, string? Reason)> _state = new(Probe); - /// - public bool IsAvailable => _state.Value.Available; + /// + public bool IsAvailable => _state.Value.Available; - /// - public string? UnavailableReason => _state.Value.Reason; + /// + public string? UnavailableReason => _state.Value.Reason; - private static (bool, string?) Probe() - { - try - { - // Force the BatchSchemas lazy to evaluate — if it throws, schemas are unavailable. - _ = FakeCosmosHandler.BatchSchemasAvailable; - return (true, null); - } - catch (Exception ex) - { - var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; - return (false, - $"HybridRow batch schemas could not be resolved (SDK v{sdkVersion}). " + - $"Batch operations are unavailable. Error: {ex.Message}"); - } - } + private static (bool, string?) Probe() + { + try + { + // Force the BatchSchemas lazy to evaluate — if it throws, schemas are unavailable. + _ = FakeCosmosHandler.BatchSchemasAvailable; + return (true, null); + } + catch (Exception ex) + { + var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; + return (false, + $"HybridRow batch schemas could not be resolved (SDK v{sdkVersion}). " + + $"Batch operations are unavailable. Error: {ex.Message}"); + } + } } diff --git a/src/CosmosDB.InMemoryEmulator/DefaultQueryPlanStrategy.cs b/src/CosmosDB.InMemoryEmulator/DefaultQueryPlanStrategy.cs index a6ed3c6..ec28080 100644 --- a/src/CosmosDB.InMemoryEmulator/DefaultQueryPlanStrategy.cs +++ b/src/CosmosDB.InMemoryEmulator/DefaultQueryPlanStrategy.cs @@ -9,296 +9,313 @@ namespace CosmosDB.InMemoryEmulator; /// public sealed class DefaultQueryPlanStrategy : IQueryPlanStrategy { - /// - public JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid) - { - var queryInfo = new JObject - { - ["distinctType"] = "None", - ["top"] = null, - ["offset"] = null, - ["limit"] = null, - ["orderBy"] = new JArray(), - ["orderByExpressions"] = new JArray(), - ["groupByExpressions"] = new JArray(), - ["groupByAliases"] = new JArray(), - ["aggregates"] = new JArray(), - ["groupByAliasToAggregateType"] = new JObject(), - ["rewrittenQuery"] = "", - ["hasSelectValue"] = false, - ["hasNonStreamingOrderBy"] = false - }; + /// + public JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid) + { + var queryInfo = new JObject + { + ["distinctType"] = "None", + ["top"] = null, + ["offset"] = null, + ["limit"] = null, + ["orderBy"] = new JArray(), + ["orderByExpressions"] = new JArray(), + ["groupByExpressions"] = new JArray(), + ["groupByAliases"] = new JArray(), + ["aggregates"] = new JArray(), + ["groupByAliasToAggregateType"] = new JObject(), + ["rewrittenQuery"] = "", + ["hasSelectValue"] = false, + ["hasNonStreamingOrderBy"] = false + }; - if (parsed is not null) - { - BuildFromParsedQuery(queryInfo, parsed, sqlQuery); - } - else - { - queryInfo["rewrittenQuery"] = sqlQuery; - } + if (parsed is not null) + { + BuildFromParsedQuery(queryInfo, parsed, sqlQuery); + } + else + { + queryInfo["rewrittenQuery"] = sqlQuery; + } - // COUNT(DISTINCT ...) — dCountInfo - AddCountDistinctInfo(queryInfo, sqlQuery); + // COUNT(DISTINCT ...) — dCountInfo + AddCountDistinctInfo(queryInfo, sqlQuery); - return new JObject - { - ["partitionedQueryExecutionInfoVersion"] = FakeCosmosHandler.QueryPlanVersion, - ["queryInfo"] = queryInfo, - ["queryRanges"] = new JArray(new JObject - { - ["min"] = "", - ["max"] = "FF", - ["isMinInclusive"] = true, - ["isMaxInclusive"] = false - }) - }; - } + return new JObject + { + ["partitionedQueryExecutionInfoVersion"] = FakeCosmosHandler.QueryPlanVersion, + ["queryInfo"] = queryInfo, + ["queryRanges"] = new JArray(new JObject + { + ["min"] = "", + ["max"] = "FF", + ["isMinInclusive"] = true, + ["isMaxInclusive"] = false + }) + }; + } - private static void BuildFromParsedQuery(JObject queryInfo, CosmosSqlQuery parsed, string sqlQuery) - { - // ORDER BY - if (parsed.OrderByFields is { Length: > 0 }) - { - var orderByArr = new JArray(); - var orderByExprArr = new JArray(); - foreach (var field in parsed.OrderByFields) - { - orderByArr.Add(field.Ascending ? "Ascending" : "Descending"); - orderByExprArr.Add(field.Field ?? CosmosSqlParser.ExprToString(field.Expression)); - } + private static void BuildFromParsedQuery(JObject queryInfo, CosmosSqlQuery parsed, string sqlQuery) + { + // ORDER BY + if (parsed.OrderByFields is { Length: > 0 }) + { + var orderByArr = new JArray(); + var orderByExprArr = new JArray(); + foreach (var field in parsed.OrderByFields) + { + orderByArr.Add(field.Ascending ? "Ascending" : "Descending"); + orderByExprArr.Add(field.Field ?? CosmosSqlParser.ExprToString(field.Expression)); + } - queryInfo["orderBy"] = orderByArr; - queryInfo["orderByExpressions"] = orderByExprArr; - queryInfo["hasNonStreamingOrderBy"] = true; - } + queryInfo["orderBy"] = orderByArr; + queryInfo["orderByExpressions"] = orderByExprArr; + queryInfo["hasNonStreamingOrderBy"] = true; + } - // TOP - if (parsed.TopCount.HasValue) - { - queryInfo["top"] = parsed.TopCount.Value; - } + // TOP + if (parsed.TopCount.HasValue) + { + queryInfo["top"] = parsed.TopCount.Value; + } - // OFFSET / LIMIT - if (parsed.Offset.HasValue) - { - queryInfo["offset"] = parsed.Offset.Value; - } + // OFFSET / LIMIT + if (parsed.Offset.HasValue) + { + queryInfo["offset"] = parsed.Offset.Value; + } - if (parsed.Limit.HasValue) - { - queryInfo["limit"] = parsed.Limit.Value; - } + if (parsed.Limit.HasValue) + { + queryInfo["limit"] = parsed.Limit.Value; + } - // DISTINCT - if (parsed.IsDistinct) - { - var isOrdered = false; - if (parsed.IsValueSelect && parsed.OrderByFields is { Length: > 0 } && parsed.SelectFields.Length == 1) - { - var selectExpr = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr); - var orderExpr = parsed.OrderByFields[0].Field ?? CosmosSqlParser.ExprToString(parsed.OrderByFields[0].Expression); - isOrdered = string.Equals(selectExpr, orderExpr, StringComparison.OrdinalIgnoreCase); - } - queryInfo["distinctType"] = isOrdered ? "Ordered" : "Unordered"; - } + // DISTINCT + if (parsed.IsDistinct) + { + var isOrdered = false; + if (parsed.IsValueSelect && parsed.OrderByFields is { Length: > 0 } && parsed.SelectFields.Length == 1) + { + var selectExpr = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr); + var orderExpr = parsed.OrderByFields[0].Field ?? CosmosSqlParser.ExprToString(parsed.OrderByFields[0].Expression); + isOrdered = string.Equals(selectExpr, orderExpr, StringComparison.OrdinalIgnoreCase); + } + queryInfo["distinctType"] = isOrdered ? "Ordered" : "Unordered"; + } - // GROUP BY - if (parsed.GroupByFields is { Length: > 0 }) - { - queryInfo["groupByExpressions"] = new JArray(parsed.GroupByFields); - var aliases = new JArray(); - foreach (var sf in parsed.SelectFields) - { - if (sf.Alias is not null) - aliases.Add(sf.Alias); - else if (sf.Expression is not null) - aliases.Add(sf.Expression); - } - queryInfo["groupByAliases"] = aliases; - } + // GROUP BY + if (parsed.GroupByFields is { Length: > 0 }) + { + queryInfo["groupByExpressions"] = new JArray(parsed.GroupByFields); + var aliases = new JArray(); + foreach (var sf in parsed.SelectFields) + { + if (sf.Alias is not null) + aliases.Add(sf.Alias); + else if (sf.Expression is not null) + aliases.Add(sf.Expression); + } + queryInfo["groupByAliases"] = aliases; + } - // Aggregates - var aggregates = new JArray(); - var groupByAliasToAgg = new JObject(); - foreach (var field in parsed.SelectFields) - { - DetectAggregates(field.SqlExpr, aggregates, groupByAliasToAgg, field.Alias); - } + // Aggregates + var aggregates = new JArray(); + var groupByAliasToAgg = new JObject(); + foreach (var field in parsed.SelectFields) + { + DetectAggregates(field.SqlExpr, aggregates, groupByAliasToAgg, field.Alias); + } - if (aggregates.Count > 0) - { - queryInfo["aggregates"] = aggregates; - } + if (aggregates.Count > 0) + { + queryInfo["aggregates"] = aggregates; + } - if (groupByAliasToAgg.Count > 0) - { - queryInfo["groupByAliasToAggregateType"] = groupByAliasToAgg; - } + if (groupByAliasToAgg.Count > 0) + { + queryInfo["groupByAliasToAggregateType"] = groupByAliasToAgg; + } - // SELECT VALUE - if (parsed.IsValueSelect) - { - queryInfo["hasSelectValue"] = true; - } + // SELECT VALUE + if (parsed.IsValueSelect) + { + queryInfo["hasSelectValue"] = true; + } - // Suppress SDK pipeline for GROUP BY, multi-aggregate, and VALUE aggregate - // queries. Also bypass any query referencing COUNTIF — the SDK pipeline - // doesn't recognize COUNTIF and tries to apply its built-in aggregate - // accumulator, which expects a {payload: {alias: {item: value}}} envelope - // the handler only produces for GROUP BY responses. - var isGroupByBypass = parsed.GroupByFields is { Length: > 0 }; - var aggregateFieldCount = parsed.SelectFields.Count(f => ContainsAggregate(f.SqlExpr)); - var isMultiAggregateBypass = !isGroupByBypass && !parsed.IsValueSelect - && (aggregateFieldCount > 1 || aggregates.Count > 1); - var isValueAggregateBypass = !isGroupByBypass && parsed.IsValueSelect && aggregateFieldCount > 0; - var isCountIfBypass = !isGroupByBypass - && parsed.SelectFields.Any(f => ContainsCountIf(f.SqlExpr)); + // Suppress SDK pipeline for GROUP BY, multi-aggregate, and VALUE aggregate + // queries. Also bypass any query referencing COUNTIF — the SDK pipeline + // doesn't recognize COUNTIF and tries to apply its built-in aggregate + // accumulator, which expects a {payload: {alias: {item: value}}} envelope + // the handler only produces for GROUP BY responses. + // Additionally bypass when literal expressions appear alongside aggregates + // (e.g. SELECT 42 AS X, COUNT(1) AS N) — the SDK's AggregateQueryPipelineStage + // doesn't handle non-aggregate expression fields and crashes with + // "Underlying object does not have an 'payload' field". + // Also bypass single-aggregate projection queries (e.g. SELECT SUM(c.x) AS Total FROM c) + // for the same reason — the SDK's AggregateQueryPipelineStage expects a {payload: ...} + // envelope that the emulator doesn't produce for projection-style aggregate queries. + var isGroupByBypass = parsed.GroupByFields is { Length: > 0 }; + var aggregateFieldCount = parsed.SelectFields.Count(f => ContainsAggregate(f.SqlExpr)); + var isMultiAggregateBypass = !isGroupByBypass && !parsed.IsValueSelect + && (aggregateFieldCount > 1 || aggregates.Count > 1); + var isValueAggregateBypass = !isGroupByBypass && parsed.IsValueSelect && aggregateFieldCount > 0; + var isCountIfBypass = !isGroupByBypass + && parsed.SelectFields.Any(f => ContainsCountIf(f.SqlExpr)); + // Bypass when there are non-aggregate expression fields (literals, function calls) + // alongside at least one aggregate — the SDK pipeline can't project these. + var isLiteralWithAggregateBypass = !isGroupByBypass && !parsed.IsValueSelect + && aggregateFieldCount > 0 + && parsed.SelectFields.Any(f => !ContainsAggregate(f.SqlExpr) + && f.SqlExpr is not null and not IdentifierExpression and not PropertyAccessExpression); + // Bypass single-aggregate projection: SELECT SUM(c.x) AS Total FROM c + // The emulator returns results directly without the payload envelope the SDK expects. + var isSingleAggregateProjectionBypass = !isGroupByBypass && !parsed.IsValueSelect + && aggregateFieldCount == 1 && aggregates.Count == 1; - if (isGroupByBypass || isMultiAggregateBypass || isValueAggregateBypass || isCountIfBypass) - { - queryInfo["groupByExpressions"] = new JArray(); - queryInfo["groupByAliases"] = new JArray(); - queryInfo["groupByAliasToAggregateType"] = new JObject(); - queryInfo["aggregates"] = new JArray(); - if (isGroupByBypass) - { - queryInfo["hasNonStreamingOrderBy"] = false; - queryInfo["orderBy"] = new JArray(); - queryInfo["orderByExpressions"] = new JArray(); - } - } + if (isGroupByBypass || isMultiAggregateBypass || isValueAggregateBypass || isCountIfBypass || isLiteralWithAggregateBypass || isSingleAggregateProjectionBypass) + { + queryInfo["groupByExpressions"] = new JArray(); + queryInfo["groupByAliases"] = new JArray(); + queryInfo["groupByAliasToAggregateType"] = new JObject(); + queryInfo["aggregates"] = new JArray(); + if (isGroupByBypass) + { + queryInfo["hasNonStreamingOrderBy"] = false; + queryInfo["orderBy"] = new JArray(); + queryInfo["orderByExpressions"] = new JArray(); + } + } - // Rewritten query - if (parsed.OrderByFields is { Length: > 0 }) - { - queryInfo["rewrittenQuery"] = FakeCosmosHandler.BuildOrderByRewrittenQueryStatic(parsed); - } - else if (parsed.Offset.HasValue || parsed.Limit.HasValue) - { - queryInfo["rewrittenQuery"] = FakeCosmosHandler.StripOffsetLimitStatic(sqlQuery); - } - else - { - queryInfo["rewrittenQuery"] = sqlQuery; - } - } + // Rewritten query + if (parsed.OrderByFields is { Length: > 0 }) + { + queryInfo["rewrittenQuery"] = FakeCosmosHandler.BuildOrderByRewrittenQueryStatic(parsed); + } + else if (parsed.Offset.HasValue || parsed.Limit.HasValue) + { + queryInfo["rewrittenQuery"] = FakeCosmosHandler.StripOffsetLimitStatic(sqlQuery); + } + else + { + queryInfo["rewrittenQuery"] = sqlQuery; + } + } - internal static void DetectAggregates( - SqlExpression? expr, JArray aggregates, JObject groupByAliasToAgg, string? alias) - { - if (expr is FunctionCallExpression func) - { - var name = func.FunctionName.ToUpperInvariant(); - string? aggType = name switch - { - "COUNT" => "Count", - "COUNTIF" => "CountIf", - "SUM" => "Sum", - "MIN" => "Min", - "MAX" => "Max", - "AVG" => "Average", - _ => null - }; + internal static void DetectAggregates( + SqlExpression? expr, JArray aggregates, JObject groupByAliasToAgg, string? alias) + { + if (expr is FunctionCallExpression func) + { + var name = func.FunctionName.ToUpperInvariant(); + string? aggType = name switch + { + "COUNT" => "Count", + "COUNTIF" => "CountIf", + "SUM" => "Sum", + "MIN" => "Min", + "MAX" => "Max", + "AVG" => "Average", + _ => null + }; - if (aggType is not null) - { - if (!aggregates.Any(a => a.ToString() == aggType)) - { - aggregates.Add(aggType); - } + if (aggType is not null) + { + if (!aggregates.Any(a => a.ToString() == aggType)) + { + aggregates.Add(aggType); + } - if (alias is not null) - { - groupByAliasToAgg[alias] = aggType; - } - } - else - { - foreach (var arg in func.Arguments) - { - DetectAggregates(arg, aggregates, groupByAliasToAgg, alias); - } - } - } - else if (expr is BinaryExpression bin) - { - DetectAggregates(bin.Left, aggregates, groupByAliasToAgg, alias); - DetectAggregates(bin.Right, aggregates, groupByAliasToAgg, alias); - } - else if (expr is UnaryExpression unary) - { - DetectAggregates(unary.Operand, aggregates, groupByAliasToAgg, alias); - } - else if (expr is TernaryExpression ternary) - { - DetectAggregates(ternary.Condition, aggregates, groupByAliasToAgg, alias); - DetectAggregates(ternary.IfTrue, aggregates, groupByAliasToAgg, alias); - DetectAggregates(ternary.IfFalse, aggregates, groupByAliasToAgg, alias); - } - else if (expr is CoalesceExpression coalesce) - { - DetectAggregates(coalesce.Left, aggregates, groupByAliasToAgg, alias); - DetectAggregates(coalesce.Right, aggregates, groupByAliasToAgg, alias); - } - } + if (alias is not null) + { + groupByAliasToAgg[alias] = aggType; + } + } + else + { + foreach (var arg in func.Arguments) + { + DetectAggregates(arg, aggregates, groupByAliasToAgg, alias); + } + } + } + else if (expr is BinaryExpression bin) + { + DetectAggregates(bin.Left, aggregates, groupByAliasToAgg, alias); + DetectAggregates(bin.Right, aggregates, groupByAliasToAgg, alias); + } + else if (expr is UnaryExpression unary) + { + DetectAggregates(unary.Operand, aggregates, groupByAliasToAgg, alias); + } + else if (expr is TernaryExpression ternary) + { + DetectAggregates(ternary.Condition, aggregates, groupByAliasToAgg, alias); + DetectAggregates(ternary.IfTrue, aggregates, groupByAliasToAgg, alias); + DetectAggregates(ternary.IfFalse, aggregates, groupByAliasToAgg, alias); + } + else if (expr is CoalesceExpression coalesce) + { + DetectAggregates(coalesce.Left, aggregates, groupByAliasToAgg, alias); + DetectAggregates(coalesce.Right, aggregates, groupByAliasToAgg, alias); + } + } - internal static bool ContainsAggregate(SqlExpression? expr) - { - return expr switch - { - FunctionCallExpression func => - func.FunctionName.ToUpperInvariant() is "COUNT" or "COUNTIF" or "SUM" or "MIN" or "MAX" or "AVG" - || func.Arguments.Any(ContainsAggregate), - BinaryExpression bin => ContainsAggregate(bin.Left) || ContainsAggregate(bin.Right), - UnaryExpression unary => ContainsAggregate(unary.Operand), - TernaryExpression ternary => ContainsAggregate(ternary.Condition) || ContainsAggregate(ternary.IfTrue) || ContainsAggregate(ternary.IfFalse), - CoalesceExpression coalesce => ContainsAggregate(coalesce.Left) || ContainsAggregate(coalesce.Right), - _ => false - }; - } + internal static bool ContainsAggregate(SqlExpression? expr) + { + return expr switch + { + FunctionCallExpression func => + func.FunctionName.ToUpperInvariant() is "COUNT" or "COUNTIF" or "SUM" or "MIN" or "MAX" or "AVG" + || func.Arguments.Any(ContainsAggregate), + BinaryExpression bin => ContainsAggregate(bin.Left) || ContainsAggregate(bin.Right), + UnaryExpression unary => ContainsAggregate(unary.Operand), + TernaryExpression ternary => ContainsAggregate(ternary.Condition) || ContainsAggregate(ternary.IfTrue) || ContainsAggregate(ternary.IfFalse), + CoalesceExpression coalesce => ContainsAggregate(coalesce.Left) || ContainsAggregate(coalesce.Right), + _ => false + }; + } - private static bool ContainsCountIf(SqlExpression? expr) - { - return expr switch - { - FunctionCallExpression func => - func.FunctionName.Equals("COUNTIF", StringComparison.OrdinalIgnoreCase) - || func.Arguments.Any(ContainsCountIf), - BinaryExpression bin => ContainsCountIf(bin.Left) || ContainsCountIf(bin.Right), - UnaryExpression unary => ContainsCountIf(unary.Operand), - TernaryExpression ternary => ContainsCountIf(ternary.Condition) || ContainsCountIf(ternary.IfTrue) || ContainsCountIf(ternary.IfFalse), - CoalesceExpression coalesce => ContainsCountIf(coalesce.Left) || ContainsCountIf(coalesce.Right), - _ => false - }; - } + private static bool ContainsCountIf(SqlExpression? expr) + { + return expr switch + { + FunctionCallExpression func => + func.FunctionName.Equals("COUNTIF", StringComparison.OrdinalIgnoreCase) + || func.Arguments.Any(ContainsCountIf), + BinaryExpression bin => ContainsCountIf(bin.Left) || ContainsCountIf(bin.Right), + UnaryExpression unary => ContainsCountIf(unary.Operand), + TernaryExpression ternary => ContainsCountIf(ternary.Condition) || ContainsCountIf(ternary.IfTrue) || ContainsCountIf(ternary.IfFalse), + CoalesceExpression coalesce => ContainsCountIf(coalesce.Left) || ContainsCountIf(coalesce.Right), + _ => false + }; + } - private static void AddCountDistinctInfo(JObject queryInfo, string sqlQuery) - { - var countDistinctMatch = Regex.Match(sqlQuery, - @"COUNT\s*\(\s*DISTINCT\s+(.+?)\s*\)", RegexOptions.IgnoreCase); - if (!countDistinctMatch.Success) return; + private static void AddCountDistinctInfo(JObject queryInfo, string sqlQuery) + { + var countDistinctMatch = Regex.Match(sqlQuery, + @"COUNT\s*\(\s*DISTINCT\s+(.+?)\s*\)", RegexOptions.IgnoreCase); + if (!countDistinctMatch.Success) return; - var distinctExpr = countDistinctMatch.Groups[1].Value.Trim(); + var distinctExpr = countDistinctMatch.Groups[1].Value.Trim(); - var aliasMatch = Regex.Match(sqlQuery, - @"\bFROM\s+\w+\s+(?:AS\s+)?(\w+)", RegexOptions.IgnoreCase); - if (!aliasMatch.Success) - aliasMatch = Regex.Match(sqlQuery, @"\bFROM\s+(\w+)", RegexOptions.IgnoreCase); - var fromAlias = aliasMatch.Success ? aliasMatch.Groups[1].Value : "c"; + var aliasMatch = Regex.Match(sqlQuery, + @"\bFROM\s+\w+\s+(?:AS\s+)?(\w+)", RegexOptions.IgnoreCase); + if (!aliasMatch.Success) + aliasMatch = Regex.Match(sqlQuery, @"\bFROM\s+(\w+)", RegexOptions.IgnoreCase); + var fromAlias = aliasMatch.Success ? aliasMatch.Groups[1].Value : "c"; - var distinctPath = distinctExpr; - if (distinctPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - distinctPath = distinctPath[(fromAlias.Length + 1)..]; + var distinctPath = distinctExpr; + if (distinctPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + distinctPath = distinctPath[(fromAlias.Length + 1)..]; - var isSimplePath = Regex.IsMatch(distinctPath, @"^[\w.]+$"); + var isSimplePath = Regex.IsMatch(distinctPath, @"^[\w.]+$"); - queryInfo["dCountInfo"] = new JObject - { - ["dCountAlias"] = "$1", - ["dCountExpressionBase"] = isSimplePath - ? new JObject { ["kind"] = "PropertyRef", ["propertyPath"] = distinctPath } - : new JObject { ["kind"] = "Expression", ["expression"] = distinctExpr } - }; - } + queryInfo["dCountInfo"] = new JObject + { + ["dCountAlias"] = "$1", + ["dCountExpressionBase"] = isSimplePath + ? new JObject { ["kind"] = "PropertyRef", ["propertyPath"] = distinctPath } + : new JObject { ["kind"] = "Expression", ["expression"] = distinctExpr } + }; + } } diff --git a/src/CosmosDB.InMemoryEmulator/FakeCosmosHandler.cs b/src/CosmosDB.InMemoryEmulator/FakeCosmosHandler.cs index 77e029c..9fa7abe 100644 --- a/src/CosmosDB.InMemoryEmulator/FakeCosmosHandler.cs +++ b/src/CosmosDB.InMemoryEmulator/FakeCosmosHandler.cs @@ -8,9 +8,9 @@ using Microsoft.Azure.Cosmos.Serialization.HybridRow.IO; using Microsoft.Azure.Cosmos.Serialization.HybridRow.Layouts; using Microsoft.Azure.Cosmos.Serialization.HybridRow.RecordIO; -using HybridRowSchemas = Microsoft.Azure.Cosmos.Serialization.HybridRow.Schemas; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using HybridRowSchemas = Microsoft.Azure.Cosmos.Serialization.HybridRow.Schemas; namespace CosmosDB.InMemoryEmulator; @@ -51,39 +51,39 @@ namespace CosmosDB.InMemoryEmulator; /// public sealed class FakeCosmosHandlerOptions { - /// - /// How long query result cache entries remain valid before eviction. - /// Defaults to 5 minutes. - /// - public TimeSpan CacheTtl { get; init; } = TimeSpan.FromMinutes(5); - - /// - /// Maximum number of query result cache entries before overflow eviction. - /// Defaults to 100. - /// - public int CacheMaxEntries { get; init; } = 100; - - /// - /// Number of partition key ranges to expose. The SDK sends a separate query - /// per range; the handler serves the same data regardless. Increasing this - /// exercises cross-partition fan-out paths in the SDK. - /// Defaults to 1. - /// - public int PartitionKeyRangeCount { get; init; } = 1; - - /// - /// Strategy for building query plan responses. Override to customise or fix - /// query plan generation if a new SDK version changes the expected format. - /// Defaults to . - /// - public IQueryPlanStrategy QueryPlanStrategy { get; init; } = new DefaultQueryPlanStrategy(); - - /// - /// Strategy for resolving HybridRow batch schemas. Override to customise - /// batch schema resolution if a new SDK version changes the batch wire format. - /// Defaults to . - /// - public IBatchSchemaStrategy BatchSchemaStrategy { get; init; } = new DefaultBatchSchemaStrategy(); + /// + /// How long query result cache entries remain valid before eviction. + /// Defaults to 5 minutes. + /// + public TimeSpan CacheTtl { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// Maximum number of query result cache entries before overflow eviction. + /// Defaults to 100. + /// + public int CacheMaxEntries { get; init; } = 100; + + /// + /// Number of partition key ranges to expose. The SDK sends a separate query + /// per range; the handler serves the same data regardless. Increasing this + /// exercises cross-partition fan-out paths in the SDK. + /// Defaults to 1. + /// + public int PartitionKeyRangeCount { get; init; } = 1; + + /// + /// Strategy for building query plan responses. Override to customise or fix + /// query plan generation if a new SDK version changes the expected format. + /// Defaults to . + /// + public IQueryPlanStrategy QueryPlanStrategy { get; init; } = new DefaultQueryPlanStrategy(); + + /// + /// Strategy for resolving HybridRow batch schemas. Override to customise + /// batch schema resolution if a new SDK version changes the batch wire format. + /// Defaults to . + /// + public IBatchSchemaStrategy BatchSchemaStrategy { get; init; } = new DefaultBatchSchemaStrategy(); } /// @@ -93,2155 +93,2155 @@ public sealed class FakeCosmosHandlerOptions /// public sealed class CompatibilityDocument { - [JsonProperty("id")] - public string Id { get; set; } = ""; + [JsonProperty("id")] + public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = ""; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = ""; - [JsonProperty("name")] - public string Name { get; set; } = ""; + [JsonProperty("name")] + public string Name { get; set; } = ""; - [JsonProperty("value")] - public int Value { get; set; } + [JsonProperty("value")] + public int Value { get; set; } } public class FakeCosmosHandler : HttpMessageHandler { - private readonly InMemoryContainer _container; - private readonly ConcurrentBag _requestLog = new(); - private readonly ConcurrentBag _queryLog = new(); - private readonly ConcurrentBag _unrecognisedHeaders = new(); - private readonly List _sdkVersionWarnings = new(); - private readonly QueryResultCache _queryResultCache; - private readonly string _collectionRid; - private readonly string _databaseRid; - private readonly int _partitionKeyRangeCount; - private readonly string _partitionKeyPath; - private readonly IQueryPlanStrategy _queryPlanStrategy; - private readonly IBatchSchemaStrategy _batchSchemaStrategy; - private static int _ridCounter; - private const string PkRangesEtag = "\"pk-etag-1\""; - - /// - /// The version of the PartitionedQueryExecutionInfo format returned by query plan responses. - /// If the SDK starts expecting a different version, queries may fail. - /// - public const int QueryPlanVersion = 2; - - /// Minimum Cosmos SDK version that has been tested with this handler. - public static readonly Version MinTestedSdkVersion = new(3, 35, 0, 0); - - /// Maximum Cosmos SDK version that has been tested with this handler. - public static readonly Version MaxTestedSdkVersion = new(3, 58, 0, 0); - - /// - /// Set of x-ms-* request headers that this handler recognises and processes. - /// Any x-ms-* header not in this set is recorded in . - /// - private static readonly HashSet KnownRequestHeaders = new(StringComparer.OrdinalIgnoreCase) - { - "A-IM", - "x-ms-documentdb-partitionkey", - "x-ms-documentdb-isquery", - "x-ms-documentdb-is-upsert", - "x-ms-cosmos-is-batch-request", - "x-ms-cosmos-is-query-plan-request", - "x-ms-max-item-count", - "x-ms-continuation", - "x-ms-documentdb-partitionkeyrangeid", - "x-ms-date", - "x-ms-version", - "x-ms-documentdb-query-enablecrosspartition", - "x-ms-documentdb-query-iscontinuationexpected", - "x-ms-documentdb-query-parallelizecrosspartitionquery", - "x-ms-documentdb-populatequerymetrics", - "x-ms-cosmos-correlated-activityid", - "x-ms-activity-id", - "x-ms-cosmos-sdk-supportedcapabilities", - "x-ms-session-token", - "x-ms-consistency-level", - "x-ms-request-charge", - "x-ms-cosmos-internal-is-query", - "x-ms-cosmos-query-version", - "x-ms-documentdb-collection-rid", - "x-ms-cosmos-batch-ordered-response", - "x-ms-cosmos-batch-atomic", - "x-ms-cosmos-batch-continue-on-error", - "x-ms-cosmos-sdk-version", - "x-ms-cosmos-intended-collection-rid", - "x-ms-cosmos-priority-level", - "x-ms-cosmos-allow-tentative-writes", - "x-ms-cosmos-physical-partition-count", - "x-ms-documentdb-responsecontinuationtokenlimitinkb", - "x-ms-documentdb-content-serialization-format", - "x-ms-cosmos-query-optimisticdirectexecute", - "x-ms-cosmos-supported-serialization-formats", - "x-ms-cosmos-supported-query-features", - }; - - /// - /// AsyncLocal override for partition key. When set, - /// and use this value when the standard - /// x-ms-documentdb-partitionkey header is absent. This is necessary because - /// the Cosmos SDK does not send the partition key header for prefix (hierarchical) - /// partition key queries — it routes by partition key range ID instead. - /// Used internally by the decorator. - /// - private readonly AsyncLocal _partitionKeyOverride = new(); - - /// - /// Separate AsyncLocal for the LINQ .ToFeedIterator() path. - /// Set eagerly by when - /// GetItemLinqQueryable is called with a prefix partition key, - /// because the produced by .ToFeedIterator() is - /// SDK-internal and cannot be wrapped. Uses a lower priority than - /// so that - /// (used by GetItemQueryIterator) takes precedence when both are set. - /// - private readonly AsyncLocal _partitionKeyHint = new(); - - /// - /// Sets a partition key hint for the LINQ .ToFeedIterator() path. - /// Unlike the scoped override, this value persists until overwritten - /// or the async context ends. It is used as a fallback when neither the HTTP - /// partition key header nor the scoped override is present. - /// - internal void SetPartitionKeyHint(PartitionKey? partitionKey) - { - _partitionKeyHint.Value = partitionKey; - } - - /// - /// Sets a scoped partition key override for the current async context. - /// Used internally by to forward - /// the partition key captured at the Container API surface. - /// - internal IDisposable WithPartitionKey(PartitionKey partitionKey) - { - _partitionKeyOverride.Value = partitionKey; - return new PartitionKeyScope(_partitionKeyOverride); - } - - private sealed class PartitionKeyScope(AsyncLocal asyncLocal) : IDisposable - { - public void Dispose() => asyncLocal.Value = null; - } - - /// Recorded HTTP requests in the form "METHOD /path". - public IReadOnlyCollection RequestLog => _requestLog; - - /// The backing in-memory container that stores all data for this handler. - public Container BackingContainer => _container; - - /// Internal typed access to the backing container. - internal InMemoryContainer BackingInMemoryContainer => _container; - - /// Recorded SQL query strings that were executed. - public IReadOnlyCollection QueryLog => _queryLog; - - /// - /// x-ms-* request headers seen during request processing that are not in the - /// known headers set. Populated as requests flow through . - /// Check this collection after a test run to detect new SDK headers that may need handling. - /// - public IReadOnlyCollection UnrecognisedHeaders => _unrecognisedHeaders; - - /// - /// Warnings generated during handler construction if the current Cosmos SDK version - /// falls outside the tested range ( to - /// ). - /// - public IReadOnlyList SdkVersionWarnings => _sdkVersionWarnings; - - /// - /// Optional fault injection delegate. When set, it is called before normal request - /// handling. If it returns a non-null response, that response is used immediately. - /// By default, metadata requests (account, collection, pkranges) are excluded to avoid - /// breaking SDK initialisation. Set to - /// true to also affect metadata routes. - /// - public Func? FaultInjector { get; set; } - - /// - /// When true, the delegate is also invoked for - /// metadata requests (account info, collection metadata, partition key ranges). - /// Defaults to false so SDK initialisation is not disrupted. - /// - public bool FaultInjectorIncludesMetadata { get; set; } - - internal FakeCosmosHandler(InMemoryContainer container) - : this(container, new FakeCosmosHandlerOptions()) - { - } - - internal FakeCosmosHandler(InMemoryContainer container, FakeCosmosHandlerOptions options) - { - _container = container; - _partitionKeyRangeCount = Math.Max(1, options.PartitionKeyRangeCount); - _queryResultCache = new QueryResultCache(options.CacheTtl, options.CacheMaxEntries); - (_databaseRid, _collectionRid) = GenerateResourceIds(container.Id); - _partitionKeyPath = container.PartitionKeyPaths.FirstOrDefault()?.TrimStart('/') ?? "id"; - _queryPlanStrategy = options.QueryPlanStrategy; - _batchSchemaStrategy = options.BatchSchemaStrategy; - CheckSdkVersion(); - } - - private void CheckSdkVersion() - { - var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version; - if (sdkVersion is null) return; - - if (sdkVersion < MinTestedSdkVersion) - { - var warning = $"FakeCosmosHandler: Cosmos SDK {sdkVersion} is older than the minimum tested version " + - $"({MinTestedSdkVersion}). Some features may not work correctly. " + - $"Call VerifySdkCompatibilityAsync() to check for compatibility."; - _sdkVersionWarnings.Add(warning); - System.Diagnostics.Trace.TraceWarning(warning); - } - else if (sdkVersion > MaxTestedSdkVersion) - { - var warning = $"FakeCosmosHandler: Cosmos SDK {sdkVersion} is newer than the last tested version " + - $"({MaxTestedSdkVersion}). Call VerifySdkCompatibilityAsync() in your test setup " + - $"to check for compatibility."; - _sdkVersionWarnings.Add(warning); - System.Diagnostics.Trace.TraceWarning(warning); - } - } - - /// - /// Returns true if the HybridRow batch schemas could be resolved. Used by - /// to probe availability without throwing. - /// - internal static bool BatchSchemasAvailable - { - get - { - _ = BatchSchemas.OperationLayout; - return true; - } - } - - private static (string DbRid, string CollRid) GenerateResourceIds(string containerId) - { - // Cosmos RID format: DB = 4-byte little-endian uint, Collection = DB bytes + 4 more bytes. - // Use atomic counter for the DB portion so every handler instance gets a unique RID, - // even if containers share the same name. Collection portion uses MurmurHash3 of the ID. - var instanceId = (uint)Interlocked.Increment(ref _ridCounter); - var dbBytes = BitConverter.GetBytes(instanceId); - var containerHash = PartitionKeyHash.MurmurHash3(containerId); - var collBytes = new byte[8]; - Buffer.BlockCopy(dbBytes, 0, collBytes, 0, 4); - Buffer.BlockCopy(BitConverter.GetBytes(containerHash), 0, collBytes, 4, 4); - return (Convert.ToBase64String(dbBytes), Convert.ToBase64String(collBytes)); - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri?.AbsolutePath ?? ""; - var method = request.Method.Method; - _requestLog.Add($"{method} {path}"); - - // Detect unrecognised x-ms-* headers for SDK compatibility diagnostics. - foreach (var header in request.Headers) - { - if (header.Key.StartsWith("x-ms-", StringComparison.OrdinalIgnoreCase) && - !KnownRequestHeaders.Contains(header.Key)) - { - _unrecognisedHeaders.Add(header.Key); - } - } - - // Buffer request content so FaultInjector can safely read it without - // consuming the stream that the handler needs later. - if (FaultInjector is not null && request.Content is not null) - { - var body = await request.Content.ReadAsStringAsync(cancellationToken); - var mediaType = request.Content.Headers.ContentType?.MediaType ?? "application/json"; - request.Content = new StringContent(body, Encoding.UTF8, mediaType); - } - - if (FaultInjectorIncludesMetadata && FaultInjector is not null) - { - var earlyFault = FaultInjector(request); - if (earlyFault is not null) - { - return earlyFault; - } - } - - if (method == "GET" && path is "/" or "") - { - return CreateJsonResponse(AccountMetadata); - } - - if (path.Contains("/pkranges")) - { - return HandlePartitionKeyRanges(request); - } - - if (path.Contains("/colls/") && !path.Contains("/docs")) - { - if (method == "GET") - { - return CreateJsonResponse(GetCollectionMetadata()); - } - - // PUT/DELETE only for container-level paths (not sub-resources like /sprocs, /triggers) - if (Regex.IsMatch(path, @"/colls/[^/]+/?$")) - { - if (method == "PUT") - return CreateJsonResponse(GetCollectionMetadata()); - if (method == "DELETE") - return CreateNoContentResponse(); - } - } - - // Database management: /dbs (list/create) - if (Regex.IsMatch(path, @"^/dbs/?$")) - { - if (method == "POST") - { - var body = request.Content is not null - ? await request.Content.ReadAsStringAsync(cancellationToken) - : "{}"; - var dbName = JObject.Parse(body)["id"]?.ToString() ?? "fake-db"; - return CreateJsonResponse(GetDatabaseMetadata(dbName), HttpStatusCode.Created); - } - - // GET /dbs → list databases - var dbList = new JObject - { - ["_rid"] = "", - ["Databases"] = new JArray(JObject.Parse(GetDatabaseMetadata("fake-db"))), - ["_count"] = 1 - }; - return CreateJsonResponse(dbList.ToString(Formatting.None)); - } - - // Database CRUD: /dbs/{id} (read/replace/delete) - if (Regex.IsMatch(path, @"^/dbs/[^/]+/?$")) - { - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var dbName = segments.Length > 1 ? Uri.UnescapeDataString(segments[1]) : "fake-db"; - return method switch - { - "GET" or "PUT" => CreateJsonResponse(GetDatabaseMetadata(dbName)), - "DELETE" => CreateNoContentResponse(), - _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) - }; - } - - // Container management: /dbs/{id}/colls (list/create containers) - if (Regex.IsMatch(path, @"^/dbs/[^/]+/colls/?$")) - { - if (method == "POST") - { - return CreateJsonResponse(GetCollectionMetadata(), HttpStatusCode.Created); - } - - // GET /dbs/{id}/colls → list containers - var containerList = new JObject - { - ["_rid"] = _databaseRid, - ["DocumentCollections"] = new JArray(JObject.Parse(GetCollectionMetadata())), - ["_count"] = 1 - }; - return CreateJsonResponse(containerList.ToString(Formatting.None)); - } - - if (!FaultInjectorIncludesMetadata && FaultInjector is not null) - { - var faultResponse = FaultInjector(request); - if (faultResponse is not null) - { - return faultResponse; - } - } - - // Document-specific routes: /docs/{id} (point read, replace, delete, patch) - if (path.Contains("/docs/") && HasDocumentId(path)) - { - switch (method) - { - case "GET": - return await HandlePointReadAsync(request, cancellationToken); - case "PUT": - return await HandleReplaceAsync(request, cancellationToken); - case "DELETE": - return await HandleDeleteAsync(request, cancellationToken); - case "PATCH": - return await HandlePatchAsync(request, cancellationToken); - } - } - - // POST /docs (overloaded: batch, query plan, query, upsert, create) - if (method == "POST" && path.Contains("/docs")) - { - if (IsBatchRequest(request)) - { - return await HandleBatchAsync(request, cancellationToken); - } - - if (request.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out var qpValues) && - qpValues.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase))) - { - return await HandleQueryPlanAsync(request, cancellationToken); - } - - if (IsQueryRequest(request)) - { - return await HandleQueryAsync(request, cancellationToken); - } - - if (IsUpsertRequest(request)) - { - return await HandleUpsertAsync(request, cancellationToken); - } - - return await HandleCreateAsync(request, cancellationToken); - } - - if (method == "GET" && path.Contains("/docs")) - { - return await HandleReadFeedAsync(request, cancellationToken); - } - - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/querying-offers - // ReadThroughputAsync queries the /offers endpoint to retrieve provisioned throughput. - if (path.Contains("/offers")) - { - return HandleOffersRequest(request); - } - - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - $"{{\"message\":\"FakeCosmosHandler: unrecognised route {method} {path}\"}}", - Encoding.UTF8, - "application/json") - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // CRUD route handlers - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task HandleCreateAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - var result = await _container.CreateItemStreamAsync(stream, pk, BuildItemRequestOptions(request), cancellationToken); - return ConvertToHttpResponse(result); - } - - private async Task HandleUpsertAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - var result = await _container.UpsertItemStreamAsync(stream, pk, BuildItemRequestOptions(request), cancellationToken); - return ConvertToHttpResponse(result); - } - - private async Task HandlePointReadAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var id = ExtractDocumentId(request); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - var result = await _container.ReadItemStreamAsync(id, pk, BuildItemRequestOptions(request), cancellationToken); - return ConvertToHttpResponse(result); - } - - private async Task HandleReplaceAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var id = ExtractDocumentId(request); - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - var result = await _container.ReplaceItemStreamAsync(stream, id, pk, BuildItemRequestOptions(request), cancellationToken); - return ConvertToHttpResponse(result); - } - - private async Task HandleDeleteAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var id = ExtractDocumentId(request); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - var result = await _container.DeleteItemStreamAsync(id, pk, BuildItemRequestOptions(request), cancellationToken); - return ConvertToHttpResponse(result); - } - - private async Task HandlePatchAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var id = ExtractDocumentId(request); - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - var (operations, condition) = ParsePatchBody(body); - var options = new PatchItemRequestOptions(); - if (condition is not null) - { - options.FilterPredicate = condition; - } - if (request.Headers.IfMatch.Any()) - { - options.IfMatchEtag = request.Headers.IfMatch.First().Tag; - } - var result = await _container.PatchItemStreamAsync(id, pk, operations, options, cancellationToken); - return ConvertToHttpResponse(result); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Transactional Batch handler - // ═══════════════════════════════════════════════════════════════════════════ - - /// - /// Cosmos OperationType enum values used in the HybridRow batch wire protocol. - /// Values from Microsoft.Azure.Documents.OperationType (in Microsoft.Azure.Cosmos.Direct assembly). - /// - private static class BatchOperationTypes - { - public const int Create = 0; - public const int Patch = 1; - public const int Read = 2; - public const int Delete = 4; - public const int Replace = 5; - public const int Upsert = 20; - } - - /// - /// Lazily resolved HybridRow schema objects for batch request/response serialization. - /// Constructs schemas from scratch using public HybridRow APIs to eliminate fragile - /// reflection into SDK internals (BatchSchemaProvider). Falls back to reflection - /// if the self-built approach fails, with clear error messages. - /// - private static class BatchSchemas - { - private static readonly Lazy<(Layout OperationLayout, Layout ResultLayout, LayoutResolverNamespace Resolver)> _schemas = - new(BuildSchemas); - - public static Layout OperationLayout => _schemas.Value.OperationLayout; - public static Layout ResultLayout => _schemas.Value.ResultLayout; - public static LayoutResolverNamespace Resolver => _schemas.Value.Resolver; - - private static (Layout, Layout, LayoutResolverNamespace) BuildSchemas() - { - try - { - return BuildSchemasFromDefinition(); - } - catch (Exception ex) - { - // Self-built schema construction failed. Fall back to reflecting into the - // SDK's internal BatchSchemaProvider as a last resort. - try - { - return BuildSchemasFromReflection(); - } - catch (Exception reflectionEx) - { - throw new InvalidOperationException( - $"Failed to initialise HybridRow batch schemas. " + - $"Self-built schema error: {ex.Message}. " + - $"Reflection fallback error: {reflectionEx.Message}. " + - $"This may indicate an incompatible Cosmos SDK version " + - $"({typeof(CosmosClient).Assembly.GetName().Version}). " + - $"Batch operations will not work.", ex); - } - } - } - - /// - /// Build batch schemas from scratch using public HybridRow APIs. - /// This approach defines the BatchOperation and BatchResult schemas programmatically, - /// matching the wire format the Cosmos SDK expects, without reflecting into any internal types. - /// - private static (Layout, Layout, LayoutResolverNamespace) BuildSchemasFromDefinition() - { - var batchOperationSchema = new HybridRowSchemas.Schema - { - Name = "BatchOperation", - SchemaId = new SchemaId(2145473648), - Type = HybridRowSchemas.TypeKind.Schema, - Properties = - { - new HybridRowSchemas.Property { Path = "operationType", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, - new HybridRowSchemas.Property { Path = "resourceType", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, - new HybridRowSchemas.Property { Path = "partitionKey", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "effectivePartitionKey", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "id", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "binaryId", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "resourceBody", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "indexingDirective", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - new HybridRowSchemas.Property { Path = "ifMatch", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - new HybridRowSchemas.Property { Path = "ifNoneMatch", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - new HybridRowSchemas.Property { Path = "timeToLiveInSeconds", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - new HybridRowSchemas.Property { Path = "minimalReturnPreference", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Boolean) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - }, - }; - - var batchResultSchema = new HybridRowSchemas.Schema - { - Name = "BatchResult", - SchemaId = new SchemaId(2145473649), - Type = HybridRowSchemas.TypeKind.Schema, - Properties = - { - new HybridRowSchemas.Property { Path = "statusCode", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, - new HybridRowSchemas.Property { Path = "subStatusCode", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, - new HybridRowSchemas.Property { Path = "eTag", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "resourceBody", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, - new HybridRowSchemas.Property { Path = "retryAfterMilliseconds", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.UInt32) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - new HybridRowSchemas.Property { Path = "requestCharge", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Float64) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, - }, - }; - - var ns = new HybridRowSchemas.Namespace - { - Name = "Microsoft.Azure.Cosmos.BatchApi", - Version = HybridRowSchemas.SchemaLanguageVersion.V2, - Schemas = { batchOperationSchema, batchResultSchema }, - }; - - var resolver = new LayoutResolverNamespace(ns, SystemSchema.LayoutResolver); - var opLayout = resolver.Resolve(batchOperationSchema.SchemaId); - var resultLayout = resolver.Resolve(batchResultSchema.SchemaId); - return (opLayout, resultLayout, resolver); - } - - /// - /// Fallback: resolve batch schemas via reflection into the SDK's internal BatchSchemaProvider. - /// - private static (Layout, Layout, LayoutResolverNamespace) BuildSchemasFromReflection() - { - var bspType = typeof(CosmosClient).Assembly.GetType("Microsoft.Azure.Cosmos.BatchSchemaProvider") - ?? throw new InvalidOperationException( - "Cannot find BatchSchemaProvider in the Cosmos SDK assembly. " + - "The SDK may have reorganised its internal types."); - var flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static; - var opLayout = (Layout)(bspType.GetProperty("BatchOperationLayout", flags)?.GetValue(null) - ?? throw new InvalidOperationException("BatchSchemaProvider.BatchOperationLayout not found.")); - var resultLayout = (Layout)(bspType.GetProperty("BatchResultLayout", flags)?.GetValue(null) - ?? throw new InvalidOperationException("BatchSchemaProvider.BatchResultLayout not found.")); - var resolver = (LayoutResolverNamespace)(bspType.GetProperty("BatchLayoutResolver", flags)?.GetValue(null) - ?? throw new InvalidOperationException("BatchSchemaProvider.BatchLayoutResolver not found.")); - return (opLayout, resultLayout, resolver); - } - } - - private record struct BatchOperation(int OperationType, string? Id, byte[]? ResourceBody, string? IfMatch, string? IfNoneMatch); - - private async Task HandleBatchAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - // Check if batch schemas are available before attempting to parse - if (!_batchSchemaStrategy.IsAvailable) - { - return new HttpResponseMessage(HttpStatusCode.NotImplemented) - { - Content = new StringContent( - $"{{\"message\":\"{(_batchSchemaStrategy.UnavailableReason ?? "Batch schemas unavailable").Replace("\"", "\\\"")}\"}}", - Encoding.UTF8, "application/json") - }; - } - - var pk = ExtractPartitionKey(request) ?? PartitionKey.None; - - // Parse the HybridRow/RecordIO binary request body to extract batch operations. - var batchOps = new List(); - if (request.Content is not null) - { - var bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken); - if (bodyBytes.Length == 0) - { - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("{\"message\":\"Empty batch request body.\"}", Encoding.UTF8, "application/json") - }; - } - - using var bodyStream = new MemoryStream(bodyBytes); - var batchLayoutResolver = BatchSchemas.Resolver; - - Func, Result> recordVisitor = (ReadOnlyMemory record) => - { - var row = new RowBuffer(record.Length); - if (!row.ReadFrom(record.Span, HybridRowVersion.V1, batchLayoutResolver)) - return Result.Failure; - - var reader = new RowReader(ref row); - int opType = -1; - string? id = null; - byte[]? resourceBody = null; - string? ifMatch = null; - string? ifNoneMatch = null; - - while (reader.Read()) - { - switch (reader.Path) - { - case "operationType": - reader.ReadInt32(out int ot); - opType = ot; - break; - case "id": - reader.ReadString(out string idVal); - id = idVal; - break; - case "resourceBody": - reader.ReadBinary(out byte[] rb); - resourceBody = rb; - break; - case "ifMatch": - reader.ReadString(out string im); - ifMatch = im; - break; - case "ifNoneMatch": - reader.ReadString(out string inm); - ifNoneMatch = inm; - break; - } - } - - batchOps.Add(new BatchOperation(opType, id, resourceBody, ifMatch, ifNoneMatch)); - return Result.Success; - }; - - var parseResult = await bodyStream.ReadRecordIOAsync( - recordVisitor, resizer: new MemorySpanResizer(1024)); - - if (parseResult != Result.Success) - { - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent( - $"{{\"message\":\"Failed to parse batch request body.\"}}", - Encoding.UTF8, "application/json") - }; - } - } - - if (batchOps.Count == 0) - { - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("{\"message\":\"Empty batch request.\"}", Encoding.UTF8, "application/json") - }; - } - - // Execute the batch operations atomically using InMemoryTransactionalBatch. - var batch = _container.CreateTransactionalBatch(pk); - foreach (var op in batchOps) - { - switch (op.OperationType) - { - case BatchOperationTypes.Create: - if (op.ResourceBody is not null) - batch.CreateItemStream(new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); - break; - case BatchOperationTypes.Upsert: - if (op.ResourceBody is not null) - batch.UpsertItemStream(new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); - break; - case BatchOperationTypes.Replace: - if (op.Id is not null && op.ResourceBody is not null) - batch.ReplaceItemStream(op.Id, new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); - break; - case BatchOperationTypes.Delete: - if (op.Id is not null) - batch.DeleteItem(op.Id, BuildBatchItemRequestOptions(op)); - break; - case BatchOperationTypes.Read: - if (op.Id is not null) - batch.ReadItem(op.Id, BuildBatchItemRequestOptions(op)); - break; - case BatchOperationTypes.Patch: - if (op.Id is not null && op.ResourceBody is not null) - { - var patchJson = Encoding.UTF8.GetString(op.ResourceBody); - var (patchOps, condition) = ParsePatchBody(patchJson); - var patchOptions = new TransactionalBatchPatchItemRequestOptions(); - if (condition is not null) patchOptions.FilterPredicate = condition; - if (op.IfMatch is not null) patchOptions.IfMatchEtag = op.IfMatch; - batch.PatchItem(op.Id, patchOps, patchOptions); - } - break; - } - } - - var batchResponse = await batch.ExecuteAsync(cancellationToken); - - // Build the HybridRow/RecordIO binary response. - var batchResultLayout = BatchSchemas.ResultLayout; - var batchLayoutResolverForResponse = BatchSchemas.Resolver; - - using var responseStream = new MemoryStream(); - var resizer = new MemorySpanResizer(256); - - await RecordIOStream.WriteRecordIOAsync( - responseStream, - default(Segment), - (long index, out ReadOnlyMemory buffer) => - { - if (index >= batchResponse.Count) - { - buffer = default; - return Result.Success; - } - - var opResult = batchResponse[(int)index]; - var row = new RowBuffer(256, resizer); - row.InitLayout(HybridRowVersion.V1, batchResultLayout, batchLayoutResolverForResponse); - var r = RowWriter.WriteBuffer(ref row, opResult, (ref RowWriter writer, TypeArgument _, TransactionalBatchOperationResult result) => - { - Result wr; - wr = writer.WriteInt32("statusCode", (int)result.StatusCode); - if (wr != Result.Success) return wr; - - if (result.ETag is not null) - { - wr = writer.WriteString("eTag", result.ETag); - if (wr != Result.Success) return wr; - } - - if (result.ResourceStream is not null) - { - using var ms = new MemoryStream(); - result.ResourceStream.CopyTo(ms); - result.ResourceStream.Position = 0; - wr = writer.WriteBinary("resourceBody", ms.ToArray()); - if (wr != Result.Success) return wr; - } - - wr = writer.WriteFloat64("requestCharge", 1.0); - if (wr != Result.Success) return wr; - - return Result.Success; - }); - - if (r != Result.Success) - { - buffer = default; - return r; - } - - buffer = resizer.Memory.Slice(0, row.Length); - return Result.Success; - }, - new MemorySpanResizer(128)); - - var httpResponse = new HttpResponseMessage(batchResponse.StatusCode); - httpResponse.Content = new ByteArrayContent(responseStream.ToArray()); - httpResponse.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - httpResponse.Headers.Add("x-ms-request-charge", "1"); - httpResponse.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - httpResponse.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); - return httpResponse; - } - - private static TransactionalBatchItemRequestOptions? BuildBatchItemRequestOptions(BatchOperation op) - { - if (op.IfMatch is null && op.IfNoneMatch is null) - return null; - return new TransactionalBatchItemRequestOptions - { - IfMatchEtag = op.IfMatch, - IfNoneMatchEtag = op.IfNoneMatch - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // CRUD helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private HttpResponseMessage ConvertToHttpResponse(ResponseMessage cosmosResponse) - { - var httpResponse = new HttpResponseMessage(cosmosResponse.StatusCode); - if (cosmosResponse.Content is not null) - { - using var reader = new StreamReader(cosmosResponse.Content); - var json = reader.ReadToEnd(); - if (json.Length > 0) - { - httpResponse.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - } - - httpResponse.Headers.Add("x-ms-request-charge", "1"); - httpResponse.Headers.Add("x-ms-activity-id", cosmosResponse.Headers["x-ms-activity-id"] ?? Guid.NewGuid().ToString()); - httpResponse.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); - - var subStatus = cosmosResponse.Headers["x-ms-substatus"]; - if (subStatus is not null) - { - httpResponse.Headers.Add("x-ms-substatus", subStatus); - } - - var etag = cosmosResponse.Headers["ETag"]; - if (etag is not null) - { - httpResponse.Headers.TryAddWithoutValidation("etag", etag); - } - - return httpResponse; - } - - private static string ExtractDocumentId(HttpRequestMessage request) - { - var path = request.RequestUri?.AbsolutePath ?? ""; - var docsIndex = path.LastIndexOf("/docs/", StringComparison.OrdinalIgnoreCase); - if (docsIndex >= 0) - { - var id = path[(docsIndex + 6)..]; - return Uri.UnescapeDataString(id.TrimEnd('/')); - } - return ""; - } - - private static bool HasDocumentId(string path) - { - var docsIndex = path.LastIndexOf("/docs/", StringComparison.OrdinalIgnoreCase); - if (docsIndex < 0) return false; - var afterDocs = path[(docsIndex + 6)..].TrimEnd('/'); - return afterDocs.Length > 0; - } - - private static ItemRequestOptions BuildItemRequestOptions(HttpRequestMessage request) - { - var options = new ItemRequestOptions(); - if (request.Headers.IfMatch.Any()) - { - options.IfMatchEtag = request.Headers.IfMatch.First().Tag; - } - if (request.Headers.IfNoneMatch.Any()) - { - options.IfNoneMatchEtag = request.Headers.IfNoneMatch.First().Tag; - } - return options; - } - - private static bool IsQueryRequest(HttpRequestMessage request) - { - var contentType = request.Content?.Headers?.ContentType?.MediaType ?? ""; - if (contentType.Contains("query+json", StringComparison.OrdinalIgnoreCase)) - return true; - if (request.Headers.TryGetValues("x-ms-documentdb-isquery", out var values) && - values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase))) - return true; - return false; - } - - private static bool IsUpsertRequest(HttpRequestMessage request) - { - return request.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var values) && - values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); - } - - private static bool IsBatchRequest(HttpRequestMessage request) - { - return request.Headers.TryGetValues("x-ms-cosmos-is-batch-request", out var values) && - values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); - } - - private static (IReadOnlyList Operations, string? Condition) ParsePatchBody(string body) - { - var jObj = JObject.Parse(body); - var operations = new List(); - var condition = jObj["condition"]?.ToString(); - - var opsToken = jObj["operations"]; - if (opsToken is null) - throw new InvalidOperationException("Patch body missing 'operations' array."); - - foreach (var op in opsToken.ToObject()!) - { - var opType = op["op"]!.ToString().ToLowerInvariant(); - var opPath = op["path"]!.ToString(); - var value = op["value"]; - - operations.Add(opType switch - { - "set" => PatchOperation.Set(opPath, value), - "replace" => PatchOperation.Replace(opPath, value), - "add" => PatchOperation.Add(opPath, value), - "remove" => PatchOperation.Remove(opPath), - "incr" => PatchOperation.Increment(opPath, value!.Value()), - "move" => PatchOperation.Move(op["from"]!.ToString(), opPath), - _ => throw new InvalidOperationException($"Unknown patch operation: {opType}") - }); - } - - return (operations, condition); - } - - private HttpResponseMessage HandleOffersRequest(HttpRequestMessage request) - { - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/querying-offers - // The SDK queries /offers to read provisioned throughput for a container. - // We return a single offer matching this container's throughput. - var offer = new JObject - { - ["offerVersion"] = "V2", - ["offerType"] = "Invalid", - ["content"] = new JObject - { - ["offerThroughput"] = _container._throughput, - ["offerIsRUPerMinuteThroughputEnabled"] = false - }, - ["offerResourceId"] = _collectionRid, - ["id"] = "offer-" + _collectionRid, - ["_rid"] = "offer-" + _collectionRid, - ["_self"] = "offers/offer-" + _collectionRid + "/", - ["_etag"] = $"\"{Guid.NewGuid()}\"", - ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - }; - - var wrapper = new JObject - { - ["_rid"] = "", - ["Offers"] = new JArray(offer), - ["_count"] = 1 - }; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(wrapper.ToString(Formatting.None), Encoding.UTF8, "application/json") - }; - response.Headers.Add("x-ms-request-charge", "1"); - response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - return response; - } - - private HttpResponseMessage HandlePartitionKeyRanges(HttpRequestMessage request) - { - if (request.Headers.IfNoneMatch.Any(etag => etag.Tag == PkRangesEtag)) - { - var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); - notModified.Headers.Add("x-ms-request-charge", "0"); - notModified.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - notModified.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(PkRangesEtag); - return notModified; - } - - var response = CreateJsonResponse(GetPartitionKeyRanges()); - response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(PkRangesEtag); - return response; - } - - /// - /// Handles the gateway query plan request that the SDK sends on non-Windows platforms - /// (where the native ServiceInterop DLL is unavailable). Parses the SQL query and - /// returns a PartitionedQueryExecutionInfo with accurate metadata so that the - /// SDK builds the same execution pipeline (ORDER BY merge sort, aggregate accumulation, - /// DISTINCT deduplication, etc.) as it would on Windows via ServiceInterop. - /// - private async Task HandleQueryPlanAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var queryBody = JsonParseHelpers.ParseJson(body); - var sqlQuery = queryBody["query"]?.ToString() ?? "SELECT * FROM c"; - - CosmosSqlQuery? parsed = CosmosSqlParser.TryParse(sqlQuery, out var p) ? p : null; - var queryPlan = _queryPlanStrategy.BuildQueryPlan(sqlQuery, parsed, _collectionRid); - return CreateJsonResponse(queryPlan.ToString(Formatting.None)); - } - - private static bool HasAggregateInSelect(CosmosSqlQuery parsed) - { - return parsed.SelectFields.Any(field => ContainsAggregate(field.SqlExpr)); - } - - private static bool ContainsAggregate(SqlExpression? expr) - { - return expr switch - { - FunctionCallExpression func => - func.FunctionName.ToUpperInvariant() is "COUNT" or "COUNTIF" or "SUM" or "MIN" or "MAX" or "AVG" - || func.Arguments.Any(ContainsAggregate), - BinaryExpression bin => ContainsAggregate(bin.Left) || ContainsAggregate(bin.Right), - UnaryExpression unary => ContainsAggregate(unary.Operand), - TernaryExpression ternary => ContainsAggregate(ternary.Condition) || ContainsAggregate(ternary.IfTrue) || ContainsAggregate(ternary.IfFalse), - CoalesceExpression coalesce => ContainsAggregate(coalesce.Left) || ContainsAggregate(coalesce.Right), - _ => false - }; - } - - private static bool ContainsAvg(SqlExpression? expr) - { - return expr switch - { - FunctionCallExpression func => - func.FunctionName.Equals("AVG", StringComparison.OrdinalIgnoreCase) - || func.Arguments.Any(ContainsAvg), - BinaryExpression bin => ContainsAvg(bin.Left) || ContainsAvg(bin.Right), - UnaryExpression unary => ContainsAvg(unary.Operand), - TernaryExpression ternary => ContainsAvg(ternary.Condition) || ContainsAvg(ternary.IfTrue) || ContainsAvg(ternary.IfFalse), - CoalesceExpression coalesce => ContainsAvg(coalesce.Left) || ContainsAvg(coalesce.Right), - _ => false - }; - } - - private async Task HandleQueryAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var queryBody = JsonParseHelpers.ParseJson(body); - var sqlQuery = queryBody["query"]?.ToString() ?? "SELECT * FROM c"; - _queryLog.Add(sqlQuery); - - var partitionKey = ExtractPartitionKey(request) ?? _partitionKeyOverride.Value ?? _partitionKeyHint.Value; - var maxItemCount = ExtractMaxItemCount(request); - var continuation = DecodeContinuation(request); - var rangeId = ExtractPartitionKeyRangeId(request); - - List allDocuments; - string cacheKey; - int offset; - string? payloadPropertyName = null; - - if (continuation is not null && _queryResultCache.TryGet(continuation.Value.Key, out var cached)) - { - allDocuments = cached; - cacheKey = continuation.Value.Key; - offset = continuation.Value.Offset; - } - else - { - offset = 0; - cacheKey = Guid.NewGuid().ToString("N"); - - if (CosmosSqlParser.TryParse(sqlQuery, out var parsed)) - { - var orderByItemsField = parsed.SelectFields - .FirstOrDefault(field => IsOrderByItemsArray(field.SqlExpr)); - var isOrderByQuery = orderByItemsField is not null && parsed.OrderByFields is { Length: > 0 }; - - var groupByItemsField = parsed.SelectFields - .FirstOrDefault(field => string.Equals(field.Alias, "groupByItems", StringComparison.OrdinalIgnoreCase)); - var isGroupByQuery = groupByItemsField is not null && parsed.GroupByFields is { Length: > 0 }; - - if (isGroupByQuery) - { - allDocuments = await HandleGroupByQueryAsync( - parsed, queryBody, partitionKey, cancellationToken); - } - else if (isOrderByQuery) - { - var payloadField = parsed.SelectFields - .FirstOrDefault(field => string.Equals(field.Alias, "payload", StringComparison.OrdinalIgnoreCase)) - ?? parsed.SelectFields - .FirstOrDefault(field => field != orderByItemsField); - var orderByAlias = orderByItemsField!.Alias ?? "orderByItems"; - payloadPropertyName = payloadField?.Alias ?? "payload"; - - allDocuments = await HandleOrderByQueryAsync( - parsed, queryBody, partitionKey, orderByAlias, payloadPropertyName, cancellationToken); - } - else - { - var simplifiedSql = CosmosSqlParser.SimplifySdkQuery(parsed); - var queryDef = BuildQueryDefinition(simplifiedSql, queryBody); - var requestOptions = partitionKey is not null - ? new QueryRequestOptions { PartitionKey = partitionKey } - : null; - allDocuments = await DrainIterator( - _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), - cancellationToken); - - // VALUE aggregate wrapping is no longer needed because the query plan - // now bypasses the SDK's AggregateQueryPipelineStage for VALUE - // aggregate queries. The container computes the final result directly - // and the raw value is returned without wire-format wrapping. - } - } - else - { - var queryDef = BuildQueryDefinition(sqlQuery, queryBody); - var requestOptions = partitionKey is not null - ? new QueryRequestOptions { PartitionKey = partitionKey } - : null; - allDocuments = await DrainIterator( - _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), - cancellationToken); - } - } - - allDocuments = FilterDocumentsByRange(allDocuments, rangeId, payloadPropertyName); - - return BuildPagedResponse(allDocuments, offset, maxItemCount, cacheKey); - } - - private async Task> HandleOrderByQueryAsync( - CosmosSqlQuery parsed, JObject queryBody, PartitionKey? partitionKey, - string orderByAlias, string payloadAlias, CancellationToken cancellationToken) - { - // Check if any ORDER BY expression is a function call (not a simple property path). - // When this happens (e.g. ORDER BY VectorDistance(...)), the simplified SQL is - // SELECT VALUE c FROM c ORDER BY . The raw documents won't contain the - // computed expression value, so we need to include it in the query. - var hasComplexOrderBy = parsed.OrderByFields?.Any(f => f.Field is null) ?? false; - - string simplifiedSql; - List complexOrderByAliases = []; - try - { - if (hasComplexOrderBy) - { - // Build a query that includes both the document and the computed ORDER BY values - simplifiedSql = BuildComplexOrderBySql(parsed, payloadAlias, out complexOrderByAliases); - } - else - { - simplifiedSql = CosmosSqlParser.SimplifySdkQuery(parsed); - } - } - catch - { - // If SimplifySdkQuery fails (e.g. SDK changed internal format), - // fall back to executing SQL with SELECT VALUE and ORDER BY from the parsed structure. - simplifiedSql = BuildFallbackOrderBySql(parsed); - } - - var queryDef = BuildQueryDefinition(simplifiedSql, queryBody); - var requestOptions = partitionKey is not null - ? new QueryRequestOptions { PartitionKey = partitionKey } - : null; - var feedIterator = _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions); - var rawDocuments = new List(); - while (feedIterator.HasMoreResults) - { - var page = await feedIterator.ReadNextAsync(cancellationToken); - rawDocuments.AddRange(page); - } - - List orderByPaths; - if (hasComplexOrderBy && complexOrderByAliases.Count > 0) - { - // For complex ORDER BY, the paths are the aliases we injected into the query - orderByPaths = complexOrderByAliases; - } - else - { - try - { - orderByPaths = ExtractOrderByItemPaths(parsed); - } - catch - { - // Fallback: derive from ORDER BY fields if AST walking fails - orderByPaths = parsed.OrderByFields?.Select(field => - StripFromAlias( - field.Field ?? CosmosSqlParser.ExprToString(field.Expression), - parsed.FromAlias)).ToList() ?? []; - } - } - - var documents = new List(); - // Determine if the payload is the full document or a projected expression. - // For DISTINCT+ORDER BY, the SDK rewrites payload as {"name": c.name} (an ObjectLiteral), - // meaning only the projected fields should be returned, not the full document. - var payloadField = parsed.SelectFields - .FirstOrDefault(f => string.Equals(f.Alias, payloadAlias, StringComparison.OrdinalIgnoreCase)); - var fromAlias = parsed.FromAlias ?? "c"; - - foreach (var doc in rawDocuments) - { - var orderByItems = new JArray(); - foreach (var path in orderByPaths) - { - var value = doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); - orderByItems.Add(new JObject { ["item"] = value }); - } - - JToken payloadValue; - if (hasComplexOrderBy) - { - // For complex ORDER BY queries, the doc contains _doc + _ob0/_ob1/... aliases. - // Extract the _doc field as the full document, then apply payload projection. - var fullDoc = doc["_doc"]?.DeepClone() ?? doc.DeepClone(); - // Remove the ORDER BY alias properties from the payload if they leaked in - if (fullDoc is JObject fullDocObj) - foreach (var alias in complexOrderByAliases) - fullDocObj.Remove(alias); - - // Apply payload projection if the rewritten query specified a projected payload - // (e.g. {"id": c.id, "score": VectorDistance(...)} AS payload) - if (payloadField?.SqlExpr is ObjectLiteralExpression objLit) - payloadValue = BuildComplexPayloadValue((JObject)fullDoc, objLit, fromAlias, parsed, doc, complexOrderByAliases); - else - payloadValue = fullDoc; - } - else - { - payloadValue = BuildPayloadValue(doc, payloadField, fromAlias); - } - - var wrapped = new JObject - { - ["_rid"] = (hasComplexOrderBy - ? doc["_doc"]?["_rid"]?.ToString() - : doc["_rid"]?.ToString()) ?? Guid.NewGuid().ToString("N")[..8], - [orderByAlias] = orderByItems, - [payloadAlias] = payloadValue - }; - documents.Add(wrapped); - } - - return documents; - } - - /// - /// Builds the payload value for an ORDER BY-wrapped document. - /// For simple ORDER BY, the payload is the full document. - /// For DISTINCT+ORDER BY, the SDK rewrites the payload as an ObjectLiteral - /// (e.g. {"name": c.name}), so we project just those fields. - /// - private static JToken BuildPayloadValue(JObject doc, SelectField? payloadField, string fromAlias) - { - if (payloadField?.SqlExpr is ObjectLiteralExpression objLiteral) - { - var projected = new JObject(); - foreach (var prop in objLiteral.Properties) - { - var exprStr = CosmosSqlParser.ExprToString(prop.Value); - var path = exprStr; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - path = path[(fromAlias.Length + 1)..]; - projected[prop.Key] = doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); - } - return projected; - } - - if (payloadField?.SqlExpr is IdentifierExpression id - && !string.Equals(id.Name, fromAlias, StringComparison.OrdinalIgnoreCase) - && !id.Name.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase) - && !id.Name.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) - { - var path = id.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - path = path[(fromAlias.Length + 1)..]; - return doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); - } - - return doc.DeepClone(); - } - - /// - /// Builds the payload for a complex ORDER BY query where the payload was projected - /// as an ObjectLiteral (e.g. {"id": c.id, "score": VectorDistance(...)}). - /// Simple property references are resolved from the full document; function call - /// expressions are resolved from the computed ORDER BY aliases (_ob0, _ob1, ...). - /// - private static JToken BuildComplexPayloadValue( - JObject fullDoc, ObjectLiteralExpression objLiteral, string fromAlias, - CosmosSqlQuery parsed, JObject rawDoc, List orderByAliases) - { - var projected = new JObject(); - foreach (var prop in objLiteral.Properties) - { - var exprStr = CosmosSqlParser.ExprToString(prop.Value); - - // Check if this expression matches one of the ORDER BY expressions — - // if so, use the pre-computed _ob alias value from the raw result. - JToken? resolved = null; - if (parsed.OrderByFields is not null) - { - for (var i = 0; i < parsed.OrderByFields.Length && i < orderByAliases.Count; i++) - { - var obExpr = parsed.OrderByFields[i].Field - ?? CosmosSqlParser.ExprToString(parsed.OrderByFields[i].Expression); - if (string.Equals(exprStr, obExpr, StringComparison.OrdinalIgnoreCase)) - { - resolved = rawDoc[orderByAliases[i]]?.DeepClone(); - break; - } - } - } - - if (resolved is not null) - { - projected[prop.Key] = resolved; - } - else - { - // Simple property path — resolve from the full document - var path = exprStr; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - path = path[(fromAlias.Length + 1)..]; - projected[prop.Key] = fullDoc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); - } - } - return projected; - } - - /// - /// Handles GROUP BY queries that the SDK has rewritten to the groupByItems + payload format. - /// Reconstructs the original GROUP BY query, executes on InMemoryContainer (which computes - /// final aggregates), then wraps results in the format the SDK's GroupByQueryPipelineStage expects. - /// - private async Task> HandleGroupByQueryAsync( - CosmosSqlQuery parsed, JObject queryBody, PartitionKey? partitionKey, - CancellationToken cancellationToken) - { - var originalSql = ReconstructGroupByQuery(parsed); - var queryDef = BuildQueryDefinition(originalSql, queryBody); - var requestOptions = partitionKey is not null - ? new QueryRequestOptions { PartitionKey = partitionKey } - : null; - var rawResults = await DrainIterator( - _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), - cancellationToken); - - // Extract aggregate type info from the payload ObjectLiteral - var payloadField = parsed.SelectFields - .FirstOrDefault(f => string.Equals(f.Alias, "payload", StringComparison.OrdinalIgnoreCase)); - var aggregateTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (payloadField?.SqlExpr is ObjectLiteralExpression payloadObj) - { - foreach (var prop in payloadObj.Properties) - { - if (prop.Value is ObjectLiteralExpression inner - && inner.Properties.Length == 1 - && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase) - && inner.Properties[0].Value is FunctionCallExpression func) - { - aggregateTypes[prop.Key] = func.FunctionName.ToUpperInvariant(); - } - } - } - - // Extract GROUP BY key result-property names from the payload ObjectLiteral. - // Non-aggregate properties in the payload correspond to the GROUP BY keys. - var groupByResultKeys = new List(); - if (payloadField?.SqlExpr is ObjectLiteralExpression payloadObjForKeys) - { - foreach (var prop in payloadObjForKeys.Properties) - { - if (!(prop.Value is ObjectLiteralExpression inner - && inner.Properties.Length == 1 - && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase))) - { - groupByResultKeys.Add(prop.Key); - } - } - } - - return rawResults.Select(doc => - { - var jObj = doc as JObject ?? JObject.Parse(doc.ToString()); - - var groupByItems = new JArray(); - foreach (var key in groupByResultKeys) - { - groupByItems.Add(new JObject { ["item"] = jObj.SelectToken(key)?.DeepClone() ?? JValue.CreateNull() }); - } - - var payload = new JObject(); - foreach (var prop in jObj.Properties()) - { - if (aggregateTypes.TryGetValue(prop.Name, out var aggType)) - { - JToken itemValue = aggType == "AVG" - ? new JObject { ["sum"] = prop.Value, ["count"] = 1 } - : prop.Value.DeepClone(); - payload[prop.Name] = new JObject { ["item"] = itemValue }; - } - else - { - payload[prop.Name] = prop.Value.DeepClone(); - } - } - - return (JToken)new JObject - { - ["groupByItems"] = groupByItems, - ["payload"] = payload - }; - }).ToList(); - } - - /// - /// Reconstructs the original GROUP BY SQL from the SDK-rewritten format. - /// The SDK rewrites e.g. SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name to - /// SELECT [{"item": c.name}] AS groupByItems, {"name": c.name, "cnt": {"item": COUNT(1)}} AS payload FROM c GROUP BY c.name. - /// This method extracts the original SELECT fields from the payload ObjectLiteral. - /// - private static string ReconstructGroupByQuery(CosmosSqlQuery parsed) - { - var alias = parsed.FromAlias ?? "c"; - var payloadField = parsed.SelectFields - .FirstOrDefault(f => string.Equals(f.Alias, "payload", StringComparison.OrdinalIgnoreCase)); - - if (payloadField?.SqlExpr is not ObjectLiteralExpression payloadObj) - throw new InvalidOperationException("Cannot reconstruct GROUP BY query: payload field not found."); - - var selectParts = new List(); - foreach (var prop in payloadObj.Properties) - { - if (prop.Value is ObjectLiteralExpression inner - && inner.Properties.Length == 1 - && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase)) - { - // Aggregate: {"alias": {"item": AGG(...)}} - selectParts.Add($"{CosmosSqlParser.ExprToString(inner.Properties[0].Value)} AS {prop.Key}"); - } - else - { - // Plain field: {"alias": expr} - selectParts.Add($"{CosmosSqlParser.ExprToString(prop.Value)} AS {prop.Key}"); - } - } - - var sb = new StringBuilder($"SELECT {string.Join(", ", selectParts)} FROM {alias}"); - - if (parsed.WhereExpr is not null) - sb.Append($" WHERE {CosmosSqlParser.ExprToString(parsed.WhereExpr)}"); - - sb.Append($" GROUP BY {string.Join(", ", parsed.GroupByFields!)}"); - - return sb.ToString(); - } - - /// - /// Strips OFFSET ... LIMIT ... from a SQL query string so the SDK pipeline - /// can apply OFFSET/LIMIT itself (avoiding double application). - /// - private static string StripOffsetLimit(string sql) - { - return Regex.Replace(sql, @"\s*OFFSET\s+\S+\s+LIMIT\s+\S+", "", RegexOptions.IgnoreCase).TrimEnd(); - } - - /// Static accessor for . - internal static string StripOffsetLimitStatic(string sql) => StripOffsetLimit(sql); - - /// - /// Builds the ORDER BY rewritten query in the format the SDK expects: - /// SELECT c._rid, [{"item": c.field}] AS orderByItems, c AS payload FROM c ... ORDER BY c.field ASC - /// - private static string BuildOrderByRewrittenQuery(CosmosSqlQuery parsed) - { - var alias = parsed.FromAlias ?? "c"; - - // Build orderByItems array: [{"item": c.field1}, {"item": c.field2}] - var orderByItemsParts = parsed.OrderByFields! - .Select(field => $"{{\"item\": {field.Field ?? CosmosSqlParser.ExprToString(field.Expression)}}}") - .ToList(); - var orderByItemsArray = $"[{string.Join(", ", orderByItemsParts)}]"; - - // Build SELECT with top-level fields: _rid, orderByItems, payload - var sb = new StringBuilder($"SELECT {alias}._rid, "); - sb.Append(orderByItemsArray); - sb.Append(" AS orderByItems, "); - - // For ORDER BY queries with explicit projections, the payload must be the - // projected SELECT fields (not the full document), so the SDK returns the - // correct shape to the caller. This applies to: - // 1. DISTINCT queries — for SDK deduplication - // 2. Queries with computed expressions (e.g. VectorDistance(...) AS score) — - // the computed value doesn't exist in the document and must be projected. - // All other cases (SELECT VALUE c, SELECT *, SELECT VALUE {obj}) use the full - // document as payload — the SDK or caller handles field extraction. - var hasComputedSelectField = parsed.SelectFields - .Any(f => f.SqlExpr is FunctionCallExpression); - if ((parsed.IsDistinct || hasComputedSelectField) - && parsed.SelectFields.Length > 0 - && parsed.SelectFields.All(f => f.SqlExpr is not null)) - { - var payloadParts = parsed.SelectFields.Select(f => - { - var expr = CosmosSqlParser.ExprToString(f.SqlExpr); - var key = f.Alias ?? f.Expression ?? expr; - // Strip FROM alias prefix from the key (e.g. "c.id" → "id") - // because Cosmos DB results use the property name without the alias. - if (key.StartsWith(alias + ".", StringComparison.OrdinalIgnoreCase)) - key = key[(alias.Length + 1)..]; - return $"\"{key}\": {expr}"; - }); - sb.Append($"{{{string.Join(", ", payloadParts)}}} AS payload"); - } - else - { - sb.Append($"{alias} AS payload"); - } - - // FROM clause - sb.Append($" FROM {alias}"); - - // WHERE clause — reconstruct from the where expression if present - if (parsed.WhereExpr is not null) - { - sb.Append(" WHERE "); - sb.Append(CosmosSqlParser.ExprToString(parsed.WhereExpr)); - } - - // ORDER BY clause - var orderByStr = string.Join(", ", parsed.OrderByFields!.Select(field => - $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); - sb.Append($" ORDER BY {orderByStr}"); - - return sb.ToString(); - } - - /// Static accessor for . - internal static string BuildOrderByRewrittenQueryStatic(CosmosSqlQuery parsed) => BuildOrderByRewrittenQuery(parsed); - - /// - /// Builds a SQL query that includes both the full document and computed ORDER BY - /// expression values. Used when ORDER BY contains function calls (e.g. VectorDistance) - /// that aren't simple property paths. - /// Example output: SELECT c AS _doc, VectorDistance(c.emb, [1,0,0]) AS _ob0 FROM c - /// ORDER BY VectorDistance(c.emb, [1,0,0]) ASC - /// - private static string BuildComplexOrderBySql(CosmosSqlQuery parsed, string payloadAlias, out List orderByAliases) - { - var alias = parsed.FromAlias ?? "c"; - - // Always select the full document as _doc. The payload projection (if any) - // is applied in post-processing via BuildPayloadValue, just like non-complex - // ORDER BY. This avoids inlining object literal expressions (e.g. - // {"id": c.id, "score": VectorDistance(...)}) into SELECT, which the parser - // cannot handle on re-parse. - var sb = new StringBuilder($"SELECT {alias} AS _doc"); - - orderByAliases = []; - for (var i = 0; i < parsed.OrderByFields!.Length; i++) - { - var field = parsed.OrderByFields[i]; - var obAlias = $"_ob{i}"; - orderByAliases.Add(obAlias); - var expr = field.Field ?? CosmosSqlParser.ExprToString(field.Expression); - sb.Append($", {expr} AS {obAlias}"); - } - - sb.Append($" FROM {alias}"); - - if (parsed.WhereExpr is not null) - { - var simplifiedWhere = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, alias); - if (simplifiedWhere is not null) - sb.Append($" WHERE {CosmosSqlParser.ExprToString(simplifiedWhere)}"); - } - - var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => - $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); - sb.Append($" ORDER BY {orderByStr}"); - - return sb.ToString(); - } - - private static string BuildFallbackOrderBySql(CosmosSqlQuery parsed) - { - var sb = new StringBuilder("SELECT "); - - if (parsed.IsDistinct) - { - sb.Append("DISTINCT "); - } - - if (parsed.TopCount.HasValue) - { - sb.Append($"TOP {parsed.TopCount.Value} "); - } - - sb.Append($"VALUE {parsed.FromAlias} FROM {parsed.FromAlias}"); - - if (parsed.OrderByFields is { Length: > 0 }) - { - var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => - $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); - sb.Append($" ORDER BY {orderByStr}"); - } - - return sb.ToString(); - } - - private static List ExtractOrderByItemPaths(CosmosSqlQuery parsed) - { - var orderByItemsField = parsed.SelectFields - .FirstOrDefault(field => IsOrderByItemsArray(field.SqlExpr)); - - if (orderByItemsField?.SqlExpr is ArrayLiteralExpression arrayExpr) - { - var paths = new List(); - foreach (var element in arrayExpr.Elements) - { - if (element is ObjectLiteralExpression obj) - { - var itemProp = obj.Properties.FirstOrDefault(property => - string.Equals(property.Key, "item", StringComparison.OrdinalIgnoreCase)); - if (itemProp.Value is IdentifierExpression ident) - { - paths.Add(StripFromAlias(ident.Name, parsed.FromAlias)); - } - else if (itemProp.Value is not null) - { - // Non-identifier expression (e.g. VectorDistance(...)) — use the - // corresponding ORDER BY field expression to reconstruct a path. - // The value will be looked up by evaluating the expression on the - // raw document, which won't produce a valid JPath. Instead, we - // return the expression string so the caller can handle it. - var exprStr = CosmosSqlParser.ExprToString(itemProp.Value); - paths.Add(StripFromAlias(exprStr, parsed.FromAlias)); - } - } - } - - if (paths.Count > 0) - { - return paths; - } - } - - if (parsed.OrderByFields is { Length: > 0 }) - { - return parsed.OrderByFields - .Select(field => StripFromAlias( - field.Field ?? CosmosSqlParser.ExprToString(field.Expression), - parsed.FromAlias)) - .ToList(); - } - - return []; - } - - private static string StripFromAlias(string path, string fromAlias) - { - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - return path[(fromAlias.Length + 1)..]; - } - - return path; - } - - private static bool IsOrderByItemsArray(SqlExpression? expr) - { - return expr is ArrayLiteralExpression { Elements.Length: > 0 } arrayLiteral - && arrayLiteral.Elements.All(element => - element is ObjectLiteralExpression obj - && obj.Properties.Any(prop => - string.Equals(prop.Key, "item", StringComparison.OrdinalIgnoreCase))); - } - - private List FilterDocumentsByRange(List documents, string? rangeId, string? payloadPropertyName = null) - { - if (_partitionKeyRangeCount <= 1 || rangeId is null) - { - return documents; - } - - if (!int.TryParse(rangeId, out var rangeIndex)) - { - return documents; - } - return documents.Where(document => GetRangeIndex(document, payloadPropertyName) == rangeIndex).ToList(); - } - - private int GetRangeIndex(JToken document, string? payloadPropertyName) - { - if (document is not JObject obj) - { - return 0; - } - - var targetDoc = payloadPropertyName is not null && obj[payloadPropertyName] is JObject payload - ? payload : obj; - var pkToken = targetDoc.SelectToken(_partitionKeyPath); - var pkValue = pkToken is not null ? InMemoryContainer.JTokenToTypedKey(pkToken) ?? "" : ""; - return PartitionKeyHash.GetRangeIndex(pkValue, _partitionKeyRangeCount); - } - - private async Task HandleReadFeedAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents - // "A-IM: Incremental feed" header distinguishes change feed from regular read feed. - if (IsChangeFeedRequest(request)) - { - return await HandleChangeFeedAsync(request); - } - - var maxItemCount = ExtractMaxItemCount(request); - var continuation = DecodeContinuation(request); - var rangeId = ExtractPartitionKeyRangeId(request); - - List allDocuments; - string cacheKey; - int offset; - - if (continuation is not null && _queryResultCache.TryGet(continuation.Value.Key, out var cached)) - { - allDocuments = cached; - cacheKey = continuation.Value.Key; - offset = continuation.Value.Offset; - } - else - { - offset = 0; - cacheKey = Guid.NewGuid().ToString("N"); - - var requestOptions = new QueryRequestOptions(); - var partitionKey = ExtractPartitionKey(request) ?? _partitionKeyOverride.Value ?? _partitionKeyHint.Value; - if (partitionKey is not null) - { - requestOptions.PartitionKey = partitionKey; - } - - allDocuments = await DrainIterator( - _container.GetItemQueryIterator(requestOptions: requestOptions), - cancellationToken); - } - - allDocuments = FilterDocumentsByRange(allDocuments, rangeId); - - return BuildPagedResponse(allDocuments, offset, maxItemCount, cacheKey); - } - - /// - /// Handles change feed HTTP requests (GET /docs with A-IM: Incremental feed). - /// The SDK's ChangeFeedProcessor sends these requests and expects: - /// - An etag response header containing a non-empty continuation token - /// (used by AutoCheckpointer.CheckpointAsync) - /// - HTTP 200 with documents when changes exist - /// - HTTP 304 Not Modified when no new changes are available - /// - /// - /// Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents - /// "If-None-Match: *" means read from the beginning; - /// "If-None-Match: {etag}" means resume from that checkpoint position. - /// The response etag is the logical sequence number (LSN) of the last change returned. - /// - private async Task HandleChangeFeedAsync(HttpRequestMessage request) - { - var maxItemCount = ExtractMaxItemCount(request); - var rangeId = ExtractPartitionKeyRangeId(request); - - // Determine checkpoint from If-None-Match header. - // "*" or absent → read from beginning (checkpoint 0). - // Quoted numeric string (e.g. "\"42\"") → resume from that position. - long checkpoint = 0; - if (request.Headers.IfNoneMatch.Count > 0) - { - var tag = request.Headers.IfNoneMatch.First().Tag; - // Strip surrounding quotes: "\"42\"" → "42" - var unquoted = tag?.Trim('"') ?? ""; - if (unquoted != "*" && long.TryParse(unquoted, out var parsed)) - { - checkpoint = parsed; - } - } - - // Read changes from the backing container's change feed - var iterator = _container.GetChangeFeedIterator(checkpoint); - var allChanges = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) - break; - allChanges.AddRange(page); - } - var totalChanges = allChanges.Count; - - allChanges = FilterDocumentsByRange(allChanges.Cast().ToList(), rangeId) - .Cast().ToList(); - - // Apply maxItemCount paging - var paged = maxItemCount > 0 && allChanges.Count > maxItemCount - ? allChanges.Take(maxItemCount).ToList() - : allChanges; - - var newCheckpoint = checkpoint + totalChanges; - - if (paged.Count == 0) - { - // No new changes — return 304 Not Modified with the current checkpoint as etag - var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); - notModified.Headers.Add("x-ms-request-charge", "1"); - notModified.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - notModified.Headers.Add("x-ms-item-count", "0"); - notModified.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue( - $"\"{newCheckpoint}\""); - return notModified; - } - - var responseBody = new JObject - { - ["_rid"] = _collectionRid, - ["Documents"] = new JArray(paged), - ["_count"] = paged.Count - }; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseBody.ToString(), Encoding.UTF8, "application/json") - }; - - response.Headers.Add("x-ms-request-charge", "1"); - response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); - response.Headers.Add("x-ms-item-count", paged.Count.ToString()); - - // The SDK reads the etag header as the continuation token for CheckpointAsync. - // This MUST be non-empty or DocumentServiceLeaseCheckpointerCore.CheckpointAsync throws. - response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue( - $"\"{newCheckpoint}\""); - - // If there are more changes beyond this page, set continuation to signal more pages - if (paged.Count < allChanges.Count) - { - response.Headers.Add("x-ms-continuation", $"\"{newCheckpoint}\""); - } - - return response; - } - - private static bool IsChangeFeedRequest(HttpRequestMessage request) - { - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents - // The A-IM header with value "Incremental feed" indicates a change feed request. - if (!request.Headers.TryGetValues("A-IM", out var values)) - { - return false; - } - return values.Any(v => v.Contains("Incremental", StringComparison.OrdinalIgnoreCase)); - } - - private HttpResponseMessage BuildPagedResponse( - List allDocuments, int offset, int maxItemCount, string cacheKey) - { - var paged = allDocuments.Skip(offset).ToList(); - string? continuationToken = null; - if (maxItemCount > 0 && paged.Count > maxItemCount) - { - paged = paged.Take(maxItemCount).ToList(); - _queryResultCache.Set(cacheKey, allDocuments); - continuationToken = EncodeContinuation(cacheKey, offset + maxItemCount); - } - else - { - _queryResultCache.Remove(cacheKey); - } - - var responseBody = new JObject - { - ["_rid"] = _collectionRid, - ["Documents"] = new JArray(paged), - ["_count"] = paged.Count - }; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(responseBody.ToString(), Encoding.UTF8, "application/json") - }; - - response.Headers.Add("x-ms-request-charge", "1"); - response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); - response.Headers.Add("x-ms-item-count", paged.Count.ToString()); - - if (continuationToken is not null) - { - response.Headers.Add("x-ms-continuation", continuationToken); - } - - return response; - } - - private static async Task> DrainIterator( - FeedIterator feedIterator, CancellationToken cancellationToken) - where T : JToken - { - var allDocuments = new List(); - while (feedIterator.HasMoreResults) - { - var page = await feedIterator.ReadNextAsync(cancellationToken); - allDocuments.AddRange(page); - } - - return allDocuments; - } - - private static QueryDefinition BuildQueryDefinition(string sqlQuery, JObject queryBody) - { - var queryDef = new QueryDefinition(sqlQuery); - if (queryBody["parameters"] is JArray parameters) - { - foreach (var parameter in parameters) - { - var paramName = parameter["name"]?.ToString(); - var paramValue = parameter["value"]; - if (paramName is not null) - { - queryDef = queryDef.WithParameter(paramName, paramValue); - } - } - } - - return queryDef; - } - - private static PartitionKey? ExtractPartitionKey(HttpRequestMessage request) - { - if (request.Headers.TryGetValues("x-ms-documentdb-partitionkey", out var values)) - { - var raw = values.FirstOrDefault(); - if (raw is not null) - { - try - { - var arr = JArray.Parse(raw); - if (arr.Count == 1) - { - return arr[0].Type switch - { - JTokenType.String => new PartitionKey(arr[0].Value()), - JTokenType.Integer or JTokenType.Float => new PartitionKey(arr[0].Value()), - JTokenType.Boolean => new PartitionKey(arr[0].Value()), - JTokenType.Null => PartitionKey.Null, - JTokenType.Object => PartitionKey.None, // SDK sends [{}] for PartitionKey.None - _ => new PartitionKey(arr[0].ToString()) - }; - } - - if (arr.Count > 1) - { - var builder = new PartitionKeyBuilder(); - foreach (var token in arr) - { - switch (token.Type) - { - case JTokenType.String: - builder.Add(token.Value()!); - break; - case JTokenType.Integer or JTokenType.Float: - builder.Add(token.Value()); - break; - case JTokenType.Boolean: - builder.Add(token.Value()); - break; - case JTokenType.Null: - builder.AddNullValue(); - break; - default: - builder.Add(token.ToString()); - break; - } - } - return builder.Build(); - } - } - catch - { - // Ignore malformed partition key headers - } - } - } - - return null; - } - - private static int ExtractMaxItemCount(HttpRequestMessage request) - { - if (request.Headers.TryGetValues("x-ms-max-item-count", out var values) && - int.TryParse(values.FirstOrDefault(), out var count) && count > 0) - { - return count; - } - - return 0; - } - - private static string? ExtractPartitionKeyRangeId(HttpRequestMessage request) - { - if (request.Headers.TryGetValues("x-ms-documentdb-partitionkeyrangeid", out var values)) - { - return values.FirstOrDefault(); - } - - return null; - } - - private static (string Key, int Offset)? DecodeContinuation(HttpRequestMessage request) - { - if (!request.Headers.TryGetValues("x-ms-continuation", out var values)) - { - return null; - } - - var raw = values.FirstOrDefault(); - if (raw is null) - { - return null; - } - - try - { - var json = Encoding.UTF8.GetString(Convert.FromBase64String(raw)); - var obj = JsonParseHelpers.ParseJson(json); - var key = obj["key"]?.Value(); - var offset = obj["offset"]?.Value() ?? 0; - return key is not null ? (key, offset) : null; - } - catch - { - return null; - } - } - - private static string EncodeContinuation(string cacheKey, int offset) - { - var json = $"{{\"v\":2,\"key\":\"{cacheKey}\",\"offset\":{offset}}}"; - return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); - } - - private HttpResponseMessage CreateJsonResponse(string json) - => CreateJsonResponse(json, HttpStatusCode.OK); - - private HttpResponseMessage CreateJsonResponse(string json, HttpStatusCode statusCode) - { - var response = new HttpResponseMessage(statusCode) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - response.Headers.Add("x-ms-request-charge", "0"); - response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); - return response; - } - - private HttpResponseMessage CreateNoContentResponse() - { - var response = new HttpResponseMessage(HttpStatusCode.NoContent); - response.Headers.Add("x-ms-request-charge", "0"); - response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - return response; - } - - private string GetDatabaseMetadata(string databaseName) - { - var metadata = new JObject - { - ["id"] = databaseName, - ["_rid"] = _databaseRid, - ["_self"] = $"dbs/{_databaseRid}/", - ["_etag"] = "\"00000000-0000-0000-0000-000000000000\"", - ["_colls"] = "colls/", - ["_users"] = "users/", - ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - }; - return metadata.ToString(Formatting.None); - } - - private const string AccountMetadata = """ + private readonly InMemoryContainer _container; + private readonly ConcurrentBag _requestLog = new(); + private readonly ConcurrentBag _queryLog = new(); + private readonly ConcurrentBag _unrecognisedHeaders = new(); + private readonly List _sdkVersionWarnings = new(); + private readonly QueryResultCache _queryResultCache; + private readonly string _collectionRid; + private readonly string _databaseRid; + private readonly int _partitionKeyRangeCount; + private readonly string _partitionKeyPath; + private readonly IQueryPlanStrategy _queryPlanStrategy; + private readonly IBatchSchemaStrategy _batchSchemaStrategy; + private static int _ridCounter; + private const string PkRangesEtag = "\"pk-etag-1\""; + + /// + /// The version of the PartitionedQueryExecutionInfo format returned by query plan responses. + /// If the SDK starts expecting a different version, queries may fail. + /// + public const int QueryPlanVersion = 2; + + /// Minimum Cosmos SDK version that has been tested with this handler. + public static readonly Version MinTestedSdkVersion = new(3, 35, 0, 0); + + /// Maximum Cosmos SDK version that has been tested with this handler. + public static readonly Version MaxTestedSdkVersion = new(3, 58, 0, 0); + + /// + /// Set of x-ms-* request headers that this handler recognises and processes. + /// Any x-ms-* header not in this set is recorded in . + /// + private static readonly HashSet KnownRequestHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "A-IM", + "x-ms-documentdb-partitionkey", + "x-ms-documentdb-isquery", + "x-ms-documentdb-is-upsert", + "x-ms-cosmos-is-batch-request", + "x-ms-cosmos-is-query-plan-request", + "x-ms-max-item-count", + "x-ms-continuation", + "x-ms-documentdb-partitionkeyrangeid", + "x-ms-date", + "x-ms-version", + "x-ms-documentdb-query-enablecrosspartition", + "x-ms-documentdb-query-iscontinuationexpected", + "x-ms-documentdb-query-parallelizecrosspartitionquery", + "x-ms-documentdb-populatequerymetrics", + "x-ms-cosmos-correlated-activityid", + "x-ms-activity-id", + "x-ms-cosmos-sdk-supportedcapabilities", + "x-ms-session-token", + "x-ms-consistency-level", + "x-ms-request-charge", + "x-ms-cosmos-internal-is-query", + "x-ms-cosmos-query-version", + "x-ms-documentdb-collection-rid", + "x-ms-cosmos-batch-ordered-response", + "x-ms-cosmos-batch-atomic", + "x-ms-cosmos-batch-continue-on-error", + "x-ms-cosmos-sdk-version", + "x-ms-cosmos-intended-collection-rid", + "x-ms-cosmos-priority-level", + "x-ms-cosmos-allow-tentative-writes", + "x-ms-cosmos-physical-partition-count", + "x-ms-documentdb-responsecontinuationtokenlimitinkb", + "x-ms-documentdb-content-serialization-format", + "x-ms-cosmos-query-optimisticdirectexecute", + "x-ms-cosmos-supported-serialization-formats", + "x-ms-cosmos-supported-query-features", + }; + + /// + /// AsyncLocal override for partition key. When set, + /// and use this value when the standard + /// x-ms-documentdb-partitionkey header is absent. This is necessary because + /// the Cosmos SDK does not send the partition key header for prefix (hierarchical) + /// partition key queries — it routes by partition key range ID instead. + /// Used internally by the decorator. + /// + private readonly AsyncLocal _partitionKeyOverride = new(); + + /// + /// Separate AsyncLocal for the LINQ .ToFeedIterator() path. + /// Set eagerly by when + /// GetItemLinqQueryable is called with a prefix partition key, + /// because the produced by .ToFeedIterator() is + /// SDK-internal and cannot be wrapped. Uses a lower priority than + /// so that + /// (used by GetItemQueryIterator) takes precedence when both are set. + /// + private readonly AsyncLocal _partitionKeyHint = new(); + + /// + /// Sets a partition key hint for the LINQ .ToFeedIterator() path. + /// Unlike the scoped override, this value persists until overwritten + /// or the async context ends. It is used as a fallback when neither the HTTP + /// partition key header nor the scoped override is present. + /// + internal void SetPartitionKeyHint(PartitionKey? partitionKey) + { + _partitionKeyHint.Value = partitionKey; + } + + /// + /// Sets a scoped partition key override for the current async context. + /// Used internally by to forward + /// the partition key captured at the Container API surface. + /// + internal IDisposable WithPartitionKey(PartitionKey partitionKey) + { + _partitionKeyOverride.Value = partitionKey; + return new PartitionKeyScope(_partitionKeyOverride); + } + + private sealed class PartitionKeyScope(AsyncLocal asyncLocal) : IDisposable + { + public void Dispose() => asyncLocal.Value = null; + } + + /// Recorded HTTP requests in the form "METHOD /path". + public IReadOnlyCollection RequestLog => _requestLog; + + /// The backing in-memory container that stores all data for this handler. + public Container BackingContainer => _container; + + /// Internal typed access to the backing container. + internal InMemoryContainer BackingInMemoryContainer => _container; + + /// Recorded SQL query strings that were executed. + public IReadOnlyCollection QueryLog => _queryLog; + + /// + /// x-ms-* request headers seen during request processing that are not in the + /// known headers set. Populated as requests flow through . + /// Check this collection after a test run to detect new SDK headers that may need handling. + /// + public IReadOnlyCollection UnrecognisedHeaders => _unrecognisedHeaders; + + /// + /// Warnings generated during handler construction if the current Cosmos SDK version + /// falls outside the tested range ( to + /// ). + /// + public IReadOnlyList SdkVersionWarnings => _sdkVersionWarnings; + + /// + /// Optional fault injection delegate. When set, it is called before normal request + /// handling. If it returns a non-null response, that response is used immediately. + /// By default, metadata requests (account, collection, pkranges) are excluded to avoid + /// breaking SDK initialisation. Set to + /// true to also affect metadata routes. + /// + public Func? FaultInjector { get; set; } + + /// + /// When true, the delegate is also invoked for + /// metadata requests (account info, collection metadata, partition key ranges). + /// Defaults to false so SDK initialisation is not disrupted. + /// + public bool FaultInjectorIncludesMetadata { get; set; } + + internal FakeCosmosHandler(InMemoryContainer container) + : this(container, new FakeCosmosHandlerOptions()) + { + } + + internal FakeCosmosHandler(InMemoryContainer container, FakeCosmosHandlerOptions options) + { + _container = container; + _partitionKeyRangeCount = Math.Max(1, options.PartitionKeyRangeCount); + _queryResultCache = new QueryResultCache(options.CacheTtl, options.CacheMaxEntries); + (_databaseRid, _collectionRid) = GenerateResourceIds(container.Id); + _partitionKeyPath = container.PartitionKeyPaths.FirstOrDefault()?.TrimStart('/') ?? "id"; + _queryPlanStrategy = options.QueryPlanStrategy; + _batchSchemaStrategy = options.BatchSchemaStrategy; + CheckSdkVersion(); + } + + private void CheckSdkVersion() + { + var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version; + if (sdkVersion is null) return; + + if (sdkVersion < MinTestedSdkVersion) + { + var warning = $"FakeCosmosHandler: Cosmos SDK {sdkVersion} is older than the minimum tested version " + + $"({MinTestedSdkVersion}). Some features may not work correctly. " + + $"Call VerifySdkCompatibilityAsync() to check for compatibility."; + _sdkVersionWarnings.Add(warning); + System.Diagnostics.Trace.TraceWarning(warning); + } + else if (sdkVersion > MaxTestedSdkVersion) + { + var warning = $"FakeCosmosHandler: Cosmos SDK {sdkVersion} is newer than the last tested version " + + $"({MaxTestedSdkVersion}). Call VerifySdkCompatibilityAsync() in your test setup " + + $"to check for compatibility."; + _sdkVersionWarnings.Add(warning); + System.Diagnostics.Trace.TraceWarning(warning); + } + } + + /// + /// Returns true if the HybridRow batch schemas could be resolved. Used by + /// to probe availability without throwing. + /// + internal static bool BatchSchemasAvailable + { + get + { + _ = BatchSchemas.OperationLayout; + return true; + } + } + + private static (string DbRid, string CollRid) GenerateResourceIds(string containerId) + { + // Cosmos RID format: DB = 4-byte little-endian uint, Collection = DB bytes + 4 more bytes. + // Use atomic counter for the DB portion so every handler instance gets a unique RID, + // even if containers share the same name. Collection portion uses MurmurHash3 of the ID. + var instanceId = (uint)Interlocked.Increment(ref _ridCounter); + var dbBytes = BitConverter.GetBytes(instanceId); + var containerHash = PartitionKeyHash.MurmurHash3(containerId); + var collBytes = new byte[8]; + Buffer.BlockCopy(dbBytes, 0, collBytes, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(containerHash), 0, collBytes, 4, 4); + return (Convert.ToBase64String(dbBytes), Convert.ToBase64String(collBytes)); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? ""; + var method = request.Method.Method; + _requestLog.Add($"{method} {path}"); + + // Detect unrecognised x-ms-* headers for SDK compatibility diagnostics. + foreach (var header in request.Headers) + { + if (header.Key.StartsWith("x-ms-", StringComparison.OrdinalIgnoreCase) && + !KnownRequestHeaders.Contains(header.Key)) + { + _unrecognisedHeaders.Add(header.Key); + } + } + + // Buffer request content so FaultInjector can safely read it without + // consuming the stream that the handler needs later. + if (FaultInjector is not null && request.Content is not null) + { + var body = await request.Content.ReadAsStringAsync(cancellationToken); + var mediaType = request.Content.Headers.ContentType?.MediaType ?? "application/json"; + request.Content = new StringContent(body, Encoding.UTF8, mediaType); + } + + if (FaultInjectorIncludesMetadata && FaultInjector is not null) + { + var earlyFault = FaultInjector(request); + if (earlyFault is not null) + { + return earlyFault; + } + } + + if (method == "GET" && path is "/" or "") + { + return CreateJsonResponse(AccountMetadata); + } + + if (path.Contains("/pkranges")) + { + return HandlePartitionKeyRanges(request); + } + + if (path.Contains("/colls/") && !path.Contains("/docs")) + { + if (method == "GET") + { + return CreateJsonResponse(GetCollectionMetadata()); + } + + // PUT/DELETE only for container-level paths (not sub-resources like /sprocs, /triggers) + if (Regex.IsMatch(path, @"/colls/[^/]+/?$")) + { + if (method == "PUT") + return CreateJsonResponse(GetCollectionMetadata()); + if (method == "DELETE") + return CreateNoContentResponse(); + } + } + + // Database management: /dbs (list/create) + if (Regex.IsMatch(path, @"^/dbs/?$")) + { + if (method == "POST") + { + var body = request.Content is not null + ? await request.Content.ReadAsStringAsync(cancellationToken) + : "{}"; + var dbName = JObject.Parse(body)["id"]?.ToString() ?? "fake-db"; + return CreateJsonResponse(GetDatabaseMetadata(dbName), HttpStatusCode.Created); + } + + // GET /dbs → list databases + var dbList = new JObject + { + ["_rid"] = "", + ["Databases"] = new JArray(JObject.Parse(GetDatabaseMetadata("fake-db"))), + ["_count"] = 1 + }; + return CreateJsonResponse(dbList.ToString(Formatting.None)); + } + + // Database CRUD: /dbs/{id} (read/replace/delete) + if (Regex.IsMatch(path, @"^/dbs/[^/]+/?$")) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var dbName = segments.Length > 1 ? Uri.UnescapeDataString(segments[1]) : "fake-db"; + return method switch + { + "GET" or "PUT" => CreateJsonResponse(GetDatabaseMetadata(dbName)), + "DELETE" => CreateNoContentResponse(), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) + }; + } + + // Container management: /dbs/{id}/colls (list/create containers) + if (Regex.IsMatch(path, @"^/dbs/[^/]+/colls/?$")) + { + if (method == "POST") + { + return CreateJsonResponse(GetCollectionMetadata(), HttpStatusCode.Created); + } + + // GET /dbs/{id}/colls → list containers + var containerList = new JObject + { + ["_rid"] = _databaseRid, + ["DocumentCollections"] = new JArray(JObject.Parse(GetCollectionMetadata())), + ["_count"] = 1 + }; + return CreateJsonResponse(containerList.ToString(Formatting.None)); + } + + if (!FaultInjectorIncludesMetadata && FaultInjector is not null) + { + var faultResponse = FaultInjector(request); + if (faultResponse is not null) + { + return faultResponse; + } + } + + // Document-specific routes: /docs/{id} (point read, replace, delete, patch) + if (path.Contains("/docs/") && HasDocumentId(path)) + { + switch (method) + { + case "GET": + return await HandlePointReadAsync(request, cancellationToken); + case "PUT": + return await HandleReplaceAsync(request, cancellationToken); + case "DELETE": + return await HandleDeleteAsync(request, cancellationToken); + case "PATCH": + return await HandlePatchAsync(request, cancellationToken); + } + } + + // POST /docs (overloaded: batch, query plan, query, upsert, create) + if (method == "POST" && path.Contains("/docs")) + { + if (IsBatchRequest(request)) + { + return await HandleBatchAsync(request, cancellationToken); + } + + if (request.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out var qpValues) && + qpValues.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase))) + { + return await HandleQueryPlanAsync(request, cancellationToken); + } + + if (IsQueryRequest(request)) + { + return await HandleQueryAsync(request, cancellationToken); + } + + if (IsUpsertRequest(request)) + { + return await HandleUpsertAsync(request, cancellationToken); + } + + return await HandleCreateAsync(request, cancellationToken); + } + + if (method == "GET" && path.Contains("/docs")) + { + return await HandleReadFeedAsync(request, cancellationToken); + } + + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/querying-offers + // ReadThroughputAsync queries the /offers endpoint to retrieve provisioned throughput. + if (path.Contains("/offers")) + { + return HandleOffersRequest(request); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + $"{{\"message\":\"FakeCosmosHandler: unrecognised route {method} {path}\"}}", + Encoding.UTF8, + "application/json") + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CRUD route handlers + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task HandleCreateAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + var result = await _container.CreateItemStreamAsync(stream, pk, BuildItemRequestOptions(request), cancellationToken); + return ConvertToHttpResponse(result); + } + + private async Task HandleUpsertAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + var result = await _container.UpsertItemStreamAsync(stream, pk, BuildItemRequestOptions(request), cancellationToken); + return ConvertToHttpResponse(result); + } + + private async Task HandlePointReadAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var id = ExtractDocumentId(request); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + var result = await _container.ReadItemStreamAsync(id, pk, BuildItemRequestOptions(request), cancellationToken); + return ConvertToHttpResponse(result); + } + + private async Task HandleReplaceAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var id = ExtractDocumentId(request); + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + var result = await _container.ReplaceItemStreamAsync(stream, id, pk, BuildItemRequestOptions(request), cancellationToken); + return ConvertToHttpResponse(result); + } + + private async Task HandleDeleteAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var id = ExtractDocumentId(request); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + var result = await _container.DeleteItemStreamAsync(id, pk, BuildItemRequestOptions(request), cancellationToken); + return ConvertToHttpResponse(result); + } + + private async Task HandlePatchAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var id = ExtractDocumentId(request); + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + var (operations, condition) = ParsePatchBody(body); + var options = new PatchItemRequestOptions(); + if (condition is not null) + { + options.FilterPredicate = condition; + } + if (request.Headers.IfMatch.Any()) + { + options.IfMatchEtag = request.Headers.IfMatch.First().Tag; + } + var result = await _container.PatchItemStreamAsync(id, pk, operations, options, cancellationToken); + return ConvertToHttpResponse(result); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Transactional Batch handler + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Cosmos OperationType enum values used in the HybridRow batch wire protocol. + /// Values from Microsoft.Azure.Documents.OperationType (in Microsoft.Azure.Cosmos.Direct assembly). + /// + private static class BatchOperationTypes + { + public const int Create = 0; + public const int Patch = 1; + public const int Read = 2; + public const int Delete = 4; + public const int Replace = 5; + public const int Upsert = 20; + } + + /// + /// Lazily resolved HybridRow schema objects for batch request/response serialization. + /// Constructs schemas from scratch using public HybridRow APIs to eliminate fragile + /// reflection into SDK internals (BatchSchemaProvider). Falls back to reflection + /// if the self-built approach fails, with clear error messages. + /// + private static class BatchSchemas + { + private static readonly Lazy<(Layout OperationLayout, Layout ResultLayout, LayoutResolverNamespace Resolver)> _schemas = + new(BuildSchemas); + + public static Layout OperationLayout => _schemas.Value.OperationLayout; + public static Layout ResultLayout => _schemas.Value.ResultLayout; + public static LayoutResolverNamespace Resolver => _schemas.Value.Resolver; + + private static (Layout, Layout, LayoutResolverNamespace) BuildSchemas() + { + try + { + return BuildSchemasFromDefinition(); + } + catch (Exception ex) + { + // Self-built schema construction failed. Fall back to reflecting into the + // SDK's internal BatchSchemaProvider as a last resort. + try + { + return BuildSchemasFromReflection(); + } + catch (Exception reflectionEx) + { + throw new InvalidOperationException( + $"Failed to initialise HybridRow batch schemas. " + + $"Self-built schema error: {ex.Message}. " + + $"Reflection fallback error: {reflectionEx.Message}. " + + $"This may indicate an incompatible Cosmos SDK version " + + $"({typeof(CosmosClient).Assembly.GetName().Version}). " + + $"Batch operations will not work.", ex); + } + } + } + + /// + /// Build batch schemas from scratch using public HybridRow APIs. + /// This approach defines the BatchOperation and BatchResult schemas programmatically, + /// matching the wire format the Cosmos SDK expects, without reflecting into any internal types. + /// + private static (Layout, Layout, LayoutResolverNamespace) BuildSchemasFromDefinition() + { + var batchOperationSchema = new HybridRowSchemas.Schema + { + Name = "BatchOperation", + SchemaId = new SchemaId(2145473648), + Type = HybridRowSchemas.TypeKind.Schema, + Properties = + { + new HybridRowSchemas.Property { Path = "operationType", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, + new HybridRowSchemas.Property { Path = "resourceType", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, + new HybridRowSchemas.Property { Path = "partitionKey", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "effectivePartitionKey", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "id", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "binaryId", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "resourceBody", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "indexingDirective", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + new HybridRowSchemas.Property { Path = "ifMatch", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + new HybridRowSchemas.Property { Path = "ifNoneMatch", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + new HybridRowSchemas.Property { Path = "timeToLiveInSeconds", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + new HybridRowSchemas.Property { Path = "minimalReturnPreference", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Boolean) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + }, + }; + + var batchResultSchema = new HybridRowSchemas.Schema + { + Name = "BatchResult", + SchemaId = new SchemaId(2145473649), + Type = HybridRowSchemas.TypeKind.Schema, + Properties = + { + new HybridRowSchemas.Property { Path = "statusCode", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, + new HybridRowSchemas.Property { Path = "subStatusCode", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Int32) { Storage = HybridRowSchemas.StorageKind.Fixed, Nullable = true } }, + new HybridRowSchemas.Property { Path = "eTag", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Utf8) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "resourceBody", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Binary) { Storage = HybridRowSchemas.StorageKind.Variable, Nullable = true } }, + new HybridRowSchemas.Property { Path = "retryAfterMilliseconds", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.UInt32) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + new HybridRowSchemas.Property { Path = "requestCharge", PropertyType = new HybridRowSchemas.PrimitivePropertyType(HybridRowSchemas.TypeKind.Float64) { Storage = HybridRowSchemas.StorageKind.Sparse, Nullable = true } }, + }, + }; + + var ns = new HybridRowSchemas.Namespace + { + Name = "Microsoft.Azure.Cosmos.BatchApi", + Version = HybridRowSchemas.SchemaLanguageVersion.V2, + Schemas = { batchOperationSchema, batchResultSchema }, + }; + + var resolver = new LayoutResolverNamespace(ns, SystemSchema.LayoutResolver); + var opLayout = resolver.Resolve(batchOperationSchema.SchemaId); + var resultLayout = resolver.Resolve(batchResultSchema.SchemaId); + return (opLayout, resultLayout, resolver); + } + + /// + /// Fallback: resolve batch schemas via reflection into the SDK's internal BatchSchemaProvider. + /// + private static (Layout, Layout, LayoutResolverNamespace) BuildSchemasFromReflection() + { + var bspType = typeof(CosmosClient).Assembly.GetType("Microsoft.Azure.Cosmos.BatchSchemaProvider") + ?? throw new InvalidOperationException( + "Cannot find BatchSchemaProvider in the Cosmos SDK assembly. " + + "The SDK may have reorganised its internal types."); + var flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static; + var opLayout = (Layout)(bspType.GetProperty("BatchOperationLayout", flags)?.GetValue(null) + ?? throw new InvalidOperationException("BatchSchemaProvider.BatchOperationLayout not found.")); + var resultLayout = (Layout)(bspType.GetProperty("BatchResultLayout", flags)?.GetValue(null) + ?? throw new InvalidOperationException("BatchSchemaProvider.BatchResultLayout not found.")); + var resolver = (LayoutResolverNamespace)(bspType.GetProperty("BatchLayoutResolver", flags)?.GetValue(null) + ?? throw new InvalidOperationException("BatchSchemaProvider.BatchLayoutResolver not found.")); + return (opLayout, resultLayout, resolver); + } + } + + private record struct BatchOperation(int OperationType, string? Id, byte[]? ResourceBody, string? IfMatch, string? IfNoneMatch); + + private async Task HandleBatchAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + // Check if batch schemas are available before attempting to parse + if (!_batchSchemaStrategy.IsAvailable) + { + return new HttpResponseMessage(HttpStatusCode.NotImplemented) + { + Content = new StringContent( + $"{{\"message\":\"{(_batchSchemaStrategy.UnavailableReason ?? "Batch schemas unavailable").Replace("\"", "\\\"")}\"}}", + Encoding.UTF8, "application/json") + }; + } + + var pk = ExtractPartitionKey(request) ?? PartitionKey.None; + + // Parse the HybridRow/RecordIO binary request body to extract batch operations. + var batchOps = new List(); + if (request.Content is not null) + { + var bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken); + if (bodyBytes.Length == 0) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"message\":\"Empty batch request body.\"}", Encoding.UTF8, "application/json") + }; + } + + using var bodyStream = new MemoryStream(bodyBytes); + var batchLayoutResolver = BatchSchemas.Resolver; + + Func, Result> recordVisitor = (ReadOnlyMemory record) => + { + var row = new RowBuffer(record.Length); + if (!row.ReadFrom(record.Span, HybridRowVersion.V1, batchLayoutResolver)) + return Result.Failure; + + var reader = new RowReader(ref row); + int opType = -1; + string? id = null; + byte[]? resourceBody = null; + string? ifMatch = null; + string? ifNoneMatch = null; + + while (reader.Read()) + { + switch (reader.Path) + { + case "operationType": + reader.ReadInt32(out int ot); + opType = ot; + break; + case "id": + reader.ReadString(out string idVal); + id = idVal; + break; + case "resourceBody": + reader.ReadBinary(out byte[] rb); + resourceBody = rb; + break; + case "ifMatch": + reader.ReadString(out string im); + ifMatch = im; + break; + case "ifNoneMatch": + reader.ReadString(out string inm); + ifNoneMatch = inm; + break; + } + } + + batchOps.Add(new BatchOperation(opType, id, resourceBody, ifMatch, ifNoneMatch)); + return Result.Success; + }; + + var parseResult = await bodyStream.ReadRecordIOAsync( + recordVisitor, resizer: new MemorySpanResizer(1024)); + + if (parseResult != Result.Success) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + $"{{\"message\":\"Failed to parse batch request body.\"}}", + Encoding.UTF8, "application/json") + }; + } + } + + if (batchOps.Count == 0) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"message\":\"Empty batch request.\"}", Encoding.UTF8, "application/json") + }; + } + + // Execute the batch operations atomically using InMemoryTransactionalBatch. + var batch = _container.CreateTransactionalBatch(pk); + foreach (var op in batchOps) + { + switch (op.OperationType) + { + case BatchOperationTypes.Create: + if (op.ResourceBody is not null) + batch.CreateItemStream(new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); + break; + case BatchOperationTypes.Upsert: + if (op.ResourceBody is not null) + batch.UpsertItemStream(new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); + break; + case BatchOperationTypes.Replace: + if (op.Id is not null && op.ResourceBody is not null) + batch.ReplaceItemStream(op.Id, new MemoryStream(op.ResourceBody), BuildBatchItemRequestOptions(op)); + break; + case BatchOperationTypes.Delete: + if (op.Id is not null) + batch.DeleteItem(op.Id, BuildBatchItemRequestOptions(op)); + break; + case BatchOperationTypes.Read: + if (op.Id is not null) + batch.ReadItem(op.Id, BuildBatchItemRequestOptions(op)); + break; + case BatchOperationTypes.Patch: + if (op.Id is not null && op.ResourceBody is not null) + { + var patchJson = Encoding.UTF8.GetString(op.ResourceBody); + var (patchOps, condition) = ParsePatchBody(patchJson); + var patchOptions = new TransactionalBatchPatchItemRequestOptions(); + if (condition is not null) patchOptions.FilterPredicate = condition; + if (op.IfMatch is not null) patchOptions.IfMatchEtag = op.IfMatch; + batch.PatchItem(op.Id, patchOps, patchOptions); + } + break; + } + } + + var batchResponse = await batch.ExecuteAsync(cancellationToken); + + // Build the HybridRow/RecordIO binary response. + var batchResultLayout = BatchSchemas.ResultLayout; + var batchLayoutResolverForResponse = BatchSchemas.Resolver; + + using var responseStream = new MemoryStream(); + var resizer = new MemorySpanResizer(256); + + await RecordIOStream.WriteRecordIOAsync( + responseStream, + default(Segment), + (long index, out ReadOnlyMemory buffer) => + { + if (index >= batchResponse.Count) + { + buffer = default; + return Result.Success; + } + + var opResult = batchResponse[(int)index]; + var row = new RowBuffer(256, resizer); + row.InitLayout(HybridRowVersion.V1, batchResultLayout, batchLayoutResolverForResponse); + var r = RowWriter.WriteBuffer(ref row, opResult, (ref RowWriter writer, TypeArgument _, TransactionalBatchOperationResult result) => + { + Result wr; + wr = writer.WriteInt32("statusCode", (int)result.StatusCode); + if (wr != Result.Success) return wr; + + if (result.ETag is not null) + { + wr = writer.WriteString("eTag", result.ETag); + if (wr != Result.Success) return wr; + } + + if (result.ResourceStream is not null) + { + using var ms = new MemoryStream(); + result.ResourceStream.CopyTo(ms); + result.ResourceStream.Position = 0; + wr = writer.WriteBinary("resourceBody", ms.ToArray()); + if (wr != Result.Success) return wr; + } + + wr = writer.WriteFloat64("requestCharge", 1.0); + if (wr != Result.Success) return wr; + + return Result.Success; + }); + + if (r != Result.Success) + { + buffer = default; + return r; + } + + buffer = resizer.Memory.Slice(0, row.Length); + return Result.Success; + }, + new MemorySpanResizer(128)); + + var httpResponse = new HttpResponseMessage(batchResponse.StatusCode); + httpResponse.Content = new ByteArrayContent(responseStream.ToArray()); + httpResponse.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + httpResponse.Headers.Add("x-ms-request-charge", "1"); + httpResponse.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + httpResponse.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); + return httpResponse; + } + + private static TransactionalBatchItemRequestOptions? BuildBatchItemRequestOptions(BatchOperation op) + { + if (op.IfMatch is null && op.IfNoneMatch is null) + return null; + return new TransactionalBatchItemRequestOptions + { + IfMatchEtag = op.IfMatch, + IfNoneMatchEtag = op.IfNoneMatch + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CRUD helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private HttpResponseMessage ConvertToHttpResponse(ResponseMessage cosmosResponse) + { + var httpResponse = new HttpResponseMessage(cosmosResponse.StatusCode); + if (cosmosResponse.Content is not null) + { + using var reader = new StreamReader(cosmosResponse.Content); + var json = reader.ReadToEnd(); + if (json.Length > 0) + { + httpResponse.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + } + + httpResponse.Headers.Add("x-ms-request-charge", "1"); + httpResponse.Headers.Add("x-ms-activity-id", cosmosResponse.Headers["x-ms-activity-id"] ?? Guid.NewGuid().ToString()); + httpResponse.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); + + var subStatus = cosmosResponse.Headers["x-ms-substatus"]; + if (subStatus is not null) + { + httpResponse.Headers.Add("x-ms-substatus", subStatus); + } + + var etag = cosmosResponse.Headers["ETag"]; + if (etag is not null) + { + httpResponse.Headers.TryAddWithoutValidation("etag", etag); + } + + return httpResponse; + } + + private static string ExtractDocumentId(HttpRequestMessage request) + { + var path = request.RequestUri?.AbsolutePath ?? ""; + var docsIndex = path.LastIndexOf("/docs/", StringComparison.OrdinalIgnoreCase); + if (docsIndex >= 0) + { + var id = path[(docsIndex + 6)..]; + return Uri.UnescapeDataString(id.TrimEnd('/')); + } + return ""; + } + + private static bool HasDocumentId(string path) + { + var docsIndex = path.LastIndexOf("/docs/", StringComparison.OrdinalIgnoreCase); + if (docsIndex < 0) return false; + var afterDocs = path[(docsIndex + 6)..].TrimEnd('/'); + return afterDocs.Length > 0; + } + + private static ItemRequestOptions BuildItemRequestOptions(HttpRequestMessage request) + { + var options = new ItemRequestOptions(); + if (request.Headers.IfMatch.Any()) + { + options.IfMatchEtag = request.Headers.IfMatch.First().Tag; + } + if (request.Headers.IfNoneMatch.Any()) + { + options.IfNoneMatchEtag = request.Headers.IfNoneMatch.First().Tag; + } + return options; + } + + private static bool IsQueryRequest(HttpRequestMessage request) + { + var contentType = request.Content?.Headers?.ContentType?.MediaType ?? ""; + if (contentType.Contains("query+json", StringComparison.OrdinalIgnoreCase)) + return true; + if (request.Headers.TryGetValues("x-ms-documentdb-isquery", out var values) && + values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase))) + return true; + return false; + } + + private static bool IsUpsertRequest(HttpRequestMessage request) + { + return request.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var values) && + values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsBatchRequest(HttpRequestMessage request) + { + return request.Headers.TryGetValues("x-ms-cosmos-is-batch-request", out var values) && + values.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); + } + + private static (IReadOnlyList Operations, string? Condition) ParsePatchBody(string body) + { + var jObj = JObject.Parse(body); + var operations = new List(); + var condition = jObj["condition"]?.ToString(); + + var opsToken = jObj["operations"]; + if (opsToken is null) + throw new InvalidOperationException("Patch body missing 'operations' array."); + + foreach (var op in opsToken.ToObject()!) + { + var opType = op["op"]!.ToString().ToLowerInvariant(); + var opPath = op["path"]!.ToString(); + var value = op["value"]; + + operations.Add(opType switch + { + "set" => PatchOperation.Set(opPath, value), + "replace" => PatchOperation.Replace(opPath, value), + "add" => PatchOperation.Add(opPath, value), + "remove" => PatchOperation.Remove(opPath), + "incr" => PatchOperation.Increment(opPath, value!.Value()), + "move" => PatchOperation.Move(op["from"]!.ToString(), opPath), + _ => throw new InvalidOperationException($"Unknown patch operation: {opType}") + }); + } + + return (operations, condition); + } + + private HttpResponseMessage HandleOffersRequest(HttpRequestMessage request) + { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/querying-offers + // The SDK queries /offers to read provisioned throughput for a container. + // We return a single offer matching this container's throughput. + var offer = new JObject + { + ["offerVersion"] = "V2", + ["offerType"] = "Invalid", + ["content"] = new JObject + { + ["offerThroughput"] = _container._throughput, + ["offerIsRUPerMinuteThroughputEnabled"] = false + }, + ["offerResourceId"] = _collectionRid, + ["id"] = "offer-" + _collectionRid, + ["_rid"] = "offer-" + _collectionRid, + ["_self"] = "offers/offer-" + _collectionRid + "/", + ["_etag"] = $"\"{Guid.NewGuid()}\"", + ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + var wrapper = new JObject + { + ["_rid"] = "", + ["Offers"] = new JArray(offer), + ["_count"] = 1 + }; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(wrapper.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + response.Headers.Add("x-ms-request-charge", "1"); + response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + return response; + } + + private HttpResponseMessage HandlePartitionKeyRanges(HttpRequestMessage request) + { + if (request.Headers.IfNoneMatch.Any(etag => etag.Tag == PkRangesEtag)) + { + var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); + notModified.Headers.Add("x-ms-request-charge", "0"); + notModified.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + notModified.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(PkRangesEtag); + return notModified; + } + + var response = CreateJsonResponse(GetPartitionKeyRanges()); + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(PkRangesEtag); + return response; + } + + /// + /// Handles the gateway query plan request that the SDK sends on non-Windows platforms + /// (where the native ServiceInterop DLL is unavailable). Parses the SQL query and + /// returns a PartitionedQueryExecutionInfo with accurate metadata so that the + /// SDK builds the same execution pipeline (ORDER BY merge sort, aggregate accumulation, + /// DISTINCT deduplication, etc.) as it would on Windows via ServiceInterop. + /// + private async Task HandleQueryPlanAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var queryBody = JsonParseHelpers.ParseJson(body); + var sqlQuery = queryBody["query"]?.ToString() ?? "SELECT * FROM c"; + + CosmosSqlQuery? parsed = CosmosSqlParser.TryParse(sqlQuery, out var p) ? p : null; + var queryPlan = _queryPlanStrategy.BuildQueryPlan(sqlQuery, parsed, _collectionRid); + return CreateJsonResponse(queryPlan.ToString(Formatting.None)); + } + + private static bool HasAggregateInSelect(CosmosSqlQuery parsed) + { + return parsed.SelectFields.Any(field => ContainsAggregate(field.SqlExpr)); + } + + private static bool ContainsAggregate(SqlExpression? expr) + { + return expr switch + { + FunctionCallExpression func => + func.FunctionName.ToUpperInvariant() is "COUNT" or "COUNTIF" or "SUM" or "MIN" or "MAX" or "AVG" + || func.Arguments.Any(ContainsAggregate), + BinaryExpression bin => ContainsAggregate(bin.Left) || ContainsAggregate(bin.Right), + UnaryExpression unary => ContainsAggregate(unary.Operand), + TernaryExpression ternary => ContainsAggregate(ternary.Condition) || ContainsAggregate(ternary.IfTrue) || ContainsAggregate(ternary.IfFalse), + CoalesceExpression coalesce => ContainsAggregate(coalesce.Left) || ContainsAggregate(coalesce.Right), + _ => false + }; + } + + private static bool ContainsAvg(SqlExpression? expr) + { + return expr switch + { + FunctionCallExpression func => + func.FunctionName.Equals("AVG", StringComparison.OrdinalIgnoreCase) + || func.Arguments.Any(ContainsAvg), + BinaryExpression bin => ContainsAvg(bin.Left) || ContainsAvg(bin.Right), + UnaryExpression unary => ContainsAvg(unary.Operand), + TernaryExpression ternary => ContainsAvg(ternary.Condition) || ContainsAvg(ternary.IfTrue) || ContainsAvg(ternary.IfFalse), + CoalesceExpression coalesce => ContainsAvg(coalesce.Left) || ContainsAvg(coalesce.Right), + _ => false + }; + } + + private async Task HandleQueryAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var queryBody = JsonParseHelpers.ParseJson(body); + var sqlQuery = queryBody["query"]?.ToString() ?? "SELECT * FROM c"; + _queryLog.Add(sqlQuery); + + var partitionKey = ExtractPartitionKey(request) ?? _partitionKeyOverride.Value ?? _partitionKeyHint.Value; + var maxItemCount = ExtractMaxItemCount(request); + var continuation = DecodeContinuation(request); + var rangeId = ExtractPartitionKeyRangeId(request); + + List allDocuments; + string cacheKey; + int offset; + string? payloadPropertyName = null; + + if (continuation is not null && _queryResultCache.TryGet(continuation.Value.Key, out var cached)) + { + allDocuments = cached; + cacheKey = continuation.Value.Key; + offset = continuation.Value.Offset; + } + else + { + offset = 0; + cacheKey = Guid.NewGuid().ToString("N"); + + if (CosmosSqlParser.TryParse(sqlQuery, out var parsed)) + { + var orderByItemsField = parsed.SelectFields + .FirstOrDefault(field => IsOrderByItemsArray(field.SqlExpr)); + var isOrderByQuery = orderByItemsField is not null && parsed.OrderByFields is { Length: > 0 }; + + var groupByItemsField = parsed.SelectFields + .FirstOrDefault(field => string.Equals(field.Alias, "groupByItems", StringComparison.OrdinalIgnoreCase)); + var isGroupByQuery = groupByItemsField is not null && parsed.GroupByFields is { Length: > 0 }; + + if (isGroupByQuery) + { + allDocuments = await HandleGroupByQueryAsync( + parsed, queryBody, partitionKey, cancellationToken); + } + else if (isOrderByQuery) + { + var payloadField = parsed.SelectFields + .FirstOrDefault(field => string.Equals(field.Alias, "payload", StringComparison.OrdinalIgnoreCase)) + ?? parsed.SelectFields + .FirstOrDefault(field => field != orderByItemsField); + var orderByAlias = orderByItemsField!.Alias ?? "orderByItems"; + payloadPropertyName = payloadField?.Alias ?? "payload"; + + allDocuments = await HandleOrderByQueryAsync( + parsed, queryBody, partitionKey, orderByAlias, payloadPropertyName, cancellationToken); + } + else + { + var simplifiedSql = CosmosSqlParser.SimplifySdkQuery(parsed); + var queryDef = BuildQueryDefinition(simplifiedSql, queryBody); + var requestOptions = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = partitionKey } + : null; + allDocuments = await DrainIterator( + _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), + cancellationToken); + + // VALUE aggregate wrapping is no longer needed because the query plan + // now bypasses the SDK's AggregateQueryPipelineStage for VALUE + // aggregate queries. The container computes the final result directly + // and the raw value is returned without wire-format wrapping. + } + } + else + { + var queryDef = BuildQueryDefinition(sqlQuery, queryBody); + var requestOptions = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = partitionKey } + : null; + allDocuments = await DrainIterator( + _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), + cancellationToken); + } + } + + allDocuments = FilterDocumentsByRange(allDocuments, rangeId, payloadPropertyName); + + return BuildPagedResponse(allDocuments, offset, maxItemCount, cacheKey); + } + + private async Task> HandleOrderByQueryAsync( + CosmosSqlQuery parsed, JObject queryBody, PartitionKey? partitionKey, + string orderByAlias, string payloadAlias, CancellationToken cancellationToken) + { + // Check if any ORDER BY expression is a function call (not a simple property path). + // When this happens (e.g. ORDER BY VectorDistance(...)), the simplified SQL is + // SELECT VALUE c FROM c ORDER BY . The raw documents won't contain the + // computed expression value, so we need to include it in the query. + var hasComplexOrderBy = parsed.OrderByFields?.Any(f => f.Field is null) ?? false; + + string simplifiedSql; + List complexOrderByAliases = []; + try + { + if (hasComplexOrderBy) + { + // Build a query that includes both the document and the computed ORDER BY values + simplifiedSql = BuildComplexOrderBySql(parsed, payloadAlias, out complexOrderByAliases); + } + else + { + simplifiedSql = CosmosSqlParser.SimplifySdkQuery(parsed); + } + } + catch + { + // If SimplifySdkQuery fails (e.g. SDK changed internal format), + // fall back to executing SQL with SELECT VALUE and ORDER BY from the parsed structure. + simplifiedSql = BuildFallbackOrderBySql(parsed); + } + + var queryDef = BuildQueryDefinition(simplifiedSql, queryBody); + var requestOptions = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = partitionKey } + : null; + var feedIterator = _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions); + var rawDocuments = new List(); + while (feedIterator.HasMoreResults) + { + var page = await feedIterator.ReadNextAsync(cancellationToken); + rawDocuments.AddRange(page); + } + + List orderByPaths; + if (hasComplexOrderBy && complexOrderByAliases.Count > 0) + { + // For complex ORDER BY, the paths are the aliases we injected into the query + orderByPaths = complexOrderByAliases; + } + else + { + try + { + orderByPaths = ExtractOrderByItemPaths(parsed); + } + catch + { + // Fallback: derive from ORDER BY fields if AST walking fails + orderByPaths = parsed.OrderByFields?.Select(field => + StripFromAlias( + field.Field ?? CosmosSqlParser.ExprToString(field.Expression), + parsed.FromAlias)).ToList() ?? []; + } + } + + var documents = new List(); + // Determine if the payload is the full document or a projected expression. + // For DISTINCT+ORDER BY, the SDK rewrites payload as {"name": c.name} (an ObjectLiteral), + // meaning only the projected fields should be returned, not the full document. + var payloadField = parsed.SelectFields + .FirstOrDefault(f => string.Equals(f.Alias, payloadAlias, StringComparison.OrdinalIgnoreCase)); + var fromAlias = parsed.FromAlias ?? "c"; + + foreach (var doc in rawDocuments) + { + var orderByItems = new JArray(); + foreach (var path in orderByPaths) + { + var value = doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); + orderByItems.Add(new JObject { ["item"] = value }); + } + + JToken payloadValue; + if (hasComplexOrderBy) + { + // For complex ORDER BY queries, the doc contains _doc + _ob0/_ob1/... aliases. + // Extract the _doc field as the full document, then apply payload projection. + var fullDoc = doc["_doc"]?.DeepClone() ?? doc.DeepClone(); + // Remove the ORDER BY alias properties from the payload if they leaked in + if (fullDoc is JObject fullDocObj) + foreach (var alias in complexOrderByAliases) + fullDocObj.Remove(alias); + + // Apply payload projection if the rewritten query specified a projected payload + // (e.g. {"id": c.id, "score": VectorDistance(...)} AS payload) + if (payloadField?.SqlExpr is ObjectLiteralExpression objLit) + payloadValue = BuildComplexPayloadValue((JObject)fullDoc, objLit, fromAlias, parsed, doc, complexOrderByAliases); + else + payloadValue = fullDoc; + } + else + { + payloadValue = BuildPayloadValue(doc, payloadField, fromAlias); + } + + var wrapped = new JObject + { + ["_rid"] = (hasComplexOrderBy + ? doc["_doc"]?["_rid"]?.ToString() + : doc["_rid"]?.ToString()) ?? Guid.NewGuid().ToString("N")[..8], + [orderByAlias] = orderByItems, + [payloadAlias] = payloadValue + }; + documents.Add(wrapped); + } + + return documents; + } + + /// + /// Builds the payload value for an ORDER BY-wrapped document. + /// For simple ORDER BY, the payload is the full document. + /// For DISTINCT+ORDER BY, the SDK rewrites the payload as an ObjectLiteral + /// (e.g. {"name": c.name}), so we project just those fields. + /// + private static JToken BuildPayloadValue(JObject doc, SelectField? payloadField, string fromAlias) + { + if (payloadField?.SqlExpr is ObjectLiteralExpression objLiteral) + { + var projected = new JObject(); + foreach (var prop in objLiteral.Properties) + { + var exprStr = CosmosSqlParser.ExprToString(prop.Value); + var path = exprStr; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + path = path[(fromAlias.Length + 1)..]; + projected[prop.Key] = doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); + } + return projected; + } + + if (payloadField?.SqlExpr is IdentifierExpression id + && !string.Equals(id.Name, fromAlias, StringComparison.OrdinalIgnoreCase) + && !id.Name.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase) + && !id.Name.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) + { + var path = id.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + path = path[(fromAlias.Length + 1)..]; + return doc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); + } + + return doc.DeepClone(); + } + + /// + /// Builds the payload for a complex ORDER BY query where the payload was projected + /// as an ObjectLiteral (e.g. {"id": c.id, "score": VectorDistance(...)}). + /// Simple property references are resolved from the full document; function call + /// expressions are resolved from the computed ORDER BY aliases (_ob0, _ob1, ...). + /// + private static JToken BuildComplexPayloadValue( + JObject fullDoc, ObjectLiteralExpression objLiteral, string fromAlias, + CosmosSqlQuery parsed, JObject rawDoc, List orderByAliases) + { + var projected = new JObject(); + foreach (var prop in objLiteral.Properties) + { + var exprStr = CosmosSqlParser.ExprToString(prop.Value); + + // Check if this expression matches one of the ORDER BY expressions — + // if so, use the pre-computed _ob alias value from the raw result. + JToken? resolved = null; + if (parsed.OrderByFields is not null) + { + for (var i = 0; i < parsed.OrderByFields.Length && i < orderByAliases.Count; i++) + { + var obExpr = parsed.OrderByFields[i].Field + ?? CosmosSqlParser.ExprToString(parsed.OrderByFields[i].Expression); + if (string.Equals(exprStr, obExpr, StringComparison.OrdinalIgnoreCase)) + { + resolved = rawDoc[orderByAliases[i]]?.DeepClone(); + break; + } + } + } + + if (resolved is not null) + { + projected[prop.Key] = resolved; + } + else + { + // Simple property path — resolve from the full document + var path = exprStr; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + path = path[(fromAlias.Length + 1)..]; + projected[prop.Key] = fullDoc.SelectToken(path)?.DeepClone() ?? JValue.CreateNull(); + } + } + return projected; + } + + /// + /// Handles GROUP BY queries that the SDK has rewritten to the groupByItems + payload format. + /// Reconstructs the original GROUP BY query, executes on InMemoryContainer (which computes + /// final aggregates), then wraps results in the format the SDK's GroupByQueryPipelineStage expects. + /// + private async Task> HandleGroupByQueryAsync( + CosmosSqlQuery parsed, JObject queryBody, PartitionKey? partitionKey, + CancellationToken cancellationToken) + { + var originalSql = ReconstructGroupByQuery(parsed); + var queryDef = BuildQueryDefinition(originalSql, queryBody); + var requestOptions = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = partitionKey } + : null; + var rawResults = await DrainIterator( + _container.GetItemQueryIterator(queryDef, requestOptions: requestOptions), + cancellationToken); + + // Extract aggregate type info from the payload ObjectLiteral + var payloadField = parsed.SelectFields + .FirstOrDefault(f => string.Equals(f.Alias, "payload", StringComparison.OrdinalIgnoreCase)); + var aggregateTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (payloadField?.SqlExpr is ObjectLiteralExpression payloadObj) + { + foreach (var prop in payloadObj.Properties) + { + if (prop.Value is ObjectLiteralExpression inner + && inner.Properties.Length == 1 + && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase) + && inner.Properties[0].Value is FunctionCallExpression func) + { + aggregateTypes[prop.Key] = func.FunctionName.ToUpperInvariant(); + } + } + } + + // Extract GROUP BY key result-property names from the payload ObjectLiteral. + // Non-aggregate properties in the payload correspond to the GROUP BY keys. + var groupByResultKeys = new List(); + if (payloadField?.SqlExpr is ObjectLiteralExpression payloadObjForKeys) + { + foreach (var prop in payloadObjForKeys.Properties) + { + if (!(prop.Value is ObjectLiteralExpression inner + && inner.Properties.Length == 1 + && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase))) + { + groupByResultKeys.Add(prop.Key); + } + } + } + + return rawResults.Select(doc => + { + var jObj = doc as JObject ?? JObject.Parse(doc.ToString()); + + var groupByItems = new JArray(); + foreach (var key in groupByResultKeys) + { + groupByItems.Add(new JObject { ["item"] = jObj.SelectToken(key)?.DeepClone() ?? JValue.CreateNull() }); + } + + var payload = new JObject(); + foreach (var prop in jObj.Properties()) + { + if (aggregateTypes.TryGetValue(prop.Name, out var aggType)) + { + JToken itemValue = aggType == "AVG" + ? new JObject { ["sum"] = prop.Value, ["count"] = 1 } + : prop.Value.DeepClone(); + payload[prop.Name] = new JObject { ["item"] = itemValue }; + } + else + { + payload[prop.Name] = prop.Value.DeepClone(); + } + } + + return (JToken)new JObject + { + ["groupByItems"] = groupByItems, + ["payload"] = payload + }; + }).ToList(); + } + + /// + /// Reconstructs the original GROUP BY SQL from the SDK-rewritten format. + /// The SDK rewrites e.g. SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name to + /// SELECT [{"item": c.name}] AS groupByItems, {"name": c.name, "cnt": {"item": COUNT(1)}} AS payload FROM c GROUP BY c.name. + /// This method extracts the original SELECT fields from the payload ObjectLiteral. + /// + private static string ReconstructGroupByQuery(CosmosSqlQuery parsed) + { + var alias = parsed.FromAlias ?? "c"; + var payloadField = parsed.SelectFields + .FirstOrDefault(f => string.Equals(f.Alias, "payload", StringComparison.OrdinalIgnoreCase)); + + if (payloadField?.SqlExpr is not ObjectLiteralExpression payloadObj) + throw new InvalidOperationException("Cannot reconstruct GROUP BY query: payload field not found."); + + var selectParts = new List(); + foreach (var prop in payloadObj.Properties) + { + if (prop.Value is ObjectLiteralExpression inner + && inner.Properties.Length == 1 + && string.Equals(inner.Properties[0].Key, "item", StringComparison.OrdinalIgnoreCase)) + { + // Aggregate: {"alias": {"item": AGG(...)}} + selectParts.Add($"{CosmosSqlParser.ExprToString(inner.Properties[0].Value)} AS {prop.Key}"); + } + else + { + // Plain field: {"alias": expr} + selectParts.Add($"{CosmosSqlParser.ExprToString(prop.Value)} AS {prop.Key}"); + } + } + + var sb = new StringBuilder($"SELECT {string.Join(", ", selectParts)} FROM {alias}"); + + if (parsed.WhereExpr is not null) + sb.Append($" WHERE {CosmosSqlParser.ExprToString(parsed.WhereExpr)}"); + + sb.Append($" GROUP BY {string.Join(", ", parsed.GroupByFields!)}"); + + return sb.ToString(); + } + + /// + /// Strips OFFSET ... LIMIT ... from a SQL query string so the SDK pipeline + /// can apply OFFSET/LIMIT itself (avoiding double application). + /// + private static string StripOffsetLimit(string sql) + { + return Regex.Replace(sql, @"\s*OFFSET\s+\S+\s+LIMIT\s+\S+", "", RegexOptions.IgnoreCase).TrimEnd(); + } + + /// Static accessor for . + internal static string StripOffsetLimitStatic(string sql) => StripOffsetLimit(sql); + + /// + /// Builds the ORDER BY rewritten query in the format the SDK expects: + /// SELECT c._rid, [{"item": c.field}] AS orderByItems, c AS payload FROM c ... ORDER BY c.field ASC + /// + private static string BuildOrderByRewrittenQuery(CosmosSqlQuery parsed) + { + var alias = parsed.FromAlias ?? "c"; + + // Build orderByItems array: [{"item": c.field1}, {"item": c.field2}] + var orderByItemsParts = parsed.OrderByFields! + .Select(field => $"{{\"item\": {field.Field ?? CosmosSqlParser.ExprToString(field.Expression)}}}") + .ToList(); + var orderByItemsArray = $"[{string.Join(", ", orderByItemsParts)}]"; + + // Build SELECT with top-level fields: _rid, orderByItems, payload + var sb = new StringBuilder($"SELECT {alias}._rid, "); + sb.Append(orderByItemsArray); + sb.Append(" AS orderByItems, "); + + // For ORDER BY queries with explicit projections, the payload must be the + // projected SELECT fields (not the full document), so the SDK returns the + // correct shape to the caller. This applies to: + // 1. DISTINCT queries — for SDK deduplication + // 2. Queries with computed expressions (e.g. VectorDistance(...) AS score) — + // the computed value doesn't exist in the document and must be projected. + // All other cases (SELECT VALUE c, SELECT *, SELECT VALUE {obj}) use the full + // document as payload — the SDK or caller handles field extraction. + var hasComputedSelectField = parsed.SelectFields + .Any(f => f.SqlExpr is FunctionCallExpression); + if ((parsed.IsDistinct || hasComputedSelectField) + && parsed.SelectFields.Length > 0 + && parsed.SelectFields.All(f => f.SqlExpr is not null)) + { + var payloadParts = parsed.SelectFields.Select(f => + { + var expr = CosmosSqlParser.ExprToString(f.SqlExpr); + var key = f.Alias ?? f.Expression ?? expr; + // Strip FROM alias prefix from the key (e.g. "c.id" → "id") + // because Cosmos DB results use the property name without the alias. + if (key.StartsWith(alias + ".", StringComparison.OrdinalIgnoreCase)) + key = key[(alias.Length + 1)..]; + return $"\"{key}\": {expr}"; + }); + sb.Append($"{{{string.Join(", ", payloadParts)}}} AS payload"); + } + else + { + sb.Append($"{alias} AS payload"); + } + + // FROM clause + sb.Append($" FROM {alias}"); + + // WHERE clause — reconstruct from the where expression if present + if (parsed.WhereExpr is not null) + { + sb.Append(" WHERE "); + sb.Append(CosmosSqlParser.ExprToString(parsed.WhereExpr)); + } + + // ORDER BY clause + var orderByStr = string.Join(", ", parsed.OrderByFields!.Select(field => + $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); + sb.Append($" ORDER BY {orderByStr}"); + + return sb.ToString(); + } + + /// Static accessor for . + internal static string BuildOrderByRewrittenQueryStatic(CosmosSqlQuery parsed) => BuildOrderByRewrittenQuery(parsed); + + /// + /// Builds a SQL query that includes both the full document and computed ORDER BY + /// expression values. Used when ORDER BY contains function calls (e.g. VectorDistance) + /// that aren't simple property paths. + /// Example output: SELECT c AS _doc, VectorDistance(c.emb, [1,0,0]) AS _ob0 FROM c + /// ORDER BY VectorDistance(c.emb, [1,0,0]) ASC + /// + private static string BuildComplexOrderBySql(CosmosSqlQuery parsed, string payloadAlias, out List orderByAliases) + { + var alias = parsed.FromAlias ?? "c"; + + // Always select the full document as _doc. The payload projection (if any) + // is applied in post-processing via BuildPayloadValue, just like non-complex + // ORDER BY. This avoids inlining object literal expressions (e.g. + // {"id": c.id, "score": VectorDistance(...)}) into SELECT, which the parser + // cannot handle on re-parse. + var sb = new StringBuilder($"SELECT {alias} AS _doc"); + + orderByAliases = []; + for (var i = 0; i < parsed.OrderByFields!.Length; i++) + { + var field = parsed.OrderByFields[i]; + var obAlias = $"_ob{i}"; + orderByAliases.Add(obAlias); + var expr = field.Field ?? CosmosSqlParser.ExprToString(field.Expression); + sb.Append($", {expr} AS {obAlias}"); + } + + sb.Append($" FROM {alias}"); + + if (parsed.WhereExpr is not null) + { + var simplifiedWhere = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, alias); + if (simplifiedWhere is not null) + sb.Append($" WHERE {CosmosSqlParser.ExprToString(simplifiedWhere)}"); + } + + var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => + $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); + sb.Append($" ORDER BY {orderByStr}"); + + return sb.ToString(); + } + + private static string BuildFallbackOrderBySql(CosmosSqlQuery parsed) + { + var sb = new StringBuilder("SELECT "); + + if (parsed.IsDistinct) + { + sb.Append("DISTINCT "); + } + + if (parsed.TopCount.HasValue) + { + sb.Append($"TOP {parsed.TopCount.Value} "); + } + + sb.Append($"VALUE {parsed.FromAlias} FROM {parsed.FromAlias}"); + + if (parsed.OrderByFields is { Length: > 0 }) + { + var orderByStr = string.Join(", ", parsed.OrderByFields.Select(field => + $"{field.Field ?? CosmosSqlParser.ExprToString(field.Expression)} {(field.Ascending ? "ASC" : "DESC")}")); + sb.Append($" ORDER BY {orderByStr}"); + } + + return sb.ToString(); + } + + private static List ExtractOrderByItemPaths(CosmosSqlQuery parsed) + { + var orderByItemsField = parsed.SelectFields + .FirstOrDefault(field => IsOrderByItemsArray(field.SqlExpr)); + + if (orderByItemsField?.SqlExpr is ArrayLiteralExpression arrayExpr) + { + var paths = new List(); + foreach (var element in arrayExpr.Elements) + { + if (element is ObjectLiteralExpression obj) + { + var itemProp = obj.Properties.FirstOrDefault(property => + string.Equals(property.Key, "item", StringComparison.OrdinalIgnoreCase)); + if (itemProp.Value is IdentifierExpression ident) + { + paths.Add(StripFromAlias(ident.Name, parsed.FromAlias)); + } + else if (itemProp.Value is not null) + { + // Non-identifier expression (e.g. VectorDistance(...)) — use the + // corresponding ORDER BY field expression to reconstruct a path. + // The value will be looked up by evaluating the expression on the + // raw document, which won't produce a valid JPath. Instead, we + // return the expression string so the caller can handle it. + var exprStr = CosmosSqlParser.ExprToString(itemProp.Value); + paths.Add(StripFromAlias(exprStr, parsed.FromAlias)); + } + } + } + + if (paths.Count > 0) + { + return paths; + } + } + + if (parsed.OrderByFields is { Length: > 0 }) + { + return parsed.OrderByFields + .Select(field => StripFromAlias( + field.Field ?? CosmosSqlParser.ExprToString(field.Expression), + parsed.FromAlias)) + .ToList(); + } + + return []; + } + + private static string StripFromAlias(string path, string fromAlias) + { + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + return path[(fromAlias.Length + 1)..]; + } + + return path; + } + + private static bool IsOrderByItemsArray(SqlExpression? expr) + { + return expr is ArrayLiteralExpression { Elements.Length: > 0 } arrayLiteral + && arrayLiteral.Elements.All(element => + element is ObjectLiteralExpression obj + && obj.Properties.Any(prop => + string.Equals(prop.Key, "item", StringComparison.OrdinalIgnoreCase))); + } + + private List FilterDocumentsByRange(List documents, string? rangeId, string? payloadPropertyName = null) + { + if (_partitionKeyRangeCount <= 1 || rangeId is null) + { + return documents; + } + + if (!int.TryParse(rangeId, out var rangeIndex)) + { + return documents; + } + return documents.Where(document => GetRangeIndex(document, payloadPropertyName) == rangeIndex).ToList(); + } + + private int GetRangeIndex(JToken document, string? payloadPropertyName) + { + if (document is not JObject obj) + { + return 0; + } + + var targetDoc = payloadPropertyName is not null && obj[payloadPropertyName] is JObject payload + ? payload : obj; + var pkToken = targetDoc.SelectToken(_partitionKeyPath); + var pkValue = pkToken is not null ? InMemoryContainer.JTokenToTypedKey(pkToken) ?? "" : ""; + return PartitionKeyHash.GetRangeIndex(pkValue, _partitionKeyRangeCount); + } + + private async Task HandleReadFeedAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents + // "A-IM: Incremental feed" header distinguishes change feed from regular read feed. + if (IsChangeFeedRequest(request)) + { + return await HandleChangeFeedAsync(request); + } + + var maxItemCount = ExtractMaxItemCount(request); + var continuation = DecodeContinuation(request); + var rangeId = ExtractPartitionKeyRangeId(request); + + List allDocuments; + string cacheKey; + int offset; + + if (continuation is not null && _queryResultCache.TryGet(continuation.Value.Key, out var cached)) + { + allDocuments = cached; + cacheKey = continuation.Value.Key; + offset = continuation.Value.Offset; + } + else + { + offset = 0; + cacheKey = Guid.NewGuid().ToString("N"); + + var requestOptions = new QueryRequestOptions(); + var partitionKey = ExtractPartitionKey(request) ?? _partitionKeyOverride.Value ?? _partitionKeyHint.Value; + if (partitionKey is not null) + { + requestOptions.PartitionKey = partitionKey; + } + + allDocuments = await DrainIterator( + _container.GetItemQueryIterator(requestOptions: requestOptions), + cancellationToken); + } + + allDocuments = FilterDocumentsByRange(allDocuments, rangeId); + + return BuildPagedResponse(allDocuments, offset, maxItemCount, cacheKey); + } + + /// + /// Handles change feed HTTP requests (GET /docs with A-IM: Incremental feed). + /// The SDK's ChangeFeedProcessor sends these requests and expects: + /// - An etag response header containing a non-empty continuation token + /// (used by AutoCheckpointer.CheckpointAsync) + /// - HTTP 200 with documents when changes exist + /// - HTTP 304 Not Modified when no new changes are available + /// + /// + /// Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents + /// "If-None-Match: *" means read from the beginning; + /// "If-None-Match: {etag}" means resume from that checkpoint position. + /// The response etag is the logical sequence number (LSN) of the last change returned. + /// + private async Task HandleChangeFeedAsync(HttpRequestMessage request) + { + var maxItemCount = ExtractMaxItemCount(request); + var rangeId = ExtractPartitionKeyRangeId(request); + + // Determine checkpoint from If-None-Match header. + // "*" or absent → read from beginning (checkpoint 0). + // Quoted numeric string (e.g. "\"42\"") → resume from that position. + long checkpoint = 0; + if (request.Headers.IfNoneMatch.Count > 0) + { + var tag = request.Headers.IfNoneMatch.First().Tag; + // Strip surrounding quotes: "\"42\"" → "42" + var unquoted = tag?.Trim('"') ?? ""; + if (unquoted != "*" && long.TryParse(unquoted, out var parsed)) + { + checkpoint = parsed; + } + } + + // Read changes from the backing container's change feed + var iterator = _container.GetChangeFeedIterator(checkpoint); + var allChanges = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) + break; + allChanges.AddRange(page); + } + var totalChanges = allChanges.Count; + + allChanges = FilterDocumentsByRange(allChanges.Cast().ToList(), rangeId) + .Cast().ToList(); + + // Apply maxItemCount paging + var paged = maxItemCount > 0 && allChanges.Count > maxItemCount + ? allChanges.Take(maxItemCount).ToList() + : allChanges; + + var newCheckpoint = checkpoint + totalChanges; + + if (paged.Count == 0) + { + // No new changes — return 304 Not Modified with the current checkpoint as etag + var notModified = new HttpResponseMessage(HttpStatusCode.NotModified); + notModified.Headers.Add("x-ms-request-charge", "1"); + notModified.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + notModified.Headers.Add("x-ms-item-count", "0"); + notModified.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue( + $"\"{newCheckpoint}\""); + return notModified; + } + + var responseBody = new JObject + { + ["_rid"] = _collectionRid, + ["Documents"] = new JArray(paged), + ["_count"] = paged.Count + }; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody.ToString(), Encoding.UTF8, "application/json") + }; + + response.Headers.Add("x-ms-request-charge", "1"); + response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); + response.Headers.Add("x-ms-item-count", paged.Count.ToString()); + + // The SDK reads the etag header as the continuation token for CheckpointAsync. + // This MUST be non-empty or DocumentServiceLeaseCheckpointerCore.CheckpointAsync throws. + response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue( + $"\"{newCheckpoint}\""); + + // If there are more changes beyond this page, set continuation to signal more pages + if (paged.Count < allChanges.Count) + { + response.Headers.Add("x-ms-continuation", $"\"{newCheckpoint}\""); + } + + return response; + } + + private static bool IsChangeFeedRequest(HttpRequestMessage request) + { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-documents + // The A-IM header with value "Incremental feed" indicates a change feed request. + if (!request.Headers.TryGetValues("A-IM", out var values)) + { + return false; + } + return values.Any(v => v.Contains("Incremental", StringComparison.OrdinalIgnoreCase)); + } + + private HttpResponseMessage BuildPagedResponse( + List allDocuments, int offset, int maxItemCount, string cacheKey) + { + var paged = allDocuments.Skip(offset).ToList(); + string? continuationToken = null; + if (maxItemCount > 0 && paged.Count > maxItemCount) + { + paged = paged.Take(maxItemCount).ToList(); + _queryResultCache.Set(cacheKey, allDocuments); + continuationToken = EncodeContinuation(cacheKey, offset + maxItemCount); + } + else + { + _queryResultCache.Remove(cacheKey); + } + + var responseBody = new JObject + { + ["_rid"] = _collectionRid, + ["Documents"] = new JArray(paged), + ["_count"] = paged.Count + }; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody.ToString(), Encoding.UTF8, "application/json") + }; + + response.Headers.Add("x-ms-request-charge", "1"); + response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); + response.Headers.Add("x-ms-item-count", paged.Count.ToString()); + + if (continuationToken is not null) + { + response.Headers.Add("x-ms-continuation", continuationToken); + } + + return response; + } + + private static async Task> DrainIterator( + FeedIterator feedIterator, CancellationToken cancellationToken) + where T : JToken + { + var allDocuments = new List(); + while (feedIterator.HasMoreResults) + { + var page = await feedIterator.ReadNextAsync(cancellationToken); + allDocuments.AddRange(page); + } + + return allDocuments; + } + + private static QueryDefinition BuildQueryDefinition(string sqlQuery, JObject queryBody) + { + var queryDef = new QueryDefinition(sqlQuery); + if (queryBody["parameters"] is JArray parameters) + { + foreach (var parameter in parameters) + { + var paramName = parameter["name"]?.ToString(); + var paramValue = parameter["value"]; + if (paramName is not null) + { + queryDef = queryDef.WithParameter(paramName, paramValue); + } + } + } + + return queryDef; + } + + private static PartitionKey? ExtractPartitionKey(HttpRequestMessage request) + { + if (request.Headers.TryGetValues("x-ms-documentdb-partitionkey", out var values)) + { + var raw = values.FirstOrDefault(); + if (raw is not null) + { + try + { + var arr = JArray.Parse(raw); + if (arr.Count == 1) + { + return arr[0].Type switch + { + JTokenType.String => new PartitionKey(arr[0].Value()), + JTokenType.Integer or JTokenType.Float => new PartitionKey(arr[0].Value()), + JTokenType.Boolean => new PartitionKey(arr[0].Value()), + JTokenType.Null => PartitionKey.Null, + JTokenType.Object => PartitionKey.None, // SDK sends [{}] for PartitionKey.None + _ => new PartitionKey(arr[0].ToString()) + }; + } + + if (arr.Count > 1) + { + var builder = new PartitionKeyBuilder(); + foreach (var token in arr) + { + switch (token.Type) + { + case JTokenType.String: + builder.Add(token.Value()!); + break; + case JTokenType.Integer or JTokenType.Float: + builder.Add(token.Value()); + break; + case JTokenType.Boolean: + builder.Add(token.Value()); + break; + case JTokenType.Null: + builder.AddNullValue(); + break; + default: + builder.Add(token.ToString()); + break; + } + } + return builder.Build(); + } + } + catch + { + // Ignore malformed partition key headers + } + } + } + + return null; + } + + private static int ExtractMaxItemCount(HttpRequestMessage request) + { + if (request.Headers.TryGetValues("x-ms-max-item-count", out var values) && + int.TryParse(values.FirstOrDefault(), out var count) && count > 0) + { + return count; + } + + return 0; + } + + private static string? ExtractPartitionKeyRangeId(HttpRequestMessage request) + { + if (request.Headers.TryGetValues("x-ms-documentdb-partitionkeyrangeid", out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + private static (string Key, int Offset)? DecodeContinuation(HttpRequestMessage request) + { + if (!request.Headers.TryGetValues("x-ms-continuation", out var values)) + { + return null; + } + + var raw = values.FirstOrDefault(); + if (raw is null) + { + return null; + } + + try + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(raw)); + var obj = JsonParseHelpers.ParseJson(json); + var key = obj["key"]?.Value(); + var offset = obj["offset"]?.Value() ?? 0; + return key is not null ? (key, offset) : null; + } + catch + { + return null; + } + } + + private static string EncodeContinuation(string cacheKey, int offset) + { + var json = $"{{\"v\":2,\"key\":\"{cacheKey}\",\"offset\":{offset}}}"; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + private HttpResponseMessage CreateJsonResponse(string json) + => CreateJsonResponse(json, HttpStatusCode.OK); + + private HttpResponseMessage CreateJsonResponse(string json, HttpStatusCode statusCode) + { + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + response.Headers.Add("x-ms-request-charge", "0"); + response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + response.Headers.Add("x-ms-session-token", _container.CurrentSessionToken); + return response; + } + + private HttpResponseMessage CreateNoContentResponse() + { + var response = new HttpResponseMessage(HttpStatusCode.NoContent); + response.Headers.Add("x-ms-request-charge", "0"); + response.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + return response; + } + + private string GetDatabaseMetadata(string databaseName) + { + var metadata = new JObject + { + ["id"] = databaseName, + ["_rid"] = _databaseRid, + ["_self"] = $"dbs/{_databaseRid}/", + ["_etag"] = "\"00000000-0000-0000-0000-000000000000\"", + ["_colls"] = "colls/", + ["_users"] = "users/", + ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + return metadata.ToString(Formatting.None); + } + + private const string AccountMetadata = """ { "id": "fake-account", "_rid": "", @@ -2261,992 +2261,993 @@ private string GetDatabaseMetadata(string databaseName) } """; - private string GetCollectionMetadata() - { - var paths = new JArray(_container.PartitionKeyPaths.Select(path => (JToken)path)); - var policy = _container.IndexingPolicy; - var includedPaths = new JArray(policy.IncludedPaths.Select(p => new JObject { ["path"] = p.Path })); - var excludedPaths = new JArray(policy.ExcludedPaths.Select(p => new JObject { ["path"] = p.Path })); - if (!excludedPaths.Any(p => p["path"]?.ToString() == "/\"_etag\"/?")) - excludedPaths.Add(new JObject { ["path"] = "/\"_etag\"/?" }); - - var indexingPolicyObj = new JObject - { - ["indexingMode"] = policy.IndexingMode.ToString().ToLowerInvariant(), - ["automatic"] = policy.Automatic, - ["includedPaths"] = includedPaths, - ["excludedPaths"] = excludedPaths - }; - - if (policy.CompositeIndexes.Count > 0) - { - var compositeIndexes = new JArray(policy.CompositeIndexes.Select(indexSet => - new JArray(indexSet.Select(idx => new JObject - { - ["path"] = idx.Path, - ["order"] = idx.Order.ToString().ToLowerInvariant() - })))); - indexingPolicyObj["compositeIndexes"] = compositeIndexes; - } - - if (policy.SpatialIndexes.Count > 0) - { - var spatialIndexes = new JArray(policy.SpatialIndexes.Select(si => { - var obj = new JObject { ["path"] = si.Path }; - if (si.SpatialTypes.Count > 0) - obj["types"] = new JArray(si.SpatialTypes.Select(t => (JToken)t.ToString())); - return obj; - })); - indexingPolicyObj["spatialIndexes"] = spatialIndexes; - } - - var metadata = new JObject - { - ["id"] = _container.Id, - ["_rid"] = _collectionRid, - ["_self"] = $"dbs/{_databaseRid}/colls/{_collectionRid}/", - ["_etag"] = "\"00000000-0000-0000-0000-000000000000\"", - ["_ts"] = 1700000000, - ["partitionKey"] = new JObject - { - ["paths"] = paths, - ["kind"] = paths.Count > 1 ? "MultiHash" : "Hash", - ["version"] = 2 - }, - ["indexingPolicy"] = indexingPolicyObj, - ["geospatialConfig"] = new JObject { ["type"] = "Geography" } - }; - return metadata.ToString(Formatting.None); - } - - private string GetPartitionKeyRanges() - { - var ranges = new JArray(); - var step = 0x1_0000_0000L / _partitionKeyRangeCount; - for (var i = 0; i < _partitionKeyRangeCount; i++) - { - var minInclusive = PartitionKeyHash.RangeBoundaryToHex(i * step); - var maxExclusive = i == _partitionKeyRangeCount - 1 ? "FF" : PartitionKeyHash.RangeBoundaryToHex((i + 1) * step); - ranges.Add(new JObject - { - ["id"] = i.ToString(), - ["_rid"] = _collectionRid, - ["minInclusive"] = minInclusive, - ["maxExclusive"] = maxExclusive, - ["throughputFraction"] = 1.0 / _partitionKeyRangeCount, - ["status"] = "online", - ["_self"] = $"dbs/{_databaseRid}/colls/{_collectionRid}/pkranges/{i}/", - ["_ts"] = 1700000000, - ["_etag"] = PkRangesEtag.Trim('"') - }); - } - - var result = new JObject - { - ["_rid"] = _collectionRid, - ["PartitionKeyRanges"] = ranges, - ["_count"] = _partitionKeyRangeCount - }; - return result.ToString(Formatting.None); - } - - /// - /// Creates a routing that dispatches requests to - /// different instances based on the container name - /// in the URL path. This enables a single to query - /// multiple in-memory containers. - /// - /// - /// A dictionary mapping container names to their handlers. The first entry is used - /// as the default for account-level requests (e.g. GET /). - /// - internal static HttpMessageHandler CreateRouter( - IReadOnlyDictionary handlers) - { - return new RoutingHandler(handlers); - } - - internal const string FakeConnectionString = "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;"; - - /// - /// Wraps an existing so that containers returned by - /// automatically capture prefix partition - /// keys for hierarchical PK queries. Use this when you have already constructed - /// a with a custom HTTP pipeline (e.g. tracking handlers) - /// and want to add prefix PK support on top. - /// - /// - /// - /// var trackingHandler = new CosmosTrackingMessageHandler(opts, fakeHandler); - /// var innerClient = new CosmosClient(connStr, new CosmosClientOptions - /// { - /// ConnectionMode = ConnectionMode.Gateway, - /// HttpClientFactory = () => new HttpClient(trackingHandler) - /// }); - /// var client = handler.WrapClient(innerClient); - /// - /// - /// - /// - /// The to wrap. - /// - /// A whose - /// returns containers with automatic prefix PK capturing. - /// - internal CosmosClient WrapClient(CosmosClient innerClient) - => WrapClient(innerClient, new Dictionary { [_container.Id] = this }); - - /// - /// Wraps an existing with prefix partition key capturing - /// for multiple containers. Each container returned by - /// will be matched against the dictionary to find the - /// correct for PK capture. - /// - /// The to wrap. - /// - /// A dictionary mapping container names (or "database/container" keys) to their handlers. - /// - internal static CosmosClient WrapClient( - CosmosClient innerClient, - IReadOnlyDictionary handlers) - => new PkAwareCosmosClient(innerClient, handlers); - - /// - /// Creates a backed by this handler that automatically - /// captures prefix partition keys for hierarchical PK queries. This is a convenience - /// method that builds the and wraps it in a single call. - /// - /// For custom HTTP pipelines (e.g. tracking or logging handlers), construct the - /// yourself and use instead. - /// - /// - internal CosmosClient CreateClient() - => CreateClient(new Dictionary { [_container.Id] = this }); - - /// - /// Creates a with a routing handler that dispatches - /// to multiple instances, with automatic prefix - /// partition key capturing for hierarchical PK queries. - /// - /// For custom HTTP pipelines, construct the yourself - /// with and use - /// instead. - /// - /// - internal static CosmosClient CreateClient( - Dictionary handlers) - { - HttpMessageHandler httpHandler = handlers.Count == 1 - ? handlers.Values.First() - : CreateRouter(handlers); - - var innerClient = new CosmosClient( - FakeConnectionString, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(httpHandler) - }); - - return WrapClient(innerClient, handlers); - } - - /// - /// Runs a self-test to verify that the current Cosmos SDK version still uses the - /// HTTP contract this handler expects (URL patterns, header names, response formats, - /// ORDER BY wrapping, pagination, aggregates). Call this once during test suite setup - /// to detect SDK breaking changes early, rather than getting silent data corruption. - /// - public static async Task VerifySdkCompatibilityAsync() - { - var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; - - var container = new InMemoryContainer("compat-check", "/partitionKey"); - await container.CreateItemAsync( - new CompatibilityDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new CompatibilityDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new CompatibilityDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, - new PartitionKey("pk")); - - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var cosmosContainer = client.GetContainer("fakeDb", "compat-check"); - - var allItems = await DrainFeedIteratorAsync( - cosmosContainer.GetItemLinqQueryable().ToFeedIterator()); - if (allItems.Count != 3) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): expected 3 items from basic query but got {allItems.Count}. " + - "The Cosmos SDK may have changed its internal HTTP contract."); - } - - var ordered = await DrainFeedIteratorAsync( - cosmosContainer.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIterator()); - if (ordered.Count != 3 || ordered[0].Name != "Alice" || ordered[2].Name != "Charlie") - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): ORDER BY query returned unexpected results. " + - "The Cosmos SDK ORDER BY response format may have changed."); - } - - var filtered = await DrainFeedIteratorAsync( - cosmosContainer.GetItemLinqQueryable() - .Where(document => document.Value > 15) - .ToFeedIterator()); - if (filtered.Count != 2) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): expected 2 items from filtered query but got {filtered.Count}. " + - "The Cosmos SDK may have changed its query or header format."); - } - - var paginatedItems = new List(); - var pageIterator = cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }) - .ToFeedIterator(); - var pageCount = 0; - while (pageIterator.HasMoreResults) - { - var page = await pageIterator.ReadNextAsync(); - paginatedItems.AddRange(page); - pageCount++; - } - - if (paginatedItems.Count != 3) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): paginated query returned {paginatedItems.Count} items instead of 3. " + - "The Cosmos SDK may have changed its pagination or continuation token format."); - } - - if (pageCount < 2) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): expected multiple pages with MaxItemCount=1 but got {pageCount} page(s). " + - "The Cosmos SDK may have changed how it sends the x-ms-max-item-count header."); - } - - var countResult = await cosmosContainer.GetItemLinqQueryable().CountAsync(); - if (countResult.Resource != 3) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): CountAsync returned {countResult.Resource} instead of 3. " + - "The Cosmos SDK may have changed its aggregate query format."); - } - - if (!handler.RequestLog.Any(entry => entry.Contains("/pkranges"))) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): no partition key range request was detected. " + - "The Cosmos SDK may have changed how it discovers partition key ranges."); - } - - if (!handler.QueryLog.Any()) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): no query was logged. " + - "The Cosmos SDK may have changed how it sends query requests."); - } - - // CRUD roundtrip — verifies that create/read/delete HTTP routes work through the SDK - var crudDoc = new CompatibilityDocument { Id = "crud-test", PartitionKey = "pk", Name = "CrudTest", Value = 99 }; - var createResponse = await cosmosContainer.CreateItemAsync(crudDoc, new PartitionKey("pk")); - if (createResponse.StatusCode != HttpStatusCode.Created) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): CreateItemAsync returned {createResponse.StatusCode} instead of Created. " + - "The Cosmos SDK may have changed its CRUD HTTP contract."); - } - - var readResponse = await cosmosContainer.ReadItemAsync("crud-test", new PartitionKey("pk")); - if (readResponse.Resource?.Name != "CrudTest") - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): ReadItemAsync returned unexpected resource. " + - "The Cosmos SDK may have changed its point-read HTTP contract."); - } - - var deleteResponse = await cosmosContainer.DeleteItemAsync("crud-test", new PartitionKey("pk")); - if (deleteResponse.StatusCode != HttpStatusCode.NoContent) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): DeleteItemAsync returned {deleteResponse.StatusCode} instead of NoContent. " + - "The Cosmos SDK may have changed its delete HTTP contract."); - } - - // DISTINCT query (before upsert so item count is deterministic) - var distinctItems = await DrainFeedIteratorAsync( - cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT c.name FROM c"))); - if (distinctItems.Count != 3) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): DISTINCT query returned {distinctItems.Count} items instead of 3. " + - "The Cosmos SDK may have changed its DISTINCT query handling."); - } - - // OFFSET/LIMIT query (before upsert so item count is deterministic) - var offsetLimitItems = await DrainFeedIteratorAsync( - cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c OFFSET 1 LIMIT 2"))); - if (offsetLimitItems.Count != 2) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): OFFSET/LIMIT query returned {offsetLimitItems.Count} items instead of 2. " + - "The Cosmos SDK may have changed its OFFSET/LIMIT handling."); - } - - // Upsert roundtrip - var upsertDoc = new CompatibilityDocument { Id = "upsert-test", PartitionKey = "pk", Name = "Upserted", Value = 42 }; - var upsertResponse = await cosmosContainer.UpsertItemAsync(upsertDoc, new PartitionKey("pk")); - if (upsertResponse.StatusCode is not (HttpStatusCode.Created or HttpStatusCode.OK)) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): UpsertItemAsync returned {upsertResponse.StatusCode}. " + - "The Cosmos SDK may have changed its upsert HTTP contract."); - } - - // Patch roundtrip - var patchOps = new[] { PatchOperation.Set("/value", 99) }; - var patchResponse = await cosmosContainer.PatchItemAsync( - "upsert-test", new PartitionKey("pk"), patchOps); - if (patchResponse.Resource?.Value != 99) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): PatchItemAsync returned unexpected value. " + - "The Cosmos SDK may have changed its patch HTTP contract."); - } - - // Clean up upsert-test doc - await cosmosContainer.DeleteItemAsync("upsert-test", new PartitionKey("pk")); - - // Transactional batch roundtrip - var batchDoc1 = new CompatibilityDocument { Id = "batch-1", PartitionKey = "pk", Name = "B1", Value = 1 }; - var batchDoc2 = new CompatibilityDocument { Id = "batch-2", PartitionKey = "pk", Name = "B2", Value = 2 }; - var batchResponse = await cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk")) - .CreateItem(batchDoc1) - .CreateItem(batchDoc2) - .ExecuteAsync(); - - if (!batchResponse.IsSuccessStatusCode) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): TransactionalBatch returned " + - $"{batchResponse.StatusCode}. The Cosmos SDK may have changed its batch wire format " + - "(HybridRow schemas or RecordIO encoding)."); - } - - if (batchResponse.Count != 2) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): batch returned {batchResponse.Count} " + - "results instead of 2."); - } - - // Clean up batch docs - await cosmosContainer.DeleteItemAsync("batch-1", new PartitionKey("pk")); - await cosmosContainer.DeleteItemAsync("batch-2", new PartitionKey("pk")); - - // ReadMany roundtrip - var readManyResult = await cosmosContainer.ReadManyItemsAsync( - new[] { ("1", new PartitionKey("pk")), ("2", new PartitionKey("pk")) }); - - if (readManyResult.StatusCode != HttpStatusCode.OK) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): ReadManyItemsAsync returned " + - $"{readManyResult.StatusCode}. The Cosmos SDK may have changed its ReadMany HTTP contract."); - } - - if (readManyResult.Count != 2) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): ReadMany returned {readManyResult.Count} " + - "items instead of 2."); - } - - // Change feed roundtrip (uses backing container API since GetChangeFeedIterator - // doesn't work through FakeCosmosHandler — it doesn't implement A-IM protocol) - var changeFeedIterator = container.GetChangeFeedIterator(0); - var changeFeedItems = new List(); - while (changeFeedIterator.HasMoreResults) - { - var page = await changeFeedIterator.ReadNextAsync(); - changeFeedItems.AddRange(page); - } - - if (changeFeedItems.Count < 3) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): Change feed returned {changeFeedItems.Count} " + - "items instead of at least 3. The change feed infrastructure may be broken."); - } - - // GROUP BY query - var groupByItems = await DrainFeedIteratorAsync( - cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"))); - - if (groupByItems.Count < 1) - { - throw new InvalidOperationException( - $"SDK compatibility check failed (v{sdkVersion}): GROUP BY query returned no results. " + - "The Cosmos SDK may have changed its GROUP BY handling."); - } - - // Verify no unrecognised headers were seen during the test - if (handler.UnrecognisedHeaders.Count > 0) - { - var unknownHeaders = string.Join(", ", handler.UnrecognisedHeaders.Distinct()); - System.Diagnostics.Trace.TraceWarning( - $"FakeCosmosHandler: SDK v{sdkVersion} sent unrecognised x-ms-* headers: {unknownHeaders}. " + - "These may indicate new SDK features that are not yet handled."); - } - } - - private static async Task> DrainFeedIteratorAsync(FeedIterator iterator) - { - var items = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - items.AddRange(page); - } - - return items; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _queryResultCache.Clear(); - _container.Dispose(); - } - - base.Dispose(disposing); - } - - /// - /// Shared registry for dynamic container management. Holds the mutable dictionaries - /// that both RoutingHandler and InMemoryCosmosResult reference. - /// - internal sealed class DynamicContainerRegistry - { - public ConcurrentDictionary Handlers { get; } = new(StringComparer.Ordinal); - public ConcurrentDictionary BackingContainers { get; } = new(StringComparer.Ordinal); - public ConcurrentDictionary SdkContainers { get; } = new(StringComparer.Ordinal); - public HashSet KnownDatabases { get; } = new(StringComparer.Ordinal); - - /// - /// Maps database name → (container name → registry key). - /// Registry keys are plain container names for default DB, compound "db/container" for explicit databases. - /// - public ConcurrentDictionary> DatabaseContainerKeys { get; } = new(StringComparer.Ordinal); - - /// Set of database names that use compound registry keys (added via AddDatabase). - public HashSet CompoundKeyDatabases { get; } = new(StringComparer.Ordinal); - - /// Client reference set after client creation so dynamic containers can register SDK containers. - public CosmosClient? Client { get; set; } - - /// Database name used for dynamic container registration. - public string DatabaseName { get; set; } = "default"; - } - - internal sealed class RoutingHandler : HttpMessageHandler - { - private readonly DynamicContainerRegistry _registry; - private readonly FakeCosmosHandler _default; - - public RoutingHandler(IReadOnlyDictionary handlers) - : this(handlers, new DynamicContainerRegistry()) - { - } - - public RoutingHandler(IReadOnlyDictionary handlers, DynamicContainerRegistry registry) - { - if (!handlers.Any()) - throw new ArgumentException("At least one handler must be registered.", nameof(handlers)); - _registry = registry; - foreach (var kvp in handlers) - _registry.Handlers[kvp.Key] = kvp.Value; - _default = handlers.Values.First(); - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri?.AbsolutePath ?? ""; - var method = request.Method.Method; - - if (path is "/" or "") - { - return await InvokeHandlerAsync(_default, request, cancellationToken); - } - - // Container creation: POST /dbs/{db}/colls - // Container listing: GET /dbs/{db}/colls - var collsMatch = Regex.Match(path, @"^/dbs/([^/]+)/colls/?$"); - if (collsMatch.Success) - { - var dbNameForColls = collsMatch.Groups[1].Value; - if (method == "POST") - return await HandleCreateContainerAsync(request, dbNameForColls, cancellationToken); - if (method == "GET") - return HandleListContainers(dbNameForColls); - } - - // Database management: /dbs - if (Regex.IsMatch(path, @"^/dbs/?$")) - { - return await HandleDatabaseListOrCreate(request, method, cancellationToken); - } - - // Database CRUD: /dbs/{id} - if (Regex.IsMatch(path, @"^/dbs/[^/]+/?$") && !path.Contains("/colls")) - { - return HandleDatabaseCrud(request, path, method); - } - - var match = Regex.Match(path, @"/dbs/([^/]+)/colls/([^/]+)"); - if (match.Success) - { - var dbName = match.Groups[1].Value; - var containerName = match.Groups[2].Value; - - // Container-level operations (not sub-resources) - if (Regex.IsMatch(path, @"/colls/[^/]+/?$")) - { - // DELETE container - if (method == "DELETE") - { - return HandleDeleteContainer(dbName, containerName); - } - - // GET container metadata - if (method == "GET") - { - return HandleReadContainer(dbName, containerName); - } - - // PUT container (replace) - if (method == "PUT") - { - return await HandleReplaceContainer(request, dbName, containerName, cancellationToken); - } - } - - // Container listing: GET /dbs/{db}/colls (handled above, but also match here for safety) - - // Try database+container compound key first, then fall back to container-only - var compoundKey = $"{dbName}/{containerName}"; - if (_registry.Handlers.TryGetValue(compoundKey, out var compoundHandler)) - { - return await InvokeHandlerAsync(compoundHandler, request, cancellationToken); - } - - if (_registry.Handlers.TryGetValue(containerName, out var handler)) - { - return await InvokeHandlerAsync(handler, request, cancellationToken); - } - - // SDK internal routes use base64-encoded RIDs (e.g. "AQAAAA==") for - // partition key range and other metadata requests. Fall back to the - // default handler for these rather than throwing. - if (containerName.Contains('=') || path.Contains("/pkranges")) - { - return await InvokeHandlerAsync(_default, request, cancellationToken); - } - - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - $"{{\"message\":\"Container '{containerName}' not found. " + - $"Available containers: {string.Join(", ", _registry.Handlers.Keys.OrderBy(k => k))}\"}}", - Encoding.UTF8, "application/json") - }; - } - - return await InvokeHandlerAsync(_default, request, cancellationToken); - } - - private async Task HandleCreateContainerAsync( - HttpRequestMessage request, string dbName, CancellationToken cancellationToken) - { - var body = request.Content is not null - ? await request.Content.ReadAsStringAsync(cancellationToken) - : "{}"; - var containerProps = JsonConvert.DeserializeObject(body) - ?? new ContainerProperties("unknown", "/id"); - - var containerName = containerProps.Id; - var registryKey = GetNewRegistryKey(dbName, containerName); - - // IfNotExists: check x-ms-cosmos-is-upsert or use existence check - var isIfNotExists = request.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var upsertValues) && - upsertValues.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); - - if (_registry.Handlers.ContainsKey(registryKey)) - { - if (isIfNotExists) - { - // Return existing container metadata - if (_registry.Handlers.TryGetValue(registryKey, out var existingHandler)) - { - return CreateContainerMetadataResponse(existingHandler, HttpStatusCode.OK); - } - } - // If not IfNotExists, SDK sends 409 Conflict - return new HttpResponseMessage(HttpStatusCode.Conflict) - { - Content = new StringContent( - $"{{\"message\":\"Container '{containerName}' already exists.\"}}", - Encoding.UTF8, "application/json") - }; - } - - // Create the new container - var newContainer = new InMemoryContainer(containerProps); - - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-collection - // "x-ms-offer-throughput: The user specified throughput for the collection" - if (request.Headers.TryGetValues("x-ms-offer-throughput", out var throughputValues) && - int.TryParse(throughputValues.FirstOrDefault(), out var throughputValue)) - { - newContainer._throughput = throughputValue; - } - - var newHandler = new FakeCosmosHandler(newContainer); - - _registry.Handlers[registryKey] = newHandler; - _registry.BackingContainers[registryKey] = newContainer; - - // Track in database container keys - var dbMap = _registry.DatabaseContainerKeys.GetOrAdd(dbName, _ => new(StringComparer.Ordinal)); - dbMap[containerName] = registryKey; - - // Register the SDK container if client is available - if (_registry.Client is not null) - { - _registry.SdkContainers[registryKey] = - _registry.Client.GetContainer(dbName, containerName); - } - - return CreateContainerMetadataResponse(newHandler, HttpStatusCode.Created); - } - - private HttpResponseMessage HandleDeleteContainer(string dbName, string containerName) - { - var registryKey = ResolveExistingRegistryKey(dbName, containerName); - if (registryKey is null || !_registry.Handlers.TryRemove(registryKey, out var handler)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - $"{{\"message\":\"Container '{containerName}' not found.\"}}", - Encoding.UTF8, "application/json") - }; - } - - _registry.BackingContainers.TryRemove(registryKey, out _); - _registry.SdkContainers.TryRemove(registryKey, out _); - - // Remove from database container keys - if (_registry.DatabaseContainerKeys.TryGetValue(dbName, out var dbMap)) - dbMap.TryRemove(containerName, out _); - - // Mark the backing container as deleted so future operations through - // any cached handler reference return 404 - handler.BackingInMemoryContainer._isDeleted = true; - - return new HttpResponseMessage(HttpStatusCode.NoContent) - { - Headers = { { "x-ms-request-charge", "1" } } - }; - } - - private HttpResponseMessage HandleReadContainer(string dbName, string containerName) - { - var registryKey = ResolveExistingRegistryKey(dbName, containerName); - if (registryKey is not null && _registry.Handlers.TryGetValue(registryKey, out var handler)) - { - return CreateContainerMetadataResponse(handler, HttpStatusCode.OK); - } - - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - $"{{\"message\":\"Container '{containerName}' not found.\"}}", - Encoding.UTF8, "application/json") - }; - } - - private HttpResponseMessage HandleListContainers(string dbName) - { - var containerArray = new JArray(); - - if (_registry.DatabaseContainerKeys.TryGetValue(dbName, out var containerKeys)) - { - foreach (var (_, registryKey) in containerKeys) - { - if (_registry.Handlers.TryGetValue(registryKey, out var h)) - containerArray.Add(JObject.Parse(h.GetCollectionMetadata())); - } - } - else - { - // Fallback for backward compatibility (no DatabaseContainerKeys populated) - foreach (var handler in _registry.Handlers.Values) - { - containerArray.Add(JObject.Parse(handler.GetCollectionMetadata())); - } - } - - var result = new JObject - { - ["_rid"] = _default._databaseRid, - ["DocumentCollections"] = containerArray, - ["_count"] = containerArray.Count - }; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(result.ToString(Formatting.None), Encoding.UTF8, "application/json") - }; - response.Headers.TryAddWithoutValidation("x-ms-request-charge", "1"); - return response; - } - - private async Task HandleReplaceContainer( - HttpRequestMessage request, string dbName, string containerName, CancellationToken cancellationToken) - { - var registryKey = ResolveExistingRegistryKey(dbName, containerName); - if (registryKey is null || !_registry.Handlers.TryGetValue(registryKey, out var handler)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - // Parse the request body to check for PK path changes - if (request.Content is not null) - { - var body = await request.Content.ReadAsStringAsync(cancellationToken); - var props = JObject.Parse(body); - var newPkPath = props.SelectToken("partitionKey.paths[0]")?.ToString(); - if (newPkPath is not null) - { - var existingPkPath = handler.BackingInMemoryContainer.PartitionKeyPaths.FirstOrDefault(); - if (existingPkPath is not null && !string.Equals(newPkPath, existingPkPath, StringComparison.Ordinal)) - { - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent( - $"{{\"message\":\"Partition key path cannot be changed. " + - $"Existing: '{existingPkPath}', Requested: '{newPkPath}'\"}}", - Encoding.UTF8, "application/json") - }; - } - } - - // Apply mutable properties - var container = handler.BackingInMemoryContainer; - if (props["defaultTtl"] is { Type: JTokenType.Integer } ttlToken) - { - var ttl = ttlToken.Value(); - if (ttl != 0) container.DefaultTimeToLive = ttl; - } - else if (props["defaultTtl"] is { Type: JTokenType.Null }) - { - container.DefaultTimeToLive = null; - } - } - - return CreateContainerMetadataResponse(handler, HttpStatusCode.OK); - } - - private async Task HandleDatabaseListOrCreate( - HttpRequestMessage request, string method, CancellationToken cancellationToken) - { - // Delegate to the default handler for database operations - return await InvokeHandlerAsync(_default, request, cancellationToken); - } - - private HttpResponseMessage HandleDatabaseCrud( - HttpRequestMessage request, string path, string method) - { - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var dbName = segments.Length > 1 ? Uri.UnescapeDataString(segments[1]) : "fake-db"; - _registry.KnownDatabases.Add(dbName); - - return method switch - { - "GET" or "PUT" => _default.CreateJsonResponse(_default.GetDatabaseMetadata(dbName)), - "DELETE" => new HttpResponseMessage(HttpStatusCode.NoContent) { Headers = { { "x-ms-request-charge", "1" } } }, - _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) - }; - } - - /// Finds the registry key for an existing container, trying compound then plain. - private string? ResolveExistingRegistryKey(string dbName, string containerName) - { - var compoundKey = $"{dbName}/{containerName}"; - if (_registry.Handlers.ContainsKey(compoundKey)) - return compoundKey; - if (_registry.Handlers.ContainsKey(containerName)) - return containerName; - return null; - } - - /// Determines the registry key for a new container registration. - private string GetNewRegistryKey(string dbName, string containerName) - { - return _registry.CompoundKeyDatabases.Contains(dbName) - ? $"{dbName}/{containerName}" - : containerName; - } - - private static HttpResponseMessage CreateContainerMetadataResponse( - FakeCosmosHandler handler, HttpStatusCode statusCode) - { - var metadata = handler.GetCollectionMetadata(); - var response = new HttpResponseMessage(statusCode) - { - Content = new StringContent(metadata, Encoding.UTF8, "application/json") - }; - response.Headers.TryAddWithoutValidation("x-ms-request-charge", "1"); - response.Headers.TryAddWithoutValidation("etag", $"\"{Guid.NewGuid()}\""); - return response; - } - - private static Task InvokeHandlerAsync( - FakeCosmosHandler handler, HttpRequestMessage request, CancellationToken cancellationToken) - { - var invoker = new HttpMessageInvoker(handler, disposeHandler: false); - return invoker.SendAsync(request, cancellationToken); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - foreach (var handler in _registry.Handlers.Values) - { - handler.Dispose(); - } - } - - base.Dispose(disposing); - } - } - - /// - /// Thread-safe query result cache with TTL-based eviction and bounded size. - /// Prevents unbounded memory growth when consumers abandon mid-page iteration. - /// - private sealed class QueryResultCache - { - private readonly ConcurrentDictionary _entries = new(); - private readonly TimeSpan _ttl; - private readonly int _maxEntries; - - public QueryResultCache(TimeSpan ttl, int maxEntries) - { - _ttl = ttl; - _maxEntries = maxEntries; - } - - public bool TryGet(string key, out List value) - { - EvictStale(); - if (_entries.TryGetValue(key, out var entry) && !entry.IsExpired(_ttl)) - { - entry.Touch(); - value = entry.Items; - return true; - } - - _entries.TryRemove(key, out _); - value = null!; - return false; - } - - public void Set(string key, List items) - { - EvictStale(); - _entries[key] = new CacheEntry(items); - } - - public void Remove(string key) - { - _entries.TryRemove(key, out _); - } - - public void Clear() - { - _entries.Clear(); - } - - private void EvictStale() - { - var keysToRemove = _entries - .Where(pair => pair.Value.IsExpired(_ttl)) - .Select(pair => pair.Key) - .ToList(); - - foreach (var key in keysToRemove) - { - _entries.TryRemove(key, out _); - } - - // If still over capacity after TTL eviction, remove oldest entries - if (_entries.Count > _maxEntries) - { - var excess = _entries - .OrderBy(pair => pair.Value.LastAccessed) - .Take(_entries.Count - _maxEntries) - .Select(pair => pair.Key) - .ToList(); - - foreach (var key in excess) - { - _entries.TryRemove(key, out _); - } - } - } - - private sealed class CacheEntry - { - public List Items { get; } - public DateTime CreatedAt { get; } - public DateTime LastAccessed { get; private set; } - - public CacheEntry(List items) - { - Items = items; - CreatedAt = DateTime.UtcNow; - LastAccessed = DateTime.UtcNow; - } - - public bool IsExpired(TimeSpan ttl) => DateTime.UtcNow - CreatedAt > ttl; - - public void Touch() => LastAccessed = DateTime.UtcNow; - } - } + private string GetCollectionMetadata() + { + var paths = new JArray(_container.PartitionKeyPaths.Select(path => (JToken)path)); + var policy = _container.IndexingPolicy; + var includedPaths = new JArray(policy.IncludedPaths.Select(p => new JObject { ["path"] = p.Path })); + var excludedPaths = new JArray(policy.ExcludedPaths.Select(p => new JObject { ["path"] = p.Path })); + if (!excludedPaths.Any(p => p["path"]?.ToString() == "/\"_etag\"/?")) + excludedPaths.Add(new JObject { ["path"] = "/\"_etag\"/?" }); + + var indexingPolicyObj = new JObject + { + ["indexingMode"] = policy.IndexingMode.ToString().ToLowerInvariant(), + ["automatic"] = policy.Automatic, + ["includedPaths"] = includedPaths, + ["excludedPaths"] = excludedPaths + }; + + if (policy.CompositeIndexes.Count > 0) + { + var compositeIndexes = new JArray(policy.CompositeIndexes.Select(indexSet => + new JArray(indexSet.Select(idx => new JObject + { + ["path"] = idx.Path, + ["order"] = idx.Order.ToString().ToLowerInvariant() + })))); + indexingPolicyObj["compositeIndexes"] = compositeIndexes; + } + + if (policy.SpatialIndexes.Count > 0) + { + var spatialIndexes = new JArray(policy.SpatialIndexes.Select(si => + { + var obj = new JObject { ["path"] = si.Path }; + if (si.SpatialTypes.Count > 0) + obj["types"] = new JArray(si.SpatialTypes.Select(t => (JToken)t.ToString())); + return obj; + })); + indexingPolicyObj["spatialIndexes"] = spatialIndexes; + } + + var metadata = new JObject + { + ["id"] = _container.Id, + ["_rid"] = _collectionRid, + ["_self"] = $"dbs/{_databaseRid}/colls/{_collectionRid}/", + ["_etag"] = "\"00000000-0000-0000-0000-000000000000\"", + ["_ts"] = 1700000000, + ["partitionKey"] = new JObject + { + ["paths"] = paths, + ["kind"] = paths.Count > 1 ? "MultiHash" : "Hash", + ["version"] = 2 + }, + ["indexingPolicy"] = indexingPolicyObj, + ["geospatialConfig"] = new JObject { ["type"] = "Geography" } + }; + return metadata.ToString(Formatting.None); + } + + private string GetPartitionKeyRanges() + { + var ranges = new JArray(); + var step = 0x1_0000_0000L / _partitionKeyRangeCount; + for (var i = 0; i < _partitionKeyRangeCount; i++) + { + var minInclusive = PartitionKeyHash.RangeBoundaryToHex(i * step); + var maxExclusive = i == _partitionKeyRangeCount - 1 ? "FF" : PartitionKeyHash.RangeBoundaryToHex((i + 1) * step); + ranges.Add(new JObject + { + ["id"] = i.ToString(), + ["_rid"] = _collectionRid, + ["minInclusive"] = minInclusive, + ["maxExclusive"] = maxExclusive, + ["throughputFraction"] = 1.0 / _partitionKeyRangeCount, + ["status"] = "online", + ["_self"] = $"dbs/{_databaseRid}/colls/{_collectionRid}/pkranges/{i}/", + ["_ts"] = 1700000000, + ["_etag"] = PkRangesEtag.Trim('"') + }); + } + + var result = new JObject + { + ["_rid"] = _collectionRid, + ["PartitionKeyRanges"] = ranges, + ["_count"] = _partitionKeyRangeCount + }; + return result.ToString(Formatting.None); + } + + /// + /// Creates a routing that dispatches requests to + /// different instances based on the container name + /// in the URL path. This enables a single to query + /// multiple in-memory containers. + /// + /// + /// A dictionary mapping container names to their handlers. The first entry is used + /// as the default for account-level requests (e.g. GET /). + /// + internal static HttpMessageHandler CreateRouter( + IReadOnlyDictionary handlers) + { + return new RoutingHandler(handlers); + } + + internal const string FakeConnectionString = "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;"; + + /// + /// Wraps an existing so that containers returned by + /// automatically capture prefix partition + /// keys for hierarchical PK queries. Use this when you have already constructed + /// a with a custom HTTP pipeline (e.g. tracking handlers) + /// and want to add prefix PK support on top. + /// + /// + /// + /// var trackingHandler = new CosmosTrackingMessageHandler(opts, fakeHandler); + /// var innerClient = new CosmosClient(connStr, new CosmosClientOptions + /// { + /// ConnectionMode = ConnectionMode.Gateway, + /// HttpClientFactory = () => new HttpClient(trackingHandler) + /// }); + /// var client = handler.WrapClient(innerClient); + /// + /// + /// + /// + /// The to wrap. + /// + /// A whose + /// returns containers with automatic prefix PK capturing. + /// + internal CosmosClient WrapClient(CosmosClient innerClient) + => WrapClient(innerClient, new Dictionary { [_container.Id] = this }); + + /// + /// Wraps an existing with prefix partition key capturing + /// for multiple containers. Each container returned by + /// will be matched against the dictionary to find the + /// correct for PK capture. + /// + /// The to wrap. + /// + /// A dictionary mapping container names (or "database/container" keys) to their handlers. + /// + internal static CosmosClient WrapClient( + CosmosClient innerClient, + IReadOnlyDictionary handlers) + => new PkAwareCosmosClient(innerClient, handlers); + + /// + /// Creates a backed by this handler that automatically + /// captures prefix partition keys for hierarchical PK queries. This is a convenience + /// method that builds the and wraps it in a single call. + /// + /// For custom HTTP pipelines (e.g. tracking or logging handlers), construct the + /// yourself and use instead. + /// + /// + internal CosmosClient CreateClient() + => CreateClient(new Dictionary { [_container.Id] = this }); + + /// + /// Creates a with a routing handler that dispatches + /// to multiple instances, with automatic prefix + /// partition key capturing for hierarchical PK queries. + /// + /// For custom HTTP pipelines, construct the yourself + /// with and use + /// instead. + /// + /// + internal static CosmosClient CreateClient( + Dictionary handlers) + { + HttpMessageHandler httpHandler = handlers.Count == 1 + ? handlers.Values.First() + : CreateRouter(handlers); + + var innerClient = new CosmosClient( + FakeConnectionString, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(httpHandler) + }); + + return WrapClient(innerClient, handlers); + } + + /// + /// Runs a self-test to verify that the current Cosmos SDK version still uses the + /// HTTP contract this handler expects (URL patterns, header names, response formats, + /// ORDER BY wrapping, pagination, aggregates). Call this once during test suite setup + /// to detect SDK breaking changes early, rather than getting silent data corruption. + /// + public static async Task VerifySdkCompatibilityAsync() + { + var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; + + var container = new InMemoryContainer("compat-check", "/partitionKey"); + await container.CreateItemAsync( + new CompatibilityDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new CompatibilityDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new CompatibilityDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, + new PartitionKey("pk")); + + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var cosmosContainer = client.GetContainer("fakeDb", "compat-check"); + + var allItems = await DrainFeedIteratorAsync( + cosmosContainer.GetItemLinqQueryable().ToFeedIterator()); + if (allItems.Count != 3) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): expected 3 items from basic query but got {allItems.Count}. " + + "The Cosmos SDK may have changed its internal HTTP contract."); + } + + var ordered = await DrainFeedIteratorAsync( + cosmosContainer.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIterator()); + if (ordered.Count != 3 || ordered[0].Name != "Alice" || ordered[2].Name != "Charlie") + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): ORDER BY query returned unexpected results. " + + "The Cosmos SDK ORDER BY response format may have changed."); + } + + var filtered = await DrainFeedIteratorAsync( + cosmosContainer.GetItemLinqQueryable() + .Where(document => document.Value > 15) + .ToFeedIterator()); + if (filtered.Count != 2) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): expected 2 items from filtered query but got {filtered.Count}. " + + "The Cosmos SDK may have changed its query or header format."); + } + + var paginatedItems = new List(); + var pageIterator = cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }) + .ToFeedIterator(); + var pageCount = 0; + while (pageIterator.HasMoreResults) + { + var page = await pageIterator.ReadNextAsync(); + paginatedItems.AddRange(page); + pageCount++; + } + + if (paginatedItems.Count != 3) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): paginated query returned {paginatedItems.Count} items instead of 3. " + + "The Cosmos SDK may have changed its pagination or continuation token format."); + } + + if (pageCount < 2) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): expected multiple pages with MaxItemCount=1 but got {pageCount} page(s). " + + "The Cosmos SDK may have changed how it sends the x-ms-max-item-count header."); + } + + var countResult = await cosmosContainer.GetItemLinqQueryable().CountAsync(); + if (countResult.Resource != 3) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): CountAsync returned {countResult.Resource} instead of 3. " + + "The Cosmos SDK may have changed its aggregate query format."); + } + + if (!handler.RequestLog.Any(entry => entry.Contains("/pkranges"))) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): no partition key range request was detected. " + + "The Cosmos SDK may have changed how it discovers partition key ranges."); + } + + if (!handler.QueryLog.Any()) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): no query was logged. " + + "The Cosmos SDK may have changed how it sends query requests."); + } + + // CRUD roundtrip — verifies that create/read/delete HTTP routes work through the SDK + var crudDoc = new CompatibilityDocument { Id = "crud-test", PartitionKey = "pk", Name = "CrudTest", Value = 99 }; + var createResponse = await cosmosContainer.CreateItemAsync(crudDoc, new PartitionKey("pk")); + if (createResponse.StatusCode != HttpStatusCode.Created) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): CreateItemAsync returned {createResponse.StatusCode} instead of Created. " + + "The Cosmos SDK may have changed its CRUD HTTP contract."); + } + + var readResponse = await cosmosContainer.ReadItemAsync("crud-test", new PartitionKey("pk")); + if (readResponse.Resource?.Name != "CrudTest") + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): ReadItemAsync returned unexpected resource. " + + "The Cosmos SDK may have changed its point-read HTTP contract."); + } + + var deleteResponse = await cosmosContainer.DeleteItemAsync("crud-test", new PartitionKey("pk")); + if (deleteResponse.StatusCode != HttpStatusCode.NoContent) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): DeleteItemAsync returned {deleteResponse.StatusCode} instead of NoContent. " + + "The Cosmos SDK may have changed its delete HTTP contract."); + } + + // DISTINCT query (before upsert so item count is deterministic) + var distinctItems = await DrainFeedIteratorAsync( + cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT c.name FROM c"))); + if (distinctItems.Count != 3) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): DISTINCT query returned {distinctItems.Count} items instead of 3. " + + "The Cosmos SDK may have changed its DISTINCT query handling."); + } + + // OFFSET/LIMIT query (before upsert so item count is deterministic) + var offsetLimitItems = await DrainFeedIteratorAsync( + cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c OFFSET 1 LIMIT 2"))); + if (offsetLimitItems.Count != 2) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): OFFSET/LIMIT query returned {offsetLimitItems.Count} items instead of 2. " + + "The Cosmos SDK may have changed its OFFSET/LIMIT handling."); + } + + // Upsert roundtrip + var upsertDoc = new CompatibilityDocument { Id = "upsert-test", PartitionKey = "pk", Name = "Upserted", Value = 42 }; + var upsertResponse = await cosmosContainer.UpsertItemAsync(upsertDoc, new PartitionKey("pk")); + if (upsertResponse.StatusCode is not (HttpStatusCode.Created or HttpStatusCode.OK)) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): UpsertItemAsync returned {upsertResponse.StatusCode}. " + + "The Cosmos SDK may have changed its upsert HTTP contract."); + } + + // Patch roundtrip + var patchOps = new[] { PatchOperation.Set("/value", 99) }; + var patchResponse = await cosmosContainer.PatchItemAsync( + "upsert-test", new PartitionKey("pk"), patchOps); + if (patchResponse.Resource?.Value != 99) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): PatchItemAsync returned unexpected value. " + + "The Cosmos SDK may have changed its patch HTTP contract."); + } + + // Clean up upsert-test doc + await cosmosContainer.DeleteItemAsync("upsert-test", new PartitionKey("pk")); + + // Transactional batch roundtrip + var batchDoc1 = new CompatibilityDocument { Id = "batch-1", PartitionKey = "pk", Name = "B1", Value = 1 }; + var batchDoc2 = new CompatibilityDocument { Id = "batch-2", PartitionKey = "pk", Name = "B2", Value = 2 }; + var batchResponse = await cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk")) + .CreateItem(batchDoc1) + .CreateItem(batchDoc2) + .ExecuteAsync(); + + if (!batchResponse.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): TransactionalBatch returned " + + $"{batchResponse.StatusCode}. The Cosmos SDK may have changed its batch wire format " + + "(HybridRow schemas or RecordIO encoding)."); + } + + if (batchResponse.Count != 2) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): batch returned {batchResponse.Count} " + + "results instead of 2."); + } + + // Clean up batch docs + await cosmosContainer.DeleteItemAsync("batch-1", new PartitionKey("pk")); + await cosmosContainer.DeleteItemAsync("batch-2", new PartitionKey("pk")); + + // ReadMany roundtrip + var readManyResult = await cosmosContainer.ReadManyItemsAsync( + new[] { ("1", new PartitionKey("pk")), ("2", new PartitionKey("pk")) }); + + if (readManyResult.StatusCode != HttpStatusCode.OK) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): ReadManyItemsAsync returned " + + $"{readManyResult.StatusCode}. The Cosmos SDK may have changed its ReadMany HTTP contract."); + } + + if (readManyResult.Count != 2) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): ReadMany returned {readManyResult.Count} " + + "items instead of 2."); + } + + // Change feed roundtrip (uses backing container API since GetChangeFeedIterator + // doesn't work through FakeCosmosHandler — it doesn't implement A-IM protocol) + var changeFeedIterator = container.GetChangeFeedIterator(0); + var changeFeedItems = new List(); + while (changeFeedIterator.HasMoreResults) + { + var page = await changeFeedIterator.ReadNextAsync(); + changeFeedItems.AddRange(page); + } + + if (changeFeedItems.Count < 3) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): Change feed returned {changeFeedItems.Count} " + + "items instead of at least 3. The change feed infrastructure may be broken."); + } + + // GROUP BY query + var groupByItems = await DrainFeedIteratorAsync( + cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"))); + + if (groupByItems.Count < 1) + { + throw new InvalidOperationException( + $"SDK compatibility check failed (v{sdkVersion}): GROUP BY query returned no results. " + + "The Cosmos SDK may have changed its GROUP BY handling."); + } + + // Verify no unrecognised headers were seen during the test + if (handler.UnrecognisedHeaders.Count > 0) + { + var unknownHeaders = string.Join(", ", handler.UnrecognisedHeaders.Distinct()); + System.Diagnostics.Trace.TraceWarning( + $"FakeCosmosHandler: SDK v{sdkVersion} sent unrecognised x-ms-* headers: {unknownHeaders}. " + + "These may indicate new SDK features that are not yet handled."); + } + } + + private static async Task> DrainFeedIteratorAsync(FeedIterator iterator) + { + var items = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + items.AddRange(page); + } + + return items; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _queryResultCache.Clear(); + _container.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Shared registry for dynamic container management. Holds the mutable dictionaries + /// that both RoutingHandler and InMemoryCosmosResult reference. + /// + internal sealed class DynamicContainerRegistry + { + public ConcurrentDictionary Handlers { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary BackingContainers { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary SdkContainers { get; } = new(StringComparer.Ordinal); + public HashSet KnownDatabases { get; } = new(StringComparer.Ordinal); + + /// + /// Maps database name → (container name → registry key). + /// Registry keys are plain container names for default DB, compound "db/container" for explicit databases. + /// + public ConcurrentDictionary> DatabaseContainerKeys { get; } = new(StringComparer.Ordinal); + + /// Set of database names that use compound registry keys (added via AddDatabase). + public HashSet CompoundKeyDatabases { get; } = new(StringComparer.Ordinal); + + /// Client reference set after client creation so dynamic containers can register SDK containers. + public CosmosClient? Client { get; set; } + + /// Database name used for dynamic container registration. + public string DatabaseName { get; set; } = "default"; + } + + internal sealed class RoutingHandler : HttpMessageHandler + { + private readonly DynamicContainerRegistry _registry; + private readonly FakeCosmosHandler _default; + + public RoutingHandler(IReadOnlyDictionary handlers) + : this(handlers, new DynamicContainerRegistry()) + { + } + + public RoutingHandler(IReadOnlyDictionary handlers, DynamicContainerRegistry registry) + { + if (!handlers.Any()) + throw new ArgumentException("At least one handler must be registered.", nameof(handlers)); + _registry = registry; + foreach (var kvp in handlers) + _registry.Handlers[kvp.Key] = kvp.Value; + _default = handlers.Values.First(); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? ""; + var method = request.Method.Method; + + if (path is "/" or "") + { + return await InvokeHandlerAsync(_default, request, cancellationToken); + } + + // Container creation: POST /dbs/{db}/colls + // Container listing: GET /dbs/{db}/colls + var collsMatch = Regex.Match(path, @"^/dbs/([^/]+)/colls/?$"); + if (collsMatch.Success) + { + var dbNameForColls = collsMatch.Groups[1].Value; + if (method == "POST") + return await HandleCreateContainerAsync(request, dbNameForColls, cancellationToken); + if (method == "GET") + return HandleListContainers(dbNameForColls); + } + + // Database management: /dbs + if (Regex.IsMatch(path, @"^/dbs/?$")) + { + return await HandleDatabaseListOrCreate(request, method, cancellationToken); + } + + // Database CRUD: /dbs/{id} + if (Regex.IsMatch(path, @"^/dbs/[^/]+/?$") && !path.Contains("/colls")) + { + return HandleDatabaseCrud(request, path, method); + } + + var match = Regex.Match(path, @"/dbs/([^/]+)/colls/([^/]+)"); + if (match.Success) + { + var dbName = match.Groups[1].Value; + var containerName = match.Groups[2].Value; + + // Container-level operations (not sub-resources) + if (Regex.IsMatch(path, @"/colls/[^/]+/?$")) + { + // DELETE container + if (method == "DELETE") + { + return HandleDeleteContainer(dbName, containerName); + } + + // GET container metadata + if (method == "GET") + { + return HandleReadContainer(dbName, containerName); + } + + // PUT container (replace) + if (method == "PUT") + { + return await HandleReplaceContainer(request, dbName, containerName, cancellationToken); + } + } + + // Container listing: GET /dbs/{db}/colls (handled above, but also match here for safety) + + // Try database+container compound key first, then fall back to container-only + var compoundKey = $"{dbName}/{containerName}"; + if (_registry.Handlers.TryGetValue(compoundKey, out var compoundHandler)) + { + return await InvokeHandlerAsync(compoundHandler, request, cancellationToken); + } + + if (_registry.Handlers.TryGetValue(containerName, out var handler)) + { + return await InvokeHandlerAsync(handler, request, cancellationToken); + } + + // SDK internal routes use base64-encoded RIDs (e.g. "AQAAAA==") for + // partition key range and other metadata requests. Fall back to the + // default handler for these rather than throwing. + if (containerName.Contains('=') || path.Contains("/pkranges")) + { + return await InvokeHandlerAsync(_default, request, cancellationToken); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + $"{{\"message\":\"Container '{containerName}' not found. " + + $"Available containers: {string.Join(", ", _registry.Handlers.Keys.OrderBy(k => k))}\"}}", + Encoding.UTF8, "application/json") + }; + } + + return await InvokeHandlerAsync(_default, request, cancellationToken); + } + + private async Task HandleCreateContainerAsync( + HttpRequestMessage request, string dbName, CancellationToken cancellationToken) + { + var body = request.Content is not null + ? await request.Content.ReadAsStringAsync(cancellationToken) + : "{}"; + var containerProps = JsonConvert.DeserializeObject(body) + ?? new ContainerProperties("unknown", "/id"); + + var containerName = containerProps.Id; + var registryKey = GetNewRegistryKey(dbName, containerName); + + // IfNotExists: check x-ms-cosmos-is-upsert or use existence check + var isIfNotExists = request.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var upsertValues) && + upsertValues.Any(v => v.Equals("True", StringComparison.OrdinalIgnoreCase)); + + if (_registry.Handlers.ContainsKey(registryKey)) + { + if (isIfNotExists) + { + // Return existing container metadata + if (_registry.Handlers.TryGetValue(registryKey, out var existingHandler)) + { + return CreateContainerMetadataResponse(existingHandler, HttpStatusCode.OK); + } + } + // If not IfNotExists, SDK sends 409 Conflict + return new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent( + $"{{\"message\":\"Container '{containerName}' already exists.\"}}", + Encoding.UTF8, "application/json") + }; + } + + // Create the new container + var newContainer = new InMemoryContainer(containerProps); + + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-collection + // "x-ms-offer-throughput: The user specified throughput for the collection" + if (request.Headers.TryGetValues("x-ms-offer-throughput", out var throughputValues) && + int.TryParse(throughputValues.FirstOrDefault(), out var throughputValue)) + { + newContainer._throughput = throughputValue; + } + + var newHandler = new FakeCosmosHandler(newContainer); + + _registry.Handlers[registryKey] = newHandler; + _registry.BackingContainers[registryKey] = newContainer; + + // Track in database container keys + var dbMap = _registry.DatabaseContainerKeys.GetOrAdd(dbName, _ => new(StringComparer.Ordinal)); + dbMap[containerName] = registryKey; + + // Register the SDK container if client is available + if (_registry.Client is not null) + { + _registry.SdkContainers[registryKey] = + _registry.Client.GetContainer(dbName, containerName); + } + + return CreateContainerMetadataResponse(newHandler, HttpStatusCode.Created); + } + + private HttpResponseMessage HandleDeleteContainer(string dbName, string containerName) + { + var registryKey = ResolveExistingRegistryKey(dbName, containerName); + if (registryKey is null || !_registry.Handlers.TryRemove(registryKey, out var handler)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + $"{{\"message\":\"Container '{containerName}' not found.\"}}", + Encoding.UTF8, "application/json") + }; + } + + _registry.BackingContainers.TryRemove(registryKey, out _); + _registry.SdkContainers.TryRemove(registryKey, out _); + + // Remove from database container keys + if (_registry.DatabaseContainerKeys.TryGetValue(dbName, out var dbMap)) + dbMap.TryRemove(containerName, out _); + + // Mark the backing container as deleted so future operations through + // any cached handler reference return 404 + handler.BackingInMemoryContainer._isDeleted = true; + + return new HttpResponseMessage(HttpStatusCode.NoContent) + { + Headers = { { "x-ms-request-charge", "1" } } + }; + } + + private HttpResponseMessage HandleReadContainer(string dbName, string containerName) + { + var registryKey = ResolveExistingRegistryKey(dbName, containerName); + if (registryKey is not null && _registry.Handlers.TryGetValue(registryKey, out var handler)) + { + return CreateContainerMetadataResponse(handler, HttpStatusCode.OK); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + $"{{\"message\":\"Container '{containerName}' not found.\"}}", + Encoding.UTF8, "application/json") + }; + } + + private HttpResponseMessage HandleListContainers(string dbName) + { + var containerArray = new JArray(); + + if (_registry.DatabaseContainerKeys.TryGetValue(dbName, out var containerKeys)) + { + foreach (var (_, registryKey) in containerKeys) + { + if (_registry.Handlers.TryGetValue(registryKey, out var h)) + containerArray.Add(JObject.Parse(h.GetCollectionMetadata())); + } + } + else + { + // Fallback for backward compatibility (no DatabaseContainerKeys populated) + foreach (var handler in _registry.Handlers.Values) + { + containerArray.Add(JObject.Parse(handler.GetCollectionMetadata())); + } + } + + var result = new JObject + { + ["_rid"] = _default._databaseRid, + ["DocumentCollections"] = containerArray, + ["_count"] = containerArray.Count + }; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + response.Headers.TryAddWithoutValidation("x-ms-request-charge", "1"); + return response; + } + + private async Task HandleReplaceContainer( + HttpRequestMessage request, string dbName, string containerName, CancellationToken cancellationToken) + { + var registryKey = ResolveExistingRegistryKey(dbName, containerName); + if (registryKey is null || !_registry.Handlers.TryGetValue(registryKey, out var handler)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + // Parse the request body to check for PK path changes + if (request.Content is not null) + { + var body = await request.Content.ReadAsStringAsync(cancellationToken); + var props = JObject.Parse(body); + var newPkPath = props.SelectToken("partitionKey.paths[0]")?.ToString(); + if (newPkPath is not null) + { + var existingPkPath = handler.BackingInMemoryContainer.PartitionKeyPaths.FirstOrDefault(); + if (existingPkPath is not null && !string.Equals(newPkPath, existingPkPath, StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + $"{{\"message\":\"Partition key path cannot be changed. " + + $"Existing: '{existingPkPath}', Requested: '{newPkPath}'\"}}", + Encoding.UTF8, "application/json") + }; + } + } + + // Apply mutable properties + var container = handler.BackingInMemoryContainer; + if (props["defaultTtl"] is { Type: JTokenType.Integer } ttlToken) + { + var ttl = ttlToken.Value(); + if (ttl != 0) container.DefaultTimeToLive = ttl; + } + else if (props["defaultTtl"] is { Type: JTokenType.Null }) + { + container.DefaultTimeToLive = null; + } + } + + return CreateContainerMetadataResponse(handler, HttpStatusCode.OK); + } + + private async Task HandleDatabaseListOrCreate( + HttpRequestMessage request, string method, CancellationToken cancellationToken) + { + // Delegate to the default handler for database operations + return await InvokeHandlerAsync(_default, request, cancellationToken); + } + + private HttpResponseMessage HandleDatabaseCrud( + HttpRequestMessage request, string path, string method) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var dbName = segments.Length > 1 ? Uri.UnescapeDataString(segments[1]) : "fake-db"; + _registry.KnownDatabases.Add(dbName); + + return method switch + { + "GET" or "PUT" => _default.CreateJsonResponse(_default.GetDatabaseMetadata(dbName)), + "DELETE" => new HttpResponseMessage(HttpStatusCode.NoContent) { Headers = { { "x-ms-request-charge", "1" } } }, + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed) + }; + } + + /// Finds the registry key for an existing container, trying compound then plain. + private string? ResolveExistingRegistryKey(string dbName, string containerName) + { + var compoundKey = $"{dbName}/{containerName}"; + if (_registry.Handlers.ContainsKey(compoundKey)) + return compoundKey; + if (_registry.Handlers.ContainsKey(containerName)) + return containerName; + return null; + } + + /// Determines the registry key for a new container registration. + private string GetNewRegistryKey(string dbName, string containerName) + { + return _registry.CompoundKeyDatabases.Contains(dbName) + ? $"{dbName}/{containerName}" + : containerName; + } + + private static HttpResponseMessage CreateContainerMetadataResponse( + FakeCosmosHandler handler, HttpStatusCode statusCode) + { + var metadata = handler.GetCollectionMetadata(); + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(metadata, Encoding.UTF8, "application/json") + }; + response.Headers.TryAddWithoutValidation("x-ms-request-charge", "1"); + response.Headers.TryAddWithoutValidation("etag", $"\"{Guid.NewGuid()}\""); + return response; + } + + private static Task InvokeHandlerAsync( + FakeCosmosHandler handler, HttpRequestMessage request, CancellationToken cancellationToken) + { + var invoker = new HttpMessageInvoker(handler, disposeHandler: false); + return invoker.SendAsync(request, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var handler in _registry.Handlers.Values) + { + handler.Dispose(); + } + } + + base.Dispose(disposing); + } + } + + /// + /// Thread-safe query result cache with TTL-based eviction and bounded size. + /// Prevents unbounded memory growth when consumers abandon mid-page iteration. + /// + private sealed class QueryResultCache + { + private readonly ConcurrentDictionary _entries = new(); + private readonly TimeSpan _ttl; + private readonly int _maxEntries; + + public QueryResultCache(TimeSpan ttl, int maxEntries) + { + _ttl = ttl; + _maxEntries = maxEntries; + } + + public bool TryGet(string key, out List value) + { + EvictStale(); + if (_entries.TryGetValue(key, out var entry) && !entry.IsExpired(_ttl)) + { + entry.Touch(); + value = entry.Items; + return true; + } + + _entries.TryRemove(key, out _); + value = null!; + return false; + } + + public void Set(string key, List items) + { + EvictStale(); + _entries[key] = new CacheEntry(items); + } + + public void Remove(string key) + { + _entries.TryRemove(key, out _); + } + + public void Clear() + { + _entries.Clear(); + } + + private void EvictStale() + { + var keysToRemove = _entries + .Where(pair => pair.Value.IsExpired(_ttl)) + .Select(pair => pair.Key) + .ToList(); + + foreach (var key in keysToRemove) + { + _entries.TryRemove(key, out _); + } + + // If still over capacity after TTL eviction, remove oldest entries + if (_entries.Count > _maxEntries) + { + var excess = _entries + .OrderBy(pair => pair.Value.LastAccessed) + .Take(_entries.Count - _maxEntries) + .Select(pair => pair.Key) + .ToList(); + + foreach (var key in excess) + { + _entries.TryRemove(key, out _); + } + } + } + + private sealed class CacheEntry + { + public List Items { get; } + public DateTime CreatedAt { get; } + public DateTime LastAccessed { get; private set; } + + public CacheEntry(List items) + { + Items = items; + CreatedAt = DateTime.UtcNow; + LastAccessed = DateTime.UtcNow; + } + + public bool IsExpired(TimeSpan ttl) => DateTime.UtcNow - CreatedAt > ttl; + + public void Touch() => LastAccessed = DateTime.UtcNow; + } + } } diff --git a/src/CosmosDB.InMemoryEmulator/ICollectionContext.cs b/src/CosmosDB.InMemoryEmulator/ICollectionContext.cs index a507dea..af8a947 100644 --- a/src/CosmosDB.InMemoryEmulator/ICollectionContext.cs +++ b/src/CosmosDB.InMemoryEmulator/ICollectionContext.cs @@ -8,10 +8,10 @@ namespace CosmosDB.InMemoryEmulator; /// public interface ICollectionContext { - JObject CreateDocument(JObject document); - JObject ReadDocument(string id); - IReadOnlyList QueryDocuments(string sql); - JObject ReplaceDocument(string id, JObject document); - void DeleteDocument(string id); - string SelfLink { get; } + JObject CreateDocument(JObject document); + JObject ReadDocument(string id); + IReadOnlyList QueryDocuments(string sql); + JObject ReplaceDocument(string id, JObject document); + void DeleteDocument(string id); + string SelfLink { get; } } diff --git a/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs b/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs index c8a2eff..ee123fe 100644 --- a/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs +++ b/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs @@ -11,129 +11,129 @@ namespace CosmosDB.InMemoryEmulator; /// public interface IContainerTestSetup { - // ─── C# delegate registrations ──────────────────────────────────────────── - - /// - /// Registers a stored procedure handler invoked by ExecuteStoredProcedureAsync. - /// - void RegisterStoredProcedure(string id, Func handler); - - /// - /// Registers a user-defined function callable in SQL queries as udf.name(args). - /// - void RegisterUdf(string name, Func handler); - - /// - /// Registers a pre- or post-trigger handler. - /// For pre-triggers, the handler receives and returns the (possibly modified) document. - /// For post-triggers, wrap an in a lambda that returns the input. - /// - void RegisterTrigger(string id, TriggerType type, TriggerOperation operation, - Func handler); - - // ─── JS body registrations (requires JsTriggers package) ────────────────── - - /// - /// Registers a stored procedure from a JavaScript function body. - /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. - /// - /// - /// Thrown when the JsTriggers package is not installed. - /// - void RegisterStoredProcedure(string id, string jsBody); - - /// - /// Registers a UDF from a JavaScript function body. - /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. - /// - /// - /// Thrown when the JsTriggers package is not installed. - /// - void RegisterUdf(string name, string jsBody); - - /// - /// Registers a trigger from a JavaScript function body. - /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. - /// - /// - /// Thrown when the JsTriggers package is not installed. - /// - void RegisterTrigger(string id, TriggerType type, TriggerOperation operation, - string jsBody); - - // ─── State management ───────────────────────────────────────────────────── - - /// Exports the current container state as a JSON string. - string ExportState(); - - /// Exports the current container state to a file. - void ExportStateToFile(string path); - - /// Imports container state from a JSON string, replacing all existing data. - void ImportState(string json); - - /// Imports container state from a file, replacing all existing data. - void ImportStateFromFile(string path); - - /// - /// Restores the container to its state at the specified point in time - /// by replaying the change feed. - /// - void RestoreToPointInTime(DateTimeOffset timestamp); - - // ─── Reset ──────────────────────────────────────────────────────────────── - - /// Removes all items, ETags, timestamps, and change feed entries. - void ClearItems(); - - // ─── Container configuration ────────────────────────────────────────────── - - /// - /// Container-level default TTL in seconds. When set, items expire after this duration - /// unless overridden by a per-item _ttl property. Set to null to disable. - /// Setting to 0 throws BadRequest — use -1 for "enabled, no default expiry". - /// - int? DefaultTimeToLive { get; set; } - - /// - /// The unique key policy for this container. Unique keys enforce uniqueness constraints - /// on paths within a logical partition. Set before inserting items. - /// - UniqueKeyPolicy UniqueKeyPolicy { get; set; } - - /// - /// Number of feed ranges returned by GetFeedRangesAsync. Defaults to 1. - /// Set to a higher value to simulate multiple physical partitions. - /// - int FeedRangeCount { get; set; } - - /// - /// Maximum number of entries retained in the change feed log. Defaults to 1000. - /// Set to 0 to disable eviction (unbounded growth). - /// - int MaxChangeFeedSize { get; set; } - - /// - /// When set, the container automatically saves its state to this file path on - /// disposal and loads state from it on creation. - /// - string StateFilePath { get; set; } - - /// Returns the number of non-expired items currently stored. - int ItemCount { get; } - - /// The partition key path(s) for this container. - IReadOnlyList PartitionKeyPaths { get; } - - /// - /// Sets the JavaScript trigger engine. Requires the - /// CosmosDB.InMemoryEmulator.JsTriggers package. - /// - IJsTriggerEngine JsTriggerEngine { get; set; } - - /// - /// Sets the stored procedure engine. Requires the - /// CosmosDB.InMemoryEmulator.JsTriggers package. - /// - ISprocEngine SprocEngine { get; set; } + // ─── C# delegate registrations ──────────────────────────────────────────── + + /// + /// Registers a stored procedure handler invoked by ExecuteStoredProcedureAsync. + /// + void RegisterStoredProcedure(string id, Func handler); + + /// + /// Registers a user-defined function callable in SQL queries as udf.name(args). + /// + void RegisterUdf(string name, Func handler); + + /// + /// Registers a pre- or post-trigger handler. + /// For pre-triggers, the handler receives and returns the (possibly modified) document. + /// For post-triggers, wrap an in a lambda that returns the input. + /// + void RegisterTrigger(string id, TriggerType type, TriggerOperation operation, + Func handler); + + // ─── JS body registrations (requires JsTriggers package) ────────────────── + + /// + /// Registers a stored procedure from a JavaScript function body. + /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. + /// + /// + /// Thrown when the JsTriggers package is not installed. + /// + void RegisterStoredProcedure(string id, string jsBody); + + /// + /// Registers a UDF from a JavaScript function body. + /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. + /// + /// + /// Thrown when the JsTriggers package is not installed. + /// + void RegisterUdf(string name, string jsBody); + + /// + /// Registers a trigger from a JavaScript function body. + /// Requires the CosmosDB.InMemoryEmulator.JsTriggers package. + /// + /// + /// Thrown when the JsTriggers package is not installed. + /// + void RegisterTrigger(string id, TriggerType type, TriggerOperation operation, + string jsBody); + + // ─── State management ───────────────────────────────────────────────────── + + /// Exports the current container state as a JSON string. + string ExportState(); + + /// Exports the current container state to a file. + void ExportStateToFile(string path); + + /// Imports container state from a JSON string, replacing all existing data. + void ImportState(string json); + + /// Imports container state from a file, replacing all existing data. + void ImportStateFromFile(string path); + + /// + /// Restores the container to its state at the specified point in time + /// by replaying the change feed. + /// + void RestoreToPointInTime(DateTimeOffset timestamp); + + // ─── Reset ──────────────────────────────────────────────────────────────── + + /// Removes all items, ETags, timestamps, and change feed entries. + void ClearItems(); + + // ─── Container configuration ────────────────────────────────────────────── + + /// + /// Container-level default TTL in seconds. When set, items expire after this duration + /// unless overridden by a per-item _ttl property. Set to null to disable. + /// Setting to 0 throws BadRequest — use -1 for "enabled, no default expiry". + /// + int? DefaultTimeToLive { get; set; } + + /// + /// The unique key policy for this container. Unique keys enforce uniqueness constraints + /// on paths within a logical partition. Set before inserting items. + /// + UniqueKeyPolicy UniqueKeyPolicy { get; set; } + + /// + /// Number of feed ranges returned by GetFeedRangesAsync. Defaults to 1. + /// Set to a higher value to simulate multiple physical partitions. + /// + int FeedRangeCount { get; set; } + + /// + /// Maximum number of entries retained in the change feed log. Defaults to 1000. + /// Set to 0 to disable eviction (unbounded growth). + /// + int MaxChangeFeedSize { get; set; } + + /// + /// When set, the container automatically saves its state to this file path on + /// disposal and loads state from it on creation. + /// + string StateFilePath { get; set; } + + /// Returns the number of non-expired items currently stored. + int ItemCount { get; } + + /// The partition key path(s) for this container. + IReadOnlyList PartitionKeyPaths { get; } + + /// + /// Sets the JavaScript trigger engine. Requires the + /// CosmosDB.InMemoryEmulator.JsTriggers package. + /// + IJsTriggerEngine JsTriggerEngine { get; set; } + + /// + /// Sets the stored procedure engine. Requires the + /// CosmosDB.InMemoryEmulator.JsTriggers package. + /// + ISprocEngine SprocEngine { get; set; } } diff --git a/src/CosmosDB.InMemoryEmulator/IJsTriggerEngine.cs b/src/CosmosDB.InMemoryEmulator/IJsTriggerEngine.cs index 2861a32..ff164e6 100644 --- a/src/CosmosDB.InMemoryEmulator/IJsTriggerEngine.cs +++ b/src/CosmosDB.InMemoryEmulator/IJsTriggerEngine.cs @@ -8,24 +8,24 @@ namespace CosmosDB.InMemoryEmulator; /// public interface IJsTriggerEngine { - /// - /// Executes a pre-trigger JavaScript body. Returns the (possibly modified) document. - /// - JObject ExecutePreTrigger(string jsBody, JObject document); + /// + /// Executes a pre-trigger JavaScript body. Returns the (possibly modified) document. + /// + JObject ExecutePreTrigger(string jsBody, JObject document); - /// - /// Executes a pre-trigger JavaScript body with access to the collection context for CRUD operations. - /// - JObject ExecutePreTrigger(string jsBody, JObject document, ICollectionContext context); + /// + /// Executes a pre-trigger JavaScript body with access to the collection context for CRUD operations. + /// + JObject ExecutePreTrigger(string jsBody, JObject document, ICollectionContext context); - /// - /// Executes a post-trigger JavaScript body with access to the committed document. - /// Returns a modified response body if response.setBody() was called, or null otherwise. - /// - JObject? ExecutePostTrigger(string jsBody, JObject document); + /// + /// Executes a post-trigger JavaScript body with access to the committed document. + /// Returns a modified response body if response.setBody() was called, or null otherwise. + /// + JObject? ExecutePostTrigger(string jsBody, JObject document); - /// - /// Executes a post-trigger JavaScript body with access to the collection context for CRUD operations. - /// - JObject? ExecutePostTrigger(string jsBody, JObject document, ICollectionContext context); + /// + /// Executes a post-trigger JavaScript body with access to the collection context for CRUD operations. + /// + JObject? ExecutePostTrigger(string jsBody, JObject document, ICollectionContext context); } diff --git a/src/CosmosDB.InMemoryEmulator/IJsUdfEngine.cs b/src/CosmosDB.InMemoryEmulator/IJsUdfEngine.cs index 8841004..fa4c7a8 100644 --- a/src/CosmosDB.InMemoryEmulator/IJsUdfEngine.cs +++ b/src/CosmosDB.InMemoryEmulator/IJsUdfEngine.cs @@ -6,5 +6,5 @@ namespace CosmosDB.InMemoryEmulator; /// public interface IJsUdfEngine { - object? ExecuteUdf(string jsBody, object[] args); + object? ExecuteUdf(string jsBody, object[] args); } diff --git a/src/CosmosDB.InMemoryEmulator/ISprocEngine.cs b/src/CosmosDB.InMemoryEmulator/ISprocEngine.cs index eba4ae2..2953411 100644 --- a/src/CosmosDB.InMemoryEmulator/ISprocEngine.cs +++ b/src/CosmosDB.InMemoryEmulator/ISprocEngine.cs @@ -9,22 +9,22 @@ namespace CosmosDB.InMemoryEmulator; /// public interface ISprocEngine { - /// - /// Executes a stored procedure JavaScript body and returns the result set via response.setBody(). - /// - /// The JavaScript function body. - /// The partition key the sproc is scoped to. - /// Arguments passed by the caller. - /// The JSON string result (from response.setBody()), or null if setBody was not called. - string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args); + /// + /// Executes a stored procedure JavaScript body and returns the result set via response.setBody(). + /// + /// The JavaScript function body. + /// The partition key the sproc is scoped to. + /// Arguments passed by the caller. + /// The JSON string result (from response.setBody()), or null if setBody was not called. + string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args); - /// - /// Executes a stored procedure JavaScript body with access to the collection context for CRUD operations. - /// - string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context); + /// + /// Executes a stored procedure JavaScript body with access to the collection context for CRUD operations. + /// + string? Execute(string jsBody, PartitionKey partitionKey, dynamic[] args, ICollectionContext context); - /// - /// Log messages captured from console.log() calls during the most recent invocation. - /// - IReadOnlyList CapturedLogs { get; } + /// + /// Log messages captured from console.log() calls during the most recent invocation. + /// + IReadOnlyList CapturedLogs { get; } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs b/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs index 090dbcc..a9bfa6f 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs @@ -12,478 +12,478 @@ namespace CosmosDB.InMemoryEmulator; internal sealed class InMemoryChangeFeedProcessorContext : ChangeFeedProcessorContext { - public override string LeaseToken { get; } = "0"; - public override CosmosDiagnostics Diagnostics => null; - public override Headers Headers { get; } = new(); - public override FeedRange FeedRange => FeedRange.FromPartitionKey(PartitionKey.None); + public override string LeaseToken { get; } = "0"; + public override CosmosDiagnostics Diagnostics => null; + public override Headers Headers { get; } = new(); + public override FeedRange FeedRange => FeedRange.FromPartitionKey(PartitionKey.None); } internal sealed class NoOpChangeFeedProcessor : ChangeFeedProcessor { - public override Task StartAsync() => Task.CompletedTask; - public override Task StopAsync() => Task.CompletedTask; + public override Task StartAsync() => Task.CompletedTask; + public override Task StopAsync() => Task.CompletedTask; } internal sealed class InMemoryChangeFeedProcessor : ChangeFeedProcessor { - private readonly InMemoryContainer _container; - private readonly Func, CancellationToken, Task> _handler; - private CancellationTokenSource _cts; - private Task _pollingTask; - private long _checkpoint; - - internal InMemoryChangeFeedProcessor( - InMemoryContainer container, - Container.ChangeFeedHandler handler) - { - _container = container; - _handler = (ctx, changes, ct) => handler(ctx, changes, ct); - } - - internal InMemoryChangeFeedProcessor( - InMemoryContainer container, - Container.ChangesHandler legacyHandler) - { - _container = container; - _handler = (_, changes, ct) => legacyHandler(changes, ct); - } - - public override Task StartAsync() - { - _checkpoint = _container.GetChangeFeedCheckpoint(); - _cts = new CancellationTokenSource(); - _pollingTask = PollAsync(_cts.Token); - return Task.CompletedTask; - } - - public override async Task StopAsync() - { - if (_cts != null) - { - await _cts.CancelAsync(); - try { await _pollingTask; } - catch (OperationCanceledException) { } - _cts.Dispose(); - _cts = null; - } - } - - private async Task PollAsync(CancellationToken cancellationToken) - { - var context = new InMemoryChangeFeedProcessorContext(); - - while (!cancellationToken.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); - - var iterator = _container.GetChangeFeedIterator(_checkpoint); - var changes = new List(); - - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(cancellationToken); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - changes.AddRange(response); - } - - if (changes.Count > 0) - { - // Deduplicate: keep only the latest version per item (by id), - // matching LatestVersion change feed processor behavior. - var deduped = DeduplicateByItemId(changes); - try - { - await _handler(context, deduped, cancellationToken); - _checkpoint += changes.Count; // advance past ALL entries, including intermediates - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - // Handler threw — do not advance checkpoint so the same batch is redelivered - } - } - } - } - - private static IReadOnlyCollection DeduplicateByItemId(List changes) - { - var seen = new Dictionary(); - foreach (var item in changes) - { - var id = ExtractId(item); - if (id != null) - seen[id] = item; // last write wins — keeps latest version per item - } - return seen.Count == changes.Count ? changes : seen.Values.ToList(); - } - - private static string ExtractId(T item) - { - if (item is JObject jo) return jo["id"]?.ToString(); - var prop = typeof(T).GetProperty("Id") ?? typeof(T).GetProperty("id"); - return prop?.GetValue(item)?.ToString(); - } + private readonly InMemoryContainer _container; + private readonly Func, CancellationToken, Task> _handler; + private CancellationTokenSource _cts; + private Task _pollingTask; + private long _checkpoint; + + internal InMemoryChangeFeedProcessor( + InMemoryContainer container, + Container.ChangeFeedHandler handler) + { + _container = container; + _handler = (ctx, changes, ct) => handler(ctx, changes, ct); + } + + internal InMemoryChangeFeedProcessor( + InMemoryContainer container, + Container.ChangesHandler legacyHandler) + { + _container = container; + _handler = (_, changes, ct) => legacyHandler(changes, ct); + } + + public override Task StartAsync() + { + _checkpoint = _container.GetChangeFeedCheckpoint(); + _cts = new CancellationTokenSource(); + _pollingTask = PollAsync(_cts.Token); + return Task.CompletedTask; + } + + public override async Task StopAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + try { await _pollingTask; } + catch (OperationCanceledException) { } + _cts.Dispose(); + _cts = null; + } + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + var context = new InMemoryChangeFeedProcessorContext(); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + + var iterator = _container.GetChangeFeedIterator(_checkpoint); + var changes = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + changes.AddRange(response); + } + + if (changes.Count > 0) + { + // Deduplicate: keep only the latest version per item (by id), + // matching LatestVersion change feed processor behavior. + var deduped = DeduplicateByItemId(changes); + try + { + await _handler(context, deduped, cancellationToken); + _checkpoint += changes.Count; // advance past ALL entries, including intermediates + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Handler threw — do not advance checkpoint so the same batch is redelivered + } + } + } + } + + private static IReadOnlyCollection DeduplicateByItemId(List changes) + { + var seen = new Dictionary(); + foreach (var item in changes) + { + var id = ExtractId(item); + if (id != null) + seen[id] = item; // last write wins — keeps latest version per item + } + return seen.Count == changes.Count ? changes : seen.Values.ToList(); + } + + private static string ExtractId(T item) + { + if (item is JObject jo) return jo["id"]?.ToString(); + var prop = typeof(T).GetProperty("Id") ?? typeof(T).GetProperty("id"); + return prop?.GetValue(item)?.ToString(); + } } internal sealed class InMemoryChangeFeedStreamProcessor : ChangeFeedProcessor { - private readonly InMemoryContainer _container; - private readonly Container.ChangeFeedStreamHandler _handler; - private CancellationTokenSource _cts; - private Task _pollingTask; - private long _checkpoint; - - internal InMemoryChangeFeedStreamProcessor( - InMemoryContainer container, - Container.ChangeFeedStreamHandler handler) - { - _container = container; - _handler = handler; - } - - public override Task StartAsync() - { - _checkpoint = _container.GetChangeFeedCheckpoint(); - _cts = new CancellationTokenSource(); - _pollingTask = PollAsync(_cts.Token); - return Task.CompletedTask; - } - - public override async Task StopAsync() - { - if (_cts != null) - { - await _cts.CancelAsync(); - try { await _pollingTask; } - catch (OperationCanceledException) { } - _cts.Dispose(); - _cts = null; - } - } - - private async Task PollAsync(CancellationToken cancellationToken) - { - var context = new InMemoryChangeFeedProcessorContext(); - - while (!cancellationToken.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); - - var iterator = _container.GetChangeFeedIterator(_checkpoint); - var changes = new List(); - - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(cancellationToken); - if (response.StatusCode == HttpStatusCode.NotModified) - break; - changes.AddRange(response); - } - - if (changes.Count > 0) - { - try - { - var array = new JArray(changes); - var bytes = System.Text.Encoding.UTF8.GetBytes(array.ToString()); - using var stream = new MemoryStream(bytes); - await _handler(context, stream, cancellationToken); - _checkpoint += changes.Count; - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - // Handler threw — do not advance checkpoint so the same batch is redelivered - } - } - } - } + private readonly InMemoryContainer _container; + private readonly Container.ChangeFeedStreamHandler _handler; + private CancellationTokenSource _cts; + private Task _pollingTask; + private long _checkpoint; + + internal InMemoryChangeFeedStreamProcessor( + InMemoryContainer container, + Container.ChangeFeedStreamHandler handler) + { + _container = container; + _handler = handler; + } + + public override Task StartAsync() + { + _checkpoint = _container.GetChangeFeedCheckpoint(); + _cts = new CancellationTokenSource(); + _pollingTask = PollAsync(_cts.Token); + return Task.CompletedTask; + } + + public override async Task StopAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + try { await _pollingTask; } + catch (OperationCanceledException) { } + _cts.Dispose(); + _cts = null; + } + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + var context = new InMemoryChangeFeedProcessorContext(); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + + var iterator = _container.GetChangeFeedIterator(_checkpoint); + var changes = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken); + if (response.StatusCode == HttpStatusCode.NotModified) + break; + changes.AddRange(response); + } + + if (changes.Count > 0) + { + try + { + var array = new JArray(changes); + var bytes = System.Text.Encoding.UTF8.GetBytes(array.ToString()); + using var stream = new MemoryStream(bytes); + await _handler(context, stream, cancellationToken); + _checkpoint += changes.Count; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Handler threw — do not advance checkpoint so the same batch is redelivered + } + } + } + } } internal sealed class InMemoryManualCheckpointChangeFeedProcessor : ChangeFeedProcessor { - private readonly InMemoryContainer _container; - private readonly Container.ChangeFeedHandlerWithManualCheckpoint _handler; - private CancellationTokenSource _cts; - private Task _pollingTask; - private long _checkpoint; - - internal InMemoryManualCheckpointChangeFeedProcessor( - InMemoryContainer container, - Container.ChangeFeedHandlerWithManualCheckpoint handler) - { - _container = container; - _handler = handler; - } - - public override Task StartAsync() - { - _checkpoint = _container.GetChangeFeedCheckpoint(); - _cts = new CancellationTokenSource(); - _pollingTask = PollAsync(_cts.Token); - return Task.CompletedTask; - } - - public override async Task StopAsync() - { - if (_cts != null) - { - await _cts.CancelAsync(); - try { await _pollingTask; } - catch (OperationCanceledException) { } - _cts.Dispose(); - _cts = null; - } - } - - private async Task PollAsync(CancellationToken cancellationToken) - { - var context = new InMemoryChangeFeedProcessorContext(); - - while (!cancellationToken.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); - - var iterator = _container.GetChangeFeedIterator(_checkpoint); - var changes = new List(); - - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(cancellationToken); - if (response.StatusCode == HttpStatusCode.NotModified) - break; - changes.AddRange(response); - } - - if (changes.Count > 0) - { - var pendingCheckpoint = _checkpoint + changes.Count; - var checkpointed = false; - try - { - await _handler(context, changes, async () => { checkpointed = true; await Task.CompletedTask; }, cancellationToken); - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - // Handler threw — do not advance checkpoint - } - if (checkpointed) - _checkpoint = pendingCheckpoint; - } - } - } + private readonly InMemoryContainer _container; + private readonly Container.ChangeFeedHandlerWithManualCheckpoint _handler; + private CancellationTokenSource _cts; + private Task _pollingTask; + private long _checkpoint; + + internal InMemoryManualCheckpointChangeFeedProcessor( + InMemoryContainer container, + Container.ChangeFeedHandlerWithManualCheckpoint handler) + { + _container = container; + _handler = handler; + } + + public override Task StartAsync() + { + _checkpoint = _container.GetChangeFeedCheckpoint(); + _cts = new CancellationTokenSource(); + _pollingTask = PollAsync(_cts.Token); + return Task.CompletedTask; + } + + public override async Task StopAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + try { await _pollingTask; } + catch (OperationCanceledException) { } + _cts.Dispose(); + _cts = null; + } + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + var context = new InMemoryChangeFeedProcessorContext(); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + + var iterator = _container.GetChangeFeedIterator(_checkpoint); + var changes = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken); + if (response.StatusCode == HttpStatusCode.NotModified) + break; + changes.AddRange(response); + } + + if (changes.Count > 0) + { + var pendingCheckpoint = _checkpoint + changes.Count; + var checkpointed = false; + try + { + await _handler(context, changes, async () => { checkpointed = true; await Task.CompletedTask; }, cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Handler threw — do not advance checkpoint + } + if (checkpointed) + _checkpoint = pendingCheckpoint; + } + } + } } internal sealed class InMemoryManualCheckpointStreamChangeFeedProcessor : ChangeFeedProcessor { - private readonly InMemoryContainer _container; - private readonly Container.ChangeFeedStreamHandlerWithManualCheckpoint _handler; - private CancellationTokenSource _cts; - private Task _pollingTask; - private long _checkpoint; - - internal InMemoryManualCheckpointStreamChangeFeedProcessor( - InMemoryContainer container, - Container.ChangeFeedStreamHandlerWithManualCheckpoint handler) - { - _container = container; - _handler = handler; - } - - public override Task StartAsync() - { - _checkpoint = _container.GetChangeFeedCheckpoint(); - _cts = new CancellationTokenSource(); - _pollingTask = PollAsync(_cts.Token); - return Task.CompletedTask; - } - - public override async Task StopAsync() - { - if (_cts != null) - { - await _cts.CancelAsync(); - try { await _pollingTask; } - catch (OperationCanceledException) { } - _cts.Dispose(); - _cts = null; - } - } - - private async Task PollAsync(CancellationToken cancellationToken) - { - var context = new InMemoryChangeFeedProcessorContext(); - - while (!cancellationToken.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); - - var iterator = _container.GetChangeFeedIterator(_checkpoint); - var changes = new List(); - - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(cancellationToken); - if (response.StatusCode == HttpStatusCode.NotModified) - break; - changes.AddRange(response); - } - - if (changes.Count > 0) - { - var pendingCheckpoint = _checkpoint + changes.Count; - var checkpointed = false; - try - { - var array = new JArray(changes); - var bytes = System.Text.Encoding.UTF8.GetBytes(array.ToString()); - using var stream = new MemoryStream(bytes); - await _handler(context, stream, async () => { checkpointed = true; await Task.CompletedTask; }, cancellationToken); - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - // Handler threw — do not advance checkpoint - } - if (checkpointed) - _checkpoint = pendingCheckpoint; - } - } - } + private readonly InMemoryContainer _container; + private readonly Container.ChangeFeedStreamHandlerWithManualCheckpoint _handler; + private CancellationTokenSource _cts; + private Task _pollingTask; + private long _checkpoint; + + internal InMemoryManualCheckpointStreamChangeFeedProcessor( + InMemoryContainer container, + Container.ChangeFeedStreamHandlerWithManualCheckpoint handler) + { + _container = container; + _handler = handler; + } + + public override Task StartAsync() + { + _checkpoint = _container.GetChangeFeedCheckpoint(); + _cts = new CancellationTokenSource(); + _pollingTask = PollAsync(_cts.Token); + return Task.CompletedTask; + } + + public override async Task StopAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + try { await _pollingTask; } + catch (OperationCanceledException) { } + _cts.Dispose(); + _cts = null; + } + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + var context = new InMemoryChangeFeedProcessorContext(); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); + + var iterator = _container.GetChangeFeedIterator(_checkpoint); + var changes = new List(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken); + if (response.StatusCode == HttpStatusCode.NotModified) + break; + changes.AddRange(response); + } + + if (changes.Count > 0) + { + var pendingCheckpoint = _checkpoint + changes.Count; + var checkpointed = false; + try + { + var array = new JArray(changes); + var bytes = System.Text.Encoding.UTF8.GetBytes(array.ToString()); + using var stream = new MemoryStream(bytes); + await _handler(context, stream, async () => { checkpointed = true; await Task.CompletedTask; }, cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Handler threw — do not advance checkpoint + } + if (checkpointed) + _checkpoint = pendingCheckpoint; + } + } + } } internal static class ChangeFeedProcessorBuilderFactory { - private static readonly Assembly CosmosAssembly = typeof(Container).Assembly; - private static readonly BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; - - internal static readonly string[] RequiredFieldNames = - { - "changeFeedProcessor", "isBuilt", "changeFeedLeaseOptions", - "changeFeedProcessorOptions", "monitoredContainer", "applyBuilderConfiguration" - }; - - internal static readonly string[] RequiredInternalTypes = - { - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions", - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions", - "Microsoft.Azure.Cosmos.ContainerInlineCore", - "Microsoft.Azure.Cosmos.ContainerInternal", - "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager" - }; - - internal static bool IsReflectionCompatible() - { - var builderType = typeof(ChangeFeedProcessorBuilder); - - foreach (var fieldName in RequiredFieldNames) - { - if (builderType.GetField(fieldName, PrivateInstance) == null) - { - return false; - } - } - - foreach (var typeName in RequiredInternalTypes) - { - if (CosmosAssembly.GetType(typeName) == null) - { - return false; - } - } - - return true; - } - - internal static ChangeFeedProcessorBuilder Create(string processorName, ChangeFeedProcessor processor) - { - try - { - return CreateViaReflection(processorName, processor); - } - catch (Exception exception) - { - Trace.TraceWarning( - "InMemoryContainer: ChangeFeedProcessorBuilder reflection failed " + - $"({exception.GetType().Name}: {exception.Message}). " + - "Falling back to NSubstitute stub — processor.Build() will return a " + - "no-op processor that does not poll the change feed."); - return CreateFallbackBuilder(processor); - } - } - - private static ChangeFeedProcessorBuilder CreateViaReflection( - string processorName, ChangeFeedProcessor processor) - { - var builder = (ChangeFeedProcessorBuilder)RuntimeHelpers.GetUninitializedObject( - typeof(ChangeFeedProcessorBuilder)); - - var builderType = typeof(ChangeFeedProcessorBuilder); - - SetField(builderType, builder, "changeFeedProcessor", processor); - SetField(builderType, builder, "isBuilt", false); - - var leaseOptionsType = GetInternalType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); - var leaseOptions = Activator.CreateInstance(leaseOptionsType); - leaseOptionsType.GetProperty("LeasePrefix")!.SetValue(leaseOptions, processorName); - SetField(builderType, builder, "changeFeedLeaseOptions", leaseOptions); - - var processorOptionsType = GetInternalType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); - var processorOptions = Activator.CreateInstance(processorOptionsType, nonPublic: true); - SetField(builderType, builder, "changeFeedProcessorOptions", processorOptions); - - var containerInlineCore = GetInternalType("Microsoft.Azure.Cosmos.ContainerInlineCore"); - var fakeContainer = RuntimeHelpers.GetUninitializedObject(containerInlineCore); - SetField(builderType, builder, "monitoredContainer", fakeContainer); - - var leaseStoreManagerType = GetInternalType( - "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager"); - var containerInternalType = GetInternalType("Microsoft.Azure.Cosmos.ContainerInternal"); - - var actionType = typeof(Action<,,,,,>).MakeGenericType( - leaseStoreManagerType, - containerInternalType, - typeof(string), - leaseOptionsType, - processorOptionsType, - containerInternalType); - - var parameters = actionType.GetMethod("Invoke")!.GetParameters() - .Select(p => Expression.Parameter(p.ParameterType)) - .ToArray(); - var noopDelegate = Expression.Lambda(actionType, Expression.Empty(), parameters).Compile(); - SetField(builderType, builder, "applyBuilderConfiguration", noopDelegate); - - return builder; - } - - private static ChangeFeedProcessorBuilder CreateFallbackBuilder(ChangeFeedProcessor processor) - { - var builder = Substitute.For(); - builder.WithInstanceName(Arg.Any()).Returns(builder); - builder.WithLeaseContainer(Arg.Any()).Returns(builder); - builder.WithStartTime(Arg.Any()).Returns(builder); - builder.WithPollInterval(Arg.Any()).Returns(builder); - builder.WithMaxItems(Arg.Any()).Returns(builder); - builder.Build().Returns(processor); - return builder; - } - - private static void SetField(Type type, object target, string fieldName, object value) - { - var field = type.GetField(fieldName, PrivateInstance) - ?? throw new MissingFieldException(type.Name, fieldName); - field.SetValue(target, value); - } - - private static Type GetInternalType(string typeName) - { - return CosmosAssembly.GetType(typeName) - ?? throw new TypeLoadException( - $"Internal type '{typeName}' not found in {CosmosAssembly.GetName().Name} " + - $"v{CosmosAssembly.GetName().Version}."); - } + private static readonly Assembly CosmosAssembly = typeof(Container).Assembly; + private static readonly BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; + + internal static readonly string[] RequiredFieldNames = + { + "changeFeedProcessor", "isBuilt", "changeFeedLeaseOptions", + "changeFeedProcessorOptions", "monitoredContainer", "applyBuilderConfiguration" + }; + + internal static readonly string[] RequiredInternalTypes = + { + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions", + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions", + "Microsoft.Azure.Cosmos.ContainerInlineCore", + "Microsoft.Azure.Cosmos.ContainerInternal", + "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager" + }; + + internal static bool IsReflectionCompatible() + { + var builderType = typeof(ChangeFeedProcessorBuilder); + + foreach (var fieldName in RequiredFieldNames) + { + if (builderType.GetField(fieldName, PrivateInstance) == null) + { + return false; + } + } + + foreach (var typeName in RequiredInternalTypes) + { + if (CosmosAssembly.GetType(typeName) == null) + { + return false; + } + } + + return true; + } + + internal static ChangeFeedProcessorBuilder Create(string processorName, ChangeFeedProcessor processor) + { + try + { + return CreateViaReflection(processorName, processor); + } + catch (Exception exception) + { + Trace.TraceWarning( + "InMemoryContainer: ChangeFeedProcessorBuilder reflection failed " + + $"({exception.GetType().Name}: {exception.Message}). " + + "Falling back to NSubstitute stub — processor.Build() will return a " + + "no-op processor that does not poll the change feed."); + return CreateFallbackBuilder(processor); + } + } + + private static ChangeFeedProcessorBuilder CreateViaReflection( + string processorName, ChangeFeedProcessor processor) + { + var builder = (ChangeFeedProcessorBuilder)RuntimeHelpers.GetUninitializedObject( + typeof(ChangeFeedProcessorBuilder)); + + var builderType = typeof(ChangeFeedProcessorBuilder); + + SetField(builderType, builder, "changeFeedProcessor", processor); + SetField(builderType, builder, "isBuilt", false); + + var leaseOptionsType = GetInternalType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); + var leaseOptions = Activator.CreateInstance(leaseOptionsType); + leaseOptionsType.GetProperty("LeasePrefix")!.SetValue(leaseOptions, processorName); + SetField(builderType, builder, "changeFeedLeaseOptions", leaseOptions); + + var processorOptionsType = GetInternalType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); + var processorOptions = Activator.CreateInstance(processorOptionsType, nonPublic: true); + SetField(builderType, builder, "changeFeedProcessorOptions", processorOptions); + + var containerInlineCore = GetInternalType("Microsoft.Azure.Cosmos.ContainerInlineCore"); + var fakeContainer = RuntimeHelpers.GetUninitializedObject(containerInlineCore); + SetField(builderType, builder, "monitoredContainer", fakeContainer); + + var leaseStoreManagerType = GetInternalType( + "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager"); + var containerInternalType = GetInternalType("Microsoft.Azure.Cosmos.ContainerInternal"); + + var actionType = typeof(Action<,,,,,>).MakeGenericType( + leaseStoreManagerType, + containerInternalType, + typeof(string), + leaseOptionsType, + processorOptionsType, + containerInternalType); + + var parameters = actionType.GetMethod("Invoke")!.GetParameters() + .Select(p => Expression.Parameter(p.ParameterType)) + .ToArray(); + var noopDelegate = Expression.Lambda(actionType, Expression.Empty(), parameters).Compile(); + SetField(builderType, builder, "applyBuilderConfiguration", noopDelegate); + + return builder; + } + + private static ChangeFeedProcessorBuilder CreateFallbackBuilder(ChangeFeedProcessor processor) + { + var builder = Substitute.For(); + builder.WithInstanceName(Arg.Any()).Returns(builder); + builder.WithLeaseContainer(Arg.Any()).Returns(builder); + builder.WithStartTime(Arg.Any()).Returns(builder); + builder.WithPollInterval(Arg.Any()).Returns(builder); + builder.WithMaxItems(Arg.Any()).Returns(builder); + builder.Build().Returns(processor); + return builder; + } + + private static void SetField(Type type, object target, string fieldName, object value) + { + var field = type.GetField(fieldName, PrivateInstance) + ?? throw new MissingFieldException(type.Name, fieldName); + field.SetValue(target, value); + } + + private static Type GetInternalType(string typeName) + { + return CosmosAssembly.GetType(typeName) + ?? throw new TypeLoadException( + $"Internal type '{typeName}' not found in {CosmosAssembly.GetName().Name} " + + $"v{CosmosAssembly.GetName().Version}."); + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 07447d3..8844651 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -35,9138 +35,9274 @@ namespace CosmosDB.InMemoryEmulator; /// internal class InMemoryContainer : Container, IContainerTestSetup { - private static readonly JsonSerializerSettings JsonSettings = new() - { - TypeNameHandling = TypeNameHandling.None, - DateParseHandling = DateParseHandling.None, - ContractResolver = new DefaultContractResolver(), - Converters = { new StringEnumConverter { AllowIntegerValues = true } } - }; - - private static readonly HashSet AggregateFunctions = - new(StringComparer.OrdinalIgnoreCase) { "COUNT", "COUNTIF", "SUM", "AVG", "MIN", "MAX" }; - - /// - /// Recursively checks whether a SqlExpression tree contains any aggregate function call - /// (COUNT, COUNTIF, SUM, AVG, MIN, MAX). Used to detect aggregates inside object/array literals. - /// - private static bool ContainsAggregateCall(SqlExpression expr) => expr switch - { - FunctionCallExpression func => AggregateFunctions.Contains(func.FunctionName), - ObjectLiteralExpression obj => obj.Properties.Any(p => ContainsAggregateCall(p.Value)), - ArrayLiteralExpression arr => arr.Elements.Any(ContainsAggregateCall), - BinaryExpression bin => ContainsAggregateCall(bin.Left) || ContainsAggregateCall(bin.Right), - UnaryExpression unary => ContainsAggregateCall(unary.Operand), - TernaryExpression tern => ContainsAggregateCall(tern.Condition) || ContainsAggregateCall(tern.IfTrue) || ContainsAggregateCall(tern.IfFalse), - _ => false - }; - - private const int RegexCacheMaxSize = 256; - private static readonly ConcurrentDictionary<(string Pattern, RegexOptions Options), Regex> RegexCache = new(); - - private const int MaxDocumentSizeBytes = 2 * 1024 * 1024; - private const double SyntheticRequestCharge = 1.0; - private const double EarthRadiusMeters = 6_371_000.0; - - private static long _etagCounter; - private static int _docRidCounter; - - private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _items = new(); - private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _etags = new(); - private readonly ConcurrentDictionary<(string Id, string PartitionKey), DateTimeOffset> _timestamps = new(); - private readonly List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> _changeFeed = new(); - private readonly object _changeFeedLock = new(); - private long _changeFeedLsnCounter; - private long _sessionSequence; - private readonly object _uniqueKeyWriteLock = new(); - private readonly ConcurrentDictionary<(string Id, string PartitionKey), SemaphoreSlim> _itemLocks = new(); - private static readonly AsyncLocal> BatchWriteTracker = new(); - internal int _throughput = 400; - internal bool _isDeleted; - - private bool HasUniqueKeys => - _containerProperties.UniqueKeyPolicy?.UniqueKeys.Count > 0; - private ContainerProperties _containerProperties; - private readonly Scripts _scripts; - private readonly Dictionary> _userDefinedFunctions = new(StringComparer.Ordinal); - private static readonly Func UdfPlaceholder = _ => null; - private readonly Dictionary> _storedProcedures = new(StringComparer.Ordinal); - private readonly Dictionary _storedProcedureProperties = new(StringComparer.Ordinal); - private readonly Dictionary _triggers = new(StringComparer.Ordinal); - private readonly Dictionary _udfProperties = new(StringComparer.Ordinal); - private readonly Dictionary _triggerProperties = new(StringComparer.Ordinal); - private volatile (string Name, string FromAlias, SqlExpression Expr)[] _parsedComputedProperties; - - /// - /// Optional JavaScript trigger engine. When set, triggers that have a - /// but no C# handler will be executed via this engine. Set by calling UseJsTriggers() - /// from the CosmosDB.InMemoryEmulator.JsTriggers package. - /// - public IJsTriggerEngine JsTriggerEngine { get; set; } - - /// - /// Optional JavaScript stored procedure engine. When set, stored procedures that have a - /// but no C# handler will be executed via this engine. - /// Set by calling UseJsStoredProcedures() from the CosmosDB.InMemoryEmulator.JsTriggers package. - /// - public ISprocEngine SprocEngine { get; set; } - - private sealed record RegisteredTrigger( - TriggerType TriggerType, - TriggerOperation TriggerOperation, - Func PreHandler, - Action PostHandler); - - private sealed class UndefinedValue - { - public static readonly UndefinedValue Instance = new(); - public override string ToString() => null; - private UndefinedValue() { } - } - - /// - /// Type-aware equality comparer for JToken values, matching Cosmos DB semantics. - /// Different types (number vs string) are never equal, even if their string representations match. - /// - private sealed class JTokenValueComparer : IEqualityComparer - { - public static readonly JTokenValueComparer Instance = new(); - - public bool Equals(JToken x, JToken y) - { - if (ReferenceEquals(x, y)) return true; - if (x is null || y is null) return x is null && y is null; - return JToken.DeepEquals(x, y); - } - - public int GetHashCode(JToken obj) - { - if (obj is null) return 0; - return obj.Type switch - { - JTokenType.Integer => HashCode.Combine(obj.Type, obj.Value()), - JTokenType.Float => HashCode.Combine(obj.Type, obj.Value()), - JTokenType.String => HashCode.Combine(obj.Type, obj.Value()?.GetHashCode() ?? 0), - JTokenType.Boolean => HashCode.Combine(obj.Type, obj.Value()), - JTokenType.Null => HashCode.Combine(obj.Type), - JTokenType.Object => HashObjectOrderInsensitive((JObject)obj), - JTokenType.Array => HashArrayElements((JArray)obj), - _ => HashCode.Combine(obj.Type, obj.ToString().GetHashCode()) - }; - } - - private static int HashObjectOrderInsensitive(JObject obj) - { - int hash = 0; - foreach (var prop in obj.Properties()) - { - hash ^= HashCode.Combine(prop.Name.GetHashCode(), Instance.GetHashCode(prop.Value)); - } - return HashCode.Combine(JTokenType.Object, hash); - } - - private static int HashArrayElements(JArray arr) - { - var hash = new HashCode(); - hash.Add(JTokenType.Array); - foreach (var element in arr) - { - hash.Add(Instance.GetHashCode(element)); - } - return hash.ToHashCode(); - } - } - - /// - /// Compares JSON strings using structural equality (property-order-insensitive). - /// Used by DISTINCT to deduplicate projected results where objects may have - /// identical values but different property ordering (e.g. after Patch operations). - /// - private sealed class JsonStructuralStringComparer : IEqualityComparer - { - public static readonly JsonStructuralStringComparer Instance = new(); - - public bool Equals(string x, string y) - { - if (x == y) return true; - if (x is null || y is null) return false; - try { return JToken.DeepEquals(JToken.Parse(x), JToken.Parse(y)); } - catch { return false; } - } - - public int GetHashCode(string obj) - { - if (obj is null) return 0; - try { return JTokenValueComparer.Instance.GetHashCode(JToken.Parse(obj)); } - catch { return obj.GetHashCode(); } - } - } - - internal Action OnDeleted { get; set; } - - /// - /// Creates a new with a single partition key path. - /// - /// The container identifier. Defaults to "in-memory-container". - /// The JSON path to the partition key field (e.g. /partitionKey). Defaults to /id. - public InMemoryContainer(string id = "in-memory-container", string partitionKeyPath = "/id") - { - Id = id; - _containerProperties = new ContainerProperties(id, partitionKeyPath); - PartitionKeyPaths = new[] { partitionKeyPath }; - _scripts = new InMemoryScripts(this); - } - - /// - /// Creates a new with composite (hierarchical) partition key paths. - /// - /// The container identifier. - /// One or more JSON paths for the composite partition key. - public InMemoryContainer(string id, IReadOnlyList partitionKeyPaths) - { - Id = id; - PartitionKeyPaths = partitionKeyPaths; - _containerProperties = new ContainerProperties(id, partitionKeyPaths); - _scripts = new InMemoryScripts(this); - } - - /// - /// Creates a new from a instance. - /// This allows specifying advanced settings such as . - /// - /// The container properties to use. - public InMemoryContainer(ContainerProperties containerProperties) - { - ValidateComputedProperties(containerProperties); - _containerProperties = containerProperties; - Id = containerProperties.Id; - DefaultTimeToLive = containerProperties.DefaultTimeToLive; - var paths = containerProperties.PartitionKeyPaths; - if (paths is null || paths.Count == 0) - { - var singlePath = containerProperties.PartitionKeyPath ?? "/id"; - PartitionKeyPaths = new[] { singlePath }; - } - else - { - PartitionKeyPaths = paths; - } - _scripts = new InMemoryScripts(this); - } - - // ─── Properties ─────────────────────────────────────────────────────────── - - /// The container identifier. - public override string Id { get; } = default!; - - /// Returns a stubbed instance. - public override Database Database - { - get - { - if (_cachedDatabase is null) - { - var db = Substitute.For(); - db.Id.Returns(_parentDatabaseId ?? Id); - _cachedDatabase = db; - } - return _cachedDatabase; - } - } - private Database _cachedDatabase; - private string _parentDatabaseId; - - internal void SetParentDatabase(string databaseId) => _parentDatabaseId = databaseId; - - internal bool ExplicitlyCreated { get; set; } = true; - - /// Returns a stubbed instance. - public override Conflicts Conflicts => Substitute.For(); - - /// The instance for executing stored procedures and UDFs. - public override Scripts Scripts => _scripts; - - /// - /// Container-level default TTL in seconds. When set, items expire after this duration - /// unless overridden by a per-item _ttl property. Set to null to disable. - /// Items are lazily evicted on the next read attempt. - /// Setting to 0 throws BadRequest as in real Cosmos DB — use -1 for "enabled, no default expiry". - /// - public int? DefaultTimeToLive - { - get => _defaultTimeToLive; - set - { - if (value == 0) - throw InMemoryCosmosException.Create("The value of DefaultTimeToLive must be either null, -1, or a positive integer.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _defaultTimeToLive = value; - } - } - private int? _defaultTimeToLive; - - /// - public UniqueKeyPolicy UniqueKeyPolicy - { - get => _containerProperties.UniqueKeyPolicy; - set => _containerProperties.UniqueKeyPolicy = value ?? new UniqueKeyPolicy(); - } - - /// The partition key path(s) for this container. - public IReadOnlyList PartitionKeyPaths { get; } - - /// - /// Maximum number of entries retained in the change feed log. When a new entry - /// would exceed this limit, the oldest entries are evicted. Defaults to 1000. - /// Set to 0 to disable eviction (unbounded growth). - /// - public int MaxChangeFeedSize { get; set; } = 1000; - - /// - /// Gets the current session token for this container. - /// The token advances each time a write operation succeeds. - /// - internal string CurrentSessionToken => $"0:{_sessionSequence}#{_sessionSequence}"; - - /// - /// Number of feed ranges returned by . Defaults to 1. - /// Set to a higher value to simulate multiple physical partitions so that - /// FeedRange-scoped queries and change feed iterators return subsets of data. - /// - public int FeedRangeCount { get; set; } = 1; - - /// - /// When set, the container will automatically save its state to this file path on - /// and can load state from it via . - /// The directory will be created automatically if it does not exist. - /// - /// Use with to preserve container state between test runs. - /// Set via the StatePersistenceDirectory option on UseInMemoryCosmosDB / - /// UseInMemoryCosmosContainers for automatic DI integration. - /// - /// - public string StateFilePath { get; set; } - - // ─── Public helpers for test infrastructure ─────────────────────────────── - - /// - /// Removes all items, ETags, timestamps, and change feed entries from the container. - /// - public void ClearItems() - { - _items.Clear(); - _etags.Clear(); - _timestamps.Clear(); - lock (_changeFeedLock) { _changeFeed.Clear(); } - } - - /// Returns the number of non-expired items currently stored in the container. - public int ItemCount => DefaultTimeToLive is null - ? _items.Count - : _items.Keys.Count(k => !IsExpired(k)); - - /// - /// Saves the current container state to the file specified by . - /// Does nothing if is null. Creates the directory if it does not exist. - /// - public void Dispose() - { - if (StateFilePath is not null) - { - var dir = Path.GetDirectoryName(StateFilePath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - ExportStateToFile(StateFilePath); - } - } - - /// - /// Loads container state from the file specified by . - /// If the file does not exist, the container starts empty (no-op). - /// If the file exists, its contents are imported via . - /// - /// Thrown when is null. - public void LoadPersistedState() - { - if (StateFilePath is null) - throw new InvalidOperationException("StateFilePath must be set before calling LoadPersistedState()."); - - if (File.Exists(StateFilePath)) - ImportStateFromFile(StateFilePath); - } - - // ─── State persistence ──────────────────────────────────────────────────── - - /// - /// Exports the current container state as a JSON string. - /// - public string ExportState() - { - var items = _items - .Where(kvp => !IsExpired(kvp.Key)) - .Select(kvp => JsonParseHelpers.ParseJson(kvp.Value)).ToList(); - var state = new JObject { ["items"] = new JArray(items) }; - return state.ToString(Formatting.Indented); - } - - /// - /// Imports container state from a JSON string, replacing all existing data. - /// - public void ImportState(string json) - { - var state = JObject.Parse(json); - - if (state["items"] is not JArray items) - return; // No "items" key — do nothing (preserve existing data) - - ClearItems(); - - foreach (var item in items) - { - var itemJson = item.ToString(Formatting.None); - if (item["id"] is null) - throw new InvalidOperationException("Each imported item must have an 'id' property."); - var id = item["id"]!.ToString(); - var jObj = JsonParseHelpers.ParseJson(itemJson); - var pk = ExtractPartitionKeyValue(null, jObj); - var key = (id, pk); - - ValidateUniqueKeys(jObj, pk); - - var importEtag = GenerateETag(); - _etags[key] = importEtag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(itemJson, importEtag, _timestamps[key]); - } - } - - /// - /// Exports the current container state to a file. - /// - public void ExportStateToFile(string filePath) - { - File.WriteAllText(filePath, ExportState()); - } - - /// - /// Imports container state from a file, replacing all existing data. - /// - public void ImportStateFromFile(string filePath) - { - ImportState(File.ReadAllText(filePath)); - } - - // ─── Point-in-time restore ──────────────────────────────────────────────── - - /// - /// Restores the container to its state at the specified point in time by replaying - /// the change feed. All current data is replaced with the reconstructed state. - /// - /// The timestamp to restore to. Only changes recorded - /// at or before this time are included. - public void RestoreToPointInTime(DateTimeOffset pointInTime) - { - List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> feedSnapshot; - lock (_changeFeedLock) - { - feedSnapshot = _changeFeed - .Where(e => e.Timestamp <= pointInTime) - .ToList(); - } - - // Replay: keep the last entry per (Id, PartitionKey), skip if it was a delete. - // TTL eviction tombstones (marked with _ttlEviction) are ignored so that PITR - // can resurrect items whose TTL expired — matching real Cosmos PITR behaviour. - var lastPerKey = new Dictionary<(string Id, string PartitionKey), (string Json, bool IsDelete)>(); - foreach (var entry in feedSnapshot) - { - if (entry.IsDelete && entry.Json.Contains("\"_ttlEviction\":true", StringComparison.Ordinal)) - continue; - lastPerKey[(entry.Id, entry.PartitionKey)] = (entry.Json, entry.IsDelete); - } - - _items.Clear(); - _etags.Clear(); - _timestamps.Clear(); - _itemLocks.Clear(); - - foreach (var kvp in lastPerKey) - { - if (kvp.Value.IsDelete) continue; - - var key = kvp.Key; - var etag = $"\"{Guid.NewGuid()}\""; - _items[key] = EnrichWithSystemProperties(kvp.Value.Json, etag, pointInTime); - _etags[key] = etag; - _timestamps[key] = pointInTime; - } - } - - // ─── IndexingPolicy ─────────────────────────────────────────────────────── - - /// - /// The indexing policy for this container. Accepted and stored but does not affect - /// query performance — all queries scan all items regardless of indexing settings. - /// When set, automatically ensures /_etag/? is present in ExcludedPaths - /// to match real Cosmos DB behaviour. - /// - public IndexingPolicy IndexingPolicy - { - get => _indexingPolicy; - set - { - _indexingPolicy = value; - EnsureEtagExcludedPath(_indexingPolicy); - } - } - - private IndexingPolicy _indexingPolicy = new() - { - Automatic = true, - IndexingMode = IndexingMode.Consistent, - IncludedPaths = { new IncludedPath { Path = "/*" } }, - ExcludedPaths = { new ExcludedPath { Path = "/\"_etag\"/?" } }, - }; - - private static void EnsureEtagExcludedPath(IndexingPolicy policy) - { - const string etagPath = "/\"_etag\"/?"; - if (!policy.ExcludedPaths.Any(p => p.Path == etagPath)) - policy.ExcludedPaths.Add(new ExcludedPath { Path = etagPath }); - } - - /// - /// Registers a user-defined function that can be called in SQL queries as udf.name(args). - /// - public void RegisterUdf(string name, Func implementation) - { - ArgumentNullException.ThrowIfNull(name); - _userDefinedFunctions["UDF." + name.TrimStart('.')] = implementation; - } - - /// - /// Removes a previously registered user-defined function. - /// - public void DeregisterUdf(string name) - { - ArgumentNullException.ThrowIfNull(name); - _userDefinedFunctions.Remove("UDF." + name.TrimStart('.')); - } - - /// - /// Registers a stored procedure handler that is invoked when ExecuteStoredProcedureAsync is called. - /// - public void RegisterStoredProcedure(string sprocId, Func handler) - { - ArgumentNullException.ThrowIfNull(sprocId); - ArgumentNullException.ThrowIfNull(handler); - _storedProcedures[sprocId] = handler; - } - - /// - /// Removes a previously registered stored procedure handler. - /// - public void DeregisterStoredProcedure(string sprocId) - { - ArgumentNullException.ThrowIfNull(sprocId); - _storedProcedures.Remove(sprocId); - } - - /// - /// Registers a pre-trigger handler invoked when ItemRequestOptions.PreTriggers includes this trigger's ID. - /// The handler receives the document as a and must return the (possibly modified) document. - /// - public void RegisterTrigger(string triggerId, TriggerType triggerType, TriggerOperation triggerOperation, - Func preHandler) - { - ArgumentNullException.ThrowIfNull(triggerId); - _triggers[triggerId] = new RegisteredTrigger(triggerType, triggerOperation, preHandler, null); - } - - /// - /// Registers a post-trigger handler invoked when ItemRequestOptions.PostTriggers includes this trigger's ID. - /// The handler receives the committed document as a . - /// If the handler throws, the write is rolled back (matching real Cosmos DB transactional semantics). - /// - public void RegisterTrigger(string triggerId, TriggerType triggerType, TriggerOperation triggerOperation, - Action postHandler) - { - ArgumentNullException.ThrowIfNull(triggerId); - _triggers[triggerId] = new RegisteredTrigger(triggerType, triggerOperation, null, postHandler); - } - - /// - /// Removes a previously registered trigger handler. - /// - public void DeregisterTrigger(string triggerId) - { - ArgumentNullException.ThrowIfNull(triggerId); - _triggers.Remove(triggerId); - } - - // ─── JS body registrations (IContainerTestSetup) ────────────────────────── - - private const string JsTriggersNotInstalled = - "JavaScript stored procedure execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + - "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; - - private const string JsUdfNotInstalled = - "JavaScript UDF execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + - "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; - - private const string JsTriggerNotInstalled = - "JavaScript trigger execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + - "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; - - void IContainerTestSetup.RegisterStoredProcedure(string id, string jsBody) - { - if (SprocEngine is null) - throw new NotImplementedException(JsTriggersNotInstalled); - - RegisterStoredProcedure(id, (pk, args) => - { - var result = SprocEngine.Execute(jsBody, pk, args, new PartitionScopedCollectionContext(this, pk)); - return result ?? "null"; - }); - - _storedProcedureProperties[id] = new StoredProcedureProperties { Id = id, Body = jsBody }; - } - - void IContainerTestSetup.RegisterUdf(string name, string jsBody) - { - if (JsTriggerEngine is not IJsUdfEngine) - throw new NotImplementedException(JsUdfNotInstalled); - - // Register a placeholder — actual JS execution happens at query time via the UDF properties - _userDefinedFunctions["UDF." + name.TrimStart('.')] = UdfPlaceholder; - _udfProperties[name] = new UserDefinedFunctionProperties { Id = name, Body = jsBody }; - } - - void IContainerTestSetup.RegisterTrigger(string id, TriggerType type, TriggerOperation operation, - string jsBody) - { - if (JsTriggerEngine is null) - throw new NotImplementedException(JsTriggerNotInstalled); - - _triggerProperties[id] = new TriggerProperties - { - Id = id, - TriggerType = type, - TriggerOperation = operation, - Body = jsBody - }; - - if (type == TriggerType.Pre) - { - _triggers[id] = new RegisteredTrigger(type, operation, - doc => JsTriggerEngine.ExecutePreTrigger(jsBody, doc), null); - } - else - { - _triggers[id] = new RegisteredTrigger(type, operation, null, - doc => JsTriggerEngine.ExecutePostTrigger(jsBody, doc)); - } - } - - /// - /// Returns a checkpoint value representing the current position in the change feed. - /// Pass this to to read changes since this point. - /// - public long GetChangeFeedCheckpoint() - { - lock (_changeFeedLock) - { - return _changeFeed.Count; - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Item CRUD — Typed - // ═══════════════════════════════════════════════════════════════════════════ - - public override async Task> CreateItemAsync( - T item, PartitionKey? partitionKey = null, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = JsonConvert.SerializeObject(item, JsonSettings); - ValidateDocumentSize(json); - var jObj = JsonParseHelpers.ParseJson(json); - - jObj = ExecutePreTriggers(requestOptions, jObj, "Create"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - ValidateDocumentSize(json); - - var itemId = jObj["id"]?.ToString() ?? throw new InvalidOperationException("Item must have an 'id' property."); - - if (itemId.Length == 0) - { - throw InMemoryCosmosException.Create("The 'id' property cannot be an empty string.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - ValidatePartitionKeyConsistency(partitionKey, jObj); - ValidatePerItemTtl(jObj); - var pk = ExtractPartitionKeyValue(partitionKey, jObj); - var key = ItemKey(itemId, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - EvictIfExpired(key); - - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - ValidateUniqueKeys(jObj, pk); - if (!_items.TryAdd(key, json)) - { - throw InMemoryCosmosException.Create($"Entity with the specified id already exists in the system. id = {itemId}", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - } - else - { - if (!_items.TryAdd(key, json)) - { - throw InMemoryCosmosException.Create($"Entity with the specified id already exists in the system. id = {itemId}", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - TrackBatchWrite(key); - var etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); - - try - { - var committedDoc = JsonParseHelpers.ParseJson(_items[key]); - var responseBodyOverride = ExecutePostTriggers(requestOptions, committedDoc, "Create"); - var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); - if (postTriggerJson != _items[key]) - { - ValidateDocumentSize(postTriggerJson); - _items[key] = postTriggerJson; - } - - RecordChangeFeed(itemId, pk, _items[key]); - - var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; - if (responseBodyOverride is not null && !suppressContent) - { - return CreateItemResponse( - responseBodyOverride.ToObject(JsonSerializer.Create(JsonSettings)), - HttpStatusCode.Created, etag, suppressContent); - } - - return CreateItemResponse( - JsonConvert.DeserializeObject(_items[key], JsonSettings), - HttpStatusCode.Created, etag, suppressContent); - } - catch - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - throw; - } - } - finally - { - itemLock.Release(); - } - } - - public override Task> ReadItemAsync( - string id, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - if (!_items.TryGetValue(key, out var json) || IsExpired(key)) - { - EvictIfExpired(key); - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - CheckIfNoneMatch(requestOptions, key); - var etag = _etags.GetValueOrDefault(key); - var result = JsonConvert.DeserializeObject(json, JsonSettings); - return Task.FromResult(CreateItemResponse(result, HttpStatusCode.OK, etag)); - } - - public override async Task> UpsertItemAsync( - T item, PartitionKey? partitionKey = null, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = JsonConvert.SerializeObject(item, JsonSettings); - ValidateDocumentSize(json); - var jObj = JsonParseHelpers.ParseJson(json); - - jObj = ExecutePreTriggers(requestOptions, jObj, "Upsert"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - ValidateDocumentSize(json); - - var itemId = jObj["id"]?.ToString() ?? throw new InvalidOperationException("Item must have an 'id' property."); - ValidatePartitionKeyConsistency(partitionKey, jObj); - ValidatePerItemTtl(jObj); - var pk = ExtractPartitionKeyValue(partitionKey, jObj); - var key = ItemKey(itemId, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - EvictIfExpired(key); - - // Note: If-Match is documented as "applicable only on PUT and DELETE" in the REST API. - // Upsert is POST-based, so If-Match is not evaluated for the insert path. - // If the item exists, CheckIfMatch will evaluate it on the update path. - CheckIfMatch(requestOptions, key); - bool existed; - string previousJson; - string previousEtag; - DateTimeOffset? previousTimestamp; - string etag; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - ValidateUniqueKeys(jObj, pk, excludeItemId: itemId); - existed = _items.ContainsKey(key); - previousJson = existed ? _items[key] : null; - previousEtag = existed ? _etags.GetValueOrDefault(key) : null; - previousTimestamp = existed ? _timestamps.GetValueOrDefault(key) : default(DateTimeOffset?); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); - } - } - else - { - existed = _items.ContainsKey(key); - previousJson = existed ? _items[key] : null; - previousEtag = existed ? _etags.GetValueOrDefault(key) : null; - previousTimestamp = existed ? _timestamps.GetValueOrDefault(key) : default(DateTimeOffset?); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); - } - - TrackBatchWrite(key); - - try - { - var committedDoc = JsonParseHelpers.ParseJson(_items[key]); - ExecutePostTriggers(requestOptions, committedDoc, "Upsert"); - var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); - if (postTriggerJson != _items[key]) - { - ValidateDocumentSize(postTriggerJson); - _items[key] = postTriggerJson; - } - RecordChangeFeed(itemId, pk, _items[key]); - } - catch - { - if (existed && previousJson is not null) - { - _items[key] = previousJson; - _etags[key] = previousEtag!; - if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; - } - else - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - } - throw; - } - - var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; - return CreateItemResponse( - JsonConvert.DeserializeObject(_items[key], JsonSettings), - existed ? HttpStatusCode.OK : HttpStatusCode.Created, etag, suppressContent); - } - finally - { - itemLock.Release(); - } - } - - public override async Task> ReplaceItemAsync( - T item, string id, PartitionKey? partitionKey = null, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = JsonConvert.SerializeObject(item, JsonSettings); - ValidateDocumentSize(json); - var jObj = JsonParseHelpers.ParseJson(json); - - // Validate body id matches parameter id. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - // "The Container.ReplaceItemAsync<> method requires the provided string for the id - // parameter to match the unique identifier of the item parameter." - var bodyId = jObj["id"]?.ToString(); - if (bodyId is not null && bodyId != id) - { - throw InMemoryCosmosException.Create( - "The 'id' property in the body does not match the 'id' parameter.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - ValidatePartitionKeyConsistency(partitionKey, jObj); - - ValidatePerItemTtl(jObj); - - var pk = ExtractPartitionKeyValue(partitionKey, jObj); - var key = ItemKey(id, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - if (!_items.ContainsKey(key) || IsExpired(key)) - { - EvictIfExpired(key); - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - CheckIfMatch(requestOptions, key); - - // Pre-triggers run after ETag check (matching real Cosmos behavior) - jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - ValidateDocumentSize(json); - string previousJson; - string previousEtag; - DateTimeOffset previousTimestamp; - string etag; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - ValidateUniqueKeys(jObj, pk, excludeItemId: id); - previousJson = _items[key]; - previousEtag = _etags.GetValueOrDefault(key); - previousTimestamp = _timestamps.GetValueOrDefault(key); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); - } - } - else - { - previousJson = _items[key]; - previousEtag = _etags.GetValueOrDefault(key); - previousTimestamp = _timestamps.GetValueOrDefault(key); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); - } - - TrackBatchWrite(key); - try - { - var committedDoc = JsonParseHelpers.ParseJson(_items[key]); - ExecutePostTriggers(requestOptions, committedDoc, "Replace"); - var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); - if (postTriggerJson != _items[key]) - { - ValidateDocumentSize(postTriggerJson); - _items[key] = postTriggerJson; - } - RecordChangeFeed(id, pk, _items[key]); - } - catch - { - _items[key] = previousJson; - _etags[key] = previousEtag!; - _timestamps[key] = previousTimestamp; - throw; - } - - var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; - return CreateItemResponse(JsonConvert.DeserializeObject(_items[key], JsonSettings), HttpStatusCode.OK, etag, suppressContent); - } - finally - { - itemLock.Release(); - } - } - - public override async Task> DeleteItemAsync( - string id, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) - { - EvictIfExpired(key); - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - CheckIfMatch(requestOptions, key); - - ExecutePreTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); - - var previousEtag = _etags.TryGetValue(key, out var e) ? e : null; - var previousTimestamp = _timestamps.TryGetValue(key, out var ts) ? ts : (DateTimeOffset?)null; - - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - - TrackBatchWrite(key); - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); - RecordDeleteTombstone(id, pk, partitionKey); - } - catch - { - _items[key] = existingJson; - if (previousEtag is not null) _etags[key] = previousEtag; - if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; - throw; - } - - return CreateItemResponse(default(T), HttpStatusCode.NoContent); - } - finally - { - itemLock.Release(); - } - } - - public override async Task> PatchItemAsync( - string id, PartitionKey partitionKey, - IReadOnlyList patchOperations, - PatchItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (patchOperations is null || patchOperations.Count == 0) - { - throw InMemoryCosmosException.Create("Patch request has no operations.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - if (patchOperations.Count > 10) - { - throw InMemoryCosmosException.Create("Patch request has too many operations.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - ValidatePatchPaths(patchOperations); - - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - return PatchItemCore(id, pk, key, patchOperations, requestOptions); - } - finally - { - itemLock.Release(); - } - } - - private ItemResponse PatchItemCore( - string id, string pk, (string Id, string PartitionKey) key, - IReadOnlyList patchOperations, - PatchItemRequestOptions requestOptions) - { - if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) - { - EvictIfExpired(key); - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - CheckIfMatch(requestOptions, key); - CheckIfNoneMatchForWrite(requestOptions, key); - - var jObj = JsonParseHelpers.ParseJson(existingJson); - - if (requestOptions?.FilterPredicate is not null) - { - var predicateSql = $"SELECT * {requestOptions.FilterPredicate}"; - var predicateParsed = CosmosSqlParser.Parse(predicateSql); - if (predicateParsed.Where is not null) - { - var matches = EvaluateWhereExpression(predicateParsed.Where, jObj, predicateParsed.FromAlias, - new Dictionary(), null); - if (!matches) - { - throw InMemoryCosmosException.Create("Precondition Failed", - HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - } - - jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); - - ApplyPatchOperations(jObj, patchOperations); - ValidatePerItemTtl(jObj); - var updatedJson = jObj.ToString(Formatting.None); - ValidateDocumentSize(updatedJson); - string etag; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - ValidateUniqueKeys(jObj, pk, excludeItemId: id); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); - _items[key] = enriched; - } - } - else - { - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); - _items[key] = enriched; - } - var enrichedJson = _items[key]; - - TrackBatchWrite(key); - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); - RecordChangeFeed(id, pk, _items[key]); - - var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; - var result = JsonConvert.DeserializeObject(enrichedJson, JsonSettings); - return CreateItemResponse(result, HttpStatusCode.OK, etag, suppressContent); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Item CRUD — Stream - // ═══════════════════════════════════════════════════════════════════════════ - - public override async Task CreateItemStreamAsync( - Stream streamPayload, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = ReadStream(streamPayload); - var sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - JObject jObj; - try { jObj = JsonParseHelpers.ParseJson(json); } - catch (Newtonsoft.Json.JsonReaderException) - { return CreateResponseMessage(HttpStatusCode.BadRequest); } - - jObj = ExecutePreTriggers(requestOptions, jObj, "Create"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - var ttlError = ValidatePerItemTtlStream(jObj); - if (ttlError is not null) return ttlError; - - var itemId = jObj["id"]?.ToString() ?? Guid.NewGuid().ToString(); - - if (itemId.Length == 0) - return CreateResponseMessage(HttpStatusCode.BadRequest); - - var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); - if (pkMismatch is not null) return pkMismatch; - - var pk = ExtractPartitionKeyValue(partitionKey, jObj); - var key = ItemKey(itemId, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - EvictIfExpired(key); - - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - if (!ValidateUniqueKeysStream(jObj, pk)) - return CreateResponseMessage(HttpStatusCode.Conflict); - - if (!_items.TryAdd(key, json)) - return CreateResponseMessage(HttpStatusCode.Conflict); - } - } - else - { - if (!_items.TryAdd(key, json)) - return CreateResponseMessage(HttpStatusCode.Conflict); - } - - TrackBatchWrite(key); - var etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - var enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); - _items[key] = enrichedJson; - - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Create"); - RecordChangeFeed(itemId, pk, _items[key]); - } - catch - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - throw; - } - - return CreateResponseMessage(HttpStatusCode.Created, - requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); - } - finally - { - itemLock.Release(); - } - } - - public override Task ReadItemStreamAsync( - string id, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - if (!_items.TryGetValue(key, out var json) || IsExpired(key)) - { - EvictIfExpired(key); - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound)); - } - var etag = _etags.GetValueOrDefault(key); - if (!CheckIfNoneMatchStream(requestOptions, key)) - { - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotModified, etag: etag)); - } - return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, json, etag)); - } - - public override async Task UpsertItemStreamAsync( - Stream streamPayload, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = ReadStream(streamPayload); - var sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - JObject jObj; - try { jObj = JsonParseHelpers.ParseJson(json); } - catch (Newtonsoft.Json.JsonReaderException) - { return CreateResponseMessage(HttpStatusCode.BadRequest); } - - jObj = ExecutePreTriggers(requestOptions, jObj, "Upsert"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - var ttlError = ValidatePerItemTtlStream(jObj); - if (ttlError is not null) return ttlError; - - var itemId = jObj["id"]?.ToString(); - if (itemId is null) - return CreateResponseMessage(HttpStatusCode.BadRequest); - var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); - if (pkMismatch is not null) return pkMismatch; - var pk = ExtractPartitionKeyValue(partitionKey, jObj); - var key = ItemKey(itemId, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - EvictIfExpired(key); - - // Note: If-Match is documented as "applicable only on PUT and DELETE" in the REST API. - // Upsert is POST-based, so If-Match is not evaluated for the insert path. - // If the item exists, CheckIfMatchStream will evaluate it on the update path. - if (!CheckIfMatchStream(requestOptions, key)) - { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); - } - bool existed; - string etag; - string enrichedJson; - string previousJson = null; - string previousEtag = null; - DateTimeOffset? previousTimestamp = null; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) - return CreateResponseMessage(HttpStatusCode.Conflict); - existed = _items.TryGetValue(key, out previousJson); - if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); - _items[key] = enrichedJson; - } - } - else - { - if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) - return CreateResponseMessage(HttpStatusCode.Conflict); - existed = _items.TryGetValue(key, out previousJson); - if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); - _items[key] = enrichedJson; - } - - TrackBatchWrite(key); - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Upsert"); - RecordChangeFeed(itemId, pk, _items[key]); - } - catch - { - if (existed && previousJson is not null) - { - _items[key] = previousJson; - if (previousEtag is not null) _etags[key] = previousEtag; - if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; - } - else - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - } - throw; - } - - return CreateResponseMessage(existed ? HttpStatusCode.OK : HttpStatusCode.Created, - requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); - } - finally - { - itemLock.Release(); - } - } - - public override async Task ReplaceItemStreamAsync( - Stream streamPayload, string id, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var json = ReadStream(streamPayload); - var sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - JObject jObj; - try { jObj = JsonParseHelpers.ParseJson(json); } - catch (Newtonsoft.Json.JsonReaderException) - { return CreateResponseMessage(HttpStatusCode.BadRequest); } - - var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); - if (pkMismatch is not null) return pkMismatch; - - // Validate body id matches parameter id. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - // "The Container.ReplaceItemAsync<> method requires the provided string for the id - // parameter to match the unique identifier of the item parameter." - var bodyId = jObj["id"]?.ToString(); - if (bodyId is not null && bodyId != id) - { - return CreateResponseMessage(HttpStatusCode.BadRequest); - } - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - if (!_items.TryGetValue(key, out var previousJson) || IsExpired(key)) - { - EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); - } - - if (!CheckIfMatchStream(requestOptions, key)) - { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); - } - - var previousEtag = _etags.GetValueOrDefault(key); - var previousTimestamp = _timestamps.GetValueOrDefault(key); - - jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); - json = jObj.ToString(Newtonsoft.Json.Formatting.None); - sizeError = ValidateDocumentSizeStream(json); - if (sizeError is not null) return sizeError; - var ttlError = ValidatePerItemTtlStream(jObj); - if (ttlError is not null) return ttlError; - - string etag; - string enrichedJson; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); - _items[key] = enrichedJson; - } - } - else - { - if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); - _items[key] = enrichedJson; - } - - TrackBatchWrite(key); - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); - RecordChangeFeed(id, pk, _items[key]); - } - catch - { - _items[key] = previousJson; - if (previousEtag is not null) _etags[key] = previousEtag; - _timestamps[key] = previousTimestamp; - throw; - } - - return CreateResponseMessage(HttpStatusCode.OK, - requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); - } - finally - { - itemLock.Release(); - } - } - - public override async Task DeleteItemStreamAsync( - string id, PartitionKey partitionKey, - ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) - { - EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); - } - - if (!CheckIfMatchStream(requestOptions, key)) - { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); - } - - ExecutePreTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); - - var previousEtag = _etags.TryGetValue(key, out var e) ? e : null; - var previousTimestamp = _timestamps.TryGetValue(key, out var ts) ? ts : (DateTimeOffset?)null; - - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - - TrackBatchWrite(key); - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); - RecordDeleteTombstone(id, pk, partitionKey); - } - catch - { - _items[key] = existingJson; - if (previousEtag is not null) _etags[key] = previousEtag; - if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; - throw; - } - - return CreateResponseMessage(HttpStatusCode.NoContent); - } - finally - { - itemLock.Release(); - } - } - - public override async Task PatchItemStreamAsync( - string id, PartitionKey partitionKey, - IReadOnlyList patchOperations, - PatchItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (patchOperations is null || patchOperations.Count == 0) - { - return CreateResponseMessage(HttpStatusCode.BadRequest); - } - - if (patchOperations.Count > 10) - { - return CreateResponseMessage(HttpStatusCode.BadRequest); - } - - try - { - ValidatePatchPaths(patchOperations); - } - catch (CosmosException) - { - return CreateResponseMessage(HttpStatusCode.BadRequest); - } - - var pk = PartitionKeyToString(partitionKey); - var key = ItemKey(id, pk); - - var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await itemLock.WaitAsync(cancellationToken); - try - { - return PatchItemStreamCore(id, pk, key, patchOperations, requestOptions); - } - finally - { - itemLock.Release(); - } - } - - private ResponseMessage PatchItemStreamCore( - string id, string pk, (string Id, string PartitionKey) key, - IReadOnlyList patchOperations, - PatchItemRequestOptions requestOptions) - { - if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) - { - EvictIfExpired(key); - return CreateResponseMessage(HttpStatusCode.NotFound); - } - - if (!CheckIfMatchStream(requestOptions, key)) - { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); - } - - var jObj = JsonParseHelpers.ParseJson(existingJson); - - if (requestOptions?.FilterPredicate is not null) - { - var predicateSql = $"SELECT * {requestOptions.FilterPredicate}"; - var predicateParsed = CosmosSqlParser.Parse(predicateSql); - if (predicateParsed.Where is not null) - { - var matches = EvaluateWhereExpression(predicateParsed.Where, jObj, predicateParsed.FromAlias, - new Dictionary(), null); - if (!matches) - { - return CreateResponseMessage(HttpStatusCode.PreconditionFailed); - } - } - } - - jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); - - ApplyPatchOperations(jObj, patchOperations); - var updatedJson = jObj.ToString(Formatting.None); - var sizeError = ValidateDocumentSizeStream(updatedJson); - if (sizeError is not null) return sizeError; - var ttlError = ValidatePerItemTtlStream(jObj); - if (ttlError is not null) return ttlError; - - var previousEtag = _etags.GetValueOrDefault(key); - var previousTimestamp = _timestamps.GetValueOrDefault(key); - - string etag; - if (HasUniqueKeys) - { - lock (_uniqueKeyWriteLock) - { - if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) - return CreateResponseMessage(HttpStatusCode.Conflict); - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); - _items[key] = enriched; - } - } - else - { - etag = GenerateETag(); - _etags[key] = etag; - _timestamps[key] = DateTimeOffset.UtcNow; - var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); - _items[key] = enriched; - } - var enrichedJson = _items[key]; - - TrackBatchWrite(key); - try - { - ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); - RecordChangeFeed(id, pk, _items[key]); - } - catch - { - _items[key] = existingJson; - if (previousEtag is not null) _etags[key] = previousEtag; - _timestamps[key] = previousTimestamp; - throw; - } - - return CreateResponseMessage(HttpStatusCode.OK, - requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ReadMany - // ═══════════════════════════════════════════════════════════════════════════ - - public override Task> ReadManyItemsAsync( - IReadOnlyList<(string id, PartitionKey partitionKey)> items, - ReadManyRequestOptions readManyRequestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(items); - var results = new List(); - var etagParts = new List(); - foreach (var (itemId, pk) in items) - { - var pkStr = PartitionKeyToString(pk); - var key = ItemKey(itemId, pkStr); - if (_items.TryGetValue(key, out var json) && !IsExpired(key)) - { - var jObj = JsonParseHelpers.ParseJson(json); - var itemEtag = jObj["_etag"]?.ToString(); - if (itemEtag != null) etagParts.Add(itemEtag); - var deserialized = JsonConvert.DeserializeObject(json, JsonSettings); - if (deserialized is not null) - { - results.Add(deserialized); - } - } - } - var compositeEtag = etagParts.Count > 0 ? $"\"{string.Join(",", etagParts)}\"" : null; - if (readManyRequestOptions?.IfNoneMatchEtag != null && compositeEtag == readManyRequestOptions.IfNoneMatchEtag) - { - return Task.FromResult>(new InMemoryFeedResponse( - Array.Empty(), HttpStatusCode.NotModified, compositeEtag)); - } - return Task.FromResult>(new InMemoryFeedResponse(results, etag: compositeEtag)); - } - - public override Task ReadManyItemsStreamAsync( - IReadOnlyList<(string id, PartitionKey partitionKey)> items, - ReadManyRequestOptions readManyRequestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(items); - var results = new JArray(); - var etagParts = new List(); - foreach (var (itemId, pk) in items) - { - var pkStr = PartitionKeyToString(pk); - var key = ItemKey(itemId, pkStr); - if (_items.TryGetValue(key, out var json) && !IsExpired(key)) - { - var jObj = JsonParseHelpers.ParseJson(json); - var itemEtag = jObj["_etag"]?.ToString(); - if (itemEtag != null) etagParts.Add(itemEtag); - results.Add(jObj); - } - } - var compositeEtag = etagParts.Count > 0 ? $"\"{string.Join(",", etagParts)}\"" : null; - if (readManyRequestOptions?.IfNoneMatchEtag != null && compositeEtag == readManyRequestOptions.IfNoneMatchEtag) - { - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotModified, etag: compositeEtag)); - } - var envelope = new JObject { ["_rid"] = "", ["Documents"] = results, ["_count"] = results.Count }; - return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, envelope.ToString(Formatting.None), etag: compositeEtag)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query — Typed FeedIterator - // ═══════════════════════════════════════════════════════════════════════════ - - private static List ExecuteQuerySafe(Func> queryFunc) - { - try - { - return queryFunc(); - } - catch (CosmosException) { throw; } - catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or FormatException) - { - throw InMemoryCosmosException.Create( - ex.Message, HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - } - - public override FeedIterator GetItemQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - ValidateMaxItemCount(requestOptions); - var parameters = ExtractQueryParameters(queryDefinition); - var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions)); - var items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); - var initialOffset = ParseContinuationToken(continuationToken); - return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) - { - PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, - GuaranteeFirstPage = true - }; - } - - public override FeedIterator GetItemQueryIterator( - string queryText = null, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - ValidateMaxItemCount(requestOptions); - List items; - if (string.IsNullOrEmpty(queryText)) - { - items = GetAllItemsForPartition(requestOptions) - .Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); - } - else - { - var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryText, new Dictionary(), requestOptions)); - items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); - } - var initialOffset = ParseContinuationToken(continuationToken); - return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) - { - PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, - GuaranteeFirstPage = true - }; - } - - public override FeedIterator GetItemQueryIterator( - FeedRange feedRange, QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - ValidateMaxItemCount(requestOptions); - var parameters = ExtractQueryParameters(queryDefinition); - var preFiltered = FilterByFeedRange(GetAllItemsForPartition(requestOptions).ToList(), feedRange); - var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions, preFiltered)); - var items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); - var initialOffset = ParseContinuationToken(continuationToken); - return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) - { - PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, - GuaranteeFirstPage = true - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query — Stream FeedIterator - // ═══════════════════════════════════════════════════════════════════════════ - - public override FeedIterator GetItemQueryStreamIterator( - QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - var parameters = ExtractQueryParameters(queryDefinition); - var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions)); - return CreateStreamFeedIterator(filtered, ParseContinuationToken(continuationToken), requestOptions?.MaxItemCount); - } - - public override FeedIterator GetItemQueryStreamIterator( - string queryText = null, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - var offset = ParseContinuationToken(continuationToken); - if (string.IsNullOrEmpty(queryText)) - { - return CreateStreamFeedIterator(GetAllItemsForPartition(requestOptions).ToList(), offset, requestOptions?.MaxItemCount); - } - - return CreateStreamFeedIterator(ExecuteQuerySafe(() => FilterItemsByQuery(queryText, new Dictionary(), requestOptions)), offset, requestOptions?.MaxItemCount); - } - - public override FeedIterator GetItemQueryStreamIterator( - FeedRange feedRange, QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - var parameters = ExtractQueryParameters(queryDefinition); - var preFiltered = FilterByFeedRange(GetAllItemsForPartition(requestOptions).ToList(), feedRange); - var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions, preFiltered)); - return CreateStreamFeedIterator(filtered, ParseContinuationToken(continuationToken), requestOptions?.MaxItemCount); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ - // ═══════════════════════════════════════════════════════════════════════════ - - public override IOrderedQueryable GetItemLinqQueryable( - bool allowSynchronousQueryExecution = false, string continuationToken = null, - QueryRequestOptions requestOptions = null, CosmosLinqSerializerOptions linqSerializerOptions = null) - { - InMemoryFeedIteratorSetup.LastMaxItemCount = requestOptions?.MaxItemCount; - return GetAllItemsForPartition(requestOptions) - .Select(json => JsonConvert.DeserializeObject(json, JsonSettings)) - .AsQueryable() - .OrderBy(item => 0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // TransactionalBatch - // ═══════════════════════════════════════════════════════════════════════════ - - public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey) - => new InMemoryTransactionalBatch(this, partitionKey); - - // ═══════════════════════════════════════════════════════════════════════════ - // Change Feed — Iterators - // ═══════════════════════════════════════════════════════════════════════════ - - public override FeedIterator GetChangeFeedIterator( - ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, - ChangeFeedRequestOptions changeFeedRequestOptions = null) - { - var pageSize = changeFeedRequestOptions?.PageSizeHint; - var typeName = changeFeedStartFrom.GetType().Name; - var feedRange = ExtractFeedRangeFromStartFrom(changeFeedStartFrom); - - // "Now" and "Time" start types use lazy evaluation so items added - // after iterator creation are included when ReadNextAsync is called. - if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase) || - typeName.Contains("Time", StringComparison.OrdinalIgnoreCase)) - { - // Capture the checkpoint (current feed length) at creation time for "Now", - // or extract the timestamp for "Time" filtering. - DateTimeOffset? capturedTimestamp = null; - if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase)) - { - lock (_changeFeedLock) - { - capturedTimestamp = _changeFeed.Count > 0 - ? _changeFeed[^1].Timestamp - : DateTimeOffset.UtcNow; - } - } - else - { - var startTime = ExtractStartTime(changeFeedStartFrom); - if (startTime.HasValue) - capturedTimestamp = new DateTimeOffset(startTime.Value, TimeSpan.Zero); - } - - var isNowStart = typeName.Contains("Now", StringComparison.OrdinalIgnoreCase); - var ts = capturedTimestamp; - var capturedRange = feedRange; - return new InMemoryFeedIterator(() => - { - lock (_changeFeedLock) - { - var entries = ts.HasValue - ? _changeFeed.Where(entry => isNowStart - ? entry.Timestamp > ts.Value - : entry.Timestamp >= ts.Value).ToList() - : _changeFeed.ToList(); - - entries = FilterChangeFeedEntriesByFeedRange(entries, capturedRange); - - if (changeFeedMode == ChangeFeedMode.Incremental) - { - entries = entries - .GroupBy(entry => (entry.Id, entry.PartitionKey)) - .Select(group => group.Last()) - .Where(entry => !entry.IsDelete) - .ToList(); - } - - return entries - .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) - .ToList(); - } - }, pageSize); - } - - // "Beginning" and other start types — eager evaluation is fine - List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> eagerEntries; - lock (_changeFeedLock) - { - eagerEntries = _changeFeed.ToList(); - } - - eagerEntries = FilterChangeFeedEntriesByFeedRange(eagerEntries, feedRange); - - if (changeFeedMode == ChangeFeedMode.Incremental) - { - eagerEntries = eagerEntries - .GroupBy(entry => (entry.Id, entry.PartitionKey)) - .Select(group => group.Last()) - .Where(entry => !entry.IsDelete) - .ToList(); - } - - var items = eagerEntries - .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) - .ToList(); - return new InMemoryFeedIterator(items, pageSize); - } - - /// - /// Returns a change feed iterator that reads changes from the given checkpoint position. - /// Obtain a checkpoint via . - /// - public FeedIterator GetChangeFeedIterator(long checkpoint) - { - List items; - lock (_changeFeedLock) - { - items = _changeFeed - .Skip((int)checkpoint) - .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) - .ToList(); - } - return new InMemoryFeedIterator(items); - } - - public override FeedIterator GetChangeFeedStreamIterator( - ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, - ChangeFeedRequestOptions changeFeedRequestOptions = null) - { - var feedRange = ExtractFeedRangeFromStartFrom(changeFeedStartFrom); - var pageSizeHint = changeFeedRequestOptions?.PageSizeHint; - var creationTime = DateTimeOffset.UtcNow; - return CreateStreamFeedIteratorFromFactory(() => - { - List items; - lock (_changeFeedLock) - { - var entries = FilterChangeFeedByStartFrom(changeFeedStartFrom, creationTime); - entries = FilterChangeFeedEntriesByFeedRange(entries, feedRange); - - if (changeFeedMode == ChangeFeedMode.Incremental) - { - entries = entries - .GroupBy(entry => (entry.Id, entry.PartitionKey)) - .Select(group => group.Last()) - .Where(entry => !entry.IsDelete) - .ToList(); - } - - items = entries.Select(entry => entry.Json).ToList(); - } - return items; - }, 0, pageSizeHint); - } - - private List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> FilterChangeFeedByStartFrom( - ChangeFeedStartFrom startFrom, DateTimeOffset? creationTime = null) - { - var typeName = startFrom.GetType().Name; - - if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase)) - { - var now = creationTime ?? DateTimeOffset.UtcNow; - return _changeFeed.Where(entry => entry.Timestamp > now).ToList(); - } - - if (typeName.Contains("Time", StringComparison.OrdinalIgnoreCase)) - { - var startTime = ExtractStartTime(startFrom); - if (startTime.HasValue) - { - var dto = new DateTimeOffset(startTime.Value, TimeSpan.Zero); - return _changeFeed.Where(entry => entry.Timestamp >= dto).ToList(); - } - } - - return _changeFeed.ToList(); - } - - private static DateTime? ExtractStartTime(ChangeFeedStartFrom startFrom) - { - foreach (var prop in startFrom.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)) - { - if (prop.PropertyType == typeof(DateTime) && prop.GetValue(startFrom) is DateTime dt) - { - return dt; - } - } - - foreach (var field in startFrom.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (field.FieldType == typeof(DateTime) && field.GetValue(startFrom) is DateTime dt) - { - return dt; - } - } - - return null; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // FeedRange Filtering Helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static FeedRange ExtractFeedRangeFromStartFrom(ChangeFeedStartFrom startFrom) - { - // ChangeFeedStartFrom subtypes (Beginning, Now, Time, ContinuationAndFeedRange) - // store the FeedRange in an internal property. Use reflection to extract it. - foreach (var prop in startFrom.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)) - { - if (typeof(FeedRange).IsAssignableFrom(prop.PropertyType) && prop.GetValue(startFrom) is FeedRange fr) - return fr; - } - - foreach (var field in startFrom.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (typeof(FeedRange).IsAssignableFrom(field.FieldType) && field.GetValue(startFrom) is FeedRange fr) - return fr; - } - - return null; - } - - private List FilterByFeedRange(List items, FeedRange feedRange) - { - var pkValue = TryExtractPartitionKeyFromFeedRange(feedRange); - if (pkValue != null) - { - return items.Where(json => - { - var itemPk = ExtractPartitionKeyValueFromJson(json); - return string.Equals(itemPk, pkValue, StringComparison.Ordinal); - }).ToList(); - } - - var (min, max) = ParseFeedRangeBoundaries(feedRange); - if (min == null) return items; - - return items.Where(json => - { - var itemPk = ExtractPartitionKeyValueFromJson(json); - var hash = PartitionKeyHash.MurmurHash3(itemPk); - return IsHashInRange(hash, min.Value, max.Value); - }).ToList(); - } - - private List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> - FilterChangeFeedEntriesByFeedRange( - List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> entries, - FeedRange feedRange) - { - var pkValue = TryExtractPartitionKeyFromFeedRange(feedRange); - if (pkValue != null) - { - return entries.Where(entry => - string.Equals(entry.PartitionKey ?? "", pkValue, StringComparison.Ordinal)).ToList(); - } - - var (min, max) = ParseFeedRangeBoundaries(feedRange); - if (min == null) return entries; - - return entries.Where(entry => - { - var hash = PartitionKeyHash.MurmurHash3(entry.PartitionKey ?? ""); - return IsHashInRange(hash, min.Value, max.Value); - }).ToList(); - } - - private static (uint? Min, uint? Max) ParseFeedRangeBoundaries(FeedRange feedRange) - { - if (feedRange == null) return (null, null); - - try - { - var json = feedRange.ToJsonString(); - var obj = JObject.Parse(json); - var rangeObj = obj["Range"]; - if (rangeObj == null) return (null, null); - - var minStr = rangeObj["min"]?.ToString() ?? ""; - var maxStr = rangeObj["max"]?.ToString() ?? ""; - - var minVal = string.IsNullOrEmpty(minStr) ? 0u : Convert.ToUInt32(minStr, 16); - var maxVal = string.Equals(maxStr, "FF", StringComparison.OrdinalIgnoreCase) - ? uint.MaxValue - : Convert.ToUInt32(maxStr, 16); - - return (minVal, maxVal); - } - catch - { - return (null, null); - } - } - - private static string TryExtractPartitionKeyFromFeedRange(FeedRange feedRange) - { - if (feedRange == null) return null; - try - { - var json = feedRange.ToJsonString(); - var obj = JObject.Parse(json); - var pkToken = obj["PK"]; - if (pkToken == null) return null; - - // PK value is a string containing a JSON array, e.g. "[\"pk-5\"]" or "[42]" - var pkStr = pkToken.ToString(); - var pkArray = JArray.Parse(pkStr); - if (pkArray.Count == 0) return null; - // For multi-component (hierarchical) partition keys, join all parts with "|" - // to match the format used by ExtractPartitionKeyValueCore. - if (pkArray.Count == 1) - return JTokenToTypedKey(pkArray[0]) ?? ""; - var parts = pkArray.Select(t => JTokenToTypedKey(t) ?? "").ToList(); - return string.Join("|", parts); - } - catch - { - return null; - } - } - - private static bool IsHashInRange(uint hash, uint min, uint max) - { - return hash >= min && (max == uint.MaxValue || hash < max); - } - - private string ExtractPartitionKeyValueFromJson(string json) - { - JToken token; - try { token = JsonConvert.DeserializeObject(json, JsonSettings); } - catch { return ""; } - - if (token is not JObject jObj) return ""; - - if (PartitionKeyPaths is { Count: > 0 }) - { - var parts = PartitionKeyPaths.Select(path => - { - var t = jObj.SelectToken(path.TrimStart('/')); - return t is not null ? JTokenToTypedKey(t) : null; - }).ToList(); - if (parts.Count == 1) return parts[0] ?? ""; - return string.Join("|", parts.Select(p => p ?? "")); - } - - return ""; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Change Feed — Processor Builders - // ═══════════════════════════════════════════════════════════════════════════ - - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, Container.ChangesHandler onChangesDelegate) - => ChangeFeedProcessorBuilderFactory.Create(processorName, - new InMemoryChangeFeedProcessor(this, onChangesDelegate)); - - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, Container.ChangeFeedHandler onChangesDelegate) - => ChangeFeedProcessorBuilderFactory.Create(processorName, - new InMemoryChangeFeedProcessor(this, onChangesDelegate)); - - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, Container.ChangeFeedStreamHandler onChangesDelegate) - => ChangeFeedProcessorBuilderFactory.Create(processorName, - new InMemoryChangeFeedStreamProcessor(this, onChangesDelegate)); - - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( - string processorName, Container.ChangeFeedHandlerWithManualCheckpoint onChangesDelegate) - => ChangeFeedProcessorBuilderFactory.Create(processorName, - new InMemoryManualCheckpointChangeFeedProcessor(this, onChangesDelegate)); - - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( - string processorName, Container.ChangeFeedStreamHandlerWithManualCheckpoint onChangesDelegate) - => ChangeFeedProcessorBuilderFactory.Create(processorName, - new InMemoryManualCheckpointStreamChangeFeedProcessor(this, onChangesDelegate)); - - public override ChangeFeedEstimator GetChangeFeedEstimator( - string processorName, Container leaseContainer) - => Substitute.For(); - - public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder( - string processorName, Container.ChangesEstimationHandler estimationDelegate, - TimeSpan? estimationPeriod = null) - => ChangeFeedProcessorBuilderFactory.Create(processorName, new NoOpChangeFeedProcessor()); - - // ═══════════════════════════════════════════════════════════════════════════ - // Feed Ranges - // ═══════════════════════════════════════════════════════════════════════════ - - public override Task> GetFeedRangesAsync(CancellationToken cancellationToken = default) - { - var count = Math.Max(1, FeedRangeCount); - var ranges = new List(count); - var step = 0x1_0000_0000L / count; - - for (var i = 0; i < count; i++) - { - var min = PartitionKeyHash.RangeBoundaryToHex(i * step); - var max = (i == count - 1) ? "FF" : PartitionKeyHash.RangeBoundaryToHex((i + 1) * step); - ranges.Add(FeedRange.FromJsonString($"{{\"Range\":{{\"min\":\"{min}\",\"max\":\"{max}\"}}}}")); - } - - return Task.FromResult>(ranges); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Container management - // ═══════════════════════════════════════════════════════════════════════════ - - public override Task ReadContainerAsync( - ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (_isDeleted || (!ExplicitlyCreated && _items.IsEmpty)) - { - throw InMemoryCosmosException.Create($"Container '{Id}' not found.", HttpStatusCode.NotFound, 1003, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - _containerProperties.IndexingPolicy = IndexingPolicy; - _containerProperties.DefaultTimeToLive = DefaultTimeToLive; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(_containerProperties); - r.Container.Returns(this); - return Task.FromResult(r); - } - - public override Task ReadContainerStreamAsync( - ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (_isDeleted || (!ExplicitlyCreated && _items.IsEmpty)) - { - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound)); - } - _containerProperties.IndexingPolicy = IndexingPolicy; - _containerProperties.DefaultTimeToLive = DefaultTimeToLive; - return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, JsonConvert.SerializeObject(_containerProperties, JsonSettings))); - } - - public override Task ReplaceContainerAsync( - ContainerProperties containerProperties, ContainerRequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (containerProperties.Id != Id) - throw InMemoryCosmosException.Create($"Container id '{containerProperties.Id}' does not match the existing container id '{Id}'.", HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - ValidateContainerReplace(containerProperties); - ValidateComputedProperties(containerProperties); - // Preserve UniqueKeyPolicy from the existing properties if the replacement doesn't include it - if (_containerProperties.UniqueKeyPolicy?.UniqueKeys?.Count > 0 && - (containerProperties.UniqueKeyPolicy is null || containerProperties.UniqueKeyPolicy.UniqueKeys.Count == 0)) - { - containerProperties.UniqueKeyPolicy = _containerProperties.UniqueKeyPolicy; - } - _containerProperties = containerProperties; - _parsedComputedProperties = null; - DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (containerProperties.IndexingPolicy is not null) - IndexingPolicy = containerProperties.IndexingPolicy; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(containerProperties); - r.Container.Returns(this); - return Task.FromResult(r); - } - - public override Task ReplaceContainerStreamAsync( - ContainerProperties containerProperties, ContainerRequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (containerProperties.Id != Id) - return Task.FromResult(CreateResponseMessage(HttpStatusCode.BadRequest)); - try - { - ValidateContainerReplace(containerProperties); - ValidateComputedProperties(containerProperties); - } - catch (CosmosException) - { - return Task.FromResult(CreateResponseMessage(HttpStatusCode.BadRequest)); - } - // Preserve UniqueKeyPolicy from the existing properties if the replacement doesn't include it - if (_containerProperties.UniqueKeyPolicy?.UniqueKeys?.Count > 0 && - (containerProperties.UniqueKeyPolicy is null || containerProperties.UniqueKeyPolicy.UniqueKeys.Count == 0)) - { - containerProperties.UniqueKeyPolicy = _containerProperties.UniqueKeyPolicy; - } - _containerProperties = containerProperties; - _parsedComputedProperties = null; - DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (containerProperties.IndexingPolicy is not null) - IndexingPolicy = containerProperties.IndexingPolicy; - return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, JsonConvert.SerializeObject(containerProperties, JsonSettings))); - } - - public override Task DeleteContainerAsync( - ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - _isDeleted = true; - _items.Clear(); - _etags.Clear(); - _timestamps.Clear(); - lock (_changeFeedLock) { _changeFeed.Clear(); } - _storedProcedures.Clear(); - _userDefinedFunctions.Clear(); - _udfProperties.Clear(); - _triggers.Clear(); - _storedProcedureProperties.Clear(); - _triggerProperties.Clear(); - OnDeleted?.Invoke(); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.NoContent); - r.Container.Returns(this); - return Task.FromResult(r); - } - - public override Task DeleteContainerStreamAsync( - ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - _isDeleted = true; - _items.Clear(); - _etags.Clear(); - _timestamps.Clear(); - lock (_changeFeedLock) { _changeFeed.Clear(); } - _storedProcedures.Clear(); - _userDefinedFunctions.Clear(); - _udfProperties.Clear(); - _triggers.Clear(); - _storedProcedureProperties.Clear(); - _triggerProperties.Clear(); - OnDeleted?.Invoke(); - return Task.FromResult(CreateResponseMessage(HttpStatusCode.NoContent)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Throughput - // ═══════════════════════════════════════════════════════════════════════════ - - public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) - => Task.FromResult(_throughput); - - public override Task ReadThroughputAsync( - RequestOptions requestOptions, CancellationToken cancellationToken = default) - { - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(ThroughputProperties.CreateManualThroughput(_throughput)); - return Task.FromResult(r); - } - - public override Task ReplaceThroughputAsync( - int throughput, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - _throughput = throughput; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(ThroughputProperties.CreateManualThroughput(throughput)); - return Task.FromResult(r); - } - - public override Task ReplaceThroughputAsync( - ThroughputProperties throughputProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (throughputProperties.Throughput.HasValue) - _throughput = throughputProperties.Throughput.Value; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(throughputProperties); - return Task.FromResult(r); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Delete all items by partition key - // ═══════════════════════════════════════════════════════════════════════════ - - public override Task DeleteAllItemsByPartitionKeyStreamAsync( - PartitionKey partitionKey, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - var pk = PartitionKeyToString(partitionKey); - foreach (var key in _items.Keys.Where(k => k.PartitionKey == pk).ToList()) - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - RecordDeleteTombstone(key.Id, pk, partitionKey); - } - return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Private helpers — Key management & ETag - // ═══════════════════════════════════════════════════════════════════════════ - - private static (string Id, string PartitionKey) ItemKey(string id, string partitionKey) => (id, partitionKey); - - private string ExtractPartitionKeyValue(PartitionKey? partitionKey, JObject jObj) - { - var pk = ExtractPartitionKeyValueCore(partitionKey, jObj); - if (pk is not null && System.Text.Encoding.UTF8.GetByteCount(pk) > 2048) - throw InMemoryCosmosException.Create("Partition key value exceeds the maximum allowed size of 2KB.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - return pk; - } - - private string ExtractPartitionKeyValueCore(PartitionKey? partitionKey, JObject jObj) - { - if (partitionKey.HasValue) - { - if (partitionKey.Value == PartitionKey.Null) - { - return null; - } - - if (partitionKey.Value != PartitionKey.None) - { - return PartitionKeyToString(partitionKey.Value); - } - } - - if (PartitionKeyPaths is { Count: > 0 }) - { - if (PartitionKeyPaths.Count > 1) - { - // Composite key — preserve null positions as empty strings for consistency - // with PartitionKeyToString which also uses empty string for null components - var parts = PartitionKeyPaths.Select(path => - { - var t = jObj.SelectToken(path.TrimStart('/')); - return t is not null ? JTokenToTypedKey(t) : string.Empty; - }).ToList(); - return string.Join("|", parts.Select(p => p ?? string.Empty)); - } - - // Single path — check if the field exists - var token = jObj.SelectToken(PartitionKeyPaths[0].TrimStart('/')); - if (token is not null) - { - // Field exists — if value is null, return null (don't fall back to id) - return token.Type == JTokenType.Null ? null : JTokenToTypedKey(token); - } - } - - return null; - } - - internal static string JTokenToTypedKey(JToken token) - { - return token.Type switch - { - JTokenType.String => "S:" + token.Value(), - JTokenType.Integer or JTokenType.Float => "N:" + token.Value().ToString("R", System.Globalization.CultureInfo.InvariantCulture), - JTokenType.Boolean => "B:" + (token.Value() ? "true" : "false"), - JTokenType.Null => null, - _ => token.ToString() - }; - } - - /// - /// Converts a type-prefixed internal key back to a JToken for document fields. - /// - private static JToken TypedKeyToJToken(string typedKey) - { - if (typedKey is null) return JValue.CreateNull(); - if (typedKey.Length >= 2 && typedKey[1] == ':') - { - var value = typedKey.Substring(2); - return typedKey[0] switch - { - 'S' => new JValue(value), - 'N' => new JValue(double.Parse(value, System.Globalization.CultureInfo.InvariantCulture)), - 'B' => new JValue(value == "true"), - _ => new JValue(typedKey) - }; - } - return new JValue(typedKey); - } - - /// - /// Converts a type-prefixed internal key back to a typed PartitionKey. - /// - private static PartitionKey TypedKeyToPartitionKey(string typedKey) - { - if (typedKey is null) return PartitionKey.Null; - if (typedKey.Length >= 2 && typedKey[1] == ':') - { - var value = typedKey.Substring(2); - return typedKey[0] switch - { - 'S' => new PartitionKey(value), - 'N' => new PartitionKey(double.Parse(value, System.Globalization.CultureInfo.InvariantCulture)), - 'B' => new PartitionKey(bool.Parse(value)), - _ => new PartitionKey(typedKey) - }; - } - return new PartitionKey(typedKey); - } - - private static string PartitionKeyToString(PartitionKey partitionKey) - { - if (partitionKey == PartitionKey.None || partitionKey == PartitionKey.Null) - { - return null; - } - - var raw = partitionKey.ToString(); - if (raw.StartsWith("[")) - { - try - { - var arr = JArray.Parse(raw); - if (arr.Count == 1) - { - return JTokenToTypedKey(arr[0]); - } - - return string.Join("|", arr.Select(JTokenToTypedKey)); - } - catch { /* fall through */ } - } - return raw; - } - - private static string GenerateETag() => $"\"{Interlocked.Increment(ref _etagCounter):x16}\""; - - private void ValidatePartitionKeyConsistency(PartitionKey? explicitKey, JObject jObj) - { - if (!explicitKey.HasValue || explicitKey.Value == PartitionKey.None || explicitKey.Value == PartitionKey.Null) - return; - - // Only validate single-path partition keys (composite PKs have complex semantics) - if (PartitionKeyPaths is not { Count: 1 }) - return; - - var pkPath = PartitionKeyPaths[0].TrimStart('/'); - var bodyToken = jObj.SelectToken(pkPath); - if (bodyToken is null) - return; // Field not present in body — nothing to validate - - var explicitPk = PartitionKeyToString(explicitKey.Value); - var bodyPk = JTokenToTypedKey(bodyToken); - - if (bodyPk != explicitPk) - { - throw InMemoryCosmosException.Create( - "PartitionKey extracted from document doesn't match the one specified in the header. " + - "Learn more: https://aka.ms/CosmosDB/sql/errors/wrong-pk-value", - HttpStatusCode.BadRequest, 1001, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - /// - /// Stream-API variant of that returns - /// a instead of throwing, matching the stream API convention. - /// - private ResponseMessage ValidatePartitionKeyConsistencyStream(PartitionKey? explicitKey, JObject jObj) - { - if (!explicitKey.HasValue || explicitKey.Value == PartitionKey.None || explicitKey.Value == PartitionKey.Null) - return null; - - if (PartitionKeyPaths is not { Count: 1 }) - return null; - - var pkPath = PartitionKeyPaths[0].TrimStart('/'); - var bodyToken = jObj.SelectToken(pkPath); - if (bodyToken is null) - return null; - - var explicitPk = PartitionKeyToString(explicitKey.Value); - var bodyPk = JTokenToTypedKey(bodyToken); - - if (bodyPk != explicitPk) - { - return CreateResponseMessage(HttpStatusCode.BadRequest, subStatusCode: 1001); - } - - return null; - } - - private void ValidatePatchPaths(IReadOnlyList operations) - { - foreach (var op in operations) - { - var path = op.Path; - if (string.Equals(path, "/id", StringComparison.OrdinalIgnoreCase)) - { - throw InMemoryCosmosException.Create( - "Cannot patch the 'id' field.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - if (PartitionKeyPaths is { Count: > 0 }) - { - foreach (var pkPath in PartitionKeyPaths) - { - if (string.Equals(path, pkPath, StringComparison.OrdinalIgnoreCase)) - { - throw InMemoryCosmosException.Create( - "Cannot patch the partition key field.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - } - - var cps = _containerProperties?.ComputedProperties; - if (cps is { Count: > 0 }) - { - foreach (var cp in cps) - { - if (string.Equals(path, "/" + cp.Name, StringComparison.OrdinalIgnoreCase)) - { - throw InMemoryCosmosException.Create( - "Cannot patch a computed property path.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - } - } - } - - private void ValidateContainerReplace(ContainerProperties newProperties) - { - // Real Cosmos DB rejects partition key path changes - var existingPkPath = _containerProperties.PartitionKeyPath; - var newPkPath = newProperties.PartitionKeyPath; - if (!string.IsNullOrEmpty(newPkPath) && !string.IsNullOrEmpty(existingPkPath) && newPkPath != existingPkPath) - { - throw InMemoryCosmosException.Create( - "Partition key paths for a container cannot be changed.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private void ValidatePerItemTtl(JObject jObj) - { - var ttlToken = jObj["ttl"] ?? jObj["_ttl"]; - if (ttlToken is not null && int.TryParse(ttlToken.ToString(), out var ttlValue) && ttlValue == 0) - { - throw InMemoryCosmosException.Create( - "The value of ttl must be either -1 or a positive integer.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private ResponseMessage ValidatePerItemTtlStream(JObject jObj) - { - var ttlToken = jObj["ttl"] ?? jObj["_ttl"]; - if (ttlToken is not null && int.TryParse(ttlToken.ToString(), out var ttlValue) && ttlValue == 0) - { - return CreateResponseMessage(HttpStatusCode.BadRequest); - } - return null; - } - - private static void ValidateMaxItemCount(QueryRequestOptions requestOptions) - { - if (requestOptions?.MaxItemCount == 0) - { - throw InMemoryCosmosException.Create( - "MaxItemCount must be a positive value or -1.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - } - - private static readonly HashSet ReservedComputedPropertyNames = new(StringComparer.OrdinalIgnoreCase) - { - "id", "_rid", "_ts", "_etag", "_self", "_attachments" - }; - - private static readonly string[] ProhibitedCpClauses = - { " WHERE ", " ORDER BY ", " GROUP BY ", " TOP ", " DISTINCT ", " OFFSET ", " LIMIT ", " JOIN " }; - - private static void ValidateComputedProperties(ContainerProperties properties) - { - var cps = properties.ComputedProperties; - if (cps is null or { Count: 0 }) return; - - if (cps.Count > 20) - { - throw InMemoryCosmosException.Create( - "A container can have at most 20 computed properties.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - // Two-pass: collect all names first for cross-CP reference check (order-independent) - var allNames = new HashSet(StringComparer.Ordinal); - foreach (var cp in cps) - { - if (!allNames.Add(cp.Name)) - { - throw InMemoryCosmosException.Create( - $"Duplicate computed property name '{cp.Name}'.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - } - - foreach (var cp in cps) - { - if (ReservedComputedPropertyNames.Contains(cp.Name)) - { - throw InMemoryCosmosException.Create( - $"Computed property name '{cp.Name}' is a reserved system property name.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - if (string.IsNullOrWhiteSpace(cp.Query)) - { - throw InMemoryCosmosException.Create( - $"Computed property '{cp.Name}' must have a non-empty query.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - // Normalize whitespace for robust checking - var normalized = System.Text.RegularExpressions.Regex.Replace(cp.Query.Trim(), @"\s+", " "); - if (!normalized.StartsWith("SELECT VALUE", StringComparison.OrdinalIgnoreCase)) - { - throw InMemoryCosmosException.Create( - "Computed property query must use 'SELECT VALUE' syntax.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - // Check entire query for prohibited clauses - foreach (var clause in ProhibitedCpClauses) - { - if (normalized.IndexOf(clause, StringComparison.OrdinalIgnoreCase) >= 0) - { - throw InMemoryCosmosException.Create( - $"Computed property query cannot contain '{clause.Trim()}' clause.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - } - - // Check for self-referencing CP - var selfPattern = @"\." + System.Text.RegularExpressions.Regex.Escape(cp.Name) + @"(?![a-zA-Z0-9_])"; - if (System.Text.RegularExpressions.Regex.IsMatch(normalized, selfPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - throw InMemoryCosmosException.Create( - $"Computed property '{cp.Name}' cannot reference itself.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - // Check for cross-CP references (all other CP names, order-independent) - foreach (var otherName in allNames) - { - if (otherName.Equals(cp.Name, StringComparison.OrdinalIgnoreCase)) continue; - var crossPattern = @"\." + System.Text.RegularExpressions.Regex.Escape(otherName) + @"(?![a-zA-Z0-9_])"; - if (System.Text.RegularExpressions.Regex.IsMatch(normalized, crossPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - throw InMemoryCosmosException.Create( - $"Computed property '{cp.Name}' cannot reference another computed property '{otherName}'.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - } - } - } - - private string EnrichWithSystemProperties(string json, string etag, DateTimeOffset timestamp) - { - var jObj = JsonParseHelpers.ParseJson(json); - var containerHash = PartitionKeyHash.MurmurHash3(Id); - var docId = (uint)Interlocked.Increment(ref _docRidCounter); - var ridBytes = new byte[8]; - Buffer.BlockCopy(BitConverter.GetBytes(containerHash), 0, ridBytes, 0, 4); - Buffer.BlockCopy(BitConverter.GetBytes(docId), 0, ridBytes, 4, 4); - jObj["_rid"] = Convert.ToBase64String(ridBytes); - jObj["_self"] = $"dbs/db/colls/col/docs/{jObj["id"]}"; - jObj["_etag"] = etag; - jObj["_ts"] = timestamp.ToUnixTimeSeconds(); - jObj["_attachments"] = "attachments/"; - var enriched = jObj.ToString(Formatting.None); - ValidateDocumentSize(enriched); - return enriched; - } - - private static int ParseContinuationToken(string continuationToken) - { - if (continuationToken is null) - return 0; - - if (int.TryParse(continuationToken, out var offset)) - return offset; - - throw InMemoryCosmosException.Create( - $"Invalid continuation token '{continuationToken}'.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); - } - - private void RecordChangeFeed(string id, string partitionKey, string json, bool isDelete = false) - { - lock (_changeFeedLock) - { - _sessionSequence++; - var lsn = _changeFeedLsnCounter++; - var jsonWithLsn = json.Insert(1, $"\"_lsn\":{lsn},"); - _changeFeed.Add((DateTimeOffset.UtcNow, id, partitionKey, jsonWithLsn, isDelete)); - - if (MaxChangeFeedSize > 0 && _changeFeed.Count > MaxChangeFeedSize) - { - var excess = _changeFeed.Count - MaxChangeFeedSize; - _changeFeed.RemoveRange(0, excess); - } - } - } - - private void RecordDeleteTombstone(string id, string pk, PartitionKey partitionKey = default, bool isTtlEviction = false) - { - var tombstone = new JObject { ["id"] = id, ["_deleted"] = true, ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; - if (isTtlEviction) tombstone["_ttlEviction"] = true; - - if (PartitionKeyPaths.Count > 1 && partitionKey != default && partitionKey != PartitionKey.None && partitionKey != PartitionKey.Null) - { - // For composite keys, parse from the PartitionKey object directly to avoid - // pipe-delimiter issues when a PK value itself contains '|'. - var raw = partitionKey.ToString(); - try - { - var arr = JArray.Parse(raw); - for (var i = 0; i < PartitionKeyPaths.Count && i < arr.Count; i++) - { - tombstone[PartitionKeyPaths[i].TrimStart('/')] = arr[i].Type == JTokenType.String - ? arr[i].Value() - : arr[i].ToString(); - } - } - catch - { - // Fallback to split-based approach - var pkValues = (pk ?? string.Empty).Split('|'); - for (var i = 0; i < PartitionKeyPaths.Count; i++) - { - tombstone[PartitionKeyPaths[i].TrimStart('/')] = i < pkValues.Length ? TypedKeyToJToken(pkValues[i]) : null; - } - } - } - else if (pk is not null) - { - if (PartitionKeyPaths.Count == 1) - { - // Single PK path — convert the typed key back to a JToken value - tombstone[PartitionKeyPaths[0].TrimStart('/')] = TypedKeyToJToken(pk); - } - else - { - var pkValues = pk.Split('|'); - for (var i = 0; i < PartitionKeyPaths.Count; i++) - { - tombstone[PartitionKeyPaths[i].TrimStart('/')] = i < pkValues.Length ? TypedKeyToJToken(pkValues[i]) : null; - } - } - } - - RecordChangeFeed(id, pk, tombstone.ToString(Newtonsoft.Json.Formatting.None), isDelete: true); - } - - private static TriggerOperation OperationNameToTriggerOp(string operationName) => operationName switch - { - "Create" => TriggerOperation.Create, - "Replace" => TriggerOperation.Replace, - "Upsert" => TriggerOperation.Upsert, - "Delete" => TriggerOperation.Delete, - _ => TriggerOperation.All - }; - - private PartitionScopedCollectionContext CreateCollectionContextFromDoc(JObject jObj) - { - var pkValue = ExtractPartitionKeyValueCore(null, jObj); - var pk = TypedKeyToPartitionKey(pkValue); - return new PartitionScopedCollectionContext(this, pk); - } - - private static bool TriggerOperationMatches(TriggerOperation registered, TriggerOperation current) - => registered == TriggerOperation.All || registered == current; - - private JObject ExecutePreTriggers(ItemRequestOptions requestOptions, JObject jObj, string operationName) - { - var triggerNames = requestOptions?.PreTriggers; - if (triggerNames is null) return jObj; - - var currentOp = OperationNameToTriggerOp(operationName); - - foreach (var name in triggerNames) - { - // Priority 1: C# handler - if (_triggers.TryGetValue(name, out var trigger)) - { - if (trigger.TriggerType != TriggerType.Pre || trigger.PreHandler is null) continue; - if (!TriggerOperationMatches(trigger.TriggerOperation, currentOp)) continue; - - try - { - jObj = trigger.PreHandler(jObj); - } - catch (CosmosException) { throw; } - catch (Exception ex) - { - throw InMemoryCosmosException.Create( - $"Pre-trigger '{name}' failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - break; // Real Cosmos only fires the first matching trigger - } - - // Priority 2: JS body via JsTriggerEngine - if (_triggerProperties.TryGetValue(name, out var props) && props.Body is not null) - { - if (props.TriggerType != TriggerType.Pre) continue; - if (!TriggerOperationMatches(props.TriggerOperation, currentOp)) continue; - - if (JsTriggerEngine is null) - { - throw InMemoryCosmosException.Create( - $"Trigger '{name}' has a JavaScript body but no JS trigger engine is configured. " + - "Install the CosmosDB.InMemoryEmulator.JsTriggers package and call container.UseJsTriggers().", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - var originalId = jObj["id"]?.ToString(); - var pkPath = PartitionKeyPaths[0].TrimStart('/'); - var originalPk = jObj.SelectToken(pkPath)?.ToString(); - - jObj = JsTriggerEngine.ExecutePreTrigger(props.Body, jObj, - CreateCollectionContextFromDoc(jObj)); - - // Validate the trigger did not change id or partition key - var newId = jObj["id"]?.ToString(); - var newPk = jObj.SelectToken(pkPath)?.ToString(); - if (!string.Equals(originalId, newId, StringComparison.Ordinal)) - { - throw InMemoryCosmosException.Create( - $"Pre-trigger '{name}' is not allowed to modify the document id.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - if (!string.Equals(originalPk, newPk, StringComparison.Ordinal)) - { - throw InMemoryCosmosException.Create( - $"Pre-trigger '{name}' is not allowed to modify the partition key.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - break; // Real Cosmos only fires the first matching trigger - } - - throw InMemoryCosmosException.Create( - $"Trigger '{name}' is not registered. Register it via RegisterTrigger() or CreateTriggerAsync() before referencing it in PreTriggers.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - return jObj; - } - - private JObject ExecutePostTriggers(ItemRequestOptions requestOptions, JObject committedDoc, string operationName) - { - var triggerNames = requestOptions?.PostTriggers; - if (triggerNames is null) return null; - - JObject responseBodyOverride = null; - var currentOp = OperationNameToTriggerOp(operationName); - - foreach (var name in triggerNames) - { - // Priority 1: C# handler - if (_triggers.TryGetValue(name, out var trigger)) - { - if (trigger.TriggerType != TriggerType.Post || trigger.PostHandler is null) continue; - if (!TriggerOperationMatches(trigger.TriggerOperation, currentOp)) continue; - - try - { - trigger.PostHandler(committedDoc); - } - catch (CosmosException) - { - throw; - } - catch (Exception ex) - { - throw InMemoryCosmosException.Create( - $"Post-trigger '{name}' failed: {ex.Message}", - HttpStatusCode.InternalServerError, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - break; // Real Cosmos only fires the first matching trigger - } - - // Priority 2: JS body via JsTriggerEngine - if (_triggerProperties.TryGetValue(name, out var props) && props.Body is not null) - { - if (props.TriggerType != TriggerType.Post) continue; - if (!TriggerOperationMatches(props.TriggerOperation, currentOp)) continue; - - if (JsTriggerEngine is null) - { - throw InMemoryCosmosException.Create( - $"Trigger '{name}' has a JavaScript body but no JS trigger engine is configured. " + - "Install the CosmosDB.InMemoryEmulator.JsTriggers package and call container.UseJsTriggers().", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - try - { - var setBodyResult = JsTriggerEngine.ExecutePostTrigger(props.Body, committedDoc, - CreateCollectionContextFromDoc(committedDoc)); - if (setBodyResult is not null) - { - responseBodyOverride = setBodyResult; - } - } - catch (CosmosException) - { - throw; - } - catch (Exception ex) - { - throw InMemoryCosmosException.Create( - $"Post-trigger '{name}' failed: {ex.Message}", - HttpStatusCode.InternalServerError, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - break; // Real Cosmos only fires the first matching trigger - } - - throw InMemoryCosmosException.Create( - $"Trigger '{name}' is not registered. Register it via RegisterTrigger() or CreateTriggerAsync() before referencing it in PostTriggers.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - return responseBodyOverride; - } - - private void CheckIfMatch(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) - { - if (requestOptions?.IfMatchEtag is null) - { - return; - } - - if (requestOptions.IfMatchEtag == "*") - { - return; - } - - if (!_etags.TryGetValue(key, out var currentEtag)) - { - return; - } - - if (requestOptions.IfMatchEtag != currentEtag) - { - throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private void CheckIfNoneMatch(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) - { - if (requestOptions?.IfNoneMatchEtag is null) - { - return; - } - - if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) - { - throw InMemoryCosmosException.Create("Not Modified", HttpStatusCode.NotModified, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) - { - throw InMemoryCosmosException.Create("Not Modified", HttpStatusCode.NotModified, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - /// - /// IfNoneMatch check for write operations (Patch). Write operations return 412 PreconditionFailed - /// instead of 304 NotModified when the ETag matches. - /// - private void CheckIfNoneMatchForWrite(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) - { - if (requestOptions?.IfNoneMatchEtag is null) - { - return; - } - - if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) - { - throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) - { - throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private bool CheckIfMatchStream(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) - { - if (requestOptions?.IfMatchEtag is null) - { - return true; - } - - if (requestOptions.IfMatchEtag == "*") - { - return true; - } - - if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfMatchEtag != currentEtag) - { - return false; - } - - return true; - } - - private bool CheckIfNoneMatchStream(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) - { - if (requestOptions?.IfNoneMatchEtag is null) - { - return true; - } - - if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) - { - return false; - } - - if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) - { - return false; - } - - return true; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Private helpers — Serialization & Response factories - // ═══════════════════════════════════════════════════════════════════════════ - - private static string ReadStream(Stream stream) - { - using var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true); - return reader.ReadToEnd(); - } - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - private static readonly CosmosDiagnostics FakeDiagnostics = new InMemoryDiagnostics(); - - private sealed class InMemoryDiagnostics : CosmosDiagnostics - { - public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; - public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); - public override string ToString() => "{}"; - } - - private ItemResponse CreateItemResponse(T item, HttpStatusCode statusCode, string etag = null, bool suppressContent = false) - { - var activityId = Guid.NewGuid().ToString(); - var headers = new Headers - { - ["x-ms-session-token"] = CurrentSessionToken, - ["x-ms-activity-id"] = activityId, - ["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture) - }; - return new InMemoryItemResponse( - statusCode, headers, suppressContent ? default : item, FakeDiagnostics, - SyntheticRequestCharge, activityId, etag); - } - - private sealed class InMemoryItemResponse : ItemResponse - { - private readonly HttpStatusCode _statusCode; - private readonly Headers _headers; - private readonly T _resource; - private readonly CosmosDiagnostics _diagnostics; - private readonly double _requestCharge; - private readonly string _activityId; - private readonly string _etag; - - public InMemoryItemResponse( - HttpStatusCode statusCode, Headers headers, T resource, CosmosDiagnostics diagnostics, - double requestCharge, string activityId, string etag) - { - _statusCode = statusCode; - _headers = headers; - _resource = resource; - _diagnostics = diagnostics; - _requestCharge = requestCharge; - _activityId = activityId; - _etag = etag; - } - - public override HttpStatusCode StatusCode => _statusCode; - public override Headers Headers => _headers; - public override T Resource => _resource; - public override CosmosDiagnostics Diagnostics => _diagnostics; - public override double RequestCharge => _requestCharge; - public override string ActivityId => _activityId; - public override string ETag => _etag; - } - - private ResponseMessage CreateResponseMessage(HttpStatusCode statusCode, string json = null, string etag = null, int subStatusCode = 0) - { - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - var errorMessage = statusCode switch - { - HttpStatusCode.NotFound => "Resource Not Found. Entity with the specified id does not exist in the system.", - _ when (int)statusCode >= 400 => $"Response status code does not indicate success: {statusCode} ({(int)statusCode})", - _ => null - }; - - // For error responses with no explicit body, synthesize a JSON error body matching the - // real Cosmos DB REST API format. The SDK reads this body to populate CosmosException.ResponseBody. - // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} - if (json is null && errorMessage is not null) - { - json = $"{{\"code\":\"{statusCode}\",\"message\":\"{errorMessage}\"}}"; - } - - var msg = new ResponseMessage(statusCode, requestMessage: null, errorMessage: errorMessage) - { - Content = json is not null ? ToStream(json) : null - }; - msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - msg.Headers["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture); - msg.Headers["x-ms-session-token"] = CurrentSessionToken; - if (subStatusCode != 0) - { - msg.Headers["x-ms-substatus"] = subStatusCode.ToString(CultureInfo.InvariantCulture); - } - if (etag is not null) - { - msg.Headers["ETag"] = etag; - } - - return msg; - } - - private static void ValidateDocumentSize(string json) - { - var byteCount = Encoding.UTF8.GetByteCount(json); - if (byteCount > MaxDocumentSizeBytes) - { - throw InMemoryCosmosException.Create( - $"Request size is too large. Max allowed size in bytes: {MaxDocumentSizeBytes}. Found: {byteCount}.", - HttpStatusCode.RequestEntityTooLarge, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private ResponseMessage ValidateDocumentSizeStream(string json) - { - var byteCount = Encoding.UTF8.GetByteCount(json); - if (byteCount > MaxDocumentSizeBytes) - { - return CreateResponseMessage(HttpStatusCode.RequestEntityTooLarge); - } - return null; - } - - private void ValidateUniqueKeys(JObject jObj, string partitionKey, string excludeItemId = null) - { - var policy = _containerProperties.UniqueKeyPolicy; - if (policy == null || policy.UniqueKeys.Count == 0) return; - - foreach (var uniqueKey in policy.UniqueKeys) - { - // Cosmos DB unique key paths use /parent/child format. - // JObject.SelectToken uses dot notation (parent.child). - var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(p.TrimStart('/').Replace('/', '.'))?.ToString()).ToList(); - - foreach (var (existingKey, existingJson) in _items) - { - if (existingKey.PartitionKey != partitionKey) continue; - if (excludeItemId != null && existingKey.Id == excludeItemId) continue; - - var existingObj = JsonParseHelpers.ParseJson(existingJson); - var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(p.TrimStart('/').Replace('/', '.'))?.ToString()).ToList(); - - if (newValues.SequenceEqual(existingValues)) - { - throw InMemoryCosmosException.Create( - "Unique index constraint violation.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - } - } - - private bool ValidateUniqueKeysStream(JObject jObj, string partitionKey, string excludeItemId = null) - { - try - { - ValidateUniqueKeys(jObj, partitionKey, excludeItemId); - return true; - } - catch (CosmosException) - { - return false; - } - } - - private bool IsExpired((string Id, string PartitionKey) key) - { - // TTL feature is completely disabled when DefaultTimeToLive is null. - // Per-item _ttl is ignored in this case (matches real Cosmos DB behaviour). - if (DefaultTimeToLive is null) - { - return false; - } - - if (!_timestamps.TryGetValue(key, out var ts)) - { - return false; - } - - var elapsed = (DateTimeOffset.UtcNow - ts).TotalSeconds; - - if (_items.TryGetValue(key, out var json)) - { - var jObj = JsonParseHelpers.ParseJson(json); - var itemTtl = jObj["ttl"] ?? jObj["_ttl"]; - if (itemTtl is not null && int.TryParse(itemTtl.ToString(), out var perItemTtl)) - { - // ttl = -1 means "never expire" even if container has a default TTL - if (perItemTtl == -1) return false; - return elapsed >= perItemTtl; - } - } - - // DefaultTimeToLive = -1 means TTL is ON but items without per-item ttl don't expire - if (DefaultTimeToLive.Value <= 0) - { - return false; - } - - return elapsed >= DefaultTimeToLive.Value; - } - - private void EvictIfExpired((string Id, string PartitionKey) key) - { - if (!IsExpired(key)) - { - return; - } - - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - - // Record a delete tombstone in the change feed so consumers see TTL evictions - RecordDeleteTombstone(key.Id, key.PartitionKey, isTtlEviction: true); - } - - internal Dictionary<(string Id, string PartitionKey), string> SnapshotItems() - => new(_items); - - internal Dictionary<(string Id, string PartitionKey), string> SnapshotEtags() - => new(_etags); - - internal Dictionary<(string Id, string PartitionKey), DateTimeOffset> SnapshotTimestamps() - => new(_timestamps); - - internal int GetChangeFeedCount() - { - lock (_changeFeedLock) - { - return _changeFeed.Count; - } - } - - internal void BeginBatchTracking() - { - BatchWriteTracker.Value = new HashSet<(string Id, string PartitionKey)>(); - } - - internal HashSet<(string Id, string PartitionKey)> EndBatchTracking() - { - var keys = BatchWriteTracker.Value ?? new HashSet<(string Id, string PartitionKey)>(); - BatchWriteTracker.Value = null; - return keys; - } - - private void TrackBatchWrite((string Id, string PartitionKey) key) - { - BatchWriteTracker.Value?.Add(key); - } - - internal void RestoreSnapshot( - Dictionary<(string Id, string PartitionKey), string> itemsSnapshot, - Dictionary<(string Id, string PartitionKey), string> etagsSnapshot, - Dictionary<(string Id, string PartitionKey), DateTimeOffset> timestampsSnapshot, - int changeFeedCount, - IReadOnlySet<(string Id, string PartitionKey)> touchedKeys) - { - foreach (var key in touchedKeys) - { - if (itemsSnapshot.TryGetValue(key, out var snapshotItem)) - { - _items[key] = snapshotItem; - _etags[key] = etagsSnapshot[key]; - _timestamps[key] = timestampsSnapshot[key]; - } - else - { - _items.TryRemove(key, out _); - _etags.TryRemove(key, out _); - _timestamps.TryRemove(key, out _); - } - } - - lock (_changeFeedLock) - { - while (_changeFeed.Count > changeFeedCount) - { - _changeFeed.RemoveAt(_changeFeed.Count - 1); - } - } - } - - private sealed class InMemoryScripts : Scripts - { - private readonly InMemoryContainer _c; - - public InMemoryScripts(InMemoryContainer container) => _c = container; - - // ── Stored Procedure CRUD (typed) ───────────────────────────────── - - public override Task CreateStoredProcedureAsync( - StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' already exists.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; - EnrichStoredProcedureSystemProperties(storedProcedureProperties); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.Created); - r.Resource.Returns(storedProcedureProperties); - return Task.FromResult(r); - } - - public override Task ReadStoredProcedureAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.TryGetValue(id, out var props)) - throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - EnrichStoredProcedureSystemProperties(props); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(props); - return Task.FromResult(r); - } - - public override Task ReplaceStoredProcedureAsync( - StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; - EnrichStoredProcedureSystemProperties(storedProcedureProperties); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(storedProcedureProperties); - return Task.FromResult(r); - } - - public override Task DeleteStoredProcedureAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.Remove(id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedures.Remove(id); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.NoContent); - return Task.FromResult(r); - } - - // ── Stored Procedure CRUD (stream) ──────────────────────────────── - - public override Task CreateStoredProcedureStreamAsync( - StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' already exists.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; - EnrichStoredProcedureSystemProperties(storedProcedureProperties); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, - JsonConvert.SerializeObject(storedProcedureProperties))); - } - - public override Task ReadStoredProcedureStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.TryGetValue(id, out var props)) - throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - EnrichStoredProcedureSystemProperties(props); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(props))); - } - - public override Task ReplaceStoredProcedureStreamAsync( - StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; - EnrichStoredProcedureSystemProperties(storedProcedureProperties); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(storedProcedureProperties))); - } - - public override Task DeleteStoredProcedureStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedureProperties.Remove(id)) - throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._storedProcedures.Remove(id); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); - } - - // ── Stored Procedure Execute ────────────────────────────────────── - - public override Task> ExecuteStoredProcedureAsync( - string storedProcedureId, PartitionKey partitionKey, dynamic[] parameters, - StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedures.ContainsKey(storedProcedureId) && !_c._storedProcedureProperties.ContainsKey(storedProcedureId)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureId}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - - string handlerResult = null; - var hasHandler = false; - if (_c._storedProcedures.TryGetValue(storedProcedureId, out var handler)) - { - handlerResult = ExecuteHandler(storedProcedureId, () => handler(partitionKey, parameters)); - hasHandler = true; - } - else if (_c.SprocEngine is not null - && _c._storedProcedureProperties.TryGetValue(storedProcedureId, out var sprocProps) - && sprocProps.Body is not null) - { - handlerResult = ExecuteJsEngine(storedProcedureId, sprocProps.Body, partitionKey, parameters); - hasHandler = true; - } - - var r = Substitute.For>(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.RequestCharge.Returns(SyntheticRequestCharge); - if (hasHandler) - { - if (handlerResult is not null) - { - TOutput deserialized = typeof(TOutput) == typeof(string) - ? (TOutput)(object)handlerResult - : JsonConvert.DeserializeObject(handlerResult); - r.Resource.Returns(deserialized); - } - else - { - r.Resource.Returns(default(TOutput)); - } - } - - PopulateScriptLogHeaders(r); - return Task.FromResult(r); - } - - public override Task ExecuteStoredProcedureStreamAsync( - string storedProcedureId, PartitionKey partitionKey, dynamic[] parameters, - StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._storedProcedures.ContainsKey(storedProcedureId) && !_c._storedProcedureProperties.ContainsKey(storedProcedureId)) - throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureId}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - - string handlerResult = null; - if (_c._storedProcedures.TryGetValue(storedProcedureId, out var handler)) - { - handlerResult = ExecuteHandler(storedProcedureId, () => handler(partitionKey, parameters)); - } - else if (_c.SprocEngine is not null - && _c._storedProcedureProperties.TryGetValue(storedProcedureId, out var sprocProps) - && sprocProps.Body is not null) - { - handlerResult = ExecuteJsEngine(storedProcedureId, sprocProps.Body, partitionKey, parameters); - } - - var msg = _c.CreateResponseMessage(HttpStatusCode.OK, handlerResult); - if (_c.SprocEngine?.CapturedLogs is { Count: > 0 } logs) - msg.Headers["x-ms-documentdb-script-log-results"] = Uri.EscapeDataString(string.Join("\n", logs)); - return Task.FromResult(msg); - } - - public override Task ExecuteStoredProcedureStreamAsync( - string storedProcedureId, Stream streamPayload, PartitionKey partitionKey, - StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - using var reader = new StreamReader(streamPayload); - var json = reader.ReadToEnd(); - var parameters = JsonConvert.DeserializeObject(json) ?? Array.Empty(); - return ExecuteStoredProcedureStreamAsync(storedProcedureId, partitionKey, parameters, requestOptions, cancellationToken); - } - - // ── Trigger CRUD (typed) ────────────────────────────────────────── - - public override Task CreateTriggerAsync( - TriggerProperties triggerProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._triggerProperties.ContainsKey(triggerProperties.Id)) - throw InMemoryCosmosException.Create($"Trigger '{triggerProperties.Id}' already exists.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._triggerProperties[triggerProperties.Id] = triggerProperties; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.Created); - r.Resource.Returns(triggerProperties); - return Task.FromResult(r); - } - - public override Task ReadTriggerAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.TryGetValue(id, out var props)) - throw InMemoryCosmosException.Create($"Trigger '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(props); - return Task.FromResult(r); - } - - public override Task ReplaceTriggerAsync( - TriggerProperties triggerProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.ContainsKey(triggerProperties.Id)) - throw InMemoryCosmosException.Create($"Trigger '{triggerProperties.Id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._triggerProperties[triggerProperties.Id] = triggerProperties; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(triggerProperties); - return Task.FromResult(r); - } - - public override Task DeleteTriggerAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.Remove(id)) - throw InMemoryCosmosException.Create($"Trigger '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._triggers.Remove(id); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.NoContent); - return Task.FromResult(r); - } - - // ── Trigger CRUD (stream) ───────────────────────────────────────── - - public override Task CreateTriggerStreamAsync( - TriggerProperties triggerProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._triggerProperties.ContainsKey(triggerProperties.Id)) - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Conflict)); - _c._triggerProperties[triggerProperties.Id] = triggerProperties; - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, - JsonConvert.SerializeObject(triggerProperties))); - } - - public override Task ReadTriggerStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.TryGetValue(id, out var props)) - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(props))); - } - - public override Task ReplaceTriggerStreamAsync( - TriggerProperties triggerProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.ContainsKey(triggerProperties.Id)) - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); - _c._triggerProperties[triggerProperties.Id] = triggerProperties; - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(triggerProperties))); - } - - public override Task DeleteTriggerStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._triggerProperties.Remove(id)) - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); - _c._triggers.Remove(id); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); - } - - // ── UDF CRUD (typed) ────────────────────────────────────────────── - - public override Task CreateUserDefinedFunctionAsync( - UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) - throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' already exists.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; - if (!_c._userDefinedFunctions.ContainsKey("UDF." + userDefinedFunctionProperties.Id)) - _c._userDefinedFunctions["UDF." + userDefinedFunctionProperties.Id] = UdfPlaceholder; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.Created); - r.Resource.Returns(userDefinedFunctionProperties); - return Task.FromResult(r); - } - - public override Task ReadUserDefinedFunctionAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.TryGetValue(id, out var props)) - throw InMemoryCosmosException.Create($"UDF '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(props); - return Task.FromResult(r); - } - - public override Task ReplaceUserDefinedFunctionAsync( - UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) - throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.OK); - r.Resource.Returns(userDefinedFunctionProperties); - return Task.FromResult(r); - } - - public override Task DeleteUserDefinedFunctionAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.Remove(id)) - throw InMemoryCosmosException.Create($"UDF '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._userDefinedFunctions.Remove("UDF." + id); - var r = Substitute.For(); - r.StatusCode.Returns(HttpStatusCode.NoContent); - return Task.FromResult(r); - } - - // ── UDF CRUD (stream) ───────────────────────────────────────────── - - public override Task CreateUserDefinedFunctionStreamAsync( - UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) - throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' already exists.", - HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; - if (!_c._userDefinedFunctions.ContainsKey("UDF." + userDefinedFunctionProperties.Id)) - _c._userDefinedFunctions["UDF." + userDefinedFunctionProperties.Id] = UdfPlaceholder; - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, - JsonConvert.SerializeObject(userDefinedFunctionProperties))); - } - - public override Task ReadUserDefinedFunctionStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.TryGetValue(id, out var props)) - throw InMemoryCosmosException.Create($"UDF '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(props))); - } - - public override Task ReplaceUserDefinedFunctionStreamAsync( - UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) - throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, - JsonConvert.SerializeObject(userDefinedFunctionProperties))); - } - - public override Task DeleteUserDefinedFunctionStreamAsync( - string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (!_c._udfProperties.Remove(id)) - throw InMemoryCosmosException.Create($"UDF '{id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - _c._userDefinedFunctions.Remove("UDF." + id); - return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); - } - - // ── Query iterators (typed) ─────────────────────────────────────── - - public override FeedIterator GetStoredProcedureQueryIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._storedProcedureProperties, queryText).Cast().ToList()); - - public override FeedIterator GetStoredProcedureQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._storedProcedureProperties, queryDefinition).Cast().ToList()); - - public override FeedIterator GetTriggerQueryIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._triggerProperties, queryText).Cast().ToList()); - - public override FeedIterator GetTriggerQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._triggerProperties, queryDefinition).Cast().ToList()); - - public override FeedIterator GetUserDefinedFunctionQueryIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._udfProperties, queryText).Cast().ToList()); - - public override FeedIterator GetUserDefinedFunctionQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryFeedIterator(() => FilterById(_c._udfProperties, queryDefinition).Cast().ToList()); - - // ── Query iterators (stream) ────────────────────────────────────── - - public override FeedIterator GetStoredProcedureQueryStreamIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._storedProcedureProperties, queryText).Cast().ToList(), "StoredProcedures", () => _c.CurrentSessionToken); - - public override FeedIterator GetStoredProcedureQueryStreamIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._storedProcedureProperties, queryDefinition).Cast().ToList(), "StoredProcedures", () => _c.CurrentSessionToken); - - public override FeedIterator GetTriggerQueryStreamIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._triggerProperties, queryText).Cast().ToList(), "Triggers", () => _c.CurrentSessionToken); - - public override FeedIterator GetTriggerQueryStreamIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._triggerProperties, queryDefinition).Cast().ToList(), "Triggers", () => _c.CurrentSessionToken); - - public override FeedIterator GetUserDefinedFunctionQueryStreamIterator( - string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._udfProperties, queryText).Cast().ToList(), "UserDefinedFunctions", () => _c.CurrentSessionToken); - - public override FeedIterator GetUserDefinedFunctionQueryStreamIterator( - QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => new InMemoryStreamFeedIterator( - () => FilterById(_c._udfProperties, queryDefinition).Cast().ToList(), "UserDefinedFunctions", () => _c.CurrentSessionToken); - - // ── Private helpers ─────────────────────────────────────────────── - - private static IEnumerable FilterById( - Dictionary dict, string queryText) - { - var id = ExtractIdFromQueryText(queryText); - return id != null && dict.TryGetValue(id, out var match) - ? new[] { match } - : dict.Values; - } - - private static IEnumerable FilterById( - Dictionary dict, QueryDefinition queryDefinition) - { - if (queryDefinition == null) return dict.Values; - var id = ExtractIdFromQueryText(queryDefinition.QueryText); - if (id != null && id.StartsWith("@")) - { - var parameters = ExtractQueryParameters(queryDefinition); - if (parameters.TryGetValue(id, out var paramValue)) - id = paramValue?.ToString(); - else - return dict.Values; - } - return id != null && dict.TryGetValue(id, out var match) - ? new[] { match } - : dict.Values; - } - - private static readonly System.Text.RegularExpressions.Regex IdFilterRegex = - new(@"WHERE\s+c\.id\s*=\s*(?:'([^']+)'|""([^""]+)""|(@\w+))", - System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); - - private static string ExtractIdFromQueryText(string queryText) - { - if (string.IsNullOrWhiteSpace(queryText)) return null; - var m = IdFilterRegex.Match(queryText); - if (!m.Success) return null; - return m.Groups[1].Success ? m.Groups[1].Value - : m.Groups[2].Success ? m.Groups[2].Value - : m.Groups[3].Success ? m.Groups[3].Value - : null; - } - - private string ExecuteHandler(string sprocId, Func invoker) - { - string result; - try - { - var task = Task.Run(invoker); - if (!task.Wait(TimeSpan.FromSeconds(10))) - throw InMemoryCosmosException.Create( - $"Stored procedure '{sprocId}' exceeded the 10-second execution timeout.", - HttpStatusCode.RequestTimeout, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - result = task.Result; - } - catch (CosmosException) { throw; } - catch (AggregateException ae) when (ae.InnerException is CosmosException) { throw ae.InnerException; } - catch (AggregateException ae) - { - var inner = ae.InnerException ?? ae; - throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {inner.Message}", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - catch (Exception ex) - { - throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - const int MaxResponseSize = 2 * 1024 * 1024; - if (result != null && Encoding.UTF8.GetByteCount(result) > MaxResponseSize) - throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' response exceeds the 2MB size limit.", - (HttpStatusCode)413, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - return result; - } - - private string ExecuteJsEngine(string sprocId, string jsBody, PartitionKey pk, dynamic[] args) - { - try - { - var context = new PartitionScopedCollectionContext(_c, pk); - return _c.SprocEngine.Execute(jsBody, pk, args, context); - } - catch (CosmosException) { throw; } - catch (Exception ex) - { - throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {ex.Message}", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - } - - private void PopulateScriptLogHeaders(StoredProcedureExecuteResponse r) - { - if (_c.SprocEngine?.CapturedLogs is { Count: > 0 } logs) - { - var logString = Uri.EscapeDataString(string.Join("\n", logs)); - var headers = new Headers - { - ["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture), - ["x-ms-documentdb-script-log-results"] = logString - }; - r.Headers.Returns(headers); - r.ScriptLog.Returns(Uri.UnescapeDataString(logString)); - } - } - } - - private static void EnrichStoredProcedureSystemProperties(StoredProcedureProperties props) - { - // ETag and SelfLink are read-only on StoredProcedureProperties, - // so we use Newtonsoft.Json to populate the internal backing fields. - var json = JsonConvert.SerializeObject(props); - var jObj = JObject.Parse(json); - if (jObj["_etag"] == null || string.IsNullOrEmpty(jObj["_etag"]?.ToString())) - jObj["_etag"] = $"\"{Guid.NewGuid()}\""; - if (jObj["_self"] == null || string.IsNullOrEmpty(jObj["_self"]?.ToString())) - jObj["_self"] = $"dbs/db/colls/col/sprocs/{props.Id}"; - if (jObj["_ts"] == null || jObj["_ts"]!.Type == JTokenType.Null) - jObj["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (jObj["_rid"] == null || string.IsNullOrEmpty(jObj["_rid"]?.ToString())) - jObj["_rid"] = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).TrimEnd('='); - JsonConvert.PopulateObject(jObj.ToString(), props); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Private helpers — Query execution pipeline - // ═══════════════════════════════════════════════════════════════════════════ - - private IEnumerable GetAllItemsForPartition(QueryRequestOptions requestOptions) - { - if (requestOptions?.PartitionKey is not null - && requestOptions.PartitionKey != PartitionKey.None) - { - var pk = PartitionKeyToString(requestOptions.PartitionKey.Value); - - // Hierarchical PK prefix match: when the query PK has fewer components - // than the container's partition key paths, treat it as a prefix filter. - if (PartitionKeyPaths is { Count: > 1 }) - { - var queryComponents = CountPartitionKeyComponents(requestOptions.PartitionKey.Value); - if (queryComponents > 0 && queryComponents < PartitionKeyPaths.Count) - { - var prefix = pk + "|"; - return _items - .Where(kvp => (kvp.Key.PartitionKey?.StartsWith(prefix, StringComparison.Ordinal) ?? false) && !IsExpired(kvp.Key)) - .Select(kvp => kvp.Value); - } - } - - return _items.Where(kvp => kvp.Key.PartitionKey == pk && !IsExpired(kvp.Key)).Select(kvp => kvp.Value); - } - return _items.Where(kvp => !IsExpired(kvp.Key)).Select(kvp => kvp.Value); - } - - private static int CountPartitionKeyComponents(PartitionKey partitionKey) - { - try - { - var raw = partitionKey.ToString(); - if (raw.StartsWith("[")) - { - var arr = JArray.Parse(raw); - return arr.Count; - } - return 1; - } - catch { return 1; } - } - - private const string UdfRegistryKey = "__udf_registry__"; - private const string UdfPropertiesKey = "__udf_properties__"; - private const string UdfEngineKey = "__udf_engine__"; - - private (string Name, string FromAlias, SqlExpression Expr)[] GetParsedComputedProperties() - { - var cached = _parsedComputedProperties; - if (cached is not null) return cached; - - var cps = _containerProperties.ComputedProperties; - if (cps is null or { Count: 0 }) - { - _parsedComputedProperties = Array.Empty<(string, string, SqlExpression)>(); - return _parsedComputedProperties; - } - - var result = new (string Name, string FromAlias, SqlExpression Expr)[cps.Count]; - for (var i = 0; i < cps.Count; i++) - { - var parsed = CosmosSqlParser.Parse(cps[i].Query); - if (parsed.IsValueSelect && parsed.SelectFields.Length == 1 && parsed.SelectFields[0].SqlExpr is not null) - { - result[i] = (cps[i].Name, parsed.FromAlias, parsed.SelectFields[0].SqlExpr); - } - else - { - // Fallback: try to evaluate via the expression string - result[i] = (cps[i].Name, parsed.FromAlias, null); - } - } - - _parsedComputedProperties = result; - return result; - } - - private List AugmentWithComputedProperties( - IEnumerable items, IDictionary parameters) - { - var cps = GetParsedComputedProperties(); - if (cps.Length == 0) return items.ToList(); - - return items.Select(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - foreach (var (name, fromAlias, expr) in cps) - { - if (expr is null) continue; - var value = EvaluateSqlExpression(expr, jObj, fromAlias, parameters); - if (value is UndefinedValue) - continue; - jObj[name] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - } - return jObj.ToString(Formatting.None); - }).ToList(); - } - - private static List StripComputedProperties( - IEnumerable items, (string Name, string FromAlias, SqlExpression Expr)[] cps) - { - if (cps.Length == 0) return items.ToList(); - var names = new HashSet(cps.Select(cp => cp.Name)); - return items.Select(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - foreach (var name in names) - jObj.Remove(name); - return jObj.ToString(Formatting.None); - }).ToList(); - } - - private List FilterItemsByQuery( - string queryText, IDictionary parameters, QueryRequestOptions requestOptions, - IEnumerable preFilteredItems = null) - { - // Detect ORDER BY RANK RRF(...) early — the parser may silently drop it on some runtimes - if (System.Text.RegularExpressions.Regex.IsMatch( - queryText, @"\bORDER\s+BY\s+RANK\s+RRF\s*\(", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - throw new NotSupportedException( - "ORDER BY RANK RRF() is not implemented in the in-memory emulator."); - } - - if (_userDefinedFunctions.Count > 0) - { - parameters[UdfRegistryKey] = _userDefinedFunctions; - } - - if (_udfProperties.Count > 0) - parameters[UdfPropertiesKey] = _udfProperties; - if (JsTriggerEngine is IJsUdfEngine udfEngine) - parameters[UdfEngineKey] = udfEngine; - - // Snapshot static datetime values so they remain constant for the entire query - var now = DateTime.UtcNow; - parameters["__staticDateTime"] = now.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - parameters["__staticTicks"] = now.Ticks; - parameters["__staticTimestamp"] = new DateTimeOffset(now).ToUnixTimeMilliseconds(); - - var parsed = CosmosSqlParser.Parse(queryText); - - IEnumerable items = preFilteredItems ?? GetAllItemsForPartition(requestOptions); - - // Computed properties — augment items with virtual properties before any filtering/projection - var computedProps = GetParsedComputedProperties(); - if (computedProps.Length > 0) - { - items = AugmentWithComputedProperties(items, parameters); - } - - // FROM alias IN c.field — top-level array iteration (must come before JOINs/WHERE) - if (parsed.FromSource is not null) - { - items = ExpandFromSource(items, parsed); - } - - // JOIN expansion (supports multiple JOINs) — must come before WHERE - if (parsed.Joins is { Length: > 0 }) - { - items = ExpandAllJoins(items, parsed); - } - else if (parsed.Join is not null) - { - items = ExpandJoinedItems(items, parsed); - } - - // WHERE - if (parsed.Where is not null) - { - items = items.Where(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - return EvaluateWhereExpression(parsed.Where, jObj, parsed.FromAlias, parameters, parsed.Join); - }); - } - - // GROUP BY / HAVING — also handles SELECT projection for grouped results - var groupByApplied = false; - if (parsed.GroupByFields is { Length: > 0 }) - { - items = ApplyGroupBy(items, parsed, parameters); - groupByApplied = true; - } - - // ORDER BY - if (parsed.OrderByFields is { Length: > 0 }) - { - var effectiveOrderBy = parsed.OrderByFields; - // After GROUP BY, aggregate ORDER BY expressions (e.g. ORDER BY COUNT(1)) - // must resolve to the alias of the matching SELECT field because the items - // are already projected group results (e.g. {"cat":"A","cnt":2}). - // EvaluateSqlExpression returns pass-through values for aggregates (COUNT→1), - // which makes sorting non-deterministic when groups share the same count. - if (groupByApplied) - { - effectiveOrderBy = ResolveOrderByAliasesAfterGroupBy(parsed.OrderByFields, parsed.SelectFields); - } - items = ApplyOrderByFields(items, effectiveOrderBy, parsed.FromAlias, parameters); - } - else if (parsed.OrderBy is not null) - { - items = ApplyOrderBy(items, parsed.OrderBy, parsed.FromAlias); - } - else if (parsed.RankExpression is not null) - { - items = ApplyOrderByRank(items, parsed.RankExpression, parsed.FromAlias, parameters); - } - - // TOP — applied before projection only when DISTINCT is not active. - // When DISTINCT is active, TOP is deferred until after deduplication. - if (parsed.TopCount.HasValue && !parsed.IsDistinct) - { - items = items.Take(parsed.TopCount.Value); - } - - // OFFSET / LIMIT — when DISTINCT is active, defer until after dedup - if (!parsed.IsDistinct && parsed.Offset.HasValue) - { - items = items.Skip(parsed.Offset.Value); - } - - if (!parsed.IsDistinct && parsed.Limit.HasValue) - { - items = items.Take(parsed.Limit.Value); - } - - // SELECT projection (skip if GROUP BY already projected) - if (!groupByApplied && !parsed.IsSelectAll && parsed.SelectFields.Length > 0) - { - items = ProjectFields(items, parsed, parameters); - } - - // SELECT * must not include computed properties (they are virtual) - if (parsed.IsSelectAll && computedProps.Length > 0) - { - items = StripComputedProperties(items, computedProps); - } - - // DISTINCT — applied after projection so dedup works on projected shapes - if (parsed.IsDistinct) - { - items = items.Distinct(JsonStructuralStringComparer.Instance); - } - - // OFFSET / LIMIT — deferred application after DISTINCT - if (parsed.IsDistinct && parsed.Offset.HasValue) - { - items = items.Skip(parsed.Offset.Value); - } - - if (parsed.IsDistinct && parsed.Limit.HasValue) - { - items = items.Take(parsed.Limit.Value); - } - - // TOP — deferred application after DISTINCT - if (parsed.TopCount.HasValue && parsed.IsDistinct) - { - items = items.Take(parsed.TopCount.Value); - } - - // VALUE SELECT — unwrap scalar values from projected JObjects - if (parsed.IsValueSelect && parsed.SelectFields.Length == 1) - { - items = UnwrapValueSelect(items); - } - - return items as List ?? items.ToList(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query helpers — ORDER BY - // ═══════════════════════════════════════════════════════════════════════════ - - private static List ApplyOrderBy(IEnumerable items, OrderByClause orderBy, string fromAlias) - => ApplyOrderByFields(items, new[] { new OrderByField(orderBy.Field, orderBy.Ascending) }, fromAlias); - - private static List ApplyOrderByRank( - IEnumerable items, SqlExpression rankExpr, string fromAlias, IDictionary parameters) - { - // ORDER BY RANK sorts by the evaluated expression descending (highest score first). - return items - .OrderByDescending(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - var score = EvaluateSqlExpression(rankExpr, jObj, fromAlias, parameters); - return score switch - { - double d => d, - long l => (double)l, - int i => (double)i, - _ => 0.0 - }; - }) - .ToList(); - } - - // Sentinel for undefined (missing field) — distinct from null - private static readonly object UndefinedSortSentinel = new(); - - private static List ApplyOrderByFields( - IEnumerable items, OrderByField[] orderByFields, string fromAlias, - IDictionary parameters = null) - { - if (orderByFields.Length == 0) - { - return items.ToList(); - } - - IOrderedEnumerable ordered = null; - foreach (var field in orderByFields) - { - object KeySelector(string json) - { - var jObj = JsonParseHelpers.ParseJson(json); - - if (field.Expression is not null) - { - var value = EvaluateSqlExpression(field.Expression, jObj, fromAlias, - parameters ?? new Dictionary()); - return value switch - { - double d => (object)d, - long l => (object)(double)l, - int i => (object)(double)i, - decimal dec => (object)(double)dec, - bool b => (object)b, - string s => (object)s, - UndefinedValue => UndefinedSortSentinel, - null => null, - JToken jt => jt, - _ => (object)value.ToString() - }; - } - - var fieldPath = field.Field; - if (fieldPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - fieldPath = fieldPath[(fromAlias.Length + 1)..]; - } - - var token = jObj.SelectToken(fieldPath); - if (token is null) - { - return UndefinedSortSentinel; // field missing — undefined - } - - return token.Type switch - { - JTokenType.Null => null, // field present but null - JTokenType.Boolean => (object)token.Value(), - JTokenType.Integer => (object)token.Value(), - JTokenType.Float => (object)token.Value(), - JTokenType.String => (object)token.Value(), - _ => (object)token // Return JArray/JObject for element-by-element comparison - }; - } - - var asc = field.Ascending; - var comparer = Comparer.Create((l, r) => - { - var result = CompareValues(l, r); - return asc ? result : -result; - }); - ordered = ordered == null ? items.OrderBy(KeySelector, comparer) : ordered.ThenBy(KeySelector, comparer); - } - return ordered?.ToList() ?? items.ToList(); - } - - /// - /// After GROUP BY projection, ORDER BY aggregate expressions (e.g. COUNT(1), SUM(c.val)) - /// must be resolved to their SELECT alias so sorting works on the projected field value - /// instead of the aggregate passthrough (which would be 1 for COUNT, etc.). - /// - private static OrderByField[] ResolveOrderByAliasesAfterGroupBy( - OrderByField[] orderByFields, SelectField[] selectFields) - { - var resolved = new OrderByField[orderByFields.Length]; - for (var i = 0; i < orderByFields.Length; i++) - { - var obf = orderByFields[i]; - if (obf.Expression is FunctionCallExpression func - && AggregateFunctions.Contains(func.FunctionName)) - { - // Convert both to text form using ExprToString for consistent comparison - var orderByText = CosmosSqlParser.ExprToString(obf.Expression); - foreach (var sf in selectFields) - { - if (sf.Alias is not null && sf.Expression.Trim().Equals( - orderByText, StringComparison.OrdinalIgnoreCase)) - { - // Replace with a simple field lookup on the alias - resolved[i] = new OrderByField(sf.Alias, obf.Ascending); - break; - } - } - - resolved[i] ??= obf; // no matching alias found — keep original - } - else - { - resolved[i] = obf; - } - } - - return resolved; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query helpers — GROUP BY / HAVING - // ═══════════════════════════════════════════════════════════════════════════ - - private List ApplyGroupBy(IEnumerable items, CosmosSqlQuery parsed, IDictionary parameters) - { - var fromAlias = parsed.FromAlias; - - var groups = items.GroupBy(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - var keyParts = new List(); - var groupByExprs = parsed.GroupByExpressions; - for (var i = 0; i < parsed.GroupByFields.Length; i++) - { - // If the GROUP BY field is a function expression (e.g., LOWER(c.name)), - // evaluate it with the expression tree rather than treating it as a path. - if (groupByExprs != null && i < groupByExprs.Length - && groupByExprs[i] is not IdentifierExpression - && groupByExprs[i] is not null) - { - var val = EvaluateSqlExpression(groupByExprs[i], jObj, fromAlias, - parameters ?? new Dictionary()); - keyParts.Add(val is UndefinedValue ? "\x00undefined" : val?.ToString() ?? "null"); - } - else - { - var path = StripAliasPrefix(parsed.GroupByFields[i], fromAlias); - var token = jObj.SelectToken(path); - // Distinguish null (JTokenType.Null) from undefined (missing property) - if (token is null) - keyParts.Add("\x00undefined"); - else if (token.Type == JTokenType.Null) - keyParts.Add("\x00null"); - else - keyParts.Add(token.ToString()); - } - } - return string.Join("\x1F", keyParts.Select(k => k.Replace("\x1F", "\x1F\x1F"))); - }); - - var hasAggregate = parsed.SelectFields.Any(f => - { - var expr = f.Expression.TrimStart(); - if (AggregateFunctions.Any(fn => expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase))) - return true; - // Also detect aggregates nested inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1)}) - if (f.SqlExpr is not null) - return ContainsAggregateCall(f.SqlExpr); - return false; - }); - - if (!hasAggregate) - { - return groups.Select(g => - { - var jObj = JsonParseHelpers.ParseJson(g.First()); - var projected = new JObject(); - foreach (var field in parsed.SelectFields) - { - var outputName = field.Alias ?? field.Expression.Split('.').Last(); - - // If the select field has a non-trivial expression (function call, etc.), - // evaluate it rather than treating it as a property path. - if (field.SqlExpr is not null and not IdentifierExpression) - { - var val = EvaluateSqlExpression(field.SqlExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - projected[outputName] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); - } - else - { - var path = StripAliasPrefix(field.Expression, fromAlias); - projected[outputName] = jObj.SelectToken(path)?.DeepClone(); - } - } - return projected.ToString(Formatting.None); - }).ToList(); - } - - var result = new List(); - foreach (var group in groups) - { - var groupItems = group.ToList(); - var resultObj = new JObject(); - - foreach (var field in parsed.SelectFields) - { - var expr = field.Expression.TrimStart(); - string funcName = null; - string innerArg = null; - - foreach (var fn in AggregateFunctions) - { - if (expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase)) - { - funcName = fn; - var open = expr.IndexOf('('); - var close = expr.LastIndexOf(')'); - if (open >= 0 && close > open) - { - innerArg = expr.Substring(open + 1, close - open - 1).Trim(); - } - - break; - } - } - - var outputName = field.Alias ?? field.Expression; - - if (funcName == "COUNT") - { - if (innerArg is "1" or "*" or null) - { - resultObj[outputName] = groupItems.Count; - } - else - { - var countPath = innerArg; - if (countPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - countPath = countPath[(fromAlias.Length + 1)..]; - resultObj[outputName] = groupItems.Count(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; - }); - } - } - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // COUNTIF() counts items where the expression evaluates to true. - else if (funcName == "COUNTIF" && field.SqlExpr is FunctionCallExpression countIfFunc) - { - resultObj[outputName] = EvaluateCountIf(countIfFunc, groupItems, fromAlias, parameters); - } - else if (funcName is "SUM" or "AVG" && innerArg != null) - { - var values = ExtractNumericValues(groupItems, innerArg, fromAlias, parameters); - if (funcName == "SUM") - { - if (values.Count > 0) - resultObj[outputName] = values.Sum(); - // else: omit field entirely (undefined) — matches Cosmos DB - } - else // AVG - { - if (values.Count > 0) - resultObj[outputName] = values.Average(); - } - } - else if (funcName is "MIN" or "MAX" && innerArg != null) - { - var tokens = ExtractTokenValues(groupItems, innerArg, fromAlias, parameters); - var minMaxResult = AggregateMinMax(tokens, funcName == "MIN"); - if (minMaxResult is not UndefinedValue) - resultObj[outputName] = JToken.FromObject(minMaxResult); - } - else - { - var jObj = JsonParseHelpers.ParseJson(groupItems[0]); - - // If the select field has a non-trivial expression (function call, etc.), - // evaluate it rather than treating it as a property path. - if (field.SqlExpr is not null and not IdentifierExpression && funcName is null) - { - var fieldOutputName = field.Alias ?? field.Expression; - // Use aggregate-aware evaluation if the expression contains nested aggregates - // (e.g. SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)}) - var val = ContainsAggregateCall(field.SqlExpr) - ? EvaluateGroupByProjectionExpression(field.SqlExpr, groupItems, jObj, fromAlias, parameters) - : EvaluateSqlExpression(field.SqlExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - resultObj[fieldOutputName] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); - } - else - { - var path = StripAliasPrefix(field.Expression, fromAlias); - var fieldOutputName = field.Alias ?? path.Split('.').Last(); - resultObj[fieldOutputName] = jObj.SelectToken(path)?.DeepClone(); - } - } - } - - if (parsed.Having is not null) - { - if (!EvaluateHavingCondition(parsed.Having, groupItems, resultObj, fromAlias, parameters)) - { - continue; - } - } - - result.Add(resultObj.ToString(Formatting.None)); - } - return result; - } - - private static bool IsComplexExpression(string arg) - => arg.Contains('(') || arg.Contains('*') || arg.Contains('+') || - arg.Contains('/') || arg.Contains('%') || - (arg.Contains('-') && !arg.StartsWith("-") && arg.IndexOf('-') != arg.IndexOf(".") + 1); - - /// - /// Counts items for which evaluates to a defined, - /// non-null value. Matches Cosmos DB COUNT(expr) semantics: rows producing - /// undefined or null are excluded. - /// - private static int CountDefinedResults( - SqlExpression innerExpr, List items, string fromAlias, IDictionary parameters) - { - var count = 0; - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - var result = EvaluateSqlExpression(innerExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - if (result is null or UndefinedValue) - continue; - if (result is JToken jt && jt.Type == JTokenType.Null) - continue; - count++; - } - return count; - } - - private static List ExtractNumericValues(IEnumerable items, string innerArg, string fromAlias, IDictionary parameters = null) - { - var values = new List(); - - // Handle numeric literals (e.g., SUM(1), AVG(2.5)) - if (double.TryParse(innerArg, NumberStyles.Any, CultureInfo.InvariantCulture, out var literalValue)) - { - values.AddRange(Enumerable.Repeat(literalValue, items.Count())); - return values; - } - - var isExpr = IsComplexExpression(innerArg); - SqlExpression parsedInnerExpr = null; - if (isExpr) - { - CosmosSqlParser.TryParse($"SELECT VALUE {innerArg} FROM {fromAlias}", out var innerParsed); - if (innerParsed?.SelectFields.Length > 0) - parsedInnerExpr = innerParsed.SelectFields[0].SqlExpr; - } - - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - double? val = null; - - if (parsedInnerExpr is not null) - { - var result = EvaluateSqlExpression(parsedInnerExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - if (result is double d) - val = d; - else if (result is long l) - val = l; - else if (result != null && double.TryParse(result.ToString(), NumberStyles.Any, - CultureInfo.InvariantCulture, out var parsed2)) - val = parsed2; - } - else - { - var path = StripAliasPrefix(innerArg, fromAlias); - var token = jObj.SelectToken(path); - if (token != null && double.TryParse(token.ToString(), NumberStyles.Any, - CultureInfo.InvariantCulture, out var parsed3)) - val = parsed3; - } - - if (val.HasValue) - values.Add(val.Value); - } - return values; - } - - private static List ExtractTokenValues(IEnumerable items, string innerArg, string fromAlias, IDictionary parameters = null) - { - var tokens = new List(); - - // Handle numeric literals (e.g., MIN(1), MAX(2.5)) - if (double.TryParse(innerArg, NumberStyles.Any, CultureInfo.InvariantCulture, out var literalValue)) - { - var token = new JValue(literalValue); - tokens.AddRange(Enumerable.Repeat((JToken)token, items.Count())); - return tokens; - } - - var isExpr = IsComplexExpression(innerArg); - SqlExpression parsedInnerExpr = null; - if (isExpr) - { - CosmosSqlParser.TryParse($"SELECT VALUE {innerArg} FROM {fromAlias}", out var innerParsed); - if (innerParsed?.SelectFields.Length > 0) - parsedInnerExpr = innerParsed.SelectFields[0].SqlExpr; - } - - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - - if (parsedInnerExpr is not null) - { - var result = EvaluateSqlExpression(parsedInnerExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - if (result is not null and not UndefinedValue) - tokens.Add(JToken.FromObject(result)); - } - else - { - var path = StripAliasPrefix(innerArg, fromAlias); - var token = jObj.SelectToken(path); - if (token != null && token.Type != JTokenType.Null) - tokens.Add(token); - } - } - return tokens; - } - - /// - /// Computes MIN or MAX across a list of JTokens, respecting Cosmos DB type ordering: - /// boolean < number < string. Booleans compare as false < true. - /// Returns when is empty. - /// - private static object AggregateMinMax(List tokens, bool isMin) - { - if (tokens.Count == 0) - return UndefinedValue.Instance; - - // Cosmos DB MIN/MAX type rank: boolean(0) < number(1) < string(2) - // MIN returns the value with the lowest rank; within a rank, the smallest value. - // MAX returns the value with the highest rank; within a rank, the largest value. - static int MinMaxTypeRank(JToken t) => t.Type switch - { - JTokenType.Boolean => 0, - JTokenType.Integer or JTokenType.Float => 1, - JTokenType.String => 2, - _ => 3 // other types ignored below - }; - - var eligible = tokens.Where(t => MinMaxTypeRank(t) <= 2).ToList(); - if (eligible.Count == 0) - return UndefinedValue.Instance; - - JToken best = eligible[0]; - foreach (var t in eligible.Skip(1)) - { - var rankBest = MinMaxTypeRank(best); - var rankT = MinMaxTypeRank(t); - bool tIsBetter; - if (rankBest != rankT) - { - tIsBetter = isMin ? rankT < rankBest : rankT > rankBest; - } - else - { - tIsBetter = rankBest switch - { - 0 => isMin - ? !t.Value() && best.Value() // false < true - : t.Value() && !best.Value(), - 1 => isMin - ? t.Value() < best.Value() - : t.Value() > best.Value(), - 2 => isMin - ? string.Compare(t.Value(), best.Value(), StringComparison.Ordinal) < 0 - : string.Compare(t.Value(), best.Value(), StringComparison.Ordinal) > 0, - _ => false - }; - } - if (tIsBetter) best = t; - } - - return best.Type switch - { - JTokenType.Boolean => best.Value(), - JTokenType.Integer => best.Value(), - JTokenType.Float => best.Value(), - JTokenType.String => best.Value(), - _ => UndefinedValue.Instance - }; - } - - private static bool EvaluateHavingCondition( - WhereExpression having, List groupItems, JObject resultObj, - string fromAlias, IDictionary parameters) - { - if (having is SqlExpressionCondition sec) - { - return IsTruthy(EvaluateHavingSqlExpression(sec.Expression, groupItems, resultObj, fromAlias, parameters)); - } - - return EvaluateWhereExpression(having, resultObj, fromAlias, parameters, null); - } - - /// - /// Strips the FROM alias prefix from a property path, handling both - /// dot notation (c.name) and bracket notation (c["name"]) from the LINQ provider. - /// - private static string StripAliasPrefix(string path, string fromAlias) - { - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - return path[(fromAlias.Length + 1)..]; - if (path.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) - { - var bracketPart = path[fromAlias.Length..]; // ["name"] or ['name'] - if (bracketPart.StartsWith("[\"") && bracketPart.EndsWith("\"]")) - return bracketPart[2..^2]; // Extract property name from ["prop"] - if (bracketPart.StartsWith("['") && bracketPart.EndsWith("']")) - return bracketPart[2..^2]; // Extract property name from ['prop'] - return bracketPart; - } - return path; - } - - private static object EvaluateHavingSqlExpression( - SqlExpression expr, List groupItems, JObject resultObj, - string fromAlias, IDictionary parameters) - { - return expr switch - { - FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName) => - EvaluateHavingAggregate(func, groupItems, fromAlias, parameters), - BinaryExpression bin => EvaluateHavingBinaryExpression(bin, groupItems, resultObj, fromAlias, parameters), - LiteralExpression lit => lit.Value, - UndefinedLiteralExpression => UndefinedValue.Instance, - IdentifierExpression ident => ResolveValue(ident.Name, resultObj, fromAlias, parameters), - _ => EvaluateSqlExpression(expr, resultObj, fromAlias, parameters) - }; - } - - private static object EvaluateHavingBinaryExpression( - BinaryExpression bin, List groupItems, JObject resultObj, - string fromAlias, IDictionary parameters) - { - var left = EvaluateHavingSqlExpression(bin.Left, groupItems, resultObj, fromAlias, parameters); - var right = EvaluateHavingSqlExpression(bin.Right, groupItems, resultObj, fromAlias, parameters); - return bin.Operator switch - { - BinaryOp.GreaterThan => CompareValues(left, right) > 0, - BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, - BinaryOp.LessThan => CompareValues(left, right) < 0, - BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, - BinaryOp.Equal => (object)ValuesEqual(left, right), - BinaryOp.NotEqual => !ValuesEqual(left, right), - BinaryOp.And => IsTruthy(left) && IsTruthy(right), - BinaryOp.Or => IsTruthy(left) || IsTruthy(right), - BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), - BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), - BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), - BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), - BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), - _ => null - }; - } - - /// - /// Evaluates COUNTIF() — counts items where the boolean expression evaluates to true. - /// Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - /// "Adds an aggregate operator for CountIf" — undocumented server-side aggregate. - /// - private static int EvaluateCountIf( - FunctionCallExpression func, List items, string fromAlias, - IDictionary parameters) - { - if (func.Arguments.Length < 1) return 0; - var boolExpr = func.Arguments[0]; - return items.Count(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - var val = EvaluateSqlExpression(boolExpr, jObj, fromAlias, - parameters ?? new Dictionary()); - return IsTruthy(val); - }); - } - - private static object EvaluateHavingAggregate( - FunctionCallExpression func, List groupItems, string fromAlias, - IDictionary parameters = null) - { - switch (func.FunctionName) - { - case "COUNT": - { - // COUNT(1) / COUNT(*) — count all items in group - // COUNT(c.field) — count only items where field is defined - if (func.Arguments.Length < 1) - return (double)groupItems.Count; - var countArg = func.Arguments[0] is IdentifierExpression countIdent ? countIdent.Name : "1"; - if (countArg is "1" or "*") - return (double)groupItems.Count; - var countPath = countArg; - if (countPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - countPath = countPath[(fromAlias.Length + 1)..]; - return (double)groupItems.Count(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; - }); - } - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // COUNTIF() counts items where the expression evaluates to true. - case "COUNTIF": - return (double)EvaluateCountIf(func, groupItems, fromAlias, parameters); - case "SUM": - case "AVG": - case "MIN": - case "MAX": - { - if (func.Arguments.Length < 1) - { - return 0.0; - } - - var innerArg = func.Arguments[0] is IdentifierExpression ident ? ident.Name : "1"; - var values = ExtractNumericValues(groupItems, innerArg, fromAlias, parameters); - if (func.FunctionName is "MIN" or "MAX") - { - var tokens = ExtractTokenValues(groupItems, innerArg, fromAlias, parameters); - return AggregateMinMax(tokens, func.FunctionName == "MIN"); - } - return func.FunctionName switch - { - "SUM" => values.Count > 0 ? (object)values.Sum() : UndefinedValue.Instance, - "AVG" => values.Count > 0 ? (object)values.Average() : UndefinedValue.Instance, - _ => 0.0 - }; - } - default: return null; - } - } - - /// - /// Evaluates a SELECT expression within GROUP BY context, resolving aggregate calls - /// (COUNT, COUNTIF, SUM, AVG, MIN, MAX) against the group items rather than treating them as passthroughs. - /// Used for expressions like: SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)} - /// - private static object EvaluateGroupByProjectionExpression( - SqlExpression expr, List groupItems, JObject sampleItem, - string fromAlias, IDictionary parameters) - { - return expr switch - { - FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName) - => EvaluateHavingAggregate(func, groupItems, fromAlias, parameters), - ObjectLiteralExpression obj => EvaluateGroupByObjectLiteral(obj, groupItems, sampleItem, fromAlias, parameters), - ArrayLiteralExpression arr => arr.Elements - .Select(e => EvaluateGroupByProjectionExpression(e, groupItems, sampleItem, fromAlias, parameters)) - .Where(v => v is not UndefinedValue) - .Select(v => v is not null ? JToken.FromObject(v) : JValue.CreateNull()) - .ToArray(), - _ => EvaluateSqlExpression(expr, sampleItem, fromAlias, - parameters ?? new Dictionary()) - }; - } - - private static JObject EvaluateGroupByObjectLiteral( - ObjectLiteralExpression obj, List groupItems, JObject sampleItem, - string fromAlias, IDictionary parameters) - { - var result = new JObject(); - foreach (var prop in obj.Properties) - { - var val = EvaluateGroupByProjectionExpression(prop.Value, groupItems, sampleItem, fromAlias, parameters); - if (val is not UndefinedValue) - result[prop.Key] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); - } - return result; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query helpers — JOIN expansion - // ═══════════════════════════════════════════════════════════════════════════ - - private static List ExpandJoinedItems(IEnumerable items, CosmosSqlQuery parsed) - { - if (parsed.Join is null) - { - return items.ToList(); - } - - var expanded = new List(); - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - var arrayToken = jObj.SelectToken(parsed.Join.ArrayField); - if (arrayToken is JArray jArray) - { - foreach (var element in jArray) - { - var combined = new JObject(jObj.Properties()) - { - [parsed.Join.Alias] = element - }; - expanded.Add(combined.ToString(Formatting.None)); - } - } - } - return expanded; - } - - private static List ExpandAllJoins(IEnumerable items, CosmosSqlQuery parsed) - { - var current = items; - foreach (var join in parsed.Joins) - { - var expanded = new List(); - foreach (var json in current) - { - var jObj = JsonParseHelpers.ParseJson(json); - // Resolve the array from the correct source alias (e.g. "g.tags" for JOIN t IN g.tags) - var sourcePath = join.SourceAlias == parsed.FromAlias - ? join.ArrayField - : $"{join.SourceAlias}.{join.ArrayField}"; - var arrayToken = jObj.SelectToken(sourcePath); - if (arrayToken is JArray jArray) - { - foreach (var element in jArray) - { - var combined = new JObject(jObj.Properties()) - { - [join.Alias] = element - }; - expanded.Add(combined.ToString(Formatting.None)); - } - } - } - current = expanded; - } - return current as List ?? current.ToList(); - } - - /// - /// Expands top-level FROM alias IN c.field — each array element becomes a result row. - /// - private static List ExpandFromSource(IEnumerable items, CosmosSqlQuery parsed) - { - var sourcePath = parsed.FromSource; - // The FromSource is the full dotted path (e.g. "c.tags"). We need to resolve it - // relative to each document, but the outer alias isn't available here (the FROM clause - // redefines the alias). Use a simple heuristic: strip the first segment if it looks - // like an alias (contains a dot). - var dotIndex = sourcePath.IndexOf('.'); - var arrayPath = dotIndex >= 0 ? sourcePath[(dotIndex + 1)..] : sourcePath; - - var expanded = new List(); - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - var arrayToken = jObj.SelectToken(arrayPath); - if (arrayToken is not JArray jArray) - continue; - - foreach (var element in jArray) - { - // The alias is the range variable (e.g. "item" in FROM item IN c.items). - // - For object elements: spread properties at root so "item.price" → strip alias → "price" resolves. - // - Also keep the element under the alias for "SELECT item" / "SELECT VALUE item". - var combined = element is JObject elementObj - ? new JObject(elementObj.Properties()) { [parsed.FromAlias] = element.DeepClone() } - : new JObject { [parsed.FromAlias] = element }; - expanded.Add(combined.ToString(Formatting.None)); - } - } - return expanded; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query helpers — SELECT projection - // ═══════════════════════════════════════════════════════════════════════════ - - private static List ProjectFields(IEnumerable items, CosmosSqlQuery parsed, IDictionary parameters) - { - var hasAggregate = parsed.SelectFields.Any(f => - { - var expr = f.Expression.TrimStart(); - if (AggregateFunctions.Any(fn => expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase))) - return true; - // Also detect aggregates nested inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1)}) - if (f.SqlExpr is not null) - return ContainsAggregateCall(f.SqlExpr); - return false; - }); - - if (hasAggregate) - { - return ProjectAggregateFields(items, parsed, parameters); - } - - var projected = new List(); - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - var resultObj = new JObject(); - foreach (var field in parsed.SelectFields) - { - // Handle SELECT * combined with other fields (e.g. SELECT *, expr AS alias) - if (field.Expression == "*") - { - foreach (var prop in jObj.Properties()) - resultObj[prop.Name] = prop.Value.DeepClone(); - continue; - } - - var outputName = field.Alias ?? field.Expression.Split('.').Last(); - - if (field.SqlExpr is not null and not IdentifierExpression) - { - var value = EvaluateSqlExpression(field.SqlExpr, jObj, parsed.FromAlias, parameters); - if (value is not UndefinedValue) - resultObj[outputName] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - } - else - { - var path = field.Expression; - - // When the expression is exactly the FROM alias (e.g., SELECT VALUE root), - // return the entire document rather than looking for a property named "root". - // Exception: for FROM alias IN c.field, the alias is a property on the expanded - // JObject — use the alias value (the array element) instead of the whole doc. - if (string.Equals(path, parsed.FromAlias, StringComparison.OrdinalIgnoreCase)) - { - var aliasToken = jObj[parsed.FromAlias]; - if (parsed.FromSource is not null && aliasToken is not null) - { - resultObj[outputName] = aliasToken.DeepClone(); - } - else - { - resultObj[outputName] = jObj.DeepClone(); - } - continue; - } - - if (path.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(parsed.FromAlias.Length + 1)..]; - } - - var token = jObj.SelectToken(path); - outputName = field.Alias ?? path.Split('.').Last(); - if (token is not null) - resultObj[outputName] = token.DeepClone(); - } - } - projected.Add(resultObj.ToString(Formatting.None)); - } - return projected; - } - - private static List UnwrapValueSelect(IEnumerable items) - { - var unwrapped = new List(); - foreach (var json in items) - { - var jObj = JsonParseHelpers.ParseJson(json); - if (!jObj.HasValues) - { - // Empty object means the projected value was undefined — skip (omit from results) - continue; - } - - var first = jObj.Properties().FirstOrDefault(); - if (first?.Value is not null) - { - var val = first.Value; - if (val.Type == JTokenType.String) - { - unwrapped.Add(JsonConvert.SerializeObject(val.Value())); - } - else - { - unwrapped.Add(val.ToString(Formatting.None)); - } - } - else - { - unwrapped.Add("null"); - } - } - return unwrapped; - } - - private static List ProjectAggregateFields(IEnumerable itemsEnumerable, CosmosSqlQuery parsed, IDictionary parameters = null) - { - // Aggregates need multiple passes (Count, Sum, indexing) — materialize once - var items = itemsEnumerable as List ?? itemsEnumerable.ToList(); - var resultObj = new JObject(); - foreach (var field in parsed.SelectFields) - { - var expr = field.Expression.TrimStart(); - var outputName = field.Alias ?? field.Expression; - - // Prefer SqlExpr-based evaluation when the expression contains aggregates - // but is NOT a direct aggregate call (e.g., ternary/binary wrapping an aggregate). - // This prevents string-based matching from incorrectly extracting a partial aggregate. - if (field.SqlExpr is not null && ContainsAggregateCall(field.SqlExpr) - && field.SqlExpr is not FunctionCallExpression directAgg) - { - var val = EvaluateAggregateExpression(field.SqlExpr, items, parsed.FromAlias, parameters ?? new Dictionary()); - if (val is not null and not UndefinedValue) - resultObj[outputName] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); - continue; - } - - string funcName = null; - string innerArg = null; - - foreach (var fn in AggregateFunctions) - { - if (expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase)) - { - funcName = fn.ToUpperInvariant(); - var open = expr.IndexOf('('); - var close = expr.LastIndexOf(')'); - if (open >= 0 && close > open) - { - innerArg = expr.Substring(open + 1, close - open - 1).Trim(); - } - - break; - } - } - - if (funcName == "COUNT") - { - if (innerArg is "1" or "*" or null) - { - resultObj[outputName] = items.Count; - } - else if (field.SqlExpr is FunctionCallExpression countFunc && countFunc.Arguments.Length > 0) - { - resultObj[outputName] = CountDefinedResults( - countFunc.Arguments[0], items, parsed.FromAlias, - parameters ?? new Dictionary()); - } - else - { - var countPath = innerArg; - if (countPath.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) - countPath = countPath[(parsed.FromAlias.Length + 1)..]; - resultObj[outputName] = items.Count(json => - { - var jObj = JsonParseHelpers.ParseJson(json); - return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; - }); - } - } - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // COUNTIF() counts items where the expression evaluates to true. - else if (funcName == "COUNTIF" && field.SqlExpr is FunctionCallExpression countIfFunc) - { - resultObj[outputName] = EvaluateCountIf(countIfFunc, items, parsed.FromAlias, parameters); - } - else if (funcName is "SUM" or "AVG" && innerArg != null) - { - var values = ExtractNumericValues(items, innerArg, parsed.FromAlias, parameters); - if (funcName == "SUM") - { - if (values.Count > 0) - resultObj[outputName] = values.Sum(); - // else: omit field entirely (undefined) — matches Cosmos DB - } - else // AVG - { - if (values.Count > 0) - resultObj[outputName] = values.Average(); - // else: omit field entirely (undefined) - } - } - else if (funcName is "MIN" or "MAX" && innerArg != null) - { - var tokens = ExtractTokenValues(items, innerArg, parsed.FromAlias, parameters); - var minMaxResult = AggregateMinMax(tokens, funcName == "MIN"); - if (minMaxResult is not UndefinedValue) - resultObj[outputName] = JToken.FromObject(minMaxResult); - } - else if (field.SqlExpr is not null && ContainsAggregateCall(field.SqlExpr)) - { - // Handle aggregates inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)}) - var val = EvaluateAggregateExpression(field.SqlExpr, items, parsed.FromAlias, parameters ?? new Dictionary()); - if (val is not null and not UndefinedValue) - resultObj[outputName] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); - } - else - { - var path = field.Expression; - if (path.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(parsed.FromAlias.Length + 1)..]; - } - - if (items.Count > 0) - { - var jObj = JsonParseHelpers.ParseJson(items[0]); - resultObj[outputName] = jObj.SelectToken(path)?.DeepClone(); - } - } - } - return new List { resultObj.ToString(Formatting.None) }; - } - - /// - /// Evaluates a SqlExpression that may contain aggregate function calls, - /// resolving them globally across all items. Used for expressions like - /// SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.value)} FROM c. - /// - private static object EvaluateAggregateExpression( - SqlExpression expr, IEnumerable itemsEnumerable, string fromAlias, IDictionary parameters) - { - // Aggregates need multiple passes — materialize once - var items = itemsEnumerable as List ?? itemsEnumerable.ToList(); - switch (expr) - { - case FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName): - { - var innerArg = func.Arguments.Length > 0 ? CosmosSqlParser.ExprToString(func.Arguments[0]) : "1"; - return func.FunctionName.ToUpperInvariant() switch - { - "COUNT" when innerArg is "1" or "*" => (object)items.Count, - "COUNT" => (object)CountDefinedResults(func.Arguments[0], items, fromAlias, parameters), - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // COUNTIF() counts items where the expression evaluates to true. - "COUNTIF" => (object)EvaluateCountIf(func, items, fromAlias, parameters), - "SUM" => ExtractNumericValues(items, innerArg, fromAlias, parameters) is var sv && sv.Count > 0 ? sv.Sum() : UndefinedValue.Instance, - "AVG" => ExtractNumericValues(items, innerArg, fromAlias, parameters) is var av && av.Count > 0 ? av.Average() : UndefinedValue.Instance, - "MIN" => AggregateMinMax(ExtractTokenValues(items, innerArg, fromAlias, parameters), true), - "MAX" => AggregateMinMax(ExtractTokenValues(items, innerArg, fromAlias, parameters), false), - _ => null - }; - } - case ObjectLiteralExpression obj: - { - var result = new JObject(); - foreach (var prop in obj.Properties) - { - var val = EvaluateAggregateExpression(prop.Value, items, fromAlias, parameters); - if (val is not null and not UndefinedValue) - result[prop.Key] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); - } - return result; - } - case ArrayLiteralExpression arr: - { - var result = new JArray(); - foreach (var element in arr.Elements) - { - var val = EvaluateAggregateExpression(element, items, fromAlias, parameters); - result.Add(val is JToken jt ? jt.DeepClone() : val is not null and not UndefinedValue ? JToken.FromObject(val) : JValue.CreateNull()); - } - return result; - } - case BinaryExpression bin: - { - var left = EvaluateAggregateExpression(bin.Left, items, fromAlias, parameters); - var right = EvaluateAggregateExpression(bin.Right, items, fromAlias, parameters); - return bin.Operator switch - { - BinaryOp.GreaterThan => (object)(CompareValues(left, right) > 0), - BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, - BinaryOp.LessThan => CompareValues(left, right) < 0, - BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, - BinaryOp.Equal => ValuesEqual(left, right), - BinaryOp.NotEqual => !ValuesEqual(left, right), - BinaryOp.And => IsTruthy(left) && IsTruthy(right), - BinaryOp.Or => IsTruthy(left) || IsTruthy(right), - BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), - BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), - BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), - BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), - BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), - _ => null - }; - } - case TernaryExpression tern: - { - var condition = EvaluateAggregateExpression(tern.Condition, items, fromAlias, parameters); - return condition is bool b && b - ? EvaluateAggregateExpression(tern.IfTrue, items, fromAlias, parameters) - : EvaluateAggregateExpression(tern.IfFalse, items, fromAlias, parameters); - } - default: - // Non-aggregate expression — evaluate against first item (for non-aggregate fields in mixed projection) - if (items.Count > 0) - { - var jObj = JsonParseHelpers.ParseJson(items[0]); - return EvaluateSqlExpression(expr, jObj, fromAlias, parameters); - } - return null; - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Query helpers — Parameter extraction - // ═══════════════════════════════════════════════════════════════════════════ - - private static Dictionary ExtractQueryParameters(QueryDefinition queryDefinition) - { - var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - foreach (var (Name, Value) in queryDefinition.GetQueryParameters()) - { - parameters[Name] = Value; - } - - if (parameters.Count > 0) - { - return parameters; - } - } - catch { /* Fall through to reflection */ } - - try - { - var internalField = typeof(QueryDefinition) - .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(f => f.Name.Contains("parameter", StringComparison.OrdinalIgnoreCase)); - - if (internalField?.GetValue(queryDefinition) is System.Collections.IEnumerable enumerable) - { - foreach (var item in enumerable) - { - var itemType = item.GetType(); - var nameProp = itemType.GetProperty("Name") ?? itemType.GetProperty("Item1"); - var valueProp = itemType.GetProperty("Value") ?? itemType.GetProperty("Item2"); - if (nameProp is not null && valueProp is not null) - { - var name = nameProp.GetValue(item)?.ToString(); - if (name is not null) - { - parameters[name] = valueProp.GetValue(item); - } - } - else - { - var nameField = itemType.GetField("Name") ?? itemType.GetField("Item1"); - var valueField = itemType.GetField("Value") ?? itemType.GetField("Item2"); - if (nameField is not null && valueField is not null) - { - var name = nameField.GetValue(item)?.ToString(); - if (name is not null) - { - parameters[name] = valueField.GetValue(item); - } - } - } - } - } - } - catch { /* Parameters cannot be extracted */ } - - return parameters; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // WHERE expression evaluation - // ═══════════════════════════════════════════════════════════════════════════ - - private static bool EvaluateWhereExpression( - WhereExpression expression, JObject item, string fromAlias, - IDictionary parameters, JoinClause join) - { - return expression switch - { - ComparisonCondition c => EvaluateComparison(c, item, fromAlias, parameters), - AndCondition a => EvaluateWhereExpression(a.Left, item, fromAlias, parameters, join) - && EvaluateWhereExpression(a.Right, item, fromAlias, parameters, join), - OrCondition o => EvaluateWhereExpression(o.Left, item, fromAlias, parameters, join) - || EvaluateWhereExpression(o.Right, item, fromAlias, parameters, join), - NotCondition n => !EvaluateWhereExpressionIncludesUndefined(n.Inner, item, fromAlias, parameters, join) - && !EvaluateWhereExpression(n.Inner, item, fromAlias, parameters, join), - FunctionCondition f => EvaluateFunction(f, item, fromAlias, parameters), - ExistsCondition e => EvaluateExists(e, item, fromAlias, parameters, join), - SqlExpressionCondition s => IsTruthy(EvaluateSqlExpression(s.Expression, item, fromAlias, parameters)), - _ => true - }; - } - - private static bool EvaluateComparison( - ComparisonCondition comparison, JObject item, string fromAlias, IDictionary parameters) - { - var leftValue = ResolveValue(comparison.Left, item, fromAlias, parameters); - var rightValue = ResolveValue(comparison.Right, item, fromAlias, parameters); - if (leftValue is UndefinedValue || rightValue is UndefinedValue) - return false; - return comparison.Operator switch - { - ComparisonOp.Equal => ValuesEqual(leftValue, rightValue), - ComparisonOp.NotEqual => !ValuesEqual(leftValue, rightValue), - ComparisonOp.LessThan => CompareValues(leftValue, rightValue) < 0, - ComparisonOp.GreaterThan => CompareValues(leftValue, rightValue) > 0, - ComparisonOp.LessThanOrEqual => CompareValues(leftValue, rightValue) <= 0, - ComparisonOp.GreaterThanOrEqual => CompareValues(leftValue, rightValue) >= 0, - ComparisonOp.Like => IsTruthy(EvaluateLike(leftValue, rightValue)), - _ => false - }; - } - - /// - /// Returns true if the inner expression involves undefined operands (three-value logic). - /// Used by NOT to propagate undefined — NOT undefined = undefined (excluded). - /// - private static bool EvaluateWhereExpressionIncludesUndefined( - WhereExpression expression, JObject item, string fromAlias, - IDictionary parameters, JoinClause join) - { - return expression switch - { - ComparisonCondition c => ComparisonIncludesUndefined(c, item, fromAlias, parameters), - AndCondition a => - EvaluateWhereExpressionIncludesUndefined(a.Left, item, fromAlias, parameters, join) || - EvaluateWhereExpressionIncludesUndefined(a.Right, item, fromAlias, parameters, join), - OrCondition o => - EvaluateWhereExpressionIncludesUndefined(o.Left, item, fromAlias, parameters, join) || - EvaluateWhereExpressionIncludesUndefined(o.Right, item, fromAlias, parameters, join), - NotCondition n => - EvaluateWhereExpressionIncludesUndefined(n.Inner, item, fromAlias, parameters, join), - SqlExpressionCondition s => - EvaluateSqlExpression(s.Expression, item, fromAlias, parameters) is UndefinedValue, - _ => false, - }; - } - - /// - /// Checks if a comparison involves undefined semantics. For most operators, only - /// UndefinedValue counts. For LIKE, null also produces undefined (three-value logic). - /// - private static bool ComparisonIncludesUndefined( - ComparisonCondition c, JObject item, string fromAlias, IDictionary parameters) - { - var left = ResolveValue(c.Left, item, fromAlias, parameters); - var right = ResolveValue(c.Right, item, fromAlias, parameters); - if (left is UndefinedValue || right is UndefinedValue) - return true; - // LIKE with null operand(s) produces undefined per three-value logic - if (c.Operator == ComparisonOp.Like && (left is null || right is null)) - return true; - return false; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Value resolution & comparison - // ═══════════════════════════════════════════════════════════════════════════ - - private static object ResolveValue( - string expression, JObject item, string fromAlias, IDictionary parameters) - { - var trimmed = expression.Trim(); - if (trimmed.StartsWith("@")) - { - return parameters.TryGetValue(trimmed, out var v) ? v : null; - } - - if (trimmed.StartsWith("'") && trimmed.EndsWith("'")) - { - return trimmed[1..^1]; - } - - if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (string.Equals(trimmed, "null", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - if (double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) - { - return num; - } - - var jsonPath = trimmed; - if (jsonPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - jsonPath = jsonPath[(fromAlias.Length + 1)..]; - } - else if (jsonPath.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) - { - jsonPath = jsonPath[fromAlias.Length..]; // Keep brackets — SelectToken handles ['prop'] - } - - var token = item.SelectToken(jsonPath); - if (token == null) - { - // Handle string pseudo-properties: SDK LINQ translates d.Name.Length - // to root["name"]["Length"] which SelectToken can't resolve on a string value. - if (TryResolveStringPseudoProperty(jsonPath, item, out var pseudoResult)) - { - return pseudoResult; - } - - return UndefinedValue.Instance; - } - - return token.Type switch - { - JTokenType.String => token.Value(), - JTokenType.Integer => token.Value(), - JTokenType.Float => token.Value(), - JTokenType.Boolean => token.Value(), - JTokenType.Null => null, - JTokenType.Undefined => UndefinedValue.Instance, - JTokenType.Array => (object)token, - JTokenType.Object => (object)token, - _ => token.ToString() - }; - } - - /// - /// Handles string/array pseudo-properties that the SDK LINQ provider generates. - /// For example, d.Name.Length is translated to root["name"]["Length"] - /// which SelectToken cannot resolve on a string JValue. This method detects the - /// pattern and returns the string length (or array count) instead. - /// - private static bool TryResolveStringPseudoProperty(string jsonPath, JObject item, out object result) - { - result = null; - - string parentPath = null; - if (jsonPath.EndsWith("['Length']", StringComparison.OrdinalIgnoreCase)) - parentPath = jsonPath[..^"['Length']".Length]; - else if (jsonPath.EndsWith("[\"Length\"]", StringComparison.OrdinalIgnoreCase)) - parentPath = jsonPath[..^"[\"Length\"]".Length]; - else if (jsonPath.EndsWith(".Length", StringComparison.OrdinalIgnoreCase)) - parentPath = jsonPath[..^".Length".Length]; - - if (parentPath is null || parentPath.Length == 0) return false; - - var parentToken = item.SelectToken(parentPath); - if (parentToken is JValue { Type: JTokenType.String } sv) - { - result = (long)sv.Value()!.Length; - return true; - } - - if (parentToken is JArray arr) - { - result = (long)arr.Count; - return true; - } - - return false; - } - - private static bool ValuesEqual(object left, object right) - { - if (left is UndefinedValue || right is UndefinedValue) - { - return false; - } - - if (left is null && right is null) - { - return true; - } - - if (left is null || right is null) - { - return false; - } - - if ((left is double or long) && (right is double or long)) - { - if (double.TryParse(left.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && - double.TryParse(right.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) - { - return l == r; - } - } - - // Normalize non-primitive / non-JToken values (e.g. anonymous objects from parameters) to JToken - var leftJ = left is JToken ? left : NormalizeToJTokenIfComplex(left); - var rightJ = right is JToken ? right : NormalizeToJTokenIfComplex(right); - - // Cosmos DB uses strict type comparison: different type ranks are never equal - // (except numeric types which are handled above) - if (GetTypeRank(leftJ) != GetTypeRank(rightJ)) - { - return false; - } - - // Deep-compare JTokens - if (leftJ is JToken jtLeft && rightJ is JToken jtRight) - { - return JToken.DeepEquals(jtLeft, jtRight); - } - - return string.Equals(leftJ.ToString(), rightJ.ToString(), StringComparison.Ordinal); - } - - private static object NormalizeToJTokenIfComplex(object value) - { - if (value is null or string or bool or int or long or double or float or decimal or UndefinedValue) - return value; - try { return JToken.FromObject(value); } - catch { return value; } - } - - /// - /// Returns the Cosmos DB type rank for ordering: - /// undefined(0) < null(1) < bool(2) < number(3) < string(4) < array(5) < object(6). - /// - private static int GetTypeRank(object value) - { - if (ReferenceEquals(value, UndefinedSortSentinel)) return 0; - if (value is UndefinedValue) return 0; - if (value is null) return 1; - if (value is bool) return 2; - if (value is int or long or double or float or decimal) return 3; - if (value is string) return 4; - if (value is JToken jt) - { - return jt.Type switch - { - JTokenType.Undefined => 0, - JTokenType.Null => 1, - JTokenType.Boolean => 2, - JTokenType.Integer or JTokenType.Float => 3, - JTokenType.String => 4, - JTokenType.Array => 5, - JTokenType.Object => 6, - _ => 7 - }; - } - return 7; - } - - private static int CompareValues(object left, object right) - { - var leftRank = GetTypeRank(left); - var rightRank = GetTypeRank(right); - if (leftRank != rightRank) return leftRank.CompareTo(rightRank); - - // Both undefined or both null - if (leftRank <= 1) return 0; - - if (left is bool lb && right is bool rb) - return lb.CompareTo(rb); // false < true - - if (double.TryParse(left?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && - double.TryParse(right?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) - { - return l.CompareTo(r); - } - - // Element-by-element array comparison (Cosmos DB behavior) - if (leftRank == 5) - { - var leftArr = left is JArray la ? la : (left is JToken lt ? new JArray(lt) : null); - var rightArr = right is JArray ra ? ra : (right is JToken rt ? new JArray(rt) : null); - if (leftArr is not null && rightArr is not null) - { - for (var i = 0; i < Math.Min(leftArr.Count, rightArr.Count); i++) - { - var cmp = CompareValues( - JTokenToObject(leftArr[i]), - JTokenToObject(rightArr[i])); - if (cmp != 0) return cmp; - } - return leftArr.Count.CompareTo(rightArr.Count); - } - } - - // Property-by-property object comparison (sorted by property name) - if (leftRank == 6) - { - var leftObj = left as JObject ?? (left is JToken lk ? lk as JObject : null); - var rightObj = right as JObject ?? (right is JToken rk ? rk as JObject : null); - if (leftObj is not null && rightObj is not null) - { - var leftProps = leftObj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); - var rightProps = rightObj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); - for (var i = 0; i < Math.Min(leftProps.Count, rightProps.Count); i++) - { - var nameCmp = string.Compare(leftProps[i].Name, rightProps[i].Name, StringComparison.Ordinal); - if (nameCmp != 0) return nameCmp; - var valCmp = CompareValues( - JTokenToObject(leftProps[i].Value), - JTokenToObject(rightProps[i].Value)); - if (valCmp != 0) return valCmp; - } - return leftProps.Count.CompareTo(rightProps.Count); - } - } - - return string.Compare(left?.ToString(), right?.ToString(), StringComparison.Ordinal); - } - - private static object JTokenToObject(JToken token) - { - return token.Type switch - { - JTokenType.Integer => (object)token.Value(), - JTokenType.Float => token.Value(), - JTokenType.Boolean => token.Value(), - JTokenType.String => token.Value(), - JTokenType.Null => null, - JTokenType.Undefined => UndefinedValue.Instance, - _ => token // Return JArray/JObject as-is for recursive comparison - }; - } - - private static object EvaluateLike(object left, object right, string escapeChar = null) - { - if (left is UndefinedValue || right is UndefinedValue) - { - return UndefinedValue.Instance; - } - - if (left is null || right is null) - { - return UndefinedValue.Instance; - } - - // Real Cosmos DB: LIKE only operates on strings. Non-string left operand returns undefined. - if (left is not string) - { - return UndefinedValue.Instance; - } - - var patternStr = right.ToString(); - if (escapeChar is { Length: > 0 }) - { - var esc = escapeChar[0]; - var sb = new System.Text.StringBuilder(); - for (var i = 0; i < patternStr.Length; i++) - { - if (patternStr[i] == esc && i + 1 < patternStr.Length) - { - sb.Append(Regex.Escape(patternStr[i + 1].ToString())); - i++; - } - else if (patternStr[i] == '%') - sb.Append(".*"); - else if (patternStr[i] == '_') - sb.Append('.'); - else - sb.Append(Regex.Escape(patternStr[i].ToString())); - } - var pattern = $"^{sb}$"; - return GetOrCreateRegex(pattern, RegexOptions.Singleline).IsMatch(left.ToString()); - } - - var simplePattern = ConvertLikeToRegex(patternStr); - return GetOrCreateRegex(simplePattern, RegexOptions.Singleline).IsMatch(left.ToString()); - } - - private static string ConvertLikeToRegex(string pattern) - { - var sb = new System.Text.StringBuilder("^"); - foreach (var ch in pattern) - { - if (ch == '%') sb.Append(".*"); - else if (ch == '_') sb.Append('.'); - else sb.Append(Regex.Escape(ch.ToString())); - } - sb.Append('$'); - return sb.ToString(); - } - - private static Regex GetOrCreateRegex(string pattern, RegexOptions options) - { - var key = (pattern, options); - if (RegexCache.TryGetValue(key, out var cached)) - { - return cached; - } - - var regex = new Regex(pattern, options | RegexOptions.Compiled); - if (RegexCache.Count < RegexCacheMaxSize) - { - RegexCache.TryAdd(key, regex); - } - - return regex; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Legacy FunctionCondition evaluation (backward compat) - // ═══════════════════════════════════════════════════════════════════════════ - - private static bool EvaluateFunction( - FunctionCondition func, JObject item, string fromAlias, IDictionary parameters) - { - switch (func.FunctionName) - { - case "STARTSWITH": - { - if (func.Arguments.Length < 2) - { - return false; - } - - var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); - var prefix = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); - if (fieldValue is null || prefix is null) - { - return false; - } - - var ignoreCase = func.Arguments.Length >= 3 && - string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return fieldValue.StartsWith(prefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "ENDSWITH": - { - if (func.Arguments.Length < 2) - { - return false; - } - - var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); - var suffix = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); - if (fieldValue is null || suffix is null) - { - return false; - } - - var ignoreCase = func.Arguments.Length >= 3 && - string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return fieldValue.EndsWith(suffix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "CONTAINS": - { - if (func.Arguments.Length < 2) - { - return false; - } - - var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); - var search = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); - if (fieldValue is null || search is null) - { - return false; - } - - var ignoreCase = func.Arguments.Length >= 3 && - string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return fieldValue.Contains(search, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "ARRAY_CONTAINS": - { - if (func.Arguments.Length < 2) - { - return false; - } - - JArray jArray; - var firstArg = func.Arguments[0].Trim(); - if (firstArg.StartsWith("@") && parameters.TryGetValue(firstArg, out var paramVal)) - { - // First argument is a parameter (e.g. ARRAY_CONTAINS(@names, c.name)) - jArray = paramVal switch - { - JArray ja => ja, - System.Collections.IEnumerable enumerable when paramVal is not string => - new JArray(enumerable.Cast().Select(JToken.FromObject)), - _ => null - }; - } - else - { - var arrayPath = firstArg; - if (arrayPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - arrayPath = arrayPath[(fromAlias.Length + 1)..]; - } - jArray = item.SelectToken(arrayPath) as JArray; - } - - if (jArray is null) - { - return false; - } - - var searchValue = ResolveValue(func.Arguments[1], item, fromAlias, parameters); - if (searchValue is null) - { - return jArray.Any(t => t.Type == JTokenType.Null); - } - - var searchStr = searchValue.ToString(); - var partial = func.Arguments.Length >= 3 && - string.Equals(func.Arguments[2].Trim(), "true", StringComparison.OrdinalIgnoreCase); - return ArrayContainsMatch(jArray, searchStr, partial); - } - case "IS_DEFINED": - { - if (func.Arguments.Length < 1) - { - return false; - } - - var path = func.Arguments[0].Trim(); - - // Resolve parameterized values (e.g. IS_DEFINED(@param)) - // A resolved parameter is always "defined" (even if null). - if (path.StartsWith("@")) - return parameters.ContainsKey(path); - - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - var token = item.SelectToken(path); - return token is not null && token.Type != JTokenType.Undefined; - } - case "IS_NULL": - { - if (func.Arguments.Length < 1) - { - return false; - } - - var path = func.Arguments[0].Trim(); - - // Resolve parameterized values (e.g. IS_NULL(@param)) - if (path.StartsWith("@")) - { - if (parameters.TryGetValue(path, out var paramVal)) - return paramVal is null || (paramVal is JToken jt && jt.Type == JTokenType.Null); - return true; // Unresolved parameter treated as null - } - - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - var token = item.SelectToken(path); - return token is not null && token.Type is JTokenType.Null; - } - default: - return true; - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // EXISTS subquery evaluation - // ═══════════════════════════════════════════════════════════════════════════ - - private static bool EvaluateExists( - ExistsCondition exists, JObject item, string fromAlias, - IDictionary parameters, JoinClause join) - { - try - { - var raw = exists.RawSubquery.Trim(); - var queryToParse = raw.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) - ? raw - : "SELECT * FROM " + raw; - - CosmosSqlQuery subquery; - try - { - subquery = CosmosSqlParser.Parse(queryToParse); - } - catch (NotSupportedException) - { - // Malformed SQL — throw 400 like real Cosmos DB - throw InMemoryCosmosException.Create( - $"Syntax error in EXISTS subquery: {raw}", - System.Net.HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - - // Handle FROM alias IN source.path (array iteration in EXISTS subquery) - if (subquery.FromSource is not null) - { - var sourcePath = subquery.FromSource; - // Strip outer alias prefix (e.g. "c.tags" → "tags" when fromAlias is "c") - if (sourcePath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - sourcePath = sourcePath[(fromAlias.Length + 1)..]; - } - - var arrayToken = item.SelectToken(sourcePath); - if (arrayToken is not JArray jArray || jArray.Count == 0) - { - return false; - } - - if (subquery.Where is not null) - { - var iterAlias = subquery.FromAlias; - foreach (var element in jArray) - { - // Create a combined object with the array element available under its alias - var combined = new JObject(item.Properties()); - combined[iterAlias] = element is JObject elementObj - ? elementObj.DeepClone() - : new JValue(element.ToObject()); - if (EvaluateWhereExpression(subquery.Where, combined, iterAlias, parameters, null)) - { - return true; - } - } - return false; - } - return true; - } - - if (subquery.Join is not null) - { - var arrayPath = subquery.Join.ArrayField; - var sourceAlias = subquery.Join.SourceAlias; - if (sourceAlias.Equals(fromAlias, StringComparison.OrdinalIgnoreCase)) - { - var arrayToken = item.SelectToken(arrayPath); - if (arrayToken is not JArray jArray || jArray.Count == 0) - { - return false; - } - - if (subquery.Where is not null) - { - foreach (var element in jArray) - { - if (element is JObject elementObj) - { - var combined = new JObject(item.Properties()) - { - [subquery.Join.Alias] = elementObj - }; - if (EvaluateWhereExpression(subquery.Where, combined, subquery.FromAlias, parameters, subquery.Join)) - { - return true; - } - } - } - return false; - } - return true; - } - } - if (subquery.Where is not null) - { - return EvaluateWhereExpression(subquery.Where, item, fromAlias, parameters, join); - } - - return true; - } - catch (CosmosException) - { - throw; // Re-throw parse errors (400 Bad Request) - } - catch - { - return false; // Runtime evaluation errors — EXISTS evaluates to false - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SqlExpression tree evaluation - // ═══════════════════════════════════════════════════════════════════════════ - - private static object EvaluateSqlExpression( - SqlExpression expr, JObject item, string fromAlias, IDictionary parameters) - { - return expr switch - { - LiteralExpression lit => lit.Value, - UndefinedLiteralExpression => UndefinedValue.Instance, - IdentifierExpression ident => ResolveValue(ident.Name, item, fromAlias, parameters), - ParameterExpression param => parameters.TryGetValue(param.Name, out var v) ? UnwrapJToken(v) : null, - FunctionCallExpression func => EvaluateSqlFunction(func, item, fromAlias, parameters), - BinaryExpression bin => EvaluateBinaryExpression(bin, item, fromAlias, parameters), - UnaryExpression unary => EvaluateUnaryExpression(unary, item, fromAlias, parameters), - BetweenExpression between => EvalBetween(between, item, fromAlias, parameters), - InExpression inExpr => EvalIn(inExpr, item, fromAlias, parameters), - LikeExpression like => EvaluateLike( - EvaluateSqlExpression(like.Value, item, fromAlias, parameters), - EvaluateSqlExpression(like.Pattern, item, fromAlias, parameters), - like.EscapeChar), - ExistsExpression exists => EvaluateExists(new ExistsCondition(exists.RawSubquery), item, fromAlias, parameters, null), - CoalesceExpression coal => EvalCoalesce(coal, item, fromAlias, parameters), - TernaryExpression tern => EvaluateSqlExpression(tern.Condition, item, fromAlias, parameters) is bool tb && tb - ? EvaluateSqlExpression(tern.IfTrue, item, fromAlias, parameters) - : EvaluateSqlExpression(tern.IfFalse, item, fromAlias, parameters), - ObjectLiteralExpression obj => EvaluateObjectLiteral(obj, item, fromAlias, parameters), - ArrayLiteralExpression arr => EvaluateArrayLiteral(arr, item, fromAlias, parameters), - PropertyAccessExpression prop => ResolveValue( - $"{CosmosSqlParser.ExprToString(prop.Object)}.{prop.Property}", item, fromAlias, parameters), - IndexAccessExpression idx => ResolveValue( - $"{CosmosSqlParser.ExprToString(idx.Object)}[{CosmosSqlParser.ExprToString(idx.Index)}]", item, fromAlias, parameters), - SubqueryExpression sub => EvaluateSubquery(sub.Subquery, item, fromAlias, parameters), - _ => null - }; - } - - private static object UnwrapJToken(object value) - { - if (value is JValue jv) - { - return jv.Type switch - { - JTokenType.String => jv.Value(), - JTokenType.Integer => jv.Value(), - JTokenType.Float => jv.Value(), - JTokenType.Boolean => jv.Value(), - JTokenType.Null => null, - _ => value - }; - } - return value; - } - - private static object EvalBetween(BetweenExpression b, JObject item, string fromAlias, IDictionary parameters) - { - var value = EvaluateSqlExpression(b.Value, item, fromAlias, parameters); - var low = EvaluateSqlExpression(b.Low, item, fromAlias, parameters); - var high = EvaluateSqlExpression(b.High, item, fromAlias, parameters); - if (value is UndefinedValue || low is UndefinedValue || high is UndefinedValue) - return UndefinedValue.Instance; - return CompareValues(value, low) >= 0 && CompareValues(value, high) <= 0; - } - - private static object EvalCoalesce(CoalesceExpression coal, JObject item, string fromAlias, IDictionary parameters) - { - var left = EvaluateSqlExpression(coal.Left, item, fromAlias, parameters); - return left is not null and not UndefinedValue ? left : EvaluateSqlExpression(coal.Right, item, fromAlias, parameters); - } - - private static object EvalIn(InExpression inExpr, JObject item, string fromAlias, IDictionary parameters) - { - var value = EvaluateSqlExpression(inExpr.Value, item, fromAlias, parameters); - if (value is UndefinedValue) - return UndefinedValue.Instance; - return inExpr.List.Any(li => ValuesEqual(value, EvaluateSqlExpression(li, item, fromAlias, parameters))); - } - - private static object EvaluateBinaryExpression( - BinaryExpression bin, JObject item, string fromAlias, IDictionary parameters) - { - var left = EvaluateSqlExpression(bin.Left, item, fromAlias, parameters); - var right = EvaluateSqlExpression(bin.Right, item, fromAlias, parameters); - - // Three-value logic: comparisons with undefined operand(s) produce undefined - if (bin.Operator is BinaryOp.Equal or BinaryOp.NotEqual or BinaryOp.LessThan or BinaryOp.GreaterThan - or BinaryOp.LessThanOrEqual or BinaryOp.GreaterThanOrEqual or BinaryOp.Like) - { - if (left is UndefinedValue || right is UndefinedValue) - return UndefinedValue.Instance; - } - - return bin.Operator switch - { - BinaryOp.Equal => (object)ValuesEqual(left, right), - BinaryOp.NotEqual => !ValuesEqual(left, right), - BinaryOp.LessThan => CompareValues(left, right) < 0, - BinaryOp.GreaterThan => CompareValues(left, right) > 0, - BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, - BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, - // Three-value AND: false AND undefined = false; true AND undefined = undefined - BinaryOp.And => left is UndefinedValue - ? (IsTruthy(right) ? UndefinedValue.Instance : (object)false) - : right is UndefinedValue - ? (IsTruthy(left) ? UndefinedValue.Instance : (object)false) - : IsTruthy(left) && IsTruthy(right), - // Three-value OR: true OR undefined = true; false OR undefined = undefined - BinaryOp.Or => left is UndefinedValue - ? (IsTruthy(right) ? (object)true : UndefinedValue.Instance) - : right is UndefinedValue - ? (IsTruthy(left) ? (object)true : UndefinedValue.Instance) - : IsTruthy(left) || IsTruthy(right), - BinaryOp.Like => EvaluateLike(left, right), - BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), - BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), - BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), - BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), - BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), - BinaryOp.StringConcat => left is null or UndefinedValue || right is null or UndefinedValue ? UndefinedValue.Instance : left.ToString() + right.ToString(), - BinaryOp.BitwiseAnd => BitwiseOp(left, right, (a, b) => a & b), - BinaryOp.BitwiseOr => BitwiseOp(left, right, (a, b) => a | b), - BinaryOp.BitwiseXor => BitwiseOp(left, right, (a, b) => a ^ b), - _ => null - }; - } - - private static object EvaluateUnaryExpression( - UnaryExpression unary, JObject item, string fromAlias, IDictionary parameters) - { - var operand = EvaluateSqlExpression(unary.Operand, item, fromAlias, parameters); - return unary.Operator switch - { - UnaryOp.Not => operand is UndefinedValue or null ? UndefinedValue.Instance : (object)!IsTruthy(operand), - UnaryOp.Negate => operand is double d ? (object)(-d) : operand is long l ? (object)(-l) : UndefinedValue.Instance, - UnaryOp.BitwiseNot => operand is long lng ? (object)(~lng) : UndefinedValue.Instance, - _ => UndefinedValue.Instance - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SQL function evaluation - // ═══════════════════════════════════════════════════════════════════════════ - - private static object EvaluateSqlFunction( - FunctionCallExpression func, JObject item, string fromAlias, IDictionary parameters) - { - // ARRAY(subquery) — evaluate subquery and collect results into a JArray - if (string.Equals(func.FunctionName, "ARRAY", StringComparison.OrdinalIgnoreCase) && - func.Arguments.Length == 1 && func.Arguments[0] is SubqueryExpression subExpr) - { - return EvaluateArraySubquery(subExpr.Subquery, item, fromAlias, parameters); - } - - var args = func.Arguments.Select(a => EvaluateSqlExpression(a, item, fromAlias, parameters)).ToArray(); - - // Undefined propagation: most scalar functions return undefined when any - // argument is undefined (missing property). Type-checking functions (IS_*), - // emulator-specific functions that handle missing values with special - // semantics, and a few other functions are excluded. - if (args.Any(a => a is UndefinedValue)) - { - var name = func.FunctionName.ToUpperInvariant(); - if (!name.StartsWith("IS_") && name is not "COALESCE" and not "IIF" - and not "ARRAY_CONTAINS" and not "ARRAY_CONTAINS_ANY" and not "ARRAY_CONTAINS_ALL" - and not "DOCUMENTID" and not "VECTORDISTANCE" - and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" and not "FULLTEXTCONTAINSANY" - and not "INDEX_OF" and not "TYPE") - { - return UndefinedValue.Instance; - } - } - - switch (func.FunctionName) - { - // ── Type checking ── - case "IS_DEFINED": - { - if (func.Arguments.Length < 1) - { - return false; - } - - if (func.Arguments[0] is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - return item.SelectToken(path) != null; - } - // A parameter is always "defined" if it exists in the dictionary - // (even when its value is null). - if (func.Arguments[0] is ParameterExpression param) - return parameters.ContainsKey(param.Name); - return args[0] != null; - } - case "IS_NULL": return args.Length > 0 && args[0] is null; - case "IS_ARRAY": - return args.Length > 0 && (ResolveTokenType(func.Arguments, item, fromAlias) is JArray || args[0] is JArray); - case "IS_BOOL": return args.Length > 0 && args[0] is bool; - case "IS_NUMBER": return args.Length > 0 && args[0] is long or double or int or float or decimal; - case "IS_STRING": return args.Length > 0 && args[0] is string; - case "IS_OBJECT": - return args.Length > 0 && (ResolveTokenType(func.Arguments, item, fromAlias) is JObject || args[0] is JObject); - case "IS_PRIMITIVE": - { - if (args.Length == 0) - { - return false; - } - - if (args[0] is null) - { - return true; - } - - var tokenForPrimitive = ResolveTokenType(func.Arguments, item, fromAlias); - if (tokenForPrimitive is JArray or JObject) - { - return false; - } - - return args[0] is string or bool or long or double; - } - case "IS_FINITE_NUMBER": - { - if (args.Length == 0) - { - return false; - } - - return args[0] switch - { - long => true, - int => true, - double d => !double.IsInfinity(d) && !double.IsNaN(d), - float f => !float.IsInfinity(f) && !float.IsNaN(f), - decimal => true, - _ => false, - }; - } - case "IS_INTEGER": - { - if (args.Length == 0) - { - return false; - } - - return args[0] is long or int; - } - case "IS_NAN": - { - if (args.Length == 0) - { - return false; - } - - return args[0] is double d && double.IsNaN(d); - } - case "TYPE": - { - if (args.Length == 0) - { - return UndefinedValue.Instance; - } - - if (args[0] is UndefinedValue) - { - return UndefinedValue.Instance; - } - - var tokenForType = ResolveTokenType(func.Arguments, item, fromAlias); - if (tokenForType is JArray) return "array"; - if (tokenForType is JObject) return "object"; - - return args[0] switch - { - null => "null", - bool => "boolean", - long or int or double or float or decimal => "number", - string => "string", - _ => "undefined" - }; - } - - // ── String functions ── - case "STARTSWITH": - { - if (args.Length < 2) - { - return false; - } - - if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; - if (args[1] is not string p) return UndefinedValue.Instance; - - var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return s.StartsWith(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "ENDSWITH": - { - if (args.Length < 2) - { - return false; - } - - if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; - if (args[1] is not string p) return UndefinedValue.Instance; - - var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return s.EndsWith(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "CONTAINS": - { - if (args.Length < 2) - { - return false; - } - - if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; - if (args[1] is not string p) return UndefinedValue.Instance; - - var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return s.Contains(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "CONCAT": - { - // Cosmos DB: if any arg is null, undefined, or not a string, CONCAT returns undefined - if (args.Any(a => a is null or UndefinedValue)) return UndefinedValue.Instance; - if (args.Any(a => a is not string)) return UndefinedValue.Instance; - return string.Concat(args.Cast()); - } - case "LENGTH": - { - if (args.Length == 0) return null; - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - if (args[0] is not string s) return UndefinedValue.Instance; - return (object)(long)s.Length; - } - case "LOWER": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string sl ? (object)sl.ToLowerInvariant() : UndefinedValue.Instance) : null; - case "UPPER": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string su ? (object)su.ToUpperInvariant() : UndefinedValue.Instance) : null; - case "TRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string st ? (object)st.Trim() : UndefinedValue.Instance) : null; - case "LTRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string slt ? (object)slt.TrimStart() : UndefinedValue.Instance) : null; - case "RTRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string srt ? (object)srt.TrimEnd() : UndefinedValue.Instance) : null; - case "REVERSE": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string rs ? (object)new string(rs.Reverse().ToArray()) : UndefinedValue.Instance) : null; - case "LEFT": - { - if (args.Length < 2) - { - return null; - } - - if (args[0] is not string s) return UndefinedValue.Instance; - var c = ToLong(args[1]); - if (!c.HasValue) return UndefinedValue.Instance; - if (c.Value < 0) return UndefinedValue.Instance; - return s[..(int)Math.Min(c.Value, s.Length)]; - } - case "RIGHT": - { - if (args.Length < 2) - { - return null; - } - - if (args[0] is not string s) return UndefinedValue.Instance; - var c = ToLong(args[1]); - if (!c.HasValue) return UndefinedValue.Instance; - if (c.Value < 0) return UndefinedValue.Instance; - return s[Math.Max(0, s.Length - (int)c.Value)..]; - } - case "SUBSTRING": - { - if (args.Length < 3) - { - return UndefinedValue.Instance; - } - - if (args[0] is UndefinedValue || args[1] is UndefinedValue || args[2] is UndefinedValue) - { - return UndefinedValue.Instance; - } - - if (args[0] is not string s) return UndefinedValue.Instance; - var start = ToLong(args[1]); var len = ToLong(args[2]); - if (!start.HasValue || !len.HasValue) - { - return UndefinedValue.Instance; - } - - var si = (int)Math.Max(0, Math.Min(start.Value, s.Length)); - var li = (int)Math.Max(0, Math.Min(len.Value, s.Length - si)); - return s.Substring(si, li); - } - case "REPLACE": - { - if (args.Length < 3) - { - return null; - } - - var s = args[0] is string ss ? ss : null; - var find = args[1] is string fs ? fs : null; - var rep = args[2] is string rps ? rps : null; - if (s is null || find is null) return UndefinedValue.Instance; - if (args[2] is not (null or UndefinedValue or string)) return UndefinedValue.Instance; - if (find.Length == 0) return s; - return s.Replace(find, rep ?? ""); - } - case "INDEX_OF": - { - if (args.Length < 2) - { - return UndefinedValue.Instance; - } - - if (args[0] is not string s || args[1] is not string sub) - { - return UndefinedValue.Instance; - } - - if (args.Length >= 3 && double.TryParse(args[2]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var startPos)) - { - var sp = (int)startPos; - if (sp < 0 || sp > s.Length) return UndefinedValue.Instance; - return (object)(long)s.IndexOf(sub, sp, StringComparison.Ordinal); - } - return (object)(long)s.IndexOf(sub, StringComparison.Ordinal); - } - case "REGEXMATCH": - { - if (args.Length < 2) - { - return false; - } - - if (args[0] is not string input || args[1] is not string pattern) - { - return UndefinedValue.Instance; - } - - var options = RegexOptions.None; - if (args.Length >= 3) - { - var modifiers = args[2]?.ToString() ?? ""; - foreach (var ch in modifiers) - { - options |= ch switch - { - 'i' => RegexOptions.IgnoreCase, - 'm' => RegexOptions.Multiline, - 's' => RegexOptions.Singleline, - 'x' => RegexOptions.IgnorePatternWhitespace, - _ => RegexOptions.None, - }; - } - } - try - { - return GetOrCreateRegex(pattern, options).IsMatch(input); - } - catch (ArgumentException) - { - return UndefinedValue.Instance; - } - } - case "REPLICATE": - { - if (args.Length < 2) - { - return null; - } - - if (args[0] is not string s) - { - return UndefinedValue.Instance; - } - - var count = ToLong(args[1]); - if (!count.HasValue || count.Value < 0 || count.Value > 10000) - { - return UndefinedValue.Instance; - } - - return count.Value == 0 ? "" : string.Concat(Enumerable.Repeat(s, (int)count.Value)); - } - case "STRING_EQUALS": - case "STRINGEQUALS": - { - if (args.Length < 2) - { - return UndefinedValue.Instance; - } - - if (args[0] is not string s1) - { - return UndefinedValue.Instance; - } - - if (args[1] is not string s2) - { - return UndefinedValue.Instance; - } - - var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - return string.Equals(s1, s2, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - case "STRINGTOARRAY": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - var s = args[0].ToString(); - if (s is null) - { - return UndefinedValue.Instance; - } - - try - { - var token = JToken.Parse(s); - return token is JArray ? token : UndefinedValue.Instance; - } - catch - { - return UndefinedValue.Instance; - } - } - case "STRINGTOBOOLEAN": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - var s = args[0].ToString()?.Trim(); - return s switch - { - "true" => true, - "false" => (object)false, - _ => UndefinedValue.Instance, - }; - } - case "STRINGTONULL": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - return args[0].ToString()?.Trim() == "null" ? null : UndefinedValue.Instance; - } - case "STRINGTONUMBER": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - var s = args[0].ToString()?.Trim(); - if (s is null) - { - return UndefinedValue.Instance; - } - - if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var longVal)) - { - return longVal; - } - - if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleVal)) - { - if (double.IsNaN(doubleVal) || double.IsInfinity(doubleVal)) - return UndefinedValue.Instance; - return doubleVal; - } - - return UndefinedValue.Instance; - } - case "STRINGTOOBJECT": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - var s = args[0].ToString(); - if (s is null) - { - return UndefinedValue.Instance; - } - - try - { - var token = JToken.Parse(s); - return token is JObject ? token : UndefinedValue.Instance; - } - catch - { - return UndefinedValue.Instance; - } - } - case "TOSTRING" or "ToString": - if (args.Length == 0) return null; - if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; - if (args[0] is bool boolVal) return boolVal ? "true" : "false"; - if (args[0] is string or long or int or double or float or decimal) return args[0].ToString(); - if (args[0] is JArray or JObject) return ((JToken)args[0]).ToString(Newtonsoft.Json.Formatting.None); - if (args[0] is JValue jv && jv.Type == JTokenType.Null) return UndefinedValue.Instance; - return args[0].ToString(); - case "TONUMBER" or "ToNumber": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is null) - { - return UndefinedValue.Instance; - } - - if (args[0] is long or double) - { - return args[0]; - } - - if (double.TryParse(args[0].ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) - { - return n; - } - - return UndefinedValue.Instance; - } - case "TOBOOLEAN" or "ToBoolean": - { - if (args.Length == 0) - { - return null; - } - - if (args[0] is bool) - { - return args[0]; - } - - if (args[0] != null && bool.TryParse(args[0].ToString(), out var b)) - { - return b; - } - - return UndefinedValue.Instance; - } - - // ── Array functions ── - case "ARRAY_CONTAINS": - { - if (func.Arguments.Length < 2) - { - return false; - } - - JArray jArray = null; - if (func.Arguments[0] is IdentifierExpression ident) - { - var arrayPath = ident.Name; - if (arrayPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - arrayPath = arrayPath[(fromAlias.Length + 1)..]; - } - - var arrayToken = item.SelectToken(arrayPath); - jArray = arrayToken as JArray; - } - else if (args[0] is JArray evalArr) - { - jArray = evalArr; - } - - if (jArray is null) - { - return false; - } - - { - var partial = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - var searchValue = args[1]; - if (searchValue is JObject searchObj) - { - return ArrayContainsMatchJObject(jArray, searchObj, partial); - } - - if (searchValue is null) - { - return jArray.Any(t => t.Type == JTokenType.Null); - } - - var searchStr = searchValue.ToString(); - return ArrayContainsMatch(jArray, searchStr, partial); - } - } - case "ARRAY_LENGTH": - { - if (func.Arguments.Length < 1) - { - return null; - } - - if (func.Arguments[0] is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - var token = item.SelectToken(path); - return token is JArray arr ? (object)(long)arr.Count : UndefinedValue.Instance; - } - - // Support nested function calls like ARRAY_LENGTH(SetIntersect(...)) - var evaluated = EvaluateSqlExpression(func.Arguments[0], item, fromAlias, parameters); - return evaluated is JArray evalArr ? (object)(long)evalArr.Count : UndefinedValue.Instance; - } - case "ARRAY_SLICE": - { - if (func.Arguments.Length < 2) - { - return null; - } - - if (func.Arguments[0] is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - var token = item.SelectToken(path); - if (token is not JArray arr) - { - return null; - } - - var start = (int)(ToLong(args[1]) ?? 0); - if (start < 0) - { - start = Math.Max(0, arr.Count + start); - } - - var length = args.Length >= 3 ? (int)(ToLong(args[2]) ?? arr.Count) : arr.Count; - return new JArray(arr.Skip(start).Take(length)); - } - - // Support literal arrays and nested expressions like ARRAY_SLICE([1,2,3], 0, 2) - var sliceEval = EvaluateSqlExpression(func.Arguments[0], item, fromAlias, parameters); - if (sliceEval is JArray evalArr2) - { - var start2 = (int)(ToLong(args[1]) ?? 0); - if (start2 < 0) start2 = Math.Max(0, evalArr2.Count + start2); - var length2 = args.Length >= 3 ? (int)(ToLong(args[2]) ?? evalArr2.Count) : evalArr2.Count; - return new JArray(evalArr2.Skip(start2).Take(length2)); - } - return null; - } - case "ARRAY_CONCAT": - { - var result = new JArray(); - foreach (var argExpr in func.Arguments) - { - JArray ja = null; - if (argExpr is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - ja = item.SelectToken(path) as JArray; - } - else - { - var evaluated = EvaluateSqlExpression(argExpr, item, fromAlias, parameters); - ja = evaluated as JArray; - } - - if (ja is null) - { - return UndefinedValue.Instance; - } - - foreach (var el in ja) - { - result.Add(el.DeepClone()); - } - } - return result; - } - - // ── Math functions ── - case "ABS": return args.Length > 0 ? MathOp(args[0], Math.Abs) : null; - case "CEILING": return args.Length > 0 ? MathOp(args[0], Math.Ceiling) : null; - case "FLOOR": return args.Length > 0 ? MathOp(args[0], Math.Floor) : null; - case "ROUND": - if (args.Length >= 2 && double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var roundVal) - && double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var roundPrec)) - { - var prec = (int)roundPrec; - if (prec < 0) - { - var factor = Math.Pow(10, -prec); - return Math.Round(roundVal / factor, MidpointRounding.AwayFromZero) * factor; - } - return Math.Round(roundVal, prec, MidpointRounding.AwayFromZero); - } - return args.Length > 0 ? MathOp(args[0], v => Math.Round(v, MidpointRounding.AwayFromZero)) : null; - case "SQRT": return args.Length > 0 ? MathOp(args[0], Math.Sqrt) : null; - case "SQUARE": return args.Length > 0 ? MathOp(args[0], v => v * v) : null; - case "POWER": return args.Length >= 2 ? ArithmeticOp(args[0], args[1], Math.Pow) : null; - case "EXP": return args.Length > 0 ? MathOp(args[0], Math.Exp) : null; - case "LOG": - if (args.Length >= 2 && double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var logVal) - && double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var logBase)) - { - var logResult = Math.Log(logVal, logBase); - if (double.IsNaN(logResult) || double.IsInfinity(logResult)) - return UndefinedValue.Instance; - return logResult; - } - return args.Length > 0 ? MathOp(args[0], Math.Log) : null; - case "LOG10": return args.Length > 0 ? MathOp(args[0], Math.Log10) : null; - case "SIGN": return args.Length > 0 ? MathOp(args[0], v => Math.Sign(v)) : null; - case "TRUNC": return args.Length > 0 ? MathOp(args[0], Math.Truncate) : null; - case "PI": return Math.PI; - case "SIN": return args.Length > 0 ? MathOp(args[0], Math.Sin) : null; - case "COS": return args.Length > 0 ? MathOp(args[0], Math.Cos) : null; - case "TAN": return args.Length > 0 ? MathOp(args[0], Math.Tan) : null; - case "COT": return args.Length > 0 ? MathOp(args[0], v => 1.0 / Math.Tan(v)) : null; - case "ASIN": return args.Length > 0 ? MathOp(args[0], Math.Asin) : null; - case "ACOS": return args.Length > 0 ? MathOp(args[0], Math.Acos) : null; - case "ATAN": return args.Length > 0 ? MathOp(args[0], Math.Atan) : null; - case "ATN2": return args.Length >= 2 ? ArithmeticOp(args[0], args[1], Math.Atan2) : null; - case "DEGREES": return args.Length > 0 ? MathOp(args[0], v => v * (180.0 / Math.PI)) : null; - case "RADIANS": return args.Length > 0 ? MathOp(args[0], v => v * (Math.PI / 180.0)) : null; - case "RAND": return Random.Shared.NextDouble(); - - // ── Integer math functions ── - case "NUMBERBIN": - { - if (args.Length < 2) - { - return null; - } - - if (double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var val) && - double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var binSize) && - binSize > 0) - { - return Math.Floor(val / binSize) * binSize; - } - - return UndefinedValue.Instance; - } - case "INTADD": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a + b) : null; - case "INTSUB": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a - b) : null; - case "INTMUL": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a * b) : null; - case "INTDIV": - { - if (args.Length < 2) - { - return UndefinedValue.Instance; - } - - var dividend = ToLong(args[0]); - var divisor = ToLong(args[1]); - if (!dividend.HasValue || !divisor.HasValue || divisor.Value == 0) - { - return UndefinedValue.Instance; - } - - return dividend.Value / divisor.Value; - } - case "INTMOD": - { - if (args.Length < 2) - { - return UndefinedValue.Instance; - } - - var dividend = ToLong(args[0]); - var divisor = ToLong(args[1]); - if (!dividend.HasValue || !divisor.HasValue || divisor.Value == 0) - { - return UndefinedValue.Instance; - } - - return dividend.Value % divisor.Value; - } - case "INTBITAND": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a & b) : null; - case "INTBITOR": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a | b) : null; - case "INTBITXOR": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a ^ b) : null; - case "INTBITNOT": - { - if (args.Length == 0) - { - return null; - } - - var value = ToLong(args[0]); - return value.HasValue ? ~value.Value : null; - } - case "INTBITLEFTSHIFT": return args.Length >= 2 ? BitwiseShiftOp(args[0], args[1], isLeft: true) : null; - case "INTBITRIGHTSHIFT": return args.Length >= 2 ? BitwiseShiftOp(args[0], args[1], isLeft: false) : null; - - // ── Aggregates (passthrough for non-GROUP-BY contexts) ── - // When there is no GROUP BY, each document is emitted individually and the - // SDK accumulates partial results client-side (cross-partition fan-out model). - // COUNT always emits 1 per document (each document counts as one row). - // SUM/MIN/MAX emit the field value so the SDK can aggregate across partitions. - // AVG is handled by the SDK via sum+count, so SUM and COUNT passthroughs suffice. - case "COUNT": - return 1L; - case "SUM": - case "AVG": - case "MIN": - case "MAX": - return args.Length > 0 ? args[0] : null; - - // ── Conditional functions ── - case "IIF": - { - if (args.Length < 3) - { - return null; - } - - // Real Cosmos DB IIF only treats boolean true as truthy. - // Non-boolean values (numbers, strings, arrays, objects) always yield the false branch. - return args[0] is bool b && b ? args[1] : args[2]; - } - - // ── Extended array functions ── - case "ARRAY_CONTAINS_ANY": - { - if (func.Arguments.Length < 2) - { - return false; - } - - var sourceArray = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); - if (sourceArray is null || sourceArray.Count == 0) - { - return false; - } - - // Support both array form: ARRAY_CONTAINS_ANY(c.tags, ['a','b']) - // and variadic form: ARRAY_CONTAINS_ANY(c.tags, 'a', 'b') - var searchArray = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); - if (searchArray is null && func.Arguments.Length >= 2) - { - searchArray = new JArray(); - for (int i = 1; i < func.Arguments.Length; i++) - { - var val = EvaluateSqlExpression(func.Arguments[i], item, fromAlias, parameters); - if (val is UndefinedValue) continue; - searchArray.Add(val is JToken jt ? jt : (val is null ? JValue.CreateNull() : JToken.FromObject(val))); - } - } - - if (searchArray is null || searchArray.Count == 0) - { - return false; - } - - var sourceValues = new HashSet(sourceArray, JTokenValueComparer.Instance); - return searchArray.Any(t => sourceValues.Contains(t)); - } - case "ARRAY_CONTAINS_ALL": - { - if (func.Arguments.Length < 2) - { - return false; - } - - var sourceArray = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); - if (sourceArray is null) - { - return false; - } - - // Support both array form: ARRAY_CONTAINS_ALL(c.tags, ['a','b']) - // and variadic form: ARRAY_CONTAINS_ALL(c.tags, 'a', 'b') - var searchArray = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); - if (searchArray is null && func.Arguments.Length >= 2) - { - searchArray = new JArray(); - for (int i = 1; i < func.Arguments.Length; i++) - { - var val = EvaluateSqlExpression(func.Arguments[i], item, fromAlias, parameters); - if (val is UndefinedValue) continue; - searchArray.Add(val is JToken jt ? jt : (val is null ? JValue.CreateNull() : JToken.FromObject(val))); - } - } - - if (searchArray is null || searchArray.Count == 0) - { - return true; - } - - var sourceValues = new HashSet(sourceArray, JTokenValueComparer.Instance); - return searchArray.All(t => sourceValues.Contains(t)); - } - case "SETINTERSECT": - { - if (func.Arguments.Length < 2) - { - return new JArray(); - } - - var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); - var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); - if (arr1 is null || arr2 is null) - { - return UndefinedValue.Instance; - } - - var set2 = new HashSet(arr2, JTokenValueComparer.Instance); - var result = new JArray(); - var seen = new HashSet(JTokenValueComparer.Instance); - foreach (var element in arr1) - { - if (set2.Contains(element) && seen.Add(element)) - { - result.Add(element.DeepClone()); - } - } - return result; - } - case "SETUNION": - { - if (func.Arguments.Length < 2) - { - return new JArray(); - } - - var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); - var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); - if (arr1 is null || arr2 is null) - { - return UndefinedValue.Instance; - } - var result = new JArray(); - var seen = new HashSet(JTokenValueComparer.Instance); - foreach (var element in arr1) - { - if (seen.Add(element)) - { - result.Add(element.DeepClone()); - } - } - foreach (var element in arr2) - { - if (seen.Add(element)) - { - result.Add(element.DeepClone()); - } - } - return result; - } - case "SETDIFFERENCE": - { - if (func.Arguments.Length < 2) - { - return new JArray(); - } - - var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); - var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); - if (arr1 is null || arr2 is null) - { - return UndefinedValue.Instance; - } - - var exclude = new HashSet(arr2, JTokenValueComparer.Instance); - var result = new JArray(); - var seen = new HashSet(JTokenValueComparer.Instance); - foreach (var element in arr1) - { - if (!exclude.Contains(element) && seen.Add(element)) - { - result.Add(element.DeepClone()); - } - } - return result; - } - - // ── Array/Object utility functions ── - case "CHOOSE": - { - if (args.Length < 2) return UndefinedValue.Instance; - var idx = ToLong(args[0]); - if (!idx.HasValue || idx.Value < 1 || idx.Value >= args.Length) return UndefinedValue.Instance; - return args[(int)idx.Value]; - } - case "OBJECTTOARRAY": - { - if (args.Length < 1) return null; - JObject obj; - if (args[0] is JObject jo) obj = jo; - else if (args[0] is string s) { try { obj = JObject.Parse(s); } catch { return UndefinedValue.Instance; } } - else return UndefinedValue.Instance; - var result = new JArray(); - foreach (var prop in obj.Properties()) - { - result.Add(new JObject { ["k"] = prop.Name, ["v"] = prop.Value.DeepClone() }); - } - return result; - } - case "ARRAYTOOBJECT": - { - if (args.Length < 1) return UndefinedValue.Instance; - JArray arr; - if (args[0] is JArray ja) arr = ja; - else if (args[0] is string s) { try { arr = JArray.Parse(s); } catch { return UndefinedValue.Instance; } } - else return UndefinedValue.Instance; - var result = new JObject(); - foreach (var element in arr) - { - if (element is JObject kvObj && kvObj["k"] != null && kvObj["v"] != null) - { - result[kvObj["k"]!.Value()] = kvObj["v"]!.DeepClone(); - } - else - { - return UndefinedValue.Instance; - } - } - return result; - } - - // ── String utility functions ── - case "STRINGJOIN": - { - if (args.Length < 2) return null; - var separator = args[1]?.ToString(); - if (separator is null) return null; - JArray joinArr; - if (args[0] is JArray ja) joinArr = ja; - else if (args[0] is string s) { try { joinArr = JArray.Parse(s); } catch { return null; } } - else return null; - return string.Join(separator, joinArr.Select(t => t.Value())); - } - case "STRINGSPLIT": - { - if (args.Length < 2) return null; - var input = args[0]?.ToString(); - var delimiter = args[1]?.ToString(); - if (input is null || delimiter is null) return UndefinedValue.Instance; - if (delimiter.Length == 0) return UndefinedValue.Instance; - var parts = input.Split(delimiter); - return new JArray(parts.Select(p => (JToken)p)); - } - - // ── Item functions ── - case "DOCUMENTID": - { - // DOCUMENTID returns the _rid system property of the current document. - // The emulator synthesises from id since it doesn't generate _rid. - return item["_rid"]?.Value() ?? item["id"]?.Value(); - } - - // ── Full-text search functions (approximate) ── - // These use case-insensitive substring matching instead of real NLP tokenization. - case "FULLTEXTCONTAINS": - { - if (args.Length < 2) return false; - var text = args[0]?.ToString(); - var term = args[1]?.ToString(); - if (text is null || term is null) return false; - return text.Contains(term, StringComparison.OrdinalIgnoreCase); - } - case "FULLTEXTCONTAINSALL": - { - if (args.Length < 2) return false; - var text = args[0]?.ToString(); - if (text is null) return false; - for (var i = 1; i < args.Length; i++) - { - var term = args[i]?.ToString(); - if (term is null || !text.Contains(term, StringComparison.OrdinalIgnoreCase)) - return false; - } - return true; - } - case "FULLTEXTCONTAINSANY": - { - if (args.Length < 2) return false; - var text = args[0]?.ToString(); - if (text is null) return false; - for (var i = 1; i < args.Length; i++) - { - var term = args[i]?.ToString(); - if (term is not null && text.Contains(term, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } - case "FULLTEXTSCORE": - { - // Naive term-frequency scoring: count occurrences of each search term - // in the field text. Returns the total count as a double. - // Real Cosmos DB uses BM25 with IDF and length normalization. - if (args.Length < 2) return 0.0; - var text = args[0]?.ToString(); - if (text is null) return 0.0; - - var score = 0.0; - // arg[1] may be a JArray (from [...] literal) or individual terms - if (args[1] is JArray searchTerms) - { - foreach (var termToken in searchTerms) - { - var term = termToken.Value(); - if (term is not null) - score += CountOccurrences(text, term); - } - } - else - { - for (var i = 1; i < args.Length; i++) - { - var term = args[i]?.ToString(); - if (term is not null) - score += CountOccurrences(text, term); - } - } - return score; - } - - // ── Spatial functions ── - case "ST_DISTANCE": - return args.Length >= 2 ? StDistance(args[0], args[1]) : null; - case "ST_WITHIN": - return args.Length >= 2 ? (object)StWithin(args[0], args[1]) : null; - case "ST_INTERSECTS": - return args.Length >= 2 ? (object)StIntersects(args[0], args[1]) : null; - case "ST_ISVALID": - return args.Length >= 1 ? (object)StIsValid(args[0]) : false; - case "ST_ISVALIDDETAILED": - return args.Length >= 1 ? StIsValidDetailed(args[0]) : null; - case "ST_AREA": - return args.Length >= 1 ? StArea(args[0]) : null; - - // ── Vector functions ── - case "VECTORDISTANCE": - return args.Length >= 2 ? VectorDistanceFunc(args) : null; - - // ── Date/time functions ── - case "GETCURRENTDATETIME": - case "GETCURRENTDATETIMESTATIC": return parameters.TryGetValue("__staticDateTime", out var sdt) ? sdt : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - case "GETCURRENTTIMESTAMP": - case "GETCURRENTTIMESTAMPSTATIC": return parameters.TryGetValue("__staticTimestamp", out var sts) ? sts : (object)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - case "GETCURRENTTICKS": - case "GETCURRENTTICKSSTATIC": return parameters.TryGetValue("__staticTicks", out var stk) ? stk : (object)DateTime.UtcNow.Ticks; - case "DATETIMEADD": - { - if (args.Length < 3) - { - return UndefinedValue.Instance; - } - - var part = args[0]?.ToString()?.ToLowerInvariant(); - var number = ToLong(args[1]); - var dateTime = args[2] is not null and not UndefinedValue ? args[2].ToString() : null; - if (part is null || !number.HasValue || dateTime is null) - { - return UndefinedValue.Instance; - } - - if (!DateTime.TryParse(dateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) - { - return UndefinedValue.Instance; - } - - var n = (int)number.Value; - try - { - DateTime? result = part switch - { - "year" or "yyyy" or "yy" => dt.AddYears(n), - "month" or "mm" or "m" => dt.AddMonths(n), - "day" or "dd" or "d" => dt.AddDays(n), - "hour" or "hh" => dt.AddHours(n), - "minute" or "mi" or "n" => dt.AddMinutes(n), - "second" or "ss" or "s" => dt.AddSeconds(n), - "millisecond" or "ms" => dt.AddMilliseconds(n), - "microsecond" or "mcs" => dt.AddTicks(n * 10L), - "nanosecond" or "ns" => dt.AddTicks(n / 100L), - _ => null, - }; - if (result is null) return UndefinedValue.Instance; - return result.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - } - catch (ArgumentOutOfRangeException) - { - return UndefinedValue.Instance; - } - } - case "DATETIMEPART": - { - if (args.Length < 2) - { - return UndefinedValue.Instance; - } - - var part = args[0]?.ToString()?.ToLowerInvariant(); - var dateTime = args[1] is not null and not UndefinedValue ? args[1].ToString() : null; - if (part is null || dateTime is null) - { - return UndefinedValue.Instance; - } - - if (!DateTime.TryParse(dateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) - { - return UndefinedValue.Instance; - } - - return part switch - { - "year" or "yyyy" or "yy" => (object)(long)dt.Year, - "month" or "mm" or "m" => (long)dt.Month, - "day" or "dd" or "d" => (long)dt.Day, - "hour" or "hh" => (long)dt.Hour, - "minute" or "mi" or "n" => (long)dt.Minute, - "second" or "ss" or "s" => (long)dt.Second, - "millisecond" or "ms" => (long)dt.Millisecond, - "microsecond" or "mcs" => (long)(dt.Ticks % TimeSpan.TicksPerSecond / 10), - "nanosecond" or "ns" => (long)(dt.Ticks % TimeSpan.TicksPerSecond * 100), - "weekday" or "dw" or "w" => (long)(dt.DayOfWeek + 1), // Sunday=1..Saturday=7 - _ => null, - }; - } - case "DATETIMEDIFF": - { - if (args.Length < 3) return UndefinedValue.Instance; - var part = args[0]?.ToString()?.ToLowerInvariant(); - var startStr = args[1]?.ToString(); - var endStr = args[2]?.ToString(); - if (part is null || startStr is null || endStr is null) return UndefinedValue.Instance; - if (!DateTime.TryParse(startStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dtStart)) return UndefinedValue.Instance; - if (!DateTime.TryParse(endStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dtEnd)) return UndefinedValue.Instance; - - return part switch - { - "year" or "yyyy" or "yy" => (object)(long)(dtEnd.Year - dtStart.Year), - "month" or "mm" or "m" => (long)((dtEnd.Year - dtStart.Year) * 12 + dtEnd.Month - dtStart.Month), - "day" or "dd" or "d" => (long)(dtEnd.Date - dtStart.Date).TotalDays, - "hour" or "hh" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerHour) - FloorToUnit(dtStart, TimeSpan.TicksPerHour)), - "minute" or "mi" or "n" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerMinute) - FloorToUnit(dtStart, TimeSpan.TicksPerMinute)), - "second" or "ss" or "s" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerSecond) - FloorToUnit(dtStart, TimeSpan.TicksPerSecond)), - "millisecond" or "ms" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerMillisecond) - FloorToUnit(dtStart, TimeSpan.TicksPerMillisecond)), - "microsecond" or "mcs" => (long)((dtEnd - dtStart).Ticks / 10), - "nanosecond" or "ns" => (long)((dtEnd - dtStart).Ticks * 100), - _ => UndefinedValue.Instance, - }; - } - case "DATETIMEFROMPARTS": - { - if (args.Length < 3) return UndefinedValue.Instance; - var y = ToLong(args[0]); - var mo = ToLong(args[1]); - var d = ToLong(args[2]); - var h = args.Length > 3 ? ToLong(args[3]) : 0; - var mi = args.Length > 4 ? ToLong(args[4]) : 0; - var s = args.Length > 5 ? ToLong(args[5]) : 0; - var fraction = args.Length > 6 ? ToLong(args[6]) : 0; - if (!y.HasValue || !mo.HasValue || !d.HasValue || !h.HasValue || !mi.HasValue || !s.HasValue || !fraction.HasValue) return UndefinedValue.Instance; - if (fraction.Value < 0 || fraction.Value > 9999999) return UndefinedValue.Instance; - try - { - var dt = new DateTime((int)y.Value, (int)mo.Value, (int)d.Value, - (int)h.Value, (int)mi.Value, (int)s.Value, DateTimeKind.Utc).AddTicks(fraction.Value); - return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - } - catch (ArgumentOutOfRangeException) - { - return UndefinedValue.Instance; - } - } - case "DATETIMEBIN": - { - if (args.Length < 2) return UndefinedValue.Instance; - var dtStr = args[0] is not null and not UndefinedValue ? args[0].ToString() : null; - var part = args[1]?.ToString()?.ToLowerInvariant(); - var binSize = args.Length >= 3 ? ToLong(args[2]) : 1; - if (dtStr is null || part is null || !binSize.HasValue) return UndefinedValue.Instance; - if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; - - var bs = (int)binSize.Value; - if (bs <= 0) return UndefinedValue.Instance; - - DateTime origin; - if (args.Length >= 4) - { - if (args[3] is not string originStr || !DateTime.TryParse(originStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out origin)) - return UndefinedValue.Instance; - } - else - { - origin = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - } - - if (origin.Year < 1601) return UndefinedValue.Instance; - - if (part is "year" or "yyyy" or "yy") - { - var yearBin = (int)(Math.Floor((double)(dt.Year - origin.Year) / bs) * bs); - dt = new DateTime(origin.Year + yearBin, 1, 1, 0, 0, 0, DateTimeKind.Utc); - } - else if (part is "month" or "mm" or "m") - { - var totalMonths = (dt.Year - origin.Year) * 12 + (dt.Month - origin.Month); - var binned = (int)(Math.Floor((double)totalMonths / bs) * bs); - dt = new DateTime(origin.Year, origin.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(binned); - } - else - { - var ticksPerUnit = part switch - { - "day" or "dd" or "d" => TimeSpan.TicksPerDay, - "hour" or "hh" => TimeSpan.TicksPerHour, - "minute" or "mi" or "n" => TimeSpan.TicksPerMinute, - "second" or "ss" or "s" => TimeSpan.TicksPerSecond, - "millisecond" or "ms" => TimeSpan.TicksPerMillisecond, - "microsecond" or "mcs" => 10L, - "nanosecond" or "ns" => 1L, - _ => -1L, - }; - if (ticksPerUnit < 0) return UndefinedValue.Instance; - var tickSpan = (dt - origin).Ticks; - var binTicks = ticksPerUnit * bs; - var binnedTicks = (long)Math.Floor((double)tickSpan / binTicks) * binTicks; - dt = origin.AddTicks(binnedTicks); - } - return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - } - case "DATETIMETOTICKS": - { - if (args.Length < 1) return null; - var dtStr = args[0]?.ToString(); - if (dtStr is null) return UndefinedValue.Instance; - if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; - if (dt.Kind == DateTimeKind.Unspecified) dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); - return (object)dt.ToUniversalTime().Ticks; - } - case "TICKSTODATETIME": - { - if (args.Length < 1) return UndefinedValue.Instance; - var ticks = ToLong(args[0]); - if (!ticks.HasValue) return UndefinedValue.Instance; - try - { - var dt = new DateTime(ticks.Value, DateTimeKind.Utc); - return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - } - catch (ArgumentOutOfRangeException) - { - return UndefinedValue.Instance; - } - } - case "DATETIMETOTIMESTAMP": - { - if (args.Length < 1) return UndefinedValue.Instance; - var dtStr = args[0]?.ToString(); - if (dtStr is null) return UndefinedValue.Instance; - if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; - if (dt.Kind == DateTimeKind.Unspecified) dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); - return (object)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds(); - } - case "TIMESTAMPTODATETIME": - { - if (args.Length < 1) return UndefinedValue.Instance; - var ms = ToLong(args[0]); - if (!ms.HasValue) return UndefinedValue.Instance; - var dt = DateTimeOffset.FromUnixTimeMilliseconds(ms.Value).UtcDateTime; - return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); - } - // ── COALESCE function ── - // COALESCE skips null and undefined, returning the first concrete value. - // When all args are exhausted: returns null if any arg was null, - // otherwise returns undefined (omitted from results). - case "COALESCE": - { - // COALESCE returns the first expression that is NOT undefined. - // null is a defined value and should be returned (unlike ?? which skips both null and undefined). - foreach (var arg in args) - { - if (arg is UndefinedValue) - continue; - return arg; // returns first non-undefined value (including null) - } - - return UndefinedValue.Instance; - } - - default: - if (func.FunctionName.StartsWith("UDF.", StringComparison.OrdinalIgnoreCase)) - { - var udfName = func.FunctionName.Substring(4); // strip "UDF." prefix - - // Priority 1: C# handler (skip placeholder delegates) - if (parameters.TryGetValue(UdfRegistryKey, out var registry) && - registry is Dictionary> udfs && - udfs.TryGetValue(func.FunctionName, out var udfImpl) && - !ReferenceEquals(udfImpl, UdfPlaceholder)) - { - return udfImpl(args); - } - - // Priority 2: JS body via engine - if (parameters.TryGetValue(UdfPropertiesKey, out var propsObj) && - propsObj is Dictionary udfProps && - udfProps.TryGetValue(udfName, out var udfProp) && - udfProp.Body is not null && - parameters.TryGetValue(UdfEngineKey, out var engineObj) && - engineObj is IJsUdfEngine jsUdfEngine) - { - return jsUdfEngine.ExecuteUdf(udfProp.Body, args); - } - - throw new NotSupportedException( - $"Unregistered user-defined function: {func.FunctionName}. " + - "Call RegisterUdf() on the InMemoryContainer to register it before querying."); - } - - throw new NotSupportedException($"Unsupported Cosmos SQL function: {func.FunctionName}"); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Utility helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static bool IsTruthy(object value) => value switch - { - null => false, - UndefinedValue => false, - bool b => b, - // Cosmos DB: WHERE requires strict boolean — non-boolean values evaluate to false - _ => false - }; - - private static int CountOccurrences(string text, string term) - { - if (term.Length == 0) return 0; - var count = 0; - var idx = 0; - while ((idx = text.IndexOf(term, idx, StringComparison.OrdinalIgnoreCase)) >= 0) - { - count++; - idx += term.Length; - } - return count; - } - - private static JObject EvaluateObjectLiteral( - ObjectLiteralExpression obj, JObject item, string fromAlias, IDictionary parameters) - { - var result = new JObject(); - foreach (var prop in obj.Properties) - { - var value = EvaluateSqlExpression(prop.Value, item, fromAlias, parameters); - if (value is not UndefinedValue) - result[prop.Key] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - } - return result; - } - - private static JArray EvaluateArrayLiteral( - ArrayLiteralExpression arr, JObject item, string fromAlias, IDictionary parameters) - { - var result = new JArray(); - foreach (var element in arr.Elements) - { - var value = EvaluateSqlExpression(element, item, fromAlias, parameters); - result.Add(value is not null and not UndefinedValue ? JToken.FromObject(value) : JValue.CreateNull()); - } - return result; - } - - private static object EvaluateSubquery( - CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) - { - var results = ExecuteSubqueryAgainstItem(subquery, item, fromAlias, parameters); - if (subquery.IsValueSelect && results.Count > 0) - { - return JsonParseHelpers.ParseJsonToken(results[0]).ToObject(); - } - - return results.Count > 0 ? JsonParseHelpers.ParseJsonToken(results[0]).ToObject() : null; - } - - private static object EvaluateSubqueryAggregate( - FunctionCallExpression func, List sourceItems, string fromAlias, IDictionary parameters) - { - var name = func.FunctionName.ToUpperInvariant(); - if (name is "COUNT") - { - // COUNT(1) / COUNT(*) → count all items; COUNT(c.field) → count defined only - if (func.Arguments.Length == 1 && func.Arguments[0] is IdentifierExpression ident) - { - var fieldPath = ident.Name; - if (fieldPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - fieldPath = fieldPath[(fromAlias.Length + 1)..]; - return (double)sourceItems.Count(si => si.SelectToken(fieldPath) is not null); - } - return (double)sourceItems.Count; - } - - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // COUNTIF() counts items where the expression evaluates to true. - if (name is "COUNTIF") - { - if (func.Arguments.Length < 1) return 0.0; - var boolExpr = func.Arguments[0]; - return (double)sourceItems.Count(si => - { - var val = EvaluateSqlExpression(boolExpr, si, fromAlias, parameters); - return IsTruthy(val); - }); - } - - // For SUM/AVG/MIN/MAX, extract numeric values from the first argument - var argExpr = func.Arguments.Length > 0 ? func.Arguments[0] : null; - if (argExpr is null) return null; - - var values = new List(); - foreach (var si in sourceItems) - { - var val = EvaluateSqlExpression(argExpr, si, fromAlias, parameters); - if (val is not null and not UndefinedValue) - values.Add(JToken.FromObject(val)); - } - - return name switch - { - "SUM" => values.Count > 0 ? values.Sum(v => v.Value()) : (object)UndefinedValue.Instance, - "AVG" => values.Count > 0 ? values.Average(v => v.Value()) : (object)UndefinedValue.Instance, - "MIN" => AggregateMinMax(values, true), - "MAX" => AggregateMinMax(values, false), - _ => null - }; - } - - private static object EvaluateArraySubquery( - CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) - { - var results = ExecuteSubqueryAgainstItem(subquery, item, fromAlias, parameters); - var jArray = new JArray(); - foreach (var resultJson in results) - { - jArray.Add(JsonParseHelpers.ParseJsonToken(resultJson)); - } - return jArray; - } - - private static List ExecuteSubqueryAgainstItem( - CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) - { - // Determine the data to iterate over - List sourceItems; - - if (subquery.FromSource is not null) - { - // FROM alias IN path — expand the array at path, aliasing each element - var sourcePath = subquery.FromSource; - if (sourcePath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - sourcePath = sourcePath[(fromAlias.Length + 1)..]; - } - - var arrayToken = item.SelectToken(sourcePath); - if (arrayToken is not JArray sourceArray) - { - return []; - } - - sourceItems = []; - foreach (var element in sourceArray) - { - var combined = new JObject(item.Properties()) - { - [subquery.FromAlias] = element - }; - sourceItems.Add(combined); - } - } - else if (subquery.Join is not null) - { - // Correlated subquery with JOIN — expand the join array from the parent item - var arrayPath = subquery.Join.ArrayField; - var sourceAlias = subquery.Join.SourceAlias; - JToken arrayToken; - - if (sourceAlias.Equals(fromAlias, StringComparison.OrdinalIgnoreCase) || - sourceAlias.Equals(subquery.FromAlias, StringComparison.OrdinalIgnoreCase)) - { - arrayToken = item.SelectToken(arrayPath); - } - else - { - arrayToken = item.SelectToken($"{sourceAlias}.{arrayPath}"); - } - - if (arrayToken is not JArray jArray) - { - return []; - } - - sourceItems = []; - foreach (var element in jArray) - { - var combined = new JObject(item.Properties()) - { - [subquery.Join.Alias] = element - }; - sourceItems.Add(combined); - } - } - else - { - // Non-correlated — treat the current item as the sole source - sourceItems = [item]; - } - - // Apply WHERE filter - if (subquery.Where is not null) - { - sourceItems = sourceItems.Where(sourceItem => - EvaluateWhereExpression(subquery.Where, sourceItem, subquery.FromAlias, parameters, subquery.Join) - ).ToList(); - } - - // Apply SELECT projection - // Check if any SELECT field contains an aggregate function — if so, collapse into a single row - var hasAggregateSelect = !subquery.IsSelectAll && - subquery.SelectFields.Any(f => f.SqlExpr is not null && ContainsAggregateCall(f.SqlExpr)); - - var results = new List(); - if (hasAggregateSelect) - { - // Aggregate subquery: compute aggregates over all sourceItems and return one row - var projected = new JObject(); - foreach (var field in subquery.SelectFields) - { - var outputName = field.Alias ?? field.Expression.Split('.').Last(); - if (field.SqlExpr is FunctionCallExpression func && AggregateFunctions.Contains(func.FunctionName)) - { - var aggValue = EvaluateSubqueryAggregate(func, sourceItems, subquery.FromAlias, parameters); - if (aggValue is not null and not UndefinedValue) - projected[outputName] = JToken.FromObject(aggValue); - } - else if (field.SqlExpr is not null) - { - // Non-aggregate expression in an aggregate query — evaluate against first item - if (sourceItems.Count > 0) - { - var value = EvaluateSqlExpression(field.SqlExpr, sourceItems[0], subquery.FromAlias, parameters); - if (value is not null and not UndefinedValue) - projected[outputName] = JToken.FromObject(value); - } - } - } - - if (subquery.IsValueSelect) - { - var first = projected.Properties().FirstOrDefault(); - if (first?.Value is not null) - { - results.Add(first.Value.Type == JTokenType.String - ? JsonConvert.SerializeObject(first.Value.Value()) - : first.Value.ToString(Formatting.None)); - } - } - else - { - results.Add(projected.ToString(Formatting.None)); - } - } - else - { - foreach (var sourceItem in sourceItems) - { - if (subquery.IsSelectAll) - { - results.Add(sourceItem.ToString(Formatting.None)); - } - else - { - var projected = new JObject(); - foreach (var field in subquery.SelectFields) - { - var outputName = field.Alias ?? field.Expression.Split('.').Last(); - if (field.SqlExpr is not null and not IdentifierExpression) - { - var value = EvaluateSqlExpression(field.SqlExpr, sourceItem, subquery.FromAlias, parameters); - if (value is not UndefinedValue) - projected[outputName] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - } - else - { - var path = field.Expression; - if (string.Equals(path, subquery.FromAlias, StringComparison.OrdinalIgnoreCase)) - { - // For range variables (FROM t IN c.tags), use the aliased element - var aliasToken = sourceItem[subquery.FromAlias]; - projected[outputName] = aliasToken is not null - ? aliasToken.DeepClone() - : sourceItem.DeepClone(); - continue; - } - if (path.StartsWith(subquery.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(subquery.FromAlias.Length + 1)..]; - } - var token = sourceItem.SelectToken(path); - outputName = field.Alias ?? path.Split('.').Last(); - projected[outputName] = token?.DeepClone(); - } - } - - if (subquery.IsValueSelect) - { - var first = projected.Properties().FirstOrDefault(); - if (first?.Value is not null) - { - results.Add(first.Value.Type == JTokenType.String - ? JsonConvert.SerializeObject(first.Value.Value()) - : first.Value.ToString(Formatting.None)); - } - } - else - { - results.Add(projected.ToString(Formatting.None)); - } - } - } - } // end else (non-aggregate) - - // Apply DISTINCT - if (subquery.IsDistinct) - { - results = results.Distinct(JsonStructuralStringComparer.Instance).ToList(); - } - - // Apply ORDER BY (must happen before TOP so TOP takes from sorted results) - if (subquery.OrderByFields is { Length: > 0 }) - { - IOrderedEnumerable ordered = null; - foreach (var field in subquery.OrderByFields) - { - var fieldPath = field.Field; - if (fieldPath.StartsWith(subquery.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) - fieldPath = fieldPath[(subquery.FromAlias.Length + 1)..]; - - object KeySelector(string json) - { - var token = JsonParseHelpers.ParseJsonToken(json); - // For SELECT VALUE results, the token is the scalar value itself - if (token is not JObject obj) - { - // If the ORDER BY field matches the FROM alias, use the raw value - return token.Type switch - { - JTokenType.Integer => token.Value(), - JTokenType.Float => token.Value(), - _ => (object)token.ToString() - }; - } - var selected = obj.SelectToken(fieldPath); - if (selected is null) return null; - return selected.Type switch - { - JTokenType.Integer => selected.Value(), - JTokenType.Float => selected.Value(), - _ => (object)selected.ToString() - }; - } - - var asc = field.Ascending; - var comparer = Comparer.Create((l, r) => - { - var result = CompareValues(l, r); - return asc ? result : -result; - }); - ordered = ordered == null ? results.OrderBy(KeySelector, comparer) : ordered.ThenBy(KeySelector, comparer); - } - results = ordered?.ToList() ?? results; - } - - // Apply TOP (after ORDER BY so we take from sorted results) - if (subquery.TopCount.HasValue) - { - results = results.Take(subquery.TopCount.Value).ToList(); - } - - // Apply OFFSET - if (subquery.Offset.HasValue) - { - results = results.Skip(subquery.Offset.Value).ToList(); - } - - // Apply LIMIT - if (subquery.Limit.HasValue) - { - results = results.Take(subquery.Limit.Value).ToList(); - } - - return results; - } - - private static object ArithmeticOp(object left, object right, Func op) - { - if (left is null or UndefinedValue || right is null or UndefinedValue) - { - return UndefinedValue.Instance; - } - - if (double.TryParse(left.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && - double.TryParse(right.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) - { - var result = op(l, r); - // Cosmos DB treats NaN and Infinity as undefined - if (double.IsNaN(result) || double.IsInfinity(result)) - return UndefinedValue.Instance; - // Only convert back to long when both operands were integers and the result - // fits within long range. We use strict < for MaxValue because (double)long.MaxValue - // rounds up beyond the actual max, causing (long) cast to overflow. - if (left is long or int && right is long or int && result == Math.Floor(result) - && result >= long.MinValue && result < 9.2233720368547758E+18) - { - return (long)result; - } - - return result; - } - - return null; - } - - private static object BitwiseOp(object left, object right, Func op) - { - if (left is null or UndefinedValue || right is null or UndefinedValue) - { - return UndefinedValue.Instance; - } - - if (long.TryParse(left.ToString(), out var l) && long.TryParse(right.ToString(), out var r)) - { - return op(l, r); - } - - return UndefinedValue.Instance; - } - - private static object BitwiseShiftOp(object left, object right, bool isLeft) - { - if (left is null or UndefinedValue || right is null or UndefinedValue) - { - return UndefinedValue.Instance; - } - - if (long.TryParse(left.ToString(), out var l) && long.TryParse(right.ToString(), out var r)) - { - if (r < 0 || r >= 64) return UndefinedValue.Instance; - return isLeft ? l << (int)r : l >> (int)r; - } - - return UndefinedValue.Instance; - } - - private static object MathOp(object value, Func op) - { - if (value is null or UndefinedValue) - { - return UndefinedValue.Instance; - } - - var isIntegerInput = value is long or int; - - if (double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) - { - var result = op(n); - // Cosmos DB treats NaN and Infinity as undefined - if (double.IsNaN(result) || double.IsInfinity(result)) - return UndefinedValue.Instance; - // Preserve integer type when input was integer and result is a whole number - if (isIntegerInput && result == Math.Floor(result) && result is >= long.MinValue and <= long.MaxValue) - return (long)result; - return result; - } - - return UndefinedValue.Instance; - } - - private static long? ToLong(object value) => value switch - { - long l => l, - double d => (long)d, - int i => i, - _ when value != null && long.TryParse(value.ToString(), out var p) => p, - _ => null - }; - - private static long FloorToUnit(DateTime dt, long ticksPerUnit) - => dt.Ticks / ticksPerUnit; - - private static JToken ResolveTokenType(SqlExpression[] arguments, JObject item, string fromAlias) - { - if (arguments.Length < 1) - { - return null; - } - - if (arguments[0] is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - return item.SelectToken(path); - } - return null; - } - - private static JArray ResolveJArray( - SqlExpression argument, JObject item, string fromAlias, IDictionary parameters) - { - if (argument is IdentifierExpression ident) - { - var path = ident.Name; - if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) - { - path = path[(fromAlias.Length + 1)..]; - } - - return item.SelectToken(path) as JArray; - } - - var evaluated = EvaluateSqlExpression(argument, item, fromAlias, parameters); - return evaluated as JArray; - } - - private static bool ArrayContainsMatch(JArray jArray, string searchStr, bool partial) - { - foreach (var element in jArray) - { - if (element.Type == JTokenType.Object && searchStr.StartsWith("{")) - { - var searchObj = JsonParseHelpers.ParseJson(searchStr); - if (partial) - { - var allMatch = searchObj.Properties().All(prop => - { - var elementValue = element[prop.Name]; - return elementValue is not null && JToken.DeepEquals(elementValue, prop.Value); - }); - if (allMatch) - { - return true; - } - } - else if (JToken.DeepEquals(element, searchObj)) - { - return true; - } - } - else if (string.Equals(element.ToString(), searchStr, StringComparison.Ordinal)) - { - return true; - } - } - return false; - } - - private static bool ArrayContainsMatchJObject(JArray jArray, JObject searchObj, bool partial) - { - foreach (var element in jArray) - { - if (element.Type != JTokenType.Object) - { - continue; - } - - if (partial) - { - var allMatch = searchObj.Properties().All(prop => - { - var elementValue = element[prop.Name]; - return elementValue is not null && JToken.DeepEquals(elementValue, prop.Value); - }); - if (allMatch) - { - return true; - } - } - else if (JToken.DeepEquals(element, searchObj)) - { - return true; - } - } - return false; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Patch operations - // ═══════════════════════════════════════════════════════════════════════════ - - private static readonly HashSet SystemProperties = new(StringComparer.OrdinalIgnoreCase) - { "/_ts", "/_etag", "/_rid", "/_self", "/_attachments" }; - - private static void ApplyPatchOperations(JObject jObj, IReadOnlyList patchOperations) - { - foreach (var operation in patchOperations) - { - var path = GetPatchPath(operation); - - // Reject patches to system-generated properties - if (SystemProperties.Contains(path)) - { - throw InMemoryCosmosException.Create( - $"Cannot patch system property '{path}'.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - var segments = path.TrimStart('/').Split('/'); - var propertyName = segments.Last(); - var parentPath = BuildSelectTokenPath(segments.Take(segments.Length - 1)); - var rawParent = segments.Length > 1 ? jObj.SelectToken(parentPath) : (JToken)jObj; - - switch (operation.OperationType) - { - case PatchOperationType.Add: - { - var value = GetPatchValue(operation); - var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - - if (propertyName == "-" && rawParent is JArray appendArray) - { - appendArray.Add(newToken); - } - else if (int.TryParse(propertyName, out var insertIdx) && rawParent is JArray insertArray) - { - if (insertIdx < 0 || insertIdx > insertArray.Count) - { - throw InMemoryCosmosException.Create("Array index out of bounds.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - insertArray.Insert(insertIdx, newToken); - } - else - { - var parent = rawParent as JObject ?? jObj; - parent[propertyName] = newToken; - } - - break; - } - case PatchOperationType.Set: - { - var value = GetPatchValue(operation); - var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) - { - if (idx < 0 || idx >= arr.Count) - { - throw InMemoryCosmosException.Create("Array index out of bounds.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - arr[idx] = newToken; - } - else - { - var parent = rawParent as JObject ?? jObj; - parent[propertyName] = newToken; - } - break; - } - case PatchOperationType.Replace: - { - var value = GetPatchValue(operation); - var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); - if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) - { - if (idx < 0 || idx >= arr.Count) - { - throw InMemoryCosmosException.Create("Array index out of bounds.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - arr[idx] = newToken; - } - else - { - var parent = rawParent as JObject ?? jObj; - if (parent[propertyName] is null) - { - throw InMemoryCosmosException.Create("Replace target does not exist.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - parent[propertyName] = newToken; - } - break; - } - case PatchOperationType.Remove: - { - if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) - { - if (idx < 0 || idx >= arr.Count) - { - throw InMemoryCosmosException.Create("Array index out of bounds.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - arr.RemoveAt(idx); - } - else - { - var parent = rawParent as JObject ?? jObj; - if (parent[propertyName] is null) - { - throw InMemoryCosmosException.Create("Remove target does not exist.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - parent.Remove(propertyName); - } - break; - } - case PatchOperationType.Move: - { - var sourcePath = GetPatchSourcePath(operation); - if (sourcePath is not null) - { - // Reject when destination is a child of source (e.g. move /nested → /nested/child) - if (path.StartsWith(sourcePath + "/", StringComparison.Ordinal)) - { - throw InMemoryCosmosException.Create( - "The 'path' attribute can't be a JSON child of the 'from' JSON location.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - - var sourceSegments = sourcePath.TrimStart('/').Split('/'); - var sourcePropertyName = sourceSegments.Last(); - var sourceParentPath = BuildSelectTokenPath(sourceSegments.Take(sourceSegments.Length - 1)); - var sourceParent = sourceSegments.Length > 1 - ? jObj.SelectToken(sourceParentPath) as JObject ?? jObj - : jObj; - var sourceValue = sourceParent[sourcePropertyName]; - if (sourceValue is null) - { - throw InMemoryCosmosException.Create("Move source does not exist.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - sourceParent.Remove(sourcePropertyName); - var parent = rawParent as JObject ?? jObj; - parent[propertyName] = sourceValue; - } - - break; - } - case PatchOperationType.Increment: - { - var incrementValue = GetPatchValue(operation); - if (incrementValue is not null) - { - var parent = rawParent as JObject ?? jObj; - var existingToken = parent[propertyName]; - if (existingToken is not null) - { - if (existingToken.Type is not (JTokenType.Integer or JTokenType.Float)) - { - throw InMemoryCosmosException.Create( - $"Cannot increment non-numeric field '{path}'. Field type is {existingToken.Type}.", - HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); - } - var existingDouble = existingToken.Value(); - var incrementDouble = Convert.ToDouble(incrementValue); - var result = existingDouble + incrementDouble; - if (existingToken.Type == JTokenType.Integer && result == Math.Floor(result)) - { - parent[propertyName] = (long)result; - } - else - { - parent[propertyName] = result; - } - } - else - { - var incrementDouble = Convert.ToDouble(incrementValue); - if (incrementDouble == Math.Floor(incrementDouble)) - { - parent[propertyName] = (long)incrementDouble; - } - else - { - parent[propertyName] = incrementDouble; - } - } - } - break; - } - } - } - } - - private static string GetPatchPath(PatchOperation operation) => operation.Path; - - /// - /// Converts path segments to a Newtonsoft.Json SelectToken-compatible path. - /// Numeric segments become array indexers (e.g., ["runs","0"] → "runs[0]"). - /// - internal static string BuildSelectTokenPath(IEnumerable segments) - { - var sb = new System.Text.StringBuilder(); - foreach (var segment in segments) - { - if (int.TryParse(segment, out _)) - { - sb.Append('[').Append(segment).Append(']'); - } - else - { - if (sb.Length > 0) sb.Append('.'); - sb.Append(segment); - } - } - return sb.ToString(); - } - - private static string GetPatchSourcePath(PatchOperation operation) - { - var fromProp = operation.GetType() - .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - return fromProp?.GetValue(operation)?.ToString(); - } - - private static object GetPatchValue(PatchOperation operation) - { - var valueProp = operation.GetType() - .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); - return valueProp?.GetValue(operation); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Stream FeedIterator factory - // ═══════════════════════════════════════════════════════════════════════════ - - private FeedIterator CreateStreamFeedIterator(List items, int initialOffset = 0, int? maxItemCount = null) - { - return CreateStreamFeedIteratorFromFactory(() => items, initialOffset, maxItemCount ?? items.Count); - } - - private FeedIterator CreateStreamFeedIterator(Func> itemsFactory) - { - return CreateStreamFeedIteratorFromFactory(() => itemsFactory().Select(o => o?.ToString() ?? "").ToList(), 0, null); - } - - private FeedIterator CreateStreamFeedIteratorFromFactory(Func> itemsFactory, int initialOffset, int? maxItemCount) - { - var offset = initialOffset; - var done = false; - - var feedIterator = Substitute.For(); - feedIterator.HasMoreResults.Returns(_ => !done); - feedIterator.ReadNextAsync(Arg.Any()).Returns(callInfo => - { - var ct = callInfo.Arg(); - ct.ThrowIfCancellationRequested(); - var items = itemsFactory(); - var pageSize = maxItemCount ?? items.Count; - if (pageSize <= 0) pageSize = items.Count; - var page = items.Skip(offset).Take(pageSize).ToList(); - offset += page.Count; - if (offset >= items.Count) - done = true; - var documentsArray = new JArray(page.Select(JsonParseHelpers.ParseJson)); - var envelope = new JObject - { - ["Documents"] = documentsArray, - ["_count"] = documentsArray.Count, - ["_rid"] = string.Empty - }; - var stream = ToStream(envelope.ToString(Formatting.None)); - var response = new ResponseMessage(HttpStatusCode.OK) { Content = stream }; - response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - response.Headers["x-ms-request-charge"] = "1"; - response.Headers["x-ms-session-token"] = CurrentSessionToken; - response.Headers["x-ms-item-count"] = documentsArray.Count.ToString(); - if (!done) - response.Headers.Add("x-ms-continuation", offset.ToString()); - return Task.FromResult(response); - }); - return feedIterator; - } - - - - // ═══════════════════════════════════════════════════════════════════════════ - // InMemoryFeedResponse (for ReadManyItemsAsync) - // ═══════════════════════════════════════════════════════════════════════════ - - private sealed class InMemoryFeedResponse : FeedResponse - { - private readonly IReadOnlyList _items; - private readonly HttpStatusCode _statusCode; - private readonly string _etag; - - public InMemoryFeedResponse(IReadOnlyList items, HttpStatusCode statusCode = HttpStatusCode.OK, string etag = null) - { - _items = items; - _statusCode = statusCode; - _etag = etag; - Headers["x-ms-request-charge"] = SyntheticRequestCharge.ToString(); - Headers["x-ms-item-count"] = items.Count.ToString(); - } - public override Headers Headers { get; } = new(); - public override IEnumerable Resource => _items; - public override HttpStatusCode StatusCode => _statusCode; - public override CosmosDiagnostics Diagnostics => FakeDiagnostics; - public override int Count => _items.Count; - public override string IndexMetrics => null; - public override string ContinuationToken => null; - public override double RequestCharge => SyntheticRequestCharge; - public override string ActivityId { get; } = Guid.NewGuid().ToString(); - public override string ETag => _etag; - public override IEnumerator GetEnumerator() => _items.GetEnumerator(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Vector distance helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static object VectorDistanceFunc(object[] args) - { - // VECTORDISTANCE(vector1, vector2 [, bool_bruteForce] [, {distanceFunction:'cosine'|'dotproduct'|'euclidean'}) - if (args.Length > 4) - throw InMemoryCosmosException.Create("VECTORDISTANCE accepts at most 4 arguments.", HttpStatusCode.BadRequest, 0, string.Empty, 0); - - var vec1 = ToDoubleArray(args[0]); - var vec2 = ToDoubleArray(args[1]); - if (vec1 is null || vec2 is null || vec1.Length != vec2.Length || vec1.Length == 0) - return null; - - // 3rd arg (bool bruteForce) is accepted but ignored in the emulator - // 4th arg (object options) may contain distanceFunction override - var distanceFunction = "cosine"; - if (args.Length > 3) - { - var options = args[3] switch - { - JObject jo => jo, - string s when s.TrimStart().StartsWith("{") => JObject.Parse(s), - string s => new JObject { ["distanceFunction"] = s }, - _ => null, - }; - var df = options?["distanceFunction"]?.ToString(); - if (df is not null) distanceFunction = df; - } - - var result = distanceFunction.ToLowerInvariant() switch - { - "cosine" => CosineSimilarity(vec1, vec2), - "dotproduct" => (object)DotProduct(vec1, vec2), - "euclidean" => (object)EuclideanDistance(vec1, vec2), - _ => throw InMemoryCosmosException.Create($"Unknown distanceFunction '{distanceFunction}'. Supported values: 'cosine', 'dotproduct', 'euclidean'.", HttpStatusCode.BadRequest, 0, string.Empty, 0), - }; - - // Guard against Infinity/NaN which are not valid JSON numbers - if (result is double d && (double.IsInfinity(d) || double.IsNaN(d))) - return null; - - return result; - } - - private static object CosineSimilarity(double[] a, double[] b) - { - double dot = 0, magA = 0, magB = 0; - for (var i = 0; i < a.Length; i++) - { - dot += a[i] * b[i]; - magA += a[i] * a[i]; - magB += b[i] * b[i]; - } - var denominator = Math.Sqrt(magA) * Math.Sqrt(magB); - return denominator == 0 ? null : (object)(dot / denominator); - } - - private static double DotProduct(double[] a, double[] b) - { - double sum = 0; - for (var i = 0; i < a.Length; i++) - sum += a[i] * b[i]; - return sum; - } - - private static double EuclideanDistance(double[] a, double[] b) - { - double sum = 0; - for (var i = 0; i < a.Length; i++) - { - var diff = a[i] - b[i]; - sum += diff * diff; - } - return Math.Sqrt(sum); - } - - private static double[] ToDoubleArray(object value) - { - if (value is double[] dArr) return dArr; - if (value is float[] fArr) return Array.ConvertAll(fArr, f => (double)f); - if (value is IEnumerable dEnum) return dEnum.ToArray(); - - JArray ja = value switch - { - JArray arr => arr, - string s when s.TrimStart().StartsWith("[") => JArray.Parse(s), - _ => null, - }; - if (ja is null) return null; - - var result = new double[ja.Count]; - for (var i = 0; i < ja.Count; i++) - { - if (ja[i].Type is not (JTokenType.Float or JTokenType.Integer)) - return null; - result[i] = ja[i].Value(); - } - return result; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Spatial function helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static object StDistance(object left, object right) - { - var p1 = ExtractPoint(left); - var p2 = ExtractPoint(right); - if (p1 is null || p2 is null) - { - return null; - } - - return HaversineDistanceMeters(p1.Value.Lat, p1.Value.Lon, p2.Value.Lat, p2.Value.Lon); - } - - private static bool StWithin(object point, object region) - { - var p = ExtractPoint(point); - if (p is null) - { - return false; - } - - var polygon = ExtractPolygonRings(region); - if (polygon is not null) - { - return PointInPolygon(p.Value, polygon); - } - - var circle = ExtractCircle(region); - if (circle is not null) - { - var dist = HaversineDistanceMeters(p.Value.Lat, p.Value.Lon, circle.Value.Center.Lat, circle.Value.Center.Lon); - return dist <= circle.Value.RadiusMeters; - } - - return false; - } - - private static bool StIntersects(object geo1, object geo2) - { - var p1 = ExtractPoint(geo1); - var p2 = ExtractPoint(geo2); - - if (p1 is not null && p2 is not null) - { - return Math.Abs(p1.Value.Lat - p2.Value.Lat) < 1e-10 && - Math.Abs(p1.Value.Lon - p2.Value.Lon) < 1e-10; - } - - if (p1 is not null) - { - return StWithin(geo1, geo2); - } - - if (p2 is not null) - { - return StWithin(geo2, geo1); - } - - var poly1 = ExtractPolygonRings(geo1); - var poly2 = ExtractPolygonRings(geo2); - if (poly1 is not null && poly2 is not null) - { - return PolygonsShareAnyPoint(poly1, poly2); - } - - return false; - } - - private static bool StIsValid(object geo) - { - var obj = AsJObject(geo); - if (obj is null) - { - return false; - } - - var type = obj["type"]?.ToString(); - return type switch - { - "Point" => ExtractPoint(geo) is not null, - "Polygon" => ExtractPolygonRings(geo) is not null && ValidatePolygonRings(ExtractPolygonRings(geo)), - "LineString" => ExtractCoordinateArray(obj["coordinates"]) is { Count: >= 2 }, - "MultiPoint" => ExtractCoordinateArray(obj["coordinates"]) is { Count: >= 1 }, - _ => false - }; - } - - private static JObject StIsValidDetailed(object geo) - { - var obj = AsJObject(geo); - if (obj is null) - { - return JObject.FromObject(new { valid = false, reason = "Not a valid GeoJSON object." }); - } - - var type = obj["type"]?.ToString(); - if (string.IsNullOrEmpty(type)) - { - return JObject.FromObject(new { valid = false, reason = "GeoJSON object missing 'type' property." }); - } - - switch (type) - { - case "Point": - if (ExtractPoint(geo) is null) - { - return JObject.FromObject(new { valid = false, reason = "Point must have coordinates [longitude, latitude]." }); - } - - return JObject.FromObject(new { valid = true, reason = "" }); - - case "Polygon": - var rings = ExtractPolygonRings(geo); - if (rings is null) - { - return JObject.FromObject(new { valid = false, reason = "Polygon coordinates must be an array of linear rings." }); - } - - if (!ValidatePolygonRings(rings)) - { - return JObject.FromObject(new { valid = false, reason = "Polygon rings must be closed (first and last position must be identical) and have at least 4 positions." }); - } - - return JObject.FromObject(new { valid = true, reason = "" }); - - case "LineString": - if (ExtractCoordinateArray(obj["coordinates"]) is not { Count: >= 2 }) - { - return JObject.FromObject(new { valid = false, reason = "LineString must have at least 2 positions." }); - } - - return JObject.FromObject(new { valid = true, reason = "" }); - - default: - return JObject.FromObject(new { valid = false, reason = $"Unsupported GeoJSON type: {type}." }); - } - } - - private readonly record struct GeoPoint(double Lat, double Lon); - - private readonly record struct GeoCircle(GeoPoint Center, double RadiusMeters); - - private static object StArea(object geo) - { - var rings = ExtractPolygonRings(geo); - if (rings is null || rings.Count == 0) return null; - // Approximate area using spherical excess formula for the outer ring, minus holes - var area = SphericalPolygonArea(rings[0]); - for (var i = 1; i < rings.Count; i++) - area -= SphericalPolygonArea(rings[i]); - return Math.Abs(area); - } - - private static double SphericalPolygonArea(List ring) - { - // Spherical excess method (Girard's theorem) for area on a sphere - double sum = 0; - for (var i = 0; i < ring.Count - 1; i++) - { - var lon1 = ring[i].Lon * Math.PI / 180; - var lat1 = ring[i].Lat * Math.PI / 180; - var lon2 = ring[(i + 1) % (ring.Count - 1)].Lon * Math.PI / 180; - var lat2 = ring[(i + 1) % (ring.Count - 1)].Lat * Math.PI / 180; - sum += (lon2 - lon1) * (2 + Math.Sin(lat1) + Math.Sin(lat2)); - } - return Math.Abs(sum * EarthRadiusMeters * EarthRadiusMeters / 2.0); - } - - private static JObject AsJObject(object value) - { - return value switch - { - JObject jo => jo, - string s when s.TrimStart().StartsWith("{") => JsonParseHelpers.ParseJson(s), - _ => null - }; - } - - private static GeoPoint? ExtractPoint(object value) - { - var obj = AsJObject(value); - if (obj is null) - { - return null; - } - - if (!string.Equals(obj["type"]?.ToString(), "Point", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - if (obj["coordinates"] is not JArray coords || coords.Count < 2) - { - return null; - } - - var lon = coords[0].Value(); - var lat = coords[1].Value(); - if (lat < -90 || lat > 90 || lon < -180 || lon > 180) - { - return null; - } - - return new GeoPoint(lat, lon); - } - - private static List> ExtractPolygonRings(object value) - { - var obj = AsJObject(value); - if (obj is null) - { - return null; - } - - if (!string.Equals(obj["type"]?.ToString(), "Polygon", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - if (obj["coordinates"] is not JArray coords || coords.Count == 0) - { - return null; - } - - var rings = new List>(); - foreach (var ring in coords) - { - var points = ExtractCoordinateArray(ring); - if (points is null || points.Count < 4) - { - return null; - } - - rings.Add(points); - } - - return rings; - } - - private static GeoCircle? ExtractCircle(object value) - { - var obj = AsJObject(value); - if (obj is null) - { - return null; - } - - var center = obj["center"]; - var radius = obj["radius"]; - if (center is null || radius is null) - { - return null; - } - - var centerPoint = ExtractPoint(center); - if (centerPoint is null) - { - return null; - } - - if (!double.TryParse(radius.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var radiusMeters)) - { - return null; - } - - return new GeoCircle(centerPoint.Value, radiusMeters); - } - - private static List ExtractCoordinateArray(JToken token) - { - if (token is not JArray arr) - { - return null; - } - - var points = new List(); - foreach (var item in arr) - { - if (item is JArray coord && coord.Count >= 2) - { - var lon = coord[0].Value(); - var lat = coord[1].Value(); - points.Add(new GeoPoint(lat, lon)); - } - else - { - return null; - } - } - - return points; - } - - private static bool ValidatePolygonRings(List> rings) - { - foreach (var ring in rings) - { - if (ring.Count < 4) - { - return false; - } - - var first = ring[0]; - var last = ring[^1]; - if (Math.Abs(first.Lat - last.Lat) > 1e-10 || Math.Abs(first.Lon - last.Lon) > 1e-10) - { - return false; - } - } - - return true; - } - - private static double HaversineDistanceMeters(double lat1, double lon1, double lat2, double lon2) - { - var dLat = DegreesToRadians(lat2 - lat1); - var dLon = DegreesToRadians(lon2 - lon1); - var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + - Math.Cos(DegreesToRadians(lat1)) * Math.Cos(DegreesToRadians(lat2)) * - Math.Sin(dLon / 2) * Math.Sin(dLon / 2); - var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); - return EarthRadiusMeters * c; - } - - private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180.0); - - private static bool PointInPolygon(GeoPoint point, List> rings) - { - if (rings.Count == 0) - { - return false; - } - - var inOuter = PointInRing(point, rings[0]); - if (!inOuter) - { - return false; - } - - for (var i = 1; i < rings.Count; i++) - { - if (PointInRing(point, rings[i])) - { - return false; - } - } - - return true; - } - - private static bool PointInRing(GeoPoint point, List ring) - { - var inside = false; - for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++) - { - if ((ring[i].Lat > point.Lat) != (ring[j].Lat > point.Lat) && - point.Lon < (ring[j].Lon - ring[i].Lon) * (point.Lat - ring[i].Lat) / (ring[j].Lat - ring[i].Lat) + ring[i].Lon) - { - inside = !inside; - } - } - - return inside; - } - - private static bool PolygonsShareAnyPoint(List> poly1, List> poly2) - { - foreach (var point in poly1[0]) - { - if (PointInPolygon(point, poly2)) - { - return true; - } - } - - foreach (var point in poly2[0]) - { - if (PointInPolygon(point, poly1)) - { - return true; - } - } - - return false; - } + private static readonly JsonSerializerSettings JsonSettings = new() + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + ContractResolver = new DefaultContractResolver(), + Converters = { new StringEnumConverter { AllowIntegerValues = true } } + }; + + private static readonly HashSet AggregateFunctions = + new(StringComparer.OrdinalIgnoreCase) { "COUNT", "COUNTIF", "SUM", "AVG", "MIN", "MAX" }; + + /// + /// Recursively checks whether a SqlExpression tree contains any aggregate function call + /// (COUNT, COUNTIF, SUM, AVG, MIN, MAX). Used to detect aggregates inside object/array literals. + /// + private static bool ContainsAggregateCall(SqlExpression expr) => expr switch + { + FunctionCallExpression func => AggregateFunctions.Contains(func.FunctionName), + ObjectLiteralExpression obj => obj.Properties.Any(p => ContainsAggregateCall(p.Value)), + ArrayLiteralExpression arr => arr.Elements.Any(ContainsAggregateCall), + BinaryExpression bin => ContainsAggregateCall(bin.Left) || ContainsAggregateCall(bin.Right), + UnaryExpression unary => ContainsAggregateCall(unary.Operand), + TernaryExpression tern => ContainsAggregateCall(tern.Condition) || ContainsAggregateCall(tern.IfTrue) || ContainsAggregateCall(tern.IfFalse), + _ => false + }; + + private const int RegexCacheMaxSize = 256; + private static readonly ConcurrentDictionary<(string Pattern, RegexOptions Options), Regex> RegexCache = new(); + + private const int MaxDocumentSizeBytes = 2 * 1024 * 1024; + private const double SyntheticRequestCharge = 1.0; + private const double EarthRadiusMeters = 6_371_000.0; + + private static long _etagCounter; + private static int _docRidCounter; + + private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _items = new(); + private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _etags = new(); + private readonly ConcurrentDictionary<(string Id, string PartitionKey), DateTimeOffset> _timestamps = new(); + // Ref: Observed behavior on Windows Cosmos DB Emulator — documents return in + // insertion order when no ORDER BY is applied. Maintains creation-time ordering + // so GetAllItemsForPartition can enumerate deterministically. + private readonly List<(string Id, string PartitionKey)> _insertionOrder = new(); + private readonly object _insertionOrderLock = new(); + private readonly List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> _changeFeed = new(); + private readonly object _changeFeedLock = new(); + private long _changeFeedLsnCounter; + private long _sessionSequence; + private readonly object _uniqueKeyWriteLock = new(); + private readonly ConcurrentDictionary<(string Id, string PartitionKey), SemaphoreSlim> _itemLocks = new(); + private static readonly AsyncLocal> BatchWriteTracker = new(); + internal int _throughput = 400; + internal bool _isDeleted; + + private bool HasUniqueKeys => + _containerProperties.UniqueKeyPolicy?.UniqueKeys.Count > 0; + private ContainerProperties _containerProperties; + private readonly Scripts _scripts; + private readonly Dictionary> _userDefinedFunctions = new(StringComparer.Ordinal); + private static readonly Func UdfPlaceholder = _ => null; + private readonly Dictionary> _storedProcedures = new(StringComparer.Ordinal); + private readonly Dictionary _storedProcedureProperties = new(StringComparer.Ordinal); + private readonly Dictionary _triggers = new(StringComparer.Ordinal); + private readonly Dictionary _udfProperties = new(StringComparer.Ordinal); + private readonly Dictionary _triggerProperties = new(StringComparer.Ordinal); + private volatile (string Name, string FromAlias, SqlExpression Expr)[] _parsedComputedProperties; + + /// + /// Optional JavaScript trigger engine. When set, triggers that have a + /// but no C# handler will be executed via this engine. Set by calling UseJsTriggers() + /// from the CosmosDB.InMemoryEmulator.JsTriggers package. + /// + public IJsTriggerEngine JsTriggerEngine { get; set; } + + /// + /// Optional JavaScript stored procedure engine. When set, stored procedures that have a + /// but no C# handler will be executed via this engine. + /// Set by calling UseJsStoredProcedures() from the CosmosDB.InMemoryEmulator.JsTriggers package. + /// + public ISprocEngine SprocEngine { get; set; } + + private sealed record RegisteredTrigger( + TriggerType TriggerType, + TriggerOperation TriggerOperation, + Func PreHandler, + Action PostHandler); + + private sealed class UndefinedValue + { + public static readonly UndefinedValue Instance = new(); + public override string ToString() => null; + private UndefinedValue() { } + } + + /// + /// Type-aware equality comparer for JToken values, matching Cosmos DB semantics. + /// Different types (number vs string) are never equal, even if their string representations match. + /// + private sealed class JTokenValueComparer : IEqualityComparer + { + public static readonly JTokenValueComparer Instance = new(); + + public bool Equals(JToken x, JToken y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return x is null && y is null; + return JToken.DeepEquals(x, y); + } + + public int GetHashCode(JToken obj) + { + if (obj is null) return 0; + return obj.Type switch + { + JTokenType.Integer => HashCode.Combine(obj.Type, obj.Value()), + JTokenType.Float => HashCode.Combine(obj.Type, obj.Value()), + JTokenType.String => HashCode.Combine(obj.Type, obj.Value()?.GetHashCode() ?? 0), + JTokenType.Boolean => HashCode.Combine(obj.Type, obj.Value()), + JTokenType.Null => HashCode.Combine(obj.Type), + JTokenType.Object => HashObjectOrderInsensitive((JObject)obj), + JTokenType.Array => HashArrayElements((JArray)obj), + _ => HashCode.Combine(obj.Type, obj.ToString().GetHashCode()) + }; + } + + private static int HashObjectOrderInsensitive(JObject obj) + { + int hash = 0; + foreach (var prop in obj.Properties()) + { + hash ^= HashCode.Combine(prop.Name.GetHashCode(), Instance.GetHashCode(prop.Value)); + } + return HashCode.Combine(JTokenType.Object, hash); + } + + private static int HashArrayElements(JArray arr) + { + var hash = new HashCode(); + hash.Add(JTokenType.Array); + foreach (var element in arr) + { + hash.Add(Instance.GetHashCode(element)); + } + return hash.ToHashCode(); + } + } + + /// + /// Compares JSON strings using structural equality (property-order-insensitive). + /// Used by DISTINCT to deduplicate projected results where objects may have + /// identical values but different property ordering (e.g. after Patch operations). + /// + private sealed class JsonStructuralStringComparer : IEqualityComparer + { + public static readonly JsonStructuralStringComparer Instance = new(); + + public bool Equals(string x, string y) + { + if (x == y) return true; + if (x is null || y is null) return false; + try { return JToken.DeepEquals(JToken.Parse(x), JToken.Parse(y)); } + catch { return false; } + } + + public int GetHashCode(string obj) + { + if (obj is null) return 0; + try { return JTokenValueComparer.Instance.GetHashCode(JToken.Parse(obj)); } + catch { return obj.GetHashCode(); } + } + } + + internal Action OnDeleted { get; set; } + + /// + /// Creates a new with a single partition key path. + /// + /// The container identifier. Defaults to "in-memory-container". + /// The JSON path to the partition key field (e.g. /partitionKey). Defaults to /id. + public InMemoryContainer(string id = "in-memory-container", string partitionKeyPath = "/id") + { + Id = id; + _containerProperties = new ContainerProperties(id, partitionKeyPath); + PartitionKeyPaths = new[] { partitionKeyPath }; + _scripts = new InMemoryScripts(this); + } + + /// + /// Creates a new with composite (hierarchical) partition key paths. + /// + /// The container identifier. + /// One or more JSON paths for the composite partition key. + public InMemoryContainer(string id, IReadOnlyList partitionKeyPaths) + { + Id = id; + PartitionKeyPaths = partitionKeyPaths; + _containerProperties = new ContainerProperties(id, partitionKeyPaths); + _scripts = new InMemoryScripts(this); + } + + /// + /// Creates a new from a instance. + /// This allows specifying advanced settings such as . + /// + /// The container properties to use. + public InMemoryContainer(ContainerProperties containerProperties) + { + ValidateComputedProperties(containerProperties); + _containerProperties = containerProperties; + Id = containerProperties.Id; + DefaultTimeToLive = containerProperties.DefaultTimeToLive; + var paths = containerProperties.PartitionKeyPaths; + if (paths is null || paths.Count == 0) + { + var singlePath = containerProperties.PartitionKeyPath ?? "/id"; + PartitionKeyPaths = new[] { singlePath }; + } + else + { + PartitionKeyPaths = paths; + } + _scripts = new InMemoryScripts(this); + } + + // ─── Properties ─────────────────────────────────────────────────────────── + + /// The container identifier. + public override string Id { get; } = default!; + + /// Returns a stubbed instance. + public override Database Database + { + get + { + if (_cachedDatabase is null) + { + var db = Substitute.For(); + db.Id.Returns(_parentDatabaseId ?? Id); + _cachedDatabase = db; + } + return _cachedDatabase; + } + } + private Database _cachedDatabase; + private string _parentDatabaseId; + + internal void SetParentDatabase(string databaseId) => _parentDatabaseId = databaseId; + + internal bool ExplicitlyCreated { get; set; } = true; + + /// Returns a stubbed instance. + public override Conflicts Conflicts => Substitute.For(); + + /// The instance for executing stored procedures and UDFs. + public override Scripts Scripts => _scripts; + + /// + /// Container-level default TTL in seconds. When set, items expire after this duration + /// unless overridden by a per-item _ttl property. Set to null to disable. + /// Items are lazily evicted on the next read attempt. + /// Setting to 0 throws BadRequest as in real Cosmos DB — use -1 for "enabled, no default expiry". + /// + public int? DefaultTimeToLive + { + get => _defaultTimeToLive; + set + { + if (value == 0) + throw InMemoryCosmosException.Create("The value of DefaultTimeToLive must be either null, -1, or a positive integer.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _defaultTimeToLive = value; + } + } + private int? _defaultTimeToLive; + + /// + public UniqueKeyPolicy UniqueKeyPolicy + { + get => _containerProperties.UniqueKeyPolicy; + set => _containerProperties.UniqueKeyPolicy = value ?? new UniqueKeyPolicy(); + } + + /// The partition key path(s) for this container. + public IReadOnlyList PartitionKeyPaths { get; } + + /// + /// Maximum number of entries retained in the change feed log. When a new entry + /// would exceed this limit, the oldest entries are evicted. Defaults to 1000. + /// Set to 0 to disable eviction (unbounded growth). + /// + public int MaxChangeFeedSize { get; set; } = 1000; + + /// + /// Gets the current session token for this container. + /// The token advances each time a write operation succeeds. + /// + internal string CurrentSessionToken => $"0:{_sessionSequence}#{_sessionSequence}"; + + /// + /// Number of feed ranges returned by . Defaults to 1. + /// Set to a higher value to simulate multiple physical partitions so that + /// FeedRange-scoped queries and change feed iterators return subsets of data. + /// + public int FeedRangeCount { get; set; } = 1; + + /// + /// When set, the container will automatically save its state to this file path on + /// and can load state from it via . + /// The directory will be created automatically if it does not exist. + /// + /// Use with to preserve container state between test runs. + /// Set via the StatePersistenceDirectory option on UseInMemoryCosmosDB / + /// UseInMemoryCosmosContainers for automatic DI integration. + /// + /// + public string StateFilePath { get; set; } + + // ─── Public helpers for test infrastructure ─────────────────────────────── + + /// + /// Removes all items, ETags, timestamps, and change feed entries from the container. + /// + public void ClearItems() + { + _items.Clear(); + _etags.Clear(); + _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } + lock (_changeFeedLock) { _changeFeed.Clear(); } + } + + /// Returns the number of non-expired items currently stored in the container. + public int ItemCount => DefaultTimeToLive is null + ? _items.Count + : _items.Keys.Count(k => !IsExpired(k)); + + /// + /// Saves the current container state to the file specified by . + /// Does nothing if is null. Creates the directory if it does not exist. + /// + public void Dispose() + { + if (StateFilePath is not null) + { + var dir = Path.GetDirectoryName(StateFilePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + ExportStateToFile(StateFilePath); + } + } + + /// + /// Loads container state from the file specified by . + /// If the file does not exist, the container starts empty (no-op). + /// If the file exists, its contents are imported via . + /// + /// Thrown when is null. + public void LoadPersistedState() + { + if (StateFilePath is null) + throw new InvalidOperationException("StateFilePath must be set before calling LoadPersistedState()."); + + if (File.Exists(StateFilePath)) + ImportStateFromFile(StateFilePath); + } + + // ─── State persistence ──────────────────────────────────────────────────── + + /// + /// Exports the current container state as a JSON string. + /// + public string ExportState() + { + List<(string Id, string PartitionKey)> orderedKeys; + lock (_insertionOrderLock) { orderedKeys = _insertionOrder.ToList(); } + var items = orderedKeys + .Where(key => _items.ContainsKey(key) && !IsExpired(key)) + .Select(key => JsonParseHelpers.ParseJson(_items[key])).ToList(); + var state = new JObject { ["items"] = new JArray(items) }; + return state.ToString(Formatting.Indented); + } + + /// + /// Imports container state from a JSON string, replacing all existing data. + /// + public void ImportState(string json) + { + var state = JObject.Parse(json); + + if (state["items"] is not JArray items) + return; // No "items" key — do nothing (preserve existing data) + + ClearItems(); + + foreach (var item in items) + { + var itemJson = item.ToString(Formatting.None); + if (item["id"] is null) + throw new InvalidOperationException("Each imported item must have an 'id' property."); + var id = item["id"]!.ToString(); + var jObj = JsonParseHelpers.ParseJson(itemJson); + var pk = ExtractPartitionKeyValue(null, jObj); + var key = (id, pk); + + ValidateUniqueKeys(jObj, pk); + + var importEtag = GenerateETag(); + _etags[key] = importEtag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(itemJson, importEtag, _timestamps[key]); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } + } + } + + /// + /// Exports the current container state to a file. + /// + public void ExportStateToFile(string filePath) + { + File.WriteAllText(filePath, ExportState()); + } + + /// + /// Imports container state from a file, replacing all existing data. + /// + public void ImportStateFromFile(string filePath) + { + ImportState(File.ReadAllText(filePath)); + } + + // ─── Point-in-time restore ──────────────────────────────────────────────── + + /// + /// Restores the container to its state at the specified point in time by replaying + /// the change feed. All current data is replaced with the reconstructed state. + /// + /// The timestamp to restore to. Only changes recorded + /// at or before this time are included. + public void RestoreToPointInTime(DateTimeOffset pointInTime) + { + List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> feedSnapshot; + lock (_changeFeedLock) + { + feedSnapshot = _changeFeed + .Where(e => e.Timestamp <= pointInTime) + .ToList(); + } + + // Replay: keep the last entry per (Id, PartitionKey), skip if it was a delete. + // TTL eviction tombstones (marked with _ttlEviction) are ignored so that PITR + // can resurrect items whose TTL expired — matching real Cosmos PITR behaviour. + var lastPerKey = new Dictionary<(string Id, string PartitionKey), (string Json, bool IsDelete)>(); + foreach (var entry in feedSnapshot) + { + if (entry.IsDelete && entry.Json.Contains("\"_ttlEviction\":true", StringComparison.Ordinal)) + continue; + lastPerKey[(entry.Id, entry.PartitionKey)] = (entry.Json, entry.IsDelete); + } + + _items.Clear(); + _etags.Clear(); + _timestamps.Clear(); + _itemLocks.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } + + // Rebuild insertion order from the change feed replay sequence. + // Track which keys were first created (not deleted) to preserve original insertion order. + var insertionOrderKeys = new List<(string Id, string PartitionKey)>(); + foreach (var entry in feedSnapshot) + { + if (entry.IsDelete && entry.Json.Contains("\"_ttlEviction\":true", StringComparison.Ordinal)) + continue; + var entryKey = (entry.Id, entry.PartitionKey); + if (entry.IsDelete) + { + insertionOrderKeys.Remove(entryKey); + } + else if (!insertionOrderKeys.Contains(entryKey)) + { + insertionOrderKeys.Add(entryKey); + } + } + + foreach (var kvp in lastPerKey) + { + if (kvp.Value.IsDelete) continue; + + var key = kvp.Key; + var etag = $"\"{Guid.NewGuid()}\""; + _items[key] = EnrichWithSystemProperties(kvp.Value.Json, etag, pointInTime); + _etags[key] = etag; + _timestamps[key] = pointInTime; + } + + lock (_insertionOrderLock) + { + foreach (var key in insertionOrderKeys) + { + if (_items.ContainsKey(key)) + _insertionOrder.Add(key); + } + } + } + + // ─── IndexingPolicy ─────────────────────────────────────────────────────── + + /// + /// The indexing policy for this container. Accepted and stored but does not affect + /// query performance — all queries scan all items regardless of indexing settings. + /// When set, automatically ensures /_etag/? is present in ExcludedPaths + /// to match real Cosmos DB behaviour. + /// + public IndexingPolicy IndexingPolicy + { + get => _indexingPolicy; + set + { + _indexingPolicy = value; + EnsureEtagExcludedPath(_indexingPolicy); + } + } + + private IndexingPolicy _indexingPolicy = new() + { + Automatic = true, + IndexingMode = IndexingMode.Consistent, + IncludedPaths = { new IncludedPath { Path = "/*" } }, + ExcludedPaths = { new ExcludedPath { Path = "/\"_etag\"/?" } }, + }; + + private static void EnsureEtagExcludedPath(IndexingPolicy policy) + { + const string etagPath = "/\"_etag\"/?"; + if (!policy.ExcludedPaths.Any(p => p.Path == etagPath)) + policy.ExcludedPaths.Add(new ExcludedPath { Path = etagPath }); + } + + /// + /// Registers a user-defined function that can be called in SQL queries as udf.name(args). + /// + public void RegisterUdf(string name, Func implementation) + { + ArgumentNullException.ThrowIfNull(name); + _userDefinedFunctions["UDF." + name.TrimStart('.')] = implementation; + } + + /// + /// Removes a previously registered user-defined function. + /// + public void DeregisterUdf(string name) + { + ArgumentNullException.ThrowIfNull(name); + _userDefinedFunctions.Remove("UDF." + name.TrimStart('.')); + } + + /// + /// Registers a stored procedure handler that is invoked when ExecuteStoredProcedureAsync is called. + /// + public void RegisterStoredProcedure(string sprocId, Func handler) + { + ArgumentNullException.ThrowIfNull(sprocId); + ArgumentNullException.ThrowIfNull(handler); + _storedProcedures[sprocId] = handler; + } + + /// + /// Removes a previously registered stored procedure handler. + /// + public void DeregisterStoredProcedure(string sprocId) + { + ArgumentNullException.ThrowIfNull(sprocId); + _storedProcedures.Remove(sprocId); + } + + /// + /// Registers a pre-trigger handler invoked when ItemRequestOptions.PreTriggers includes this trigger's ID. + /// The handler receives the document as a and must return the (possibly modified) document. + /// + public void RegisterTrigger(string triggerId, TriggerType triggerType, TriggerOperation triggerOperation, + Func preHandler) + { + ArgumentNullException.ThrowIfNull(triggerId); + _triggers[triggerId] = new RegisteredTrigger(triggerType, triggerOperation, preHandler, null); + } + + /// + /// Registers a post-trigger handler invoked when ItemRequestOptions.PostTriggers includes this trigger's ID. + /// The handler receives the committed document as a . + /// If the handler throws, the write is rolled back (matching real Cosmos DB transactional semantics). + /// + public void RegisterTrigger(string triggerId, TriggerType triggerType, TriggerOperation triggerOperation, + Action postHandler) + { + ArgumentNullException.ThrowIfNull(triggerId); + _triggers[triggerId] = new RegisteredTrigger(triggerType, triggerOperation, null, postHandler); + } + + /// + /// Removes a previously registered trigger handler. + /// + public void DeregisterTrigger(string triggerId) + { + ArgumentNullException.ThrowIfNull(triggerId); + _triggers.Remove(triggerId); + } + + // ─── JS body registrations (IContainerTestSetup) ────────────────────────── + + private const string JsTriggersNotInstalled = + "JavaScript stored procedure execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + + "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; + + private const string JsUdfNotInstalled = + "JavaScript UDF execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + + "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; + + private const string JsTriggerNotInstalled = + "JavaScript trigger execution requires the CosmosDB.InMemoryEmulator.JsTriggers package. " + + "Install it and call container.UseJsTriggers(), or use the C# delegate overload instead."; + + void IContainerTestSetup.RegisterStoredProcedure(string id, string jsBody) + { + if (SprocEngine is null) + throw new NotImplementedException(JsTriggersNotInstalled); + + RegisterStoredProcedure(id, (pk, args) => + { + var result = SprocEngine.Execute(jsBody, pk, args, new PartitionScopedCollectionContext(this, pk)); + return result ?? "null"; + }); + + _storedProcedureProperties[id] = new StoredProcedureProperties { Id = id, Body = jsBody }; + } + + void IContainerTestSetup.RegisterUdf(string name, string jsBody) + { + if (JsTriggerEngine is not IJsUdfEngine) + throw new NotImplementedException(JsUdfNotInstalled); + + // Register a placeholder — actual JS execution happens at query time via the UDF properties + _userDefinedFunctions["UDF." + name.TrimStart('.')] = UdfPlaceholder; + _udfProperties[name] = new UserDefinedFunctionProperties { Id = name, Body = jsBody }; + } + + void IContainerTestSetup.RegisterTrigger(string id, TriggerType type, TriggerOperation operation, + string jsBody) + { + if (JsTriggerEngine is null) + throw new NotImplementedException(JsTriggerNotInstalled); + + _triggerProperties[id] = new TriggerProperties + { + Id = id, + TriggerType = type, + TriggerOperation = operation, + Body = jsBody + }; + + if (type == TriggerType.Pre) + { + _triggers[id] = new RegisteredTrigger(type, operation, + doc => JsTriggerEngine.ExecutePreTrigger(jsBody, doc), null); + } + else + { + _triggers[id] = new RegisteredTrigger(type, operation, null, + doc => JsTriggerEngine.ExecutePostTrigger(jsBody, doc)); + } + } + + /// + /// Returns a checkpoint value representing the current position in the change feed. + /// Pass this to to read changes since this point. + /// + public long GetChangeFeedCheckpoint() + { + lock (_changeFeedLock) + { + return _changeFeed.Count; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Item CRUD — Typed + // ═══════════════════════════════════════════════════════════════════════════ + + public override async Task> CreateItemAsync( + T item, PartitionKey? partitionKey = null, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = JsonConvert.SerializeObject(item, JsonSettings); + ValidateDocumentSize(json); + var jObj = JsonParseHelpers.ParseJson(json); + + jObj = ExecutePreTriggers(requestOptions, jObj, "Create"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + ValidateDocumentSize(json); + + var itemId = jObj["id"]?.ToString() ?? throw new InvalidOperationException("Item must have an 'id' property."); + + if (itemId.Length == 0) + { + throw InMemoryCosmosException.Create("The 'id' property cannot be an empty string.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + ValidatePartitionKeyConsistency(partitionKey, jObj); + ValidatePerItemTtl(jObj); + var pk = ExtractPartitionKeyValue(partitionKey, jObj); + var key = ItemKey(itemId, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + EvictIfExpired(key); + + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + ValidateUniqueKeys(jObj, pk); + if (!_items.TryAdd(key, json)) + { + throw InMemoryCosmosException.Create($"Entity with the specified id already exists in the system. id = {itemId}", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + } + else + { + if (!_items.TryAdd(key, json)) + { + throw InMemoryCosmosException.Create($"Entity with the specified id already exists in the system. id = {itemId}", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + TrackBatchWrite(key); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } + var etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); + + try + { + var committedDoc = JsonParseHelpers.ParseJson(_items[key]); + var responseBodyOverride = ExecutePostTriggers(requestOptions, committedDoc, "Create"); + var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); + if (postTriggerJson != _items[key]) + { + ValidateDocumentSize(postTriggerJson); + _items[key] = postTriggerJson; + } + + RecordChangeFeed(itemId, pk, _items[key]); + + var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; + if (responseBodyOverride is not null && !suppressContent) + { + return CreateItemResponse( + responseBodyOverride.ToObject(JsonSerializer.Create(JsonSettings)), + HttpStatusCode.Created, etag, suppressContent); + } + + return CreateItemResponse( + JsonConvert.DeserializeObject(_items[key], JsonSettings), + HttpStatusCode.Created, etag, suppressContent); + } + catch + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + throw; + } + } + finally + { + itemLock.Release(); + } + } + + public override Task> ReadItemAsync( + string id, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + if (!_items.TryGetValue(key, out var json) || IsExpired(key)) + { + EvictIfExpired(key); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + CheckIfNoneMatch(requestOptions, key); + var etag = _etags.GetValueOrDefault(key); + var result = JsonConvert.DeserializeObject(json, JsonSettings); + return Task.FromResult(CreateItemResponse(result, HttpStatusCode.OK, etag)); + } + + public override async Task> UpsertItemAsync( + T item, PartitionKey? partitionKey = null, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = JsonConvert.SerializeObject(item, JsonSettings); + ValidateDocumentSize(json); + var jObj = JsonParseHelpers.ParseJson(json); + + jObj = ExecutePreTriggers(requestOptions, jObj, "Upsert"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + ValidateDocumentSize(json); + + var itemId = jObj["id"]?.ToString() ?? throw new InvalidOperationException("Item must have an 'id' property."); + ValidatePartitionKeyConsistency(partitionKey, jObj); + ValidatePerItemTtl(jObj); + var pk = ExtractPartitionKeyValue(partitionKey, jObj); + var key = ItemKey(itemId, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + EvictIfExpired(key); + + // Note: If-Match is documented as "applicable only on PUT and DELETE" in the REST API. + // Upsert is POST-based, so If-Match is not evaluated for the insert path. + // If the item exists, CheckIfMatch will evaluate it on the update path. + CheckIfMatch(requestOptions, key); + bool existed; + string previousJson; + string previousEtag; + DateTimeOffset? previousTimestamp; + string etag; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + ValidateUniqueKeys(jObj, pk, excludeItemId: itemId); + existed = _items.ContainsKey(key); + previousJson = existed ? _items[key] : null; + previousEtag = existed ? _etags.GetValueOrDefault(key) : null; + previousTimestamp = existed ? _timestamps.GetValueOrDefault(key) : default(DateTimeOffset?); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); + } + } + else + { + existed = _items.ContainsKey(key); + previousJson = existed ? _items[key] : null; + previousEtag = existed ? _etags.GetValueOrDefault(key) : null; + previousTimestamp = existed ? _timestamps.GetValueOrDefault(key) : default(DateTimeOffset?); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); + } + + TrackBatchWrite(key); + if (!existed) { lock (_insertionOrderLock) { _insertionOrder.Add(key); } } + + try + { + var committedDoc = JsonParseHelpers.ParseJson(_items[key]); + ExecutePostTriggers(requestOptions, committedDoc, "Upsert"); + var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); + if (postTriggerJson != _items[key]) + { + ValidateDocumentSize(postTriggerJson); + _items[key] = postTriggerJson; + } + RecordChangeFeed(itemId, pk, _items[key]); + } + catch + { + if (existed && previousJson is not null) + { + _items[key] = previousJson; + _etags[key] = previousEtag!; + if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + } + else + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + } + throw; + } + + var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; + return CreateItemResponse( + JsonConvert.DeserializeObject(_items[key], JsonSettings), + existed ? HttpStatusCode.OK : HttpStatusCode.Created, etag, suppressContent); + } + finally + { + itemLock.Release(); + } + } + + public override async Task> ReplaceItemAsync( + T item, string id, PartitionKey? partitionKey = null, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = JsonConvert.SerializeObject(item, JsonSettings); + ValidateDocumentSize(json); + var jObj = JsonParseHelpers.ParseJson(json); + + // Validate body id matches parameter id. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + // "The Container.ReplaceItemAsync<> method requires the provided string for the id + // parameter to match the unique identifier of the item parameter." + var bodyId = jObj["id"]?.ToString(); + if (bodyId is not null && bodyId != id) + { + throw InMemoryCosmosException.Create( + "The 'id' property in the body does not match the 'id' parameter.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + ValidatePartitionKeyConsistency(partitionKey, jObj); + + ValidatePerItemTtl(jObj); + + var pk = ExtractPartitionKeyValue(partitionKey, jObj); + var key = ItemKey(id, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + if (!_items.ContainsKey(key) || IsExpired(key)) + { + EvictIfExpired(key); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + CheckIfMatch(requestOptions, key); + + // Pre-triggers run after ETag check (matching real Cosmos behavior) + jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + ValidateDocumentSize(json); + string previousJson; + string previousEtag; + DateTimeOffset previousTimestamp; + string etag; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + ValidateUniqueKeys(jObj, pk, excludeItemId: id); + previousJson = _items[key]; + previousEtag = _etags.GetValueOrDefault(key); + previousTimestamp = _timestamps.GetValueOrDefault(key); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); + } + } + else + { + previousJson = _items[key]; + previousEtag = _etags.GetValueOrDefault(key); + previousTimestamp = _timestamps.GetValueOrDefault(key); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + _items[key] = EnrichWithSystemProperties(json, etag, _timestamps[key]); + } + + TrackBatchWrite(key); + try + { + var committedDoc = JsonParseHelpers.ParseJson(_items[key]); + ExecutePostTriggers(requestOptions, committedDoc, "Replace"); + var postTriggerJson = committedDoc.ToString(Newtonsoft.Json.Formatting.None); + if (postTriggerJson != _items[key]) + { + ValidateDocumentSize(postTriggerJson); + _items[key] = postTriggerJson; + } + RecordChangeFeed(id, pk, _items[key]); + } + catch + { + _items[key] = previousJson; + _etags[key] = previousEtag!; + _timestamps[key] = previousTimestamp; + throw; + } + + var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; + return CreateItemResponse(JsonConvert.DeserializeObject(_items[key], JsonSettings), HttpStatusCode.OK, etag, suppressContent); + } + finally + { + itemLock.Release(); + } + } + + public override async Task> DeleteItemAsync( + string id, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) + { + EvictIfExpired(key); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + CheckIfMatch(requestOptions, key); + + ExecutePreTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); + + var previousEtag = _etags.TryGetValue(key, out var e) ? e : null; + var previousTimestamp = _timestamps.TryGetValue(key, out var ts) ? ts : (DateTimeOffset?)null; + + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + + TrackBatchWrite(key); + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); + RecordDeleteTombstone(id, pk, partitionKey); + } + catch + { + _items[key] = existingJson; + if (previousEtag is not null) _etags[key] = previousEtag; + if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + lock (_insertionOrderLock) { _insertionOrder.Add(key); } + throw; + } + + return CreateItemResponse(default(T), HttpStatusCode.NoContent); + } + finally + { + itemLock.Release(); + } + } + + public override async Task> PatchItemAsync( + string id, PartitionKey partitionKey, + IReadOnlyList patchOperations, + PatchItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (patchOperations is null || patchOperations.Count == 0) + { + throw InMemoryCosmosException.Create("Patch request has no operations.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + if (patchOperations.Count > 10) + { + throw InMemoryCosmosException.Create("Patch request has too many operations.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + ValidatePatchPaths(patchOperations); + + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + return PatchItemCore(id, pk, key, patchOperations, requestOptions); + } + finally + { + itemLock.Release(); + } + } + + private ItemResponse PatchItemCore( + string id, string pk, (string Id, string PartitionKey) key, + IReadOnlyList patchOperations, + PatchItemRequestOptions requestOptions) + { + if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) + { + EvictIfExpired(key); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + throw InMemoryCosmosException.Create($"Resource Not Found. Entity with the specified id does not exist in the system. id = {id}", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + CheckIfMatch(requestOptions, key); + CheckIfNoneMatchForWrite(requestOptions, key); + + var jObj = JsonParseHelpers.ParseJson(existingJson); + + if (requestOptions?.FilterPredicate is not null) + { + var predicateSql = $"SELECT * {requestOptions.FilterPredicate}"; + var predicateParsed = CosmosSqlParser.Parse(predicateSql); + if (predicateParsed.Where is not null) + { + var matches = EvaluateWhereExpression(predicateParsed.Where, jObj, predicateParsed.FromAlias, + new Dictionary(), null, treatUndefinedAsNull: true); + if (!matches) + { + throw InMemoryCosmosException.Create("Precondition Failed", + HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + } + + jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); + + ApplyPatchOperations(jObj, patchOperations); + ValidatePerItemTtl(jObj); + var updatedJson = jObj.ToString(Formatting.None); + ValidateDocumentSize(updatedJson); + string etag; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + ValidateUniqueKeys(jObj, pk, excludeItemId: id); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); + _items[key] = enriched; + } + } + else + { + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); + _items[key] = enriched; + } + var enrichedJson = _items[key]; + + TrackBatchWrite(key); + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); + RecordChangeFeed(id, pk, _items[key]); + + var suppressContent = requestOptions?.EnableContentResponseOnWrite == false; + var result = JsonConvert.DeserializeObject(enrichedJson, JsonSettings); + return CreateItemResponse(result, HttpStatusCode.OK, etag, suppressContent); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Item CRUD — Stream + // ═══════════════════════════════════════════════════════════════════════════ + + public override async Task CreateItemStreamAsync( + Stream streamPayload, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = ReadStream(streamPayload); + var sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + JObject jObj; + try { jObj = JsonParseHelpers.ParseJson(json); } + catch (Newtonsoft.Json.JsonReaderException) + { return CreateResponseMessage(HttpStatusCode.BadRequest); } + + jObj = ExecutePreTriggers(requestOptions, jObj, "Create"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + var ttlError = ValidatePerItemTtlStream(jObj); + if (ttlError is not null) return ttlError; + + var itemId = jObj["id"]?.ToString() ?? Guid.NewGuid().ToString(); + + if (itemId.Length == 0) + return CreateResponseMessage(HttpStatusCode.BadRequest); + + var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); + if (pkMismatch is not null) return pkMismatch; + + var pk = ExtractPartitionKeyValue(partitionKey, jObj); + var key = ItemKey(itemId, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + EvictIfExpired(key); + + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + if (!ValidateUniqueKeysStream(jObj, pk)) + return CreateResponseMessage(HttpStatusCode.Conflict); + + if (!_items.TryAdd(key, json)) + return CreateResponseMessage(HttpStatusCode.Conflict); + } + } + else + { + if (!_items.TryAdd(key, json)) + return CreateResponseMessage(HttpStatusCode.Conflict); + } + + TrackBatchWrite(key); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } + var etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + var enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); + _items[key] = enrichedJson; + + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Create"); + RecordChangeFeed(itemId, pk, _items[key]); + } + catch + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + throw; + } + + return CreateResponseMessage(HttpStatusCode.Created, + requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); + } + finally + { + itemLock.Release(); + } + } + + public override Task ReadItemStreamAsync( + string id, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + if (!_items.TryGetValue(key, out var json) || IsExpired(key)) + { + EvictIfExpired(key); + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound)); + } + var etag = _etags.GetValueOrDefault(key); + if (!CheckIfNoneMatchStream(requestOptions, key)) + { + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotModified, etag: etag)); + } + return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, json, etag)); + } + + public override async Task UpsertItemStreamAsync( + Stream streamPayload, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = ReadStream(streamPayload); + var sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + JObject jObj; + try { jObj = JsonParseHelpers.ParseJson(json); } + catch (Newtonsoft.Json.JsonReaderException) + { return CreateResponseMessage(HttpStatusCode.BadRequest); } + + jObj = ExecutePreTriggers(requestOptions, jObj, "Upsert"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + var ttlError = ValidatePerItemTtlStream(jObj); + if (ttlError is not null) return ttlError; + + var itemId = jObj["id"]?.ToString(); + if (itemId is null) + return CreateResponseMessage(HttpStatusCode.BadRequest); + var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); + if (pkMismatch is not null) return pkMismatch; + var pk = ExtractPartitionKeyValue(partitionKey, jObj); + var key = ItemKey(itemId, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + EvictIfExpired(key); + + // Note: If-Match is documented as "applicable only on PUT and DELETE" in the REST API. + // Upsert is POST-based, so If-Match is not evaluated for the insert path. + // If the item exists, CheckIfMatchStream will evaluate it on the update path. + if (!CheckIfMatchStream(requestOptions, key)) + { + return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + } + bool existed; + string etag; + string enrichedJson; + string previousJson = null; + string previousEtag = null; + DateTimeOffset? previousTimestamp = null; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) + return CreateResponseMessage(HttpStatusCode.Conflict); + existed = _items.TryGetValue(key, out previousJson); + if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); + _items[key] = enrichedJson; + } + } + else + { + if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: itemId)) + return CreateResponseMessage(HttpStatusCode.Conflict); + existed = _items.TryGetValue(key, out previousJson); + if (existed) { previousEtag = _etags.GetValueOrDefault(key); previousTimestamp = _timestamps.GetValueOrDefault(key); } + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); + _items[key] = enrichedJson; + } + + TrackBatchWrite(key); + if (!existed) { lock (_insertionOrderLock) { _insertionOrder.Add(key); } } + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Upsert"); + RecordChangeFeed(itemId, pk, _items[key]); + } + catch + { + if (existed && previousJson is not null) + { + _items[key] = previousJson; + if (previousEtag is not null) _etags[key] = previousEtag; + if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + } + else + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + } + throw; + } + + return CreateResponseMessage(existed ? HttpStatusCode.OK : HttpStatusCode.Created, + requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); + } + finally + { + itemLock.Release(); + } + } + + public override async Task ReplaceItemStreamAsync( + Stream streamPayload, string id, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = ReadStream(streamPayload); + var sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + JObject jObj; + try { jObj = JsonParseHelpers.ParseJson(json); } + catch (Newtonsoft.Json.JsonReaderException) + { return CreateResponseMessage(HttpStatusCode.BadRequest); } + + var pkMismatch = ValidatePartitionKeyConsistencyStream(partitionKey, jObj); + if (pkMismatch is not null) return pkMismatch; + + // Validate body id matches parameter id. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + // "The Container.ReplaceItemAsync<> method requires the provided string for the id + // parameter to match the unique identifier of the item parameter." + var bodyId = jObj["id"]?.ToString(); + if (bodyId is not null && bodyId != id) + { + return CreateResponseMessage(HttpStatusCode.BadRequest); + } + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + if (!_items.TryGetValue(key, out var previousJson) || IsExpired(key)) + { + EvictIfExpired(key); + return CreateResponseMessage(HttpStatusCode.NotFound); + } + + if (!CheckIfMatchStream(requestOptions, key)) + { + return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + } + + var previousEtag = _etags.GetValueOrDefault(key); + var previousTimestamp = _timestamps.GetValueOrDefault(key); + + jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); + json = jObj.ToString(Newtonsoft.Json.Formatting.None); + sizeError = ValidateDocumentSizeStream(json); + if (sizeError is not null) return sizeError; + var ttlError = ValidatePerItemTtlStream(jObj); + if (ttlError is not null) return ttlError; + + string etag; + string enrichedJson; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) + return CreateResponseMessage(HttpStatusCode.Conflict); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); + _items[key] = enrichedJson; + } + } + else + { + if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) + return CreateResponseMessage(HttpStatusCode.Conflict); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + enrichedJson = EnrichWithSystemProperties(json, etag, _timestamps[key]); + _items[key] = enrichedJson; + } + + TrackBatchWrite(key); + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); + RecordChangeFeed(id, pk, _items[key]); + } + catch + { + _items[key] = previousJson; + if (previousEtag is not null) _etags[key] = previousEtag; + _timestamps[key] = previousTimestamp; + throw; + } + + return CreateResponseMessage(HttpStatusCode.OK, + requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); + } + finally + { + itemLock.Release(); + } + } + + public override async Task DeleteItemStreamAsync( + string id, PartitionKey partitionKey, + ItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) + { + EvictIfExpired(key); + return CreateResponseMessage(HttpStatusCode.NotFound); + } + + if (!CheckIfMatchStream(requestOptions, key)) + { + return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + } + + ExecutePreTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); + + var previousEtag = _etags.TryGetValue(key, out var e) ? e : null; + var previousTimestamp = _timestamps.TryGetValue(key, out var ts) ? ts : (DateTimeOffset?)null; + + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + + TrackBatchWrite(key); + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(existingJson), "Delete"); + RecordDeleteTombstone(id, pk, partitionKey); + } + catch + { + _items[key] = existingJson; + if (previousEtag is not null) _etags[key] = previousEtag; + if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + lock (_insertionOrderLock) { _insertionOrder.Add(key); } + throw; + } + + return CreateResponseMessage(HttpStatusCode.NoContent); + } + finally + { + itemLock.Release(); + } + } + + public override async Task PatchItemStreamAsync( + string id, PartitionKey partitionKey, + IReadOnlyList patchOperations, + PatchItemRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (patchOperations is null || patchOperations.Count == 0) + { + return CreateResponseMessage(HttpStatusCode.BadRequest); + } + + if (patchOperations.Count > 10) + { + return CreateResponseMessage(HttpStatusCode.BadRequest); + } + + try + { + ValidatePatchPaths(patchOperations); + } + catch (CosmosException) + { + return CreateResponseMessage(HttpStatusCode.BadRequest); + } + + var pk = PartitionKeyToString(partitionKey); + var key = ItemKey(id, pk); + + var itemLock = _itemLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await itemLock.WaitAsync(cancellationToken); + try + { + return PatchItemStreamCore(id, pk, key, patchOperations, requestOptions); + } + finally + { + itemLock.Release(); + } + } + + private ResponseMessage PatchItemStreamCore( + string id, string pk, (string Id, string PartitionKey) key, + IReadOnlyList patchOperations, + PatchItemRequestOptions requestOptions) + { + if (!_items.TryGetValue(key, out var existingJson) || IsExpired(key)) + { + EvictIfExpired(key); + return CreateResponseMessage(HttpStatusCode.NotFound); + } + + if (!CheckIfMatchStream(requestOptions, key)) + { + return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + } + + var jObj = JsonParseHelpers.ParseJson(existingJson); + + if (requestOptions?.FilterPredicate is not null) + { + var predicateSql = $"SELECT * {requestOptions.FilterPredicate}"; + var predicateParsed = CosmosSqlParser.Parse(predicateSql); + if (predicateParsed.Where is not null) + { + var matches = EvaluateWhereExpression(predicateParsed.Where, jObj, predicateParsed.FromAlias, + new Dictionary(), null, treatUndefinedAsNull: true); + if (!matches) + { + return CreateResponseMessage(HttpStatusCode.PreconditionFailed); + } + } + } + + jObj = ExecutePreTriggers(requestOptions, jObj, "Replace"); + + ApplyPatchOperations(jObj, patchOperations); + var updatedJson = jObj.ToString(Formatting.None); + var sizeError = ValidateDocumentSizeStream(updatedJson); + if (sizeError is not null) return sizeError; + var ttlError = ValidatePerItemTtlStream(jObj); + if (ttlError is not null) return ttlError; + + var previousEtag = _etags.GetValueOrDefault(key); + var previousTimestamp = _timestamps.GetValueOrDefault(key); + + string etag; + if (HasUniqueKeys) + { + lock (_uniqueKeyWriteLock) + { + if (!ValidateUniqueKeysStream(jObj, pk, excludeItemId: id)) + return CreateResponseMessage(HttpStatusCode.Conflict); + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); + _items[key] = enriched; + } + } + else + { + etag = GenerateETag(); + _etags[key] = etag; + _timestamps[key] = DateTimeOffset.UtcNow; + var enriched = EnrichWithSystemProperties(updatedJson, etag, _timestamps[key]); + _items[key] = enriched; + } + var enrichedJson = _items[key]; + + TrackBatchWrite(key); + try + { + ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Replace"); + RecordChangeFeed(id, pk, _items[key]); + } + catch + { + _items[key] = existingJson; + if (previousEtag is not null) _etags[key] = previousEtag; + _timestamps[key] = previousTimestamp; + throw; + } + + return CreateResponseMessage(HttpStatusCode.OK, + requestOptions?.EnableContentResponseOnWrite == false ? null : enrichedJson, etag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ReadMany + // ═══════════════════════════════════════════════════════════════════════════ + + public override Task> ReadManyItemsAsync( + IReadOnlyList<(string id, PartitionKey partitionKey)> items, + ReadManyRequestOptions readManyRequestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(items); + var results = new List(); + var etagParts = new List(); + foreach (var (itemId, pk) in items) + { + var pkStr = PartitionKeyToString(pk); + var key = ItemKey(itemId, pkStr); + if (_items.TryGetValue(key, out var json) && !IsExpired(key)) + { + var jObj = JsonParseHelpers.ParseJson(json); + var itemEtag = jObj["_etag"]?.ToString(); + if (itemEtag != null) etagParts.Add(itemEtag); + var deserialized = JsonConvert.DeserializeObject(json, JsonSettings); + if (deserialized is not null) + { + results.Add(deserialized); + } + } + } + var compositeEtag = etagParts.Count > 0 ? $"\"{string.Join(",", etagParts)}\"" : null; + if (readManyRequestOptions?.IfNoneMatchEtag != null && compositeEtag == readManyRequestOptions.IfNoneMatchEtag) + { + return Task.FromResult>(new InMemoryFeedResponse( + Array.Empty(), HttpStatusCode.NotModified, compositeEtag)); + } + return Task.FromResult>(new InMemoryFeedResponse(results, etag: compositeEtag)); + } + + public override Task ReadManyItemsStreamAsync( + IReadOnlyList<(string id, PartitionKey partitionKey)> items, + ReadManyRequestOptions readManyRequestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(items); + var results = new JArray(); + var etagParts = new List(); + foreach (var (itemId, pk) in items) + { + var pkStr = PartitionKeyToString(pk); + var key = ItemKey(itemId, pkStr); + if (_items.TryGetValue(key, out var json) && !IsExpired(key)) + { + var jObj = JsonParseHelpers.ParseJson(json); + var itemEtag = jObj["_etag"]?.ToString(); + if (itemEtag != null) etagParts.Add(itemEtag); + results.Add(jObj); + } + } + var compositeEtag = etagParts.Count > 0 ? $"\"{string.Join(",", etagParts)}\"" : null; + if (readManyRequestOptions?.IfNoneMatchEtag != null && compositeEtag == readManyRequestOptions.IfNoneMatchEtag) + { + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotModified, etag: compositeEtag)); + } + var envelope = new JObject { ["_rid"] = "", ["Documents"] = results, ["_count"] = results.Count }; + return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, envelope.ToString(Formatting.None), etag: compositeEtag)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query — Typed FeedIterator + // ═══════════════════════════════════════════════════════════════════════════ + + private static List ExecuteQuerySafe(Func> queryFunc) + { + try + { + return queryFunc(); + } + catch (CosmosException) { throw; } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or FormatException) + { + throw InMemoryCosmosException.Create( + ex.Message, HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + } + + public override FeedIterator GetItemQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + ValidateMaxItemCount(requestOptions); + var parameters = ExtractQueryParameters(queryDefinition); + var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions)); + var items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); + var initialOffset = ParseContinuationToken(continuationToken); + return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) + { + PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, + GuaranteeFirstPage = true + }; + } + + public override FeedIterator GetItemQueryIterator( + string queryText = null, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + ValidateMaxItemCount(requestOptions); + List items; + if (string.IsNullOrEmpty(queryText)) + { + items = GetAllItemsForPartition(requestOptions) + .Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); + } + else + { + var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryText, new Dictionary(), requestOptions)); + items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); + } + var initialOffset = ParseContinuationToken(continuationToken); + return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) + { + PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, + GuaranteeFirstPage = true + }; + } + + public override FeedIterator GetItemQueryIterator( + FeedRange feedRange, QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + ValidateMaxItemCount(requestOptions); + var parameters = ExtractQueryParameters(queryDefinition); + var preFiltered = FilterByFeedRange(GetAllItemsForPartition(requestOptions).ToList(), feedRange); + var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions, preFiltered)); + var items = filtered.Select(json => JsonConvert.DeserializeObject(json, JsonSettings)).ToList(); + var initialOffset = ParseContinuationToken(continuationToken); + return new InMemoryFeedIterator(items, requestOptions?.MaxItemCount, initialOffset) + { + PopulateIndexMetrics = requestOptions?.PopulateIndexMetrics ?? false, + GuaranteeFirstPage = true + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query — Stream FeedIterator + // ═══════════════════════════════════════════════════════════════════════════ + + public override FeedIterator GetItemQueryStreamIterator( + QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + var parameters = ExtractQueryParameters(queryDefinition); + var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions)); + return CreateStreamFeedIterator(filtered, ParseContinuationToken(continuationToken), requestOptions?.MaxItemCount); + } + + public override FeedIterator GetItemQueryStreamIterator( + string queryText = null, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + var offset = ParseContinuationToken(continuationToken); + if (string.IsNullOrEmpty(queryText)) + { + return CreateStreamFeedIterator(GetAllItemsForPartition(requestOptions).ToList(), offset, requestOptions?.MaxItemCount); + } + + return CreateStreamFeedIterator(ExecuteQuerySafe(() => FilterItemsByQuery(queryText, new Dictionary(), requestOptions)), offset, requestOptions?.MaxItemCount); + } + + public override FeedIterator GetItemQueryStreamIterator( + FeedRange feedRange, QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + var parameters = ExtractQueryParameters(queryDefinition); + var preFiltered = FilterByFeedRange(GetAllItemsForPartition(requestOptions).ToList(), feedRange); + var filtered = ExecuteQuerySafe(() => FilterItemsByQuery(queryDefinition.QueryText, parameters, requestOptions, preFiltered)); + return CreateStreamFeedIterator(filtered, ParseContinuationToken(continuationToken), requestOptions?.MaxItemCount); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ + // ═══════════════════════════════════════════════════════════════════════════ + + public override IOrderedQueryable GetItemLinqQueryable( + bool allowSynchronousQueryExecution = false, string continuationToken = null, + QueryRequestOptions requestOptions = null, CosmosLinqSerializerOptions linqSerializerOptions = null) + { + InMemoryFeedIteratorSetup.LastMaxItemCount = requestOptions?.MaxItemCount; + return GetAllItemsForPartition(requestOptions) + .Select(json => JsonConvert.DeserializeObject(json, JsonSettings)) + .AsQueryable() + .OrderBy(item => 0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TransactionalBatch + // ═══════════════════════════════════════════════════════════════════════════ + + public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey) + => new InMemoryTransactionalBatch(this, partitionKey); + + // ═══════════════════════════════════════════════════════════════════════════ + // Change Feed — Iterators + // ═══════════════════════════════════════════════════════════════════════════ + + public override FeedIterator GetChangeFeedIterator( + ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, + ChangeFeedRequestOptions changeFeedRequestOptions = null) + { + var pageSize = changeFeedRequestOptions?.PageSizeHint; + var typeName = changeFeedStartFrom.GetType().Name; + var feedRange = ExtractFeedRangeFromStartFrom(changeFeedStartFrom); + + // "Now" and "Time" start types use lazy evaluation so items added + // after iterator creation are included when ReadNextAsync is called. + if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("Time", StringComparison.OrdinalIgnoreCase)) + { + // Capture the checkpoint (current feed length) at creation time for "Now", + // or extract the timestamp for "Time" filtering. + DateTimeOffset? capturedTimestamp = null; + if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase)) + { + lock (_changeFeedLock) + { + capturedTimestamp = _changeFeed.Count > 0 + ? _changeFeed[^1].Timestamp + : DateTimeOffset.UtcNow; + } + } + else + { + var startTime = ExtractStartTime(changeFeedStartFrom); + if (startTime.HasValue) + capturedTimestamp = new DateTimeOffset(startTime.Value, TimeSpan.Zero); + } + + var isNowStart = typeName.Contains("Now", StringComparison.OrdinalIgnoreCase); + var ts = capturedTimestamp; + var capturedRange = feedRange; + return new InMemoryFeedIterator(() => + { + lock (_changeFeedLock) + { + var entries = ts.HasValue + ? _changeFeed.Where(entry => isNowStart + ? entry.Timestamp > ts.Value + : entry.Timestamp >= ts.Value).ToList() + : _changeFeed.ToList(); + + entries = FilterChangeFeedEntriesByFeedRange(entries, capturedRange); + + if (changeFeedMode == ChangeFeedMode.Incremental) + { + entries = entries + .GroupBy(entry => (entry.Id, entry.PartitionKey)) + .Select(group => group.Last()) + .Where(entry => !entry.IsDelete) + .ToList(); + } + + return entries + .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) + .ToList(); + } + }, pageSize); + } + + // "Beginning" and other start types — eager evaluation is fine + List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> eagerEntries; + lock (_changeFeedLock) + { + eagerEntries = _changeFeed.ToList(); + } + + eagerEntries = FilterChangeFeedEntriesByFeedRange(eagerEntries, feedRange); + + if (changeFeedMode == ChangeFeedMode.Incremental) + { + eagerEntries = eagerEntries + .GroupBy(entry => (entry.Id, entry.PartitionKey)) + .Select(group => group.Last()) + .Where(entry => !entry.IsDelete) + .ToList(); + } + + var items = eagerEntries + .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) + .ToList(); + return new InMemoryFeedIterator(items, pageSize); + } + + /// + /// Returns a change feed iterator that reads changes from the given checkpoint position. + /// Obtain a checkpoint via . + /// + public FeedIterator GetChangeFeedIterator(long checkpoint) + { + List items; + lock (_changeFeedLock) + { + items = _changeFeed + .Skip((int)checkpoint) + .Select(entry => JsonConvert.DeserializeObject(entry.Json, JsonSettings)) + .ToList(); + } + return new InMemoryFeedIterator(items); + } + + public override FeedIterator GetChangeFeedStreamIterator( + ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, + ChangeFeedRequestOptions changeFeedRequestOptions = null) + { + var feedRange = ExtractFeedRangeFromStartFrom(changeFeedStartFrom); + var pageSizeHint = changeFeedRequestOptions?.PageSizeHint; + var creationTime = DateTimeOffset.UtcNow; + return CreateStreamFeedIteratorFromFactory(() => + { + List items; + lock (_changeFeedLock) + { + var entries = FilterChangeFeedByStartFrom(changeFeedStartFrom, creationTime); + entries = FilterChangeFeedEntriesByFeedRange(entries, feedRange); + + if (changeFeedMode == ChangeFeedMode.Incremental) + { + entries = entries + .GroupBy(entry => (entry.Id, entry.PartitionKey)) + .Select(group => group.Last()) + .Where(entry => !entry.IsDelete) + .ToList(); + } + + items = entries.Select(entry => entry.Json).ToList(); + } + return items; + }, 0, pageSizeHint); + } + + private List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> FilterChangeFeedByStartFrom( + ChangeFeedStartFrom startFrom, DateTimeOffset? creationTime = null) + { + var typeName = startFrom.GetType().Name; + + if (typeName.Contains("Now", StringComparison.OrdinalIgnoreCase)) + { + var now = creationTime ?? DateTimeOffset.UtcNow; + return _changeFeed.Where(entry => entry.Timestamp > now).ToList(); + } + + if (typeName.Contains("Time", StringComparison.OrdinalIgnoreCase)) + { + var startTime = ExtractStartTime(startFrom); + if (startTime.HasValue) + { + var dto = new DateTimeOffset(startTime.Value, TimeSpan.Zero); + return _changeFeed.Where(entry => entry.Timestamp >= dto).ToList(); + } + } + + return _changeFeed.ToList(); + } + + private static DateTime? ExtractStartTime(ChangeFeedStartFrom startFrom) + { + foreach (var prop in startFrom.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.PropertyType == typeof(DateTime) && prop.GetValue(startFrom) is DateTime dt) + { + return dt; + } + } + + foreach (var field in startFrom.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (field.FieldType == typeof(DateTime) && field.GetValue(startFrom) is DateTime dt) + { + return dt; + } + } + + return null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FeedRange Filtering Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static FeedRange ExtractFeedRangeFromStartFrom(ChangeFeedStartFrom startFrom) + { + // ChangeFeedStartFrom subtypes (Beginning, Now, Time, ContinuationAndFeedRange) + // store the FeedRange in an internal property. Use reflection to extract it. + foreach (var prop in startFrom.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)) + { + if (typeof(FeedRange).IsAssignableFrom(prop.PropertyType) && prop.GetValue(startFrom) is FeedRange fr) + return fr; + } + + foreach (var field in startFrom.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (typeof(FeedRange).IsAssignableFrom(field.FieldType) && field.GetValue(startFrom) is FeedRange fr) + return fr; + } + + return null; + } + + private List FilterByFeedRange(List items, FeedRange feedRange) + { + var pkValue = TryExtractPartitionKeyFromFeedRange(feedRange); + if (pkValue != null) + { + return items.Where(json => + { + var itemPk = ExtractPartitionKeyValueFromJson(json); + return string.Equals(itemPk, pkValue, StringComparison.Ordinal); + }).ToList(); + } + + var (min, max) = ParseFeedRangeBoundaries(feedRange); + if (min == null) return items; + + return items.Where(json => + { + var itemPk = ExtractPartitionKeyValueFromJson(json); + var hash = PartitionKeyHash.MurmurHash3(itemPk); + return IsHashInRange(hash, min.Value, max.Value); + }).ToList(); + } + + private List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> + FilterChangeFeedEntriesByFeedRange( + List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> entries, + FeedRange feedRange) + { + var pkValue = TryExtractPartitionKeyFromFeedRange(feedRange); + if (pkValue != null) + { + return entries.Where(entry => + string.Equals(entry.PartitionKey ?? "", pkValue, StringComparison.Ordinal)).ToList(); + } + + var (min, max) = ParseFeedRangeBoundaries(feedRange); + if (min == null) return entries; + + return entries.Where(entry => + { + var hash = PartitionKeyHash.MurmurHash3(entry.PartitionKey ?? ""); + return IsHashInRange(hash, min.Value, max.Value); + }).ToList(); + } + + private static (uint? Min, uint? Max) ParseFeedRangeBoundaries(FeedRange feedRange) + { + if (feedRange == null) return (null, null); + + try + { + var json = feedRange.ToJsonString(); + var obj = JObject.Parse(json); + var rangeObj = obj["Range"]; + if (rangeObj == null) return (null, null); + + var minStr = rangeObj["min"]?.ToString() ?? ""; + var maxStr = rangeObj["max"]?.ToString() ?? ""; + + var minVal = string.IsNullOrEmpty(minStr) ? 0u : Convert.ToUInt32(minStr, 16); + var maxVal = string.Equals(maxStr, "FF", StringComparison.OrdinalIgnoreCase) + ? uint.MaxValue + : Convert.ToUInt32(maxStr, 16); + + return (minVal, maxVal); + } + catch + { + return (null, null); + } + } + + private static string TryExtractPartitionKeyFromFeedRange(FeedRange feedRange) + { + if (feedRange == null) return null; + try + { + var json = feedRange.ToJsonString(); + var obj = JObject.Parse(json); + var pkToken = obj["PK"]; + if (pkToken == null) return null; + + // PK value is a string containing a JSON array, e.g. "[\"pk-5\"]" or "[42]" + var pkStr = pkToken.ToString(); + var pkArray = JArray.Parse(pkStr); + if (pkArray.Count == 0) return null; + // For multi-component (hierarchical) partition keys, join all parts with "|" + // to match the format used by ExtractPartitionKeyValueCore. + if (pkArray.Count == 1) + return JTokenToTypedKey(pkArray[0]) ?? ""; + var parts = pkArray.Select(t => JTokenToTypedKey(t) ?? "").ToList(); + return string.Join("|", parts); + } + catch + { + return null; + } + } + + private static bool IsHashInRange(uint hash, uint min, uint max) + { + return hash >= min && (max == uint.MaxValue || hash < max); + } + + private string ExtractPartitionKeyValueFromJson(string json) + { + JToken token; + try { token = JsonConvert.DeserializeObject(json, JsonSettings); } + catch { return ""; } + + if (token is not JObject jObj) return ""; + + if (PartitionKeyPaths is { Count: > 0 }) + { + var parts = PartitionKeyPaths.Select(path => + { + var t = jObj.SelectToken(path.TrimStart('/')); + return t is not null ? JTokenToTypedKey(t) : null; + }).ToList(); + if (parts.Count == 1) return parts[0] ?? ""; + return string.Join("|", parts.Select(p => p ?? "")); + } + + return ""; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Change Feed — Processor Builders + // ═══════════════════════════════════════════════════════════════════════════ + + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, Container.ChangesHandler onChangesDelegate) + => ChangeFeedProcessorBuilderFactory.Create(processorName, + new InMemoryChangeFeedProcessor(this, onChangesDelegate)); + + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, Container.ChangeFeedHandler onChangesDelegate) + => ChangeFeedProcessorBuilderFactory.Create(processorName, + new InMemoryChangeFeedProcessor(this, onChangesDelegate)); + + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, Container.ChangeFeedStreamHandler onChangesDelegate) + => ChangeFeedProcessorBuilderFactory.Create(processorName, + new InMemoryChangeFeedStreamProcessor(this, onChangesDelegate)); + + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( + string processorName, Container.ChangeFeedHandlerWithManualCheckpoint onChangesDelegate) + => ChangeFeedProcessorBuilderFactory.Create(processorName, + new InMemoryManualCheckpointChangeFeedProcessor(this, onChangesDelegate)); + + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( + string processorName, Container.ChangeFeedStreamHandlerWithManualCheckpoint onChangesDelegate) + => ChangeFeedProcessorBuilderFactory.Create(processorName, + new InMemoryManualCheckpointStreamChangeFeedProcessor(this, onChangesDelegate)); + + public override ChangeFeedEstimator GetChangeFeedEstimator( + string processorName, Container leaseContainer) + => Substitute.For(); + + public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder( + string processorName, Container.ChangesEstimationHandler estimationDelegate, + TimeSpan? estimationPeriod = null) + => ChangeFeedProcessorBuilderFactory.Create(processorName, new NoOpChangeFeedProcessor()); + + // ═══════════════════════════════════════════════════════════════════════════ + // Feed Ranges + // ═══════════════════════════════════════════════════════════════════════════ + + public override Task> GetFeedRangesAsync(CancellationToken cancellationToken = default) + { + var count = Math.Max(1, FeedRangeCount); + var ranges = new List(count); + var step = 0x1_0000_0000L / count; + + for (var i = 0; i < count; i++) + { + var min = PartitionKeyHash.RangeBoundaryToHex(i * step); + var max = (i == count - 1) ? "FF" : PartitionKeyHash.RangeBoundaryToHex((i + 1) * step); + ranges.Add(FeedRange.FromJsonString($"{{\"Range\":{{\"min\":\"{min}\",\"max\":\"{max}\"}}}}")); + } + + return Task.FromResult>(ranges); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Container management + // ═══════════════════════════════════════════════════════════════════════════ + + public override Task ReadContainerAsync( + ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_isDeleted || (!ExplicitlyCreated && _items.IsEmpty)) + { + throw InMemoryCosmosException.Create($"Container '{Id}' not found.", HttpStatusCode.NotFound, 1003, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + _containerProperties.IndexingPolicy = IndexingPolicy; + _containerProperties.DefaultTimeToLive = DefaultTimeToLive; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(_containerProperties); + r.Container.Returns(this); + return Task.FromResult(r); + } + + public override Task ReadContainerStreamAsync( + ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_isDeleted || (!ExplicitlyCreated && _items.IsEmpty)) + { + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NotFound)); + } + _containerProperties.IndexingPolicy = IndexingPolicy; + _containerProperties.DefaultTimeToLive = DefaultTimeToLive; + return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, JsonConvert.SerializeObject(_containerProperties, JsonSettings))); + } + + public override Task ReplaceContainerAsync( + ContainerProperties containerProperties, ContainerRequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (containerProperties.Id != Id) + throw InMemoryCosmosException.Create($"Container id '{containerProperties.Id}' does not match the existing container id '{Id}'.", HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + ValidateContainerReplace(containerProperties); + ValidateComputedProperties(containerProperties); + // Preserve UniqueKeyPolicy from the existing properties if the replacement doesn't include it + if (_containerProperties.UniqueKeyPolicy?.UniqueKeys?.Count > 0 && + (containerProperties.UniqueKeyPolicy is null || containerProperties.UniqueKeyPolicy.UniqueKeys.Count == 0)) + { + containerProperties.UniqueKeyPolicy = _containerProperties.UniqueKeyPolicy; + } + _containerProperties = containerProperties; + _parsedComputedProperties = null; + DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (containerProperties.IndexingPolicy is not null) + IndexingPolicy = containerProperties.IndexingPolicy; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(containerProperties); + r.Container.Returns(this); + return Task.FromResult(r); + } + + public override Task ReplaceContainerStreamAsync( + ContainerProperties containerProperties, ContainerRequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (containerProperties.Id != Id) + return Task.FromResult(CreateResponseMessage(HttpStatusCode.BadRequest)); + try + { + ValidateContainerReplace(containerProperties); + ValidateComputedProperties(containerProperties); + } + catch (CosmosException) + { + return Task.FromResult(CreateResponseMessage(HttpStatusCode.BadRequest)); + } + // Preserve UniqueKeyPolicy from the existing properties if the replacement doesn't include it + if (_containerProperties.UniqueKeyPolicy?.UniqueKeys?.Count > 0 && + (containerProperties.UniqueKeyPolicy is null || containerProperties.UniqueKeyPolicy.UniqueKeys.Count == 0)) + { + containerProperties.UniqueKeyPolicy = _containerProperties.UniqueKeyPolicy; + } + _containerProperties = containerProperties; + _parsedComputedProperties = null; + DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (containerProperties.IndexingPolicy is not null) + IndexingPolicy = containerProperties.IndexingPolicy; + return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK, JsonConvert.SerializeObject(containerProperties, JsonSettings))); + } + + public override Task DeleteContainerAsync( + ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _isDeleted = true; + _items.Clear(); + _etags.Clear(); + _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } + lock (_changeFeedLock) { _changeFeed.Clear(); } + _storedProcedures.Clear(); + _userDefinedFunctions.Clear(); + _udfProperties.Clear(); + _triggers.Clear(); + _storedProcedureProperties.Clear(); + _triggerProperties.Clear(); + OnDeleted?.Invoke(); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.NoContent); + r.Container.Returns(this); + return Task.FromResult(r); + } + + public override Task DeleteContainerStreamAsync( + ContainerRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _isDeleted = true; + _items.Clear(); + _etags.Clear(); + _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } + lock (_changeFeedLock) { _changeFeed.Clear(); } + _storedProcedures.Clear(); + _userDefinedFunctions.Clear(); + _udfProperties.Clear(); + _triggers.Clear(); + _storedProcedureProperties.Clear(); + _triggerProperties.Clear(); + OnDeleted?.Invoke(); + return Task.FromResult(CreateResponseMessage(HttpStatusCode.NoContent)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Throughput + // ═══════════════════════════════════════════════════════════════════════════ + + public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) + => Task.FromResult(_throughput); + + public override Task ReadThroughputAsync( + RequestOptions requestOptions, CancellationToken cancellationToken = default) + { + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(ThroughputProperties.CreateManualThroughput(_throughput)); + return Task.FromResult(r); + } + + public override Task ReplaceThroughputAsync( + int throughput, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + _throughput = throughput; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(ThroughputProperties.CreateManualThroughput(throughput)); + return Task.FromResult(r); + } + + public override Task ReplaceThroughputAsync( + ThroughputProperties throughputProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (throughputProperties.Throughput.HasValue) + _throughput = throughputProperties.Throughput.Value; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(throughputProperties); + return Task.FromResult(r); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Delete all items by partition key + // ═══════════════════════════════════════════════════════════════════════════ + + public override Task DeleteAllItemsByPartitionKeyStreamAsync( + PartitionKey partitionKey, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + var pk = PartitionKeyToString(partitionKey); + foreach (var key in _items.Keys.Where(k => k.PartitionKey == pk).ToList()) + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + RecordDeleteTombstone(key.Id, pk, partitionKey); + } + return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Private helpers — Key management & ETag + // ═══════════════════════════════════════════════════════════════════════════ + + private static (string Id, string PartitionKey) ItemKey(string id, string partitionKey) => (id, partitionKey); + + private string ExtractPartitionKeyValue(PartitionKey? partitionKey, JObject jObj) + { + var pk = ExtractPartitionKeyValueCore(partitionKey, jObj); + if (pk is not null && System.Text.Encoding.UTF8.GetByteCount(pk) > 2048) + throw InMemoryCosmosException.Create("Partition key value exceeds the maximum allowed size of 2KB.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + return pk; + } + + private string ExtractPartitionKeyValueCore(PartitionKey? partitionKey, JObject jObj) + { + if (partitionKey.HasValue) + { + if (partitionKey.Value == PartitionKey.Null) + { + return null; + } + + if (partitionKey.Value != PartitionKey.None) + { + return PartitionKeyToString(partitionKey.Value); + } + } + + if (PartitionKeyPaths is { Count: > 0 }) + { + if (PartitionKeyPaths.Count > 1) + { + // Composite key — preserve null positions as empty strings for consistency + // with PartitionKeyToString which also uses empty string for null components + var parts = PartitionKeyPaths.Select(path => + { + var t = jObj.SelectToken(path.TrimStart('/')); + return t is not null ? JTokenToTypedKey(t) : string.Empty; + }).ToList(); + return string.Join("|", parts.Select(p => p ?? string.Empty)); + } + + // Single path — check if the field exists + var token = jObj.SelectToken(PartitionKeyPaths[0].TrimStart('/')); + if (token is not null) + { + // Field exists — if value is null, return null (don't fall back to id) + return token.Type == JTokenType.Null ? null : JTokenToTypedKey(token); + } + } + + return null; + } + + internal static string JTokenToTypedKey(JToken token) + { + return token.Type switch + { + JTokenType.String => "S:" + token.Value(), + JTokenType.Integer or JTokenType.Float => "N:" + token.Value().ToString("R", System.Globalization.CultureInfo.InvariantCulture), + JTokenType.Boolean => "B:" + (token.Value() ? "true" : "false"), + JTokenType.Null => null, + _ => token.ToString() + }; + } + + /// + /// Converts a type-prefixed internal key back to a JToken for document fields. + /// + private static JToken TypedKeyToJToken(string typedKey) + { + if (typedKey is null) return JValue.CreateNull(); + if (typedKey.Length >= 2 && typedKey[1] == ':') + { + var value = typedKey.Substring(2); + return typedKey[0] switch + { + 'S' => new JValue(value), + 'N' => new JValue(double.Parse(value, System.Globalization.CultureInfo.InvariantCulture)), + 'B' => new JValue(value == "true"), + _ => new JValue(typedKey) + }; + } + return new JValue(typedKey); + } + + /// + /// Converts a type-prefixed internal key back to a typed PartitionKey. + /// + private static PartitionKey TypedKeyToPartitionKey(string typedKey) + { + if (typedKey is null) return PartitionKey.Null; + if (typedKey.Length >= 2 && typedKey[1] == ':') + { + var value = typedKey.Substring(2); + return typedKey[0] switch + { + 'S' => new PartitionKey(value), + 'N' => new PartitionKey(double.Parse(value, System.Globalization.CultureInfo.InvariantCulture)), + 'B' => new PartitionKey(bool.Parse(value)), + _ => new PartitionKey(typedKey) + }; + } + return new PartitionKey(typedKey); + } + + private static string PartitionKeyToString(PartitionKey partitionKey) + { + if (partitionKey == PartitionKey.None || partitionKey == PartitionKey.Null) + { + return null; + } + + var raw = partitionKey.ToString(); + if (raw.StartsWith("[")) + { + try + { + var arr = JArray.Parse(raw); + if (arr.Count == 1) + { + return JTokenToTypedKey(arr[0]); + } + + return string.Join("|", arr.Select(JTokenToTypedKey)); + } + catch { /* fall through */ } + } + return raw; + } + + private static string GenerateETag() => $"\"{Interlocked.Increment(ref _etagCounter):x16}\""; + + private void ValidatePartitionKeyConsistency(PartitionKey? explicitKey, JObject jObj) + { + if (!explicitKey.HasValue || explicitKey.Value == PartitionKey.None || explicitKey.Value == PartitionKey.Null) + return; + + // Only validate single-path partition keys (composite PKs have complex semantics) + if (PartitionKeyPaths is not { Count: 1 }) + return; + + var pkPath = PartitionKeyPaths[0].TrimStart('/'); + var bodyToken = jObj.SelectToken(pkPath); + if (bodyToken is null) + return; // Field not present in body — nothing to validate + + var explicitPk = PartitionKeyToString(explicitKey.Value); + var bodyPk = JTokenToTypedKey(bodyToken); + + if (bodyPk != explicitPk) + { + throw InMemoryCosmosException.Create( + "PartitionKey extracted from document doesn't match the one specified in the header. " + + "Learn more: https://aka.ms/CosmosDB/sql/errors/wrong-pk-value", + HttpStatusCode.BadRequest, 1001, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + /// + /// Stream-API variant of that returns + /// a instead of throwing, matching the stream API convention. + /// + private ResponseMessage ValidatePartitionKeyConsistencyStream(PartitionKey? explicitKey, JObject jObj) + { + if (!explicitKey.HasValue || explicitKey.Value == PartitionKey.None || explicitKey.Value == PartitionKey.Null) + return null; + + if (PartitionKeyPaths is not { Count: 1 }) + return null; + + var pkPath = PartitionKeyPaths[0].TrimStart('/'); + var bodyToken = jObj.SelectToken(pkPath); + if (bodyToken is null) + return null; + + var explicitPk = PartitionKeyToString(explicitKey.Value); + var bodyPk = JTokenToTypedKey(bodyToken); + + if (bodyPk != explicitPk) + { + return CreateResponseMessage(HttpStatusCode.BadRequest, subStatusCode: 1001); + } + + return null; + } + + private void ValidatePatchPaths(IReadOnlyList operations) + { + foreach (var op in operations) + { + var path = op.Path; + if (string.Equals(path, "/id", StringComparison.OrdinalIgnoreCase)) + { + throw InMemoryCosmosException.Create( + "Cannot patch the 'id' field.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + if (PartitionKeyPaths is { Count: > 0 }) + { + foreach (var pkPath in PartitionKeyPaths) + { + if (string.Equals(path, pkPath, StringComparison.OrdinalIgnoreCase)) + { + throw InMemoryCosmosException.Create( + "Cannot patch the partition key field.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + } + + var cps = _containerProperties?.ComputedProperties; + if (cps is { Count: > 0 }) + { + foreach (var cp in cps) + { + if (string.Equals(path, "/" + cp.Name, StringComparison.OrdinalIgnoreCase)) + { + throw InMemoryCosmosException.Create( + "Cannot patch a computed property path.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + } + } + } + + private void ValidateContainerReplace(ContainerProperties newProperties) + { + // Real Cosmos DB rejects partition key path changes + var existingPkPath = _containerProperties.PartitionKeyPath; + var newPkPath = newProperties.PartitionKeyPath; + if (!string.IsNullOrEmpty(newPkPath) && !string.IsNullOrEmpty(existingPkPath) && newPkPath != existingPkPath) + { + throw InMemoryCosmosException.Create( + "Partition key paths for a container cannot be changed.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private void ValidatePerItemTtl(JObject jObj) + { + var ttlToken = jObj["ttl"] ?? jObj["_ttl"]; + if (ttlToken is not null && int.TryParse(ttlToken.ToString(), out var ttlValue) && ttlValue == 0) + { + throw InMemoryCosmosException.Create( + "The value of ttl must be either -1 or a positive integer.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private ResponseMessage ValidatePerItemTtlStream(JObject jObj) + { + var ttlToken = jObj["ttl"] ?? jObj["_ttl"]; + if (ttlToken is not null && int.TryParse(ttlToken.ToString(), out var ttlValue) && ttlValue == 0) + { + return CreateResponseMessage(HttpStatusCode.BadRequest); + } + return null; + } + + private static void ValidateMaxItemCount(QueryRequestOptions requestOptions) + { + if (requestOptions?.MaxItemCount == 0) + { + throw InMemoryCosmosException.Create( + "MaxItemCount must be a positive value or -1.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + } + + private static readonly HashSet ReservedComputedPropertyNames = new(StringComparer.OrdinalIgnoreCase) + { + "id", "_rid", "_ts", "_etag", "_self", "_attachments" + }; + + private static readonly string[] ProhibitedCpClauses = + { " WHERE ", " ORDER BY ", " GROUP BY ", " TOP ", " DISTINCT ", " OFFSET ", " LIMIT ", " JOIN " }; + + private static void ValidateComputedProperties(ContainerProperties properties) + { + var cps = properties.ComputedProperties; + if (cps is null or { Count: 0 }) return; + + if (cps.Count > 20) + { + throw InMemoryCosmosException.Create( + "A container can have at most 20 computed properties.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + // Two-pass: collect all names first for cross-CP reference check (order-independent) + var allNames = new HashSet(StringComparer.Ordinal); + foreach (var cp in cps) + { + if (!allNames.Add(cp.Name)) + { + throw InMemoryCosmosException.Create( + $"Duplicate computed property name '{cp.Name}'.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + } + + foreach (var cp in cps) + { + if (ReservedComputedPropertyNames.Contains(cp.Name)) + { + throw InMemoryCosmosException.Create( + $"Computed property name '{cp.Name}' is a reserved system property name.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + if (string.IsNullOrWhiteSpace(cp.Query)) + { + throw InMemoryCosmosException.Create( + $"Computed property '{cp.Name}' must have a non-empty query.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + // Normalize whitespace for robust checking + var normalized = System.Text.RegularExpressions.Regex.Replace(cp.Query.Trim(), @"\s+", " "); + if (!normalized.StartsWith("SELECT VALUE", StringComparison.OrdinalIgnoreCase)) + { + throw InMemoryCosmosException.Create( + "Computed property query must use 'SELECT VALUE' syntax.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + // Check entire query for prohibited clauses + foreach (var clause in ProhibitedCpClauses) + { + if (normalized.IndexOf(clause, StringComparison.OrdinalIgnoreCase) >= 0) + { + throw InMemoryCosmosException.Create( + $"Computed property query cannot contain '{clause.Trim()}' clause.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + } + + // Check for self-referencing CP + var selfPattern = @"\." + System.Text.RegularExpressions.Regex.Escape(cp.Name) + @"(?![a-zA-Z0-9_])"; + if (System.Text.RegularExpressions.Regex.IsMatch(normalized, selfPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + throw InMemoryCosmosException.Create( + $"Computed property '{cp.Name}' cannot reference itself.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + // Check for cross-CP references (all other CP names, order-independent) + foreach (var otherName in allNames) + { + if (otherName.Equals(cp.Name, StringComparison.OrdinalIgnoreCase)) continue; + var crossPattern = @"\." + System.Text.RegularExpressions.Regex.Escape(otherName) + @"(?![a-zA-Z0-9_])"; + if (System.Text.RegularExpressions.Regex.IsMatch(normalized, crossPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + throw InMemoryCosmosException.Create( + $"Computed property '{cp.Name}' cannot reference another computed property '{otherName}'.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + } + } + } + + private string EnrichWithSystemProperties(string json, string etag, DateTimeOffset timestamp) + { + var jObj = JsonParseHelpers.ParseJson(json); + var containerHash = PartitionKeyHash.MurmurHash3(Id); + var docId = (uint)Interlocked.Increment(ref _docRidCounter); + var ridBytes = new byte[8]; + Buffer.BlockCopy(BitConverter.GetBytes(containerHash), 0, ridBytes, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(docId), 0, ridBytes, 4, 4); + jObj["_rid"] = Convert.ToBase64String(ridBytes); + jObj["_self"] = $"dbs/db/colls/col/docs/{jObj["id"]}"; + jObj["_etag"] = etag; + jObj["_ts"] = timestamp.ToUnixTimeSeconds(); + jObj["_attachments"] = "attachments/"; + var enriched = jObj.ToString(Formatting.None); + ValidateDocumentSize(enriched); + return enriched; + } + + private static int ParseContinuationToken(string continuationToken) + { + if (continuationToken is null) + return 0; + + if (int.TryParse(continuationToken, out var offset)) + return offset; + + throw InMemoryCosmosException.Create( + $"Invalid continuation token '{continuationToken}'.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), 0); + } + + private void RecordChangeFeed(string id, string partitionKey, string json, bool isDelete = false) + { + lock (_changeFeedLock) + { + _sessionSequence++; + var lsn = _changeFeedLsnCounter++; + var jsonWithLsn = json.Insert(1, $"\"_lsn\":{lsn},"); + _changeFeed.Add((DateTimeOffset.UtcNow, id, partitionKey, jsonWithLsn, isDelete)); + + if (MaxChangeFeedSize > 0 && _changeFeed.Count > MaxChangeFeedSize) + { + var excess = _changeFeed.Count - MaxChangeFeedSize; + _changeFeed.RemoveRange(0, excess); + } + } + } + + private void RecordDeleteTombstone(string id, string pk, PartitionKey partitionKey = default, bool isTtlEviction = false) + { + var tombstone = new JObject { ["id"] = id, ["_deleted"] = true, ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; + if (isTtlEviction) tombstone["_ttlEviction"] = true; + + if (PartitionKeyPaths.Count > 1 && partitionKey != default && partitionKey != PartitionKey.None && partitionKey != PartitionKey.Null) + { + // For composite keys, parse from the PartitionKey object directly to avoid + // pipe-delimiter issues when a PK value itself contains '|'. + var raw = partitionKey.ToString(); + try + { + var arr = JArray.Parse(raw); + for (var i = 0; i < PartitionKeyPaths.Count && i < arr.Count; i++) + { + tombstone[PartitionKeyPaths[i].TrimStart('/')] = arr[i].Type == JTokenType.String + ? arr[i].Value() + : arr[i].ToString(); + } + } + catch + { + // Fallback to split-based approach + var pkValues = (pk ?? string.Empty).Split('|'); + for (var i = 0; i < PartitionKeyPaths.Count; i++) + { + tombstone[PartitionKeyPaths[i].TrimStart('/')] = i < pkValues.Length ? TypedKeyToJToken(pkValues[i]) : null; + } + } + } + else if (pk is not null) + { + if (PartitionKeyPaths.Count == 1) + { + // Single PK path — convert the typed key back to a JToken value + tombstone[PartitionKeyPaths[0].TrimStart('/')] = TypedKeyToJToken(pk); + } + else + { + var pkValues = pk.Split('|'); + for (var i = 0; i < PartitionKeyPaths.Count; i++) + { + tombstone[PartitionKeyPaths[i].TrimStart('/')] = i < pkValues.Length ? TypedKeyToJToken(pkValues[i]) : null; + } + } + } + + RecordChangeFeed(id, pk, tombstone.ToString(Newtonsoft.Json.Formatting.None), isDelete: true); + } + + private static TriggerOperation OperationNameToTriggerOp(string operationName) => operationName switch + { + "Create" => TriggerOperation.Create, + "Replace" => TriggerOperation.Replace, + "Upsert" => TriggerOperation.Upsert, + "Delete" => TriggerOperation.Delete, + _ => TriggerOperation.All + }; + + private PartitionScopedCollectionContext CreateCollectionContextFromDoc(JObject jObj) + { + var pkValue = ExtractPartitionKeyValueCore(null, jObj); + var pk = TypedKeyToPartitionKey(pkValue); + return new PartitionScopedCollectionContext(this, pk); + } + + private static bool TriggerOperationMatches(TriggerOperation registered, TriggerOperation current) + => registered == TriggerOperation.All || registered == current; + + private JObject ExecutePreTriggers(ItemRequestOptions requestOptions, JObject jObj, string operationName) + { + var triggerNames = requestOptions?.PreTriggers; + if (triggerNames is null) return jObj; + + var currentOp = OperationNameToTriggerOp(operationName); + + foreach (var name in triggerNames) + { + // Priority 1: C# handler + if (_triggers.TryGetValue(name, out var trigger)) + { + if (trigger.TriggerType != TriggerType.Pre || trigger.PreHandler is null) continue; + if (!TriggerOperationMatches(trigger.TriggerOperation, currentOp)) continue; + + try + { + jObj = trigger.PreHandler(jObj); + } + catch (CosmosException) { throw; } + catch (Exception ex) + { + throw InMemoryCosmosException.Create( + $"Pre-trigger '{name}' failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + break; // Real Cosmos only fires the first matching trigger + } + + // Priority 2: JS body via JsTriggerEngine + if (_triggerProperties.TryGetValue(name, out var props) && props.Body is not null) + { + if (props.TriggerType != TriggerType.Pre) continue; + if (!TriggerOperationMatches(props.TriggerOperation, currentOp)) continue; + + if (JsTriggerEngine is null) + { + throw InMemoryCosmosException.Create( + $"Trigger '{name}' has a JavaScript body but no JS trigger engine is configured. " + + "Install the CosmosDB.InMemoryEmulator.JsTriggers package and call container.UseJsTriggers().", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + var originalId = jObj["id"]?.ToString(); + var pkPath = PartitionKeyPaths[0].TrimStart('/'); + var originalPk = jObj.SelectToken(pkPath)?.ToString(); + + jObj = JsTriggerEngine.ExecutePreTrigger(props.Body, jObj, + CreateCollectionContextFromDoc(jObj)); + + // Validate the trigger did not change id or partition key + var newId = jObj["id"]?.ToString(); + var newPk = jObj.SelectToken(pkPath)?.ToString(); + if (!string.Equals(originalId, newId, StringComparison.Ordinal)) + { + throw InMemoryCosmosException.Create( + $"Pre-trigger '{name}' is not allowed to modify the document id.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + if (!string.Equals(originalPk, newPk, StringComparison.Ordinal)) + { + throw InMemoryCosmosException.Create( + $"Pre-trigger '{name}' is not allowed to modify the partition key.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + break; // Real Cosmos only fires the first matching trigger + } + + throw InMemoryCosmosException.Create( + $"Trigger '{name}' is not registered. Register it via RegisterTrigger() or CreateTriggerAsync() before referencing it in PreTriggers.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + return jObj; + } + + private JObject ExecutePostTriggers(ItemRequestOptions requestOptions, JObject committedDoc, string operationName) + { + var triggerNames = requestOptions?.PostTriggers; + if (triggerNames is null) return null; + + JObject responseBodyOverride = null; + var currentOp = OperationNameToTriggerOp(operationName); + + foreach (var name in triggerNames) + { + // Priority 1: C# handler + if (_triggers.TryGetValue(name, out var trigger)) + { + if (trigger.TriggerType != TriggerType.Post || trigger.PostHandler is null) continue; + if (!TriggerOperationMatches(trigger.TriggerOperation, currentOp)) continue; + + try + { + trigger.PostHandler(committedDoc); + } + catch (CosmosException) + { + throw; + } + catch (Exception ex) + { + throw InMemoryCosmosException.Create( + $"Post-trigger '{name}' failed: {ex.Message}", + HttpStatusCode.InternalServerError, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + break; // Real Cosmos only fires the first matching trigger + } + + // Priority 2: JS body via JsTriggerEngine + if (_triggerProperties.TryGetValue(name, out var props) && props.Body is not null) + { + if (props.TriggerType != TriggerType.Post) continue; + if (!TriggerOperationMatches(props.TriggerOperation, currentOp)) continue; + + if (JsTriggerEngine is null) + { + throw InMemoryCosmosException.Create( + $"Trigger '{name}' has a JavaScript body but no JS trigger engine is configured. " + + "Install the CosmosDB.InMemoryEmulator.JsTriggers package and call container.UseJsTriggers().", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + try + { + var setBodyResult = JsTriggerEngine.ExecutePostTrigger(props.Body, committedDoc, + CreateCollectionContextFromDoc(committedDoc)); + if (setBodyResult is not null) + { + responseBodyOverride = setBodyResult; + } + } + catch (CosmosException) + { + throw; + } + catch (Exception ex) + { + throw InMemoryCosmosException.Create( + $"Post-trigger '{name}' failed: {ex.Message}", + HttpStatusCode.InternalServerError, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + break; // Real Cosmos only fires the first matching trigger + } + + throw InMemoryCosmosException.Create( + $"Trigger '{name}' is not registered. Register it via RegisterTrigger() or CreateTriggerAsync() before referencing it in PostTriggers.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + return responseBodyOverride; + } + + private void CheckIfMatch(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) + { + if (requestOptions?.IfMatchEtag is null) + { + return; + } + + if (requestOptions.IfMatchEtag == "*") + { + return; + } + + if (!_etags.TryGetValue(key, out var currentEtag)) + { + return; + } + + if (requestOptions.IfMatchEtag != currentEtag) + { + throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private void CheckIfNoneMatch(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) + { + if (requestOptions?.IfNoneMatchEtag is null) + { + return; + } + + if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) + { + throw InMemoryCosmosException.Create("Not Modified", HttpStatusCode.NotModified, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) + { + throw InMemoryCosmosException.Create("Not Modified", HttpStatusCode.NotModified, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + /// + /// IfNoneMatch check for write operations (Patch). Write operations return 412 PreconditionFailed + /// instead of 304 NotModified when the ETag matches. + /// + private void CheckIfNoneMatchForWrite(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) + { + if (requestOptions?.IfNoneMatchEtag is null) + { + return; + } + + if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) + { + throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) + { + throw InMemoryCosmosException.Create("Precondition Failed", HttpStatusCode.PreconditionFailed, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private bool CheckIfMatchStream(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) + { + if (requestOptions?.IfMatchEtag is null) + { + return true; + } + + if (requestOptions.IfMatchEtag == "*") + { + return true; + } + + if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfMatchEtag != currentEtag) + { + return false; + } + + return true; + } + + private bool CheckIfNoneMatchStream(ItemRequestOptions requestOptions, (string Id, string PartitionKey) key) + { + if (requestOptions?.IfNoneMatchEtag is null) + { + return true; + } + + if (requestOptions.IfNoneMatchEtag == "*" && _etags.ContainsKey(key)) + { + return false; + } + + if (_etags.TryGetValue(key, out var currentEtag) && requestOptions.IfNoneMatchEtag == currentEtag) + { + return false; + } + + return true; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Private helpers — Serialization & Response factories + // ═══════════════════════════════════════════════════════════════════════════ + + private static string ReadStream(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true); + return reader.ReadToEnd(); + } + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + private static readonly CosmosDiagnostics FakeDiagnostics = new InMemoryDiagnostics(); + + private sealed class InMemoryDiagnostics : CosmosDiagnostics + { + public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; + public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); + public override string ToString() => "{}"; + } + + private ItemResponse CreateItemResponse(T item, HttpStatusCode statusCode, string etag = null, bool suppressContent = false) + { + var activityId = Guid.NewGuid().ToString(); + var headers = new Headers + { + ["x-ms-session-token"] = CurrentSessionToken, + ["x-ms-activity-id"] = activityId, + ["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture) + }; + return new InMemoryItemResponse( + statusCode, headers, suppressContent ? default : item, FakeDiagnostics, + SyntheticRequestCharge, activityId, etag); + } + + private sealed class InMemoryItemResponse : ItemResponse + { + private readonly HttpStatusCode _statusCode; + private readonly Headers _headers; + private readonly T _resource; + private readonly CosmosDiagnostics _diagnostics; + private readonly double _requestCharge; + private readonly string _activityId; + private readonly string _etag; + + public InMemoryItemResponse( + HttpStatusCode statusCode, Headers headers, T resource, CosmosDiagnostics diagnostics, + double requestCharge, string activityId, string etag) + { + _statusCode = statusCode; + _headers = headers; + _resource = resource; + _diagnostics = diagnostics; + _requestCharge = requestCharge; + _activityId = activityId; + _etag = etag; + } + + public override HttpStatusCode StatusCode => _statusCode; + public override Headers Headers => _headers; + public override T Resource => _resource; + public override CosmosDiagnostics Diagnostics => _diagnostics; + public override double RequestCharge => _requestCharge; + public override string ActivityId => _activityId; + public override string ETag => _etag; + } + + private ResponseMessage CreateResponseMessage(HttpStatusCode statusCode, string json = null, string etag = null, int subStatusCode = 0) + { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + var errorMessage = statusCode switch + { + HttpStatusCode.NotFound => "Resource Not Found. Entity with the specified id does not exist in the system.", + _ when (int)statusCode >= 400 => $"Response status code does not indicate success: {statusCode} ({(int)statusCode})", + _ => null + }; + + // For error responses with no explicit body, synthesize a JSON error body matching the + // real Cosmos DB REST API format. The SDK reads this body to populate CosmosException.ResponseBody. + // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} + if (json is null && errorMessage is not null) + { + json = $"{{\"code\":\"{statusCode}\",\"message\":\"{errorMessage}\"}}"; + } + + var msg = new ResponseMessage(statusCode, requestMessage: null, errorMessage: errorMessage) + { + Content = json is not null ? ToStream(json) : null + }; + msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + msg.Headers["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture); + msg.Headers["x-ms-session-token"] = CurrentSessionToken; + if (subStatusCode != 0) + { + msg.Headers["x-ms-substatus"] = subStatusCode.ToString(CultureInfo.InvariantCulture); + } + if (etag is not null) + { + msg.Headers["ETag"] = etag; + } + + return msg; + } + + private static void ValidateDocumentSize(string json) + { + var byteCount = Encoding.UTF8.GetByteCount(json); + if (byteCount > MaxDocumentSizeBytes) + { + throw InMemoryCosmosException.Create( + $"Request size is too large. Max allowed size in bytes: {MaxDocumentSizeBytes}. Found: {byteCount}.", + HttpStatusCode.RequestEntityTooLarge, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private ResponseMessage ValidateDocumentSizeStream(string json) + { + var byteCount = Encoding.UTF8.GetByteCount(json); + if (byteCount > MaxDocumentSizeBytes) + { + return CreateResponseMessage(HttpStatusCode.RequestEntityTooLarge); + } + return null; + } + + private void ValidateUniqueKeys(JObject jObj, string partitionKey, string excludeItemId = null) + { + var policy = _containerProperties.UniqueKeyPolicy; + if (policy == null || policy.UniqueKeys.Count == 0) return; + + foreach (var uniqueKey in policy.UniqueKeys) + { + // Cosmos DB unique key paths use /parent/child format. + // JObject.SelectToken uses dot notation (parent.child). + var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(p.TrimStart('/').Replace('/', '.'))?.ToString()).ToList(); + + foreach (var (existingKey, existingJson) in _items) + { + if (existingKey.PartitionKey != partitionKey) continue; + if (excludeItemId != null && existingKey.Id == excludeItemId) continue; + + var existingObj = JsonParseHelpers.ParseJson(existingJson); + var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(p.TrimStart('/').Replace('/', '.'))?.ToString()).ToList(); + + if (newValues.SequenceEqual(existingValues)) + { + throw InMemoryCosmosException.Create( + "Unique index constraint violation.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + } + } + + private bool ValidateUniqueKeysStream(JObject jObj, string partitionKey, string excludeItemId = null) + { + try + { + ValidateUniqueKeys(jObj, partitionKey, excludeItemId); + return true; + } + catch (CosmosException) + { + return false; + } + } + + private bool IsExpired((string Id, string PartitionKey) key) + { + // TTL feature is completely disabled when DefaultTimeToLive is null. + // Per-item _ttl is ignored in this case (matches real Cosmos DB behaviour). + if (DefaultTimeToLive is null) + { + return false; + } + + if (!_timestamps.TryGetValue(key, out var ts)) + { + return false; + } + + var elapsed = (DateTimeOffset.UtcNow - ts).TotalSeconds; + + if (_items.TryGetValue(key, out var json)) + { + var jObj = JsonParseHelpers.ParseJson(json); + var itemTtl = jObj["ttl"] ?? jObj["_ttl"]; + if (itemTtl is not null && int.TryParse(itemTtl.ToString(), out var perItemTtl)) + { + // ttl = -1 means "never expire" even if container has a default TTL + if (perItemTtl == -1) return false; + return elapsed >= perItemTtl; + } + } + + // DefaultTimeToLive = -1 means TTL is ON but items without per-item ttl don't expire + if (DefaultTimeToLive.Value <= 0) + { + return false; + } + + return elapsed >= DefaultTimeToLive.Value; + } + + private void EvictIfExpired((string Id, string PartitionKey) key) + { + if (!IsExpired(key)) + { + return; + } + + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + + // Record a delete tombstone in the change feed so consumers see TTL evictions + RecordDeleteTombstone(key.Id, key.PartitionKey, isTtlEviction: true); + } + + internal Dictionary<(string Id, string PartitionKey), string> SnapshotItems() + => new(_items); + + internal Dictionary<(string Id, string PartitionKey), string> SnapshotEtags() + => new(_etags); + + internal Dictionary<(string Id, string PartitionKey), DateTimeOffset> SnapshotTimestamps() + => new(_timestamps); + + internal int GetChangeFeedCount() + { + lock (_changeFeedLock) + { + return _changeFeed.Count; + } + } + + internal void BeginBatchTracking() + { + BatchWriteTracker.Value = new HashSet<(string Id, string PartitionKey)>(); + } + + internal HashSet<(string Id, string PartitionKey)> EndBatchTracking() + { + var keys = BatchWriteTracker.Value ?? new HashSet<(string Id, string PartitionKey)>(); + BatchWriteTracker.Value = null; + return keys; + } + + private void TrackBatchWrite((string Id, string PartitionKey) key) + { + BatchWriteTracker.Value?.Add(key); + } + + internal void RestoreSnapshot( + Dictionary<(string Id, string PartitionKey), string> itemsSnapshot, + Dictionary<(string Id, string PartitionKey), string> etagsSnapshot, + Dictionary<(string Id, string PartitionKey), DateTimeOffset> timestampsSnapshot, + int changeFeedCount, + IReadOnlySet<(string Id, string PartitionKey)> touchedKeys) + { + foreach (var key in touchedKeys) + { + if (itemsSnapshot.TryGetValue(key, out var snapshotItem)) + { + _items[key] = snapshotItem; + _etags[key] = etagsSnapshot[key]; + _timestamps[key] = timestampsSnapshot[key]; + } + else + { + _items.TryRemove(key, out _); + _etags.TryRemove(key, out _); + _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + } + } + + // Restore insertion order for keys that were newly added during the batch + // but didn't exist in the snapshot — they need to be removed from insertion order. + // Keys that existed in the snapshot should already be in insertion order. + lock (_insertionOrderLock) + { + foreach (var key in touchedKeys) + { + if (!itemsSnapshot.ContainsKey(key)) + { + // Already removed above + } + else if (!_insertionOrder.Contains(key)) + { + // Item existed in snapshot but is missing from insertion order + // (shouldn't normally happen, but be safe) + _insertionOrder.Add(key); + } + } + } + + lock (_changeFeedLock) + { + while (_changeFeed.Count > changeFeedCount) + { + _changeFeed.RemoveAt(_changeFeed.Count - 1); + } + } + } + + private sealed class InMemoryScripts : Scripts + { + private readonly InMemoryContainer _c; + + public InMemoryScripts(InMemoryContainer container) => _c = container; + + // ── Stored Procedure CRUD (typed) ───────────────────────────────── + + public override Task CreateStoredProcedureAsync( + StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' already exists.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; + EnrichStoredProcedureSystemProperties(storedProcedureProperties); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.Created); + r.Resource.Returns(storedProcedureProperties); + return Task.FromResult(r); + } + + public override Task ReadStoredProcedureAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.TryGetValue(id, out var props)) + throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + EnrichStoredProcedureSystemProperties(props); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(props); + return Task.FromResult(r); + } + + public override Task ReplaceStoredProcedureAsync( + StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; + EnrichStoredProcedureSystemProperties(storedProcedureProperties); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(storedProcedureProperties); + return Task.FromResult(r); + } + + public override Task DeleteStoredProcedureAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.Remove(id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedures.Remove(id); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.NoContent); + return Task.FromResult(r); + } + + // ── Stored Procedure CRUD (stream) ──────────────────────────────── + + public override Task CreateStoredProcedureStreamAsync( + StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' already exists.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; + EnrichStoredProcedureSystemProperties(storedProcedureProperties); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, + JsonConvert.SerializeObject(storedProcedureProperties))); + } + + public override Task ReadStoredProcedureStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.TryGetValue(id, out var props)) + throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + EnrichStoredProcedureSystemProperties(props); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(props))); + } + + public override Task ReplaceStoredProcedureStreamAsync( + StoredProcedureProperties storedProcedureProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.ContainsKey(storedProcedureProperties.Id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureProperties.Id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedureProperties[storedProcedureProperties.Id] = storedProcedureProperties; + EnrichStoredProcedureSystemProperties(storedProcedureProperties); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(storedProcedureProperties))); + } + + public override Task DeleteStoredProcedureStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedureProperties.Remove(id)) + throw InMemoryCosmosException.Create($"StoredProcedure '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._storedProcedures.Remove(id); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); + } + + // ── Stored Procedure Execute ────────────────────────────────────── + + public override Task> ExecuteStoredProcedureAsync( + string storedProcedureId, PartitionKey partitionKey, dynamic[] parameters, + StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedures.ContainsKey(storedProcedureId) && !_c._storedProcedureProperties.ContainsKey(storedProcedureId)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureId}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + + string handlerResult = null; + var hasHandler = false; + if (_c._storedProcedures.TryGetValue(storedProcedureId, out var handler)) + { + handlerResult = ExecuteHandler(storedProcedureId, () => handler(partitionKey, parameters)); + hasHandler = true; + } + else if (_c.SprocEngine is not null + && _c._storedProcedureProperties.TryGetValue(storedProcedureId, out var sprocProps) + && sprocProps.Body is not null) + { + handlerResult = ExecuteJsEngine(storedProcedureId, sprocProps.Body, partitionKey, parameters); + hasHandler = true; + } + + var r = Substitute.For>(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.RequestCharge.Returns(SyntheticRequestCharge); + if (hasHandler) + { + if (handlerResult is not null) + { + TOutput deserialized = typeof(TOutput) == typeof(string) + ? (TOutput)(object)handlerResult + : JsonConvert.DeserializeObject(handlerResult); + r.Resource.Returns(deserialized); + } + else + { + r.Resource.Returns(default(TOutput)); + } + } + + PopulateScriptLogHeaders(r); + return Task.FromResult(r); + } + + public override Task ExecuteStoredProcedureStreamAsync( + string storedProcedureId, PartitionKey partitionKey, dynamic[] parameters, + StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._storedProcedures.ContainsKey(storedProcedureId) && !_c._storedProcedureProperties.ContainsKey(storedProcedureId)) + throw InMemoryCosmosException.Create($"StoredProcedure '{storedProcedureId}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + + string handlerResult = null; + if (_c._storedProcedures.TryGetValue(storedProcedureId, out var handler)) + { + handlerResult = ExecuteHandler(storedProcedureId, () => handler(partitionKey, parameters)); + } + else if (_c.SprocEngine is not null + && _c._storedProcedureProperties.TryGetValue(storedProcedureId, out var sprocProps) + && sprocProps.Body is not null) + { + handlerResult = ExecuteJsEngine(storedProcedureId, sprocProps.Body, partitionKey, parameters); + } + + var msg = _c.CreateResponseMessage(HttpStatusCode.OK, handlerResult); + if (_c.SprocEngine?.CapturedLogs is { Count: > 0 } logs) + msg.Headers["x-ms-documentdb-script-log-results"] = Uri.EscapeDataString(string.Join("\n", logs)); + return Task.FromResult(msg); + } + + public override Task ExecuteStoredProcedureStreamAsync( + string storedProcedureId, Stream streamPayload, PartitionKey partitionKey, + StoredProcedureRequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + using var reader = new StreamReader(streamPayload); + var json = reader.ReadToEnd(); + var parameters = JsonConvert.DeserializeObject(json) ?? Array.Empty(); + return ExecuteStoredProcedureStreamAsync(storedProcedureId, partitionKey, parameters, requestOptions, cancellationToken); + } + + // ── Trigger CRUD (typed) ────────────────────────────────────────── + + public override Task CreateTriggerAsync( + TriggerProperties triggerProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._triggerProperties.ContainsKey(triggerProperties.Id)) + throw InMemoryCosmosException.Create($"Trigger '{triggerProperties.Id}' already exists.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._triggerProperties[triggerProperties.Id] = triggerProperties; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.Created); + r.Resource.Returns(triggerProperties); + return Task.FromResult(r); + } + + public override Task ReadTriggerAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.TryGetValue(id, out var props)) + throw InMemoryCosmosException.Create($"Trigger '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(props); + return Task.FromResult(r); + } + + public override Task ReplaceTriggerAsync( + TriggerProperties triggerProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.ContainsKey(triggerProperties.Id)) + throw InMemoryCosmosException.Create($"Trigger '{triggerProperties.Id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._triggerProperties[triggerProperties.Id] = triggerProperties; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(triggerProperties); + return Task.FromResult(r); + } + + public override Task DeleteTriggerAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.Remove(id)) + throw InMemoryCosmosException.Create($"Trigger '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._triggers.Remove(id); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.NoContent); + return Task.FromResult(r); + } + + // ── Trigger CRUD (stream) ───────────────────────────────────────── + + public override Task CreateTriggerStreamAsync( + TriggerProperties triggerProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._triggerProperties.ContainsKey(triggerProperties.Id)) + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Conflict)); + _c._triggerProperties[triggerProperties.Id] = triggerProperties; + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, + JsonConvert.SerializeObject(triggerProperties))); + } + + public override Task ReadTriggerStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.TryGetValue(id, out var props)) + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(props))); + } + + public override Task ReplaceTriggerStreamAsync( + TriggerProperties triggerProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.ContainsKey(triggerProperties.Id)) + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); + _c._triggerProperties[triggerProperties.Id] = triggerProperties; + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(triggerProperties))); + } + + public override Task DeleteTriggerStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._triggerProperties.Remove(id)) + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NotFound)); + _c._triggers.Remove(id); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); + } + + // ── UDF CRUD (typed) ────────────────────────────────────────────── + + public override Task CreateUserDefinedFunctionAsync( + UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) + throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' already exists.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; + if (!_c._userDefinedFunctions.ContainsKey("UDF." + userDefinedFunctionProperties.Id)) + _c._userDefinedFunctions["UDF." + userDefinedFunctionProperties.Id] = UdfPlaceholder; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.Created); + r.Resource.Returns(userDefinedFunctionProperties); + return Task.FromResult(r); + } + + public override Task ReadUserDefinedFunctionAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.TryGetValue(id, out var props)) + throw InMemoryCosmosException.Create($"UDF '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(props); + return Task.FromResult(r); + } + + public override Task ReplaceUserDefinedFunctionAsync( + UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) + throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.OK); + r.Resource.Returns(userDefinedFunctionProperties); + return Task.FromResult(r); + } + + public override Task DeleteUserDefinedFunctionAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.Remove(id)) + throw InMemoryCosmosException.Create($"UDF '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._userDefinedFunctions.Remove("UDF." + id); + var r = Substitute.For(); + r.StatusCode.Returns(HttpStatusCode.NoContent); + return Task.FromResult(r); + } + + // ── UDF CRUD (stream) ───────────────────────────────────────────── + + public override Task CreateUserDefinedFunctionStreamAsync( + UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) + throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' already exists.", + HttpStatusCode.Conflict, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; + if (!_c._userDefinedFunctions.ContainsKey("UDF." + userDefinedFunctionProperties.Id)) + _c._userDefinedFunctions["UDF." + userDefinedFunctionProperties.Id] = UdfPlaceholder; + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.Created, + JsonConvert.SerializeObject(userDefinedFunctionProperties))); + } + + public override Task ReadUserDefinedFunctionStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.TryGetValue(id, out var props)) + throw InMemoryCosmosException.Create($"UDF '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(props))); + } + + public override Task ReplaceUserDefinedFunctionStreamAsync( + UserDefinedFunctionProperties userDefinedFunctionProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.ContainsKey(userDefinedFunctionProperties.Id)) + throw InMemoryCosmosException.Create($"UDF '{userDefinedFunctionProperties.Id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._udfProperties[userDefinedFunctionProperties.Id] = userDefinedFunctionProperties; + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.OK, + JsonConvert.SerializeObject(userDefinedFunctionProperties))); + } + + public override Task DeleteUserDefinedFunctionStreamAsync( + string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (!_c._udfProperties.Remove(id)) + throw InMemoryCosmosException.Create($"UDF '{id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + _c._userDefinedFunctions.Remove("UDF." + id); + return Task.FromResult(_c.CreateResponseMessage(HttpStatusCode.NoContent)); + } + + // ── Query iterators (typed) ─────────────────────────────────────── + + public override FeedIterator GetStoredProcedureQueryIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._storedProcedureProperties, queryText).Cast().ToList()); + + public override FeedIterator GetStoredProcedureQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._storedProcedureProperties, queryDefinition).Cast().ToList()); + + public override FeedIterator GetTriggerQueryIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._triggerProperties, queryText).Cast().ToList()); + + public override FeedIterator GetTriggerQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._triggerProperties, queryDefinition).Cast().ToList()); + + public override FeedIterator GetUserDefinedFunctionQueryIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._udfProperties, queryText).Cast().ToList()); + + public override FeedIterator GetUserDefinedFunctionQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryFeedIterator(() => FilterById(_c._udfProperties, queryDefinition).Cast().ToList()); + + // ── Query iterators (stream) ────────────────────────────────────── + + public override FeedIterator GetStoredProcedureQueryStreamIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._storedProcedureProperties, queryText).Cast().ToList(), "StoredProcedures", () => _c.CurrentSessionToken); + + public override FeedIterator GetStoredProcedureQueryStreamIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._storedProcedureProperties, queryDefinition).Cast().ToList(), "StoredProcedures", () => _c.CurrentSessionToken); + + public override FeedIterator GetTriggerQueryStreamIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._triggerProperties, queryText).Cast().ToList(), "Triggers", () => _c.CurrentSessionToken); + + public override FeedIterator GetTriggerQueryStreamIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._triggerProperties, queryDefinition).Cast().ToList(), "Triggers", () => _c.CurrentSessionToken); + + public override FeedIterator GetUserDefinedFunctionQueryStreamIterator( + string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._udfProperties, queryText).Cast().ToList(), "UserDefinedFunctions", () => _c.CurrentSessionToken); + + public override FeedIterator GetUserDefinedFunctionQueryStreamIterator( + QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => new InMemoryStreamFeedIterator( + () => FilterById(_c._udfProperties, queryDefinition).Cast().ToList(), "UserDefinedFunctions", () => _c.CurrentSessionToken); + + // ── Private helpers ─────────────────────────────────────────────── + + private static IEnumerable FilterById( + Dictionary dict, string queryText) + { + var id = ExtractIdFromQueryText(queryText); + return id != null && dict.TryGetValue(id, out var match) + ? new[] { match } + : dict.Values; + } + + private static IEnumerable FilterById( + Dictionary dict, QueryDefinition queryDefinition) + { + if (queryDefinition == null) return dict.Values; + var id = ExtractIdFromQueryText(queryDefinition.QueryText); + if (id != null && id.StartsWith("@")) + { + var parameters = ExtractQueryParameters(queryDefinition); + if (parameters.TryGetValue(id, out var paramValue)) + id = paramValue?.ToString(); + else + return dict.Values; + } + return id != null && dict.TryGetValue(id, out var match) + ? new[] { match } + : dict.Values; + } + + private static readonly System.Text.RegularExpressions.Regex IdFilterRegex = + new(@"WHERE\s+c\.id\s*=\s*(?:'([^']+)'|""([^""]+)""|(@\w+))", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + + private static string ExtractIdFromQueryText(string queryText) + { + if (string.IsNullOrWhiteSpace(queryText)) return null; + var m = IdFilterRegex.Match(queryText); + if (!m.Success) return null; + return m.Groups[1].Success ? m.Groups[1].Value + : m.Groups[2].Success ? m.Groups[2].Value + : m.Groups[3].Success ? m.Groups[3].Value + : null; + } + + private string ExecuteHandler(string sprocId, Func invoker) + { + string result; + try + { + var task = Task.Run(invoker); + if (!task.Wait(TimeSpan.FromSeconds(10))) + throw InMemoryCosmosException.Create( + $"Stored procedure '{sprocId}' exceeded the 10-second execution timeout.", + HttpStatusCode.RequestTimeout, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + result = task.Result; + } + catch (CosmosException) { throw; } + catch (AggregateException ae) when (ae.InnerException is CosmosException) { throw ae.InnerException; } + catch (AggregateException ae) + { + var inner = ae.InnerException ?? ae; + throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {inner.Message}", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + catch (Exception ex) + { + throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + const int MaxResponseSize = 2 * 1024 * 1024; + if (result != null && Encoding.UTF8.GetByteCount(result) > MaxResponseSize) + throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' response exceeds the 2MB size limit.", + (HttpStatusCode)413, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + return result; + } + + private string ExecuteJsEngine(string sprocId, string jsBody, PartitionKey pk, dynamic[] args) + { + try + { + var context = new PartitionScopedCollectionContext(_c, pk); + return _c.SprocEngine.Execute(jsBody, pk, args, context); + } + catch (CosmosException) { throw; } + catch (Exception ex) + { + throw InMemoryCosmosException.Create($"Stored procedure '{sprocId}' failed: {ex.Message}", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + } + + private void PopulateScriptLogHeaders(StoredProcedureExecuteResponse r) + { + if (_c.SprocEngine?.CapturedLogs is { Count: > 0 } logs) + { + var logString = Uri.EscapeDataString(string.Join("\n", logs)); + var headers = new Headers + { + ["x-ms-request-charge"] = SyntheticRequestCharge.ToString(CultureInfo.InvariantCulture), + ["x-ms-documentdb-script-log-results"] = logString + }; + r.Headers.Returns(headers); + r.ScriptLog.Returns(Uri.UnescapeDataString(logString)); + } + } + } + + private static void EnrichStoredProcedureSystemProperties(StoredProcedureProperties props) + { + // ETag and SelfLink are read-only on StoredProcedureProperties, + // so we use Newtonsoft.Json to populate the internal backing fields. + var json = JsonConvert.SerializeObject(props); + var jObj = JObject.Parse(json); + if (jObj["_etag"] == null || string.IsNullOrEmpty(jObj["_etag"]?.ToString())) + jObj["_etag"] = $"\"{Guid.NewGuid()}\""; + if (jObj["_self"] == null || string.IsNullOrEmpty(jObj["_self"]?.ToString())) + jObj["_self"] = $"dbs/db/colls/col/sprocs/{props.Id}"; + if (jObj["_ts"] == null || jObj["_ts"]!.Type == JTokenType.Null) + jObj["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (jObj["_rid"] == null || string.IsNullOrEmpty(jObj["_rid"]?.ToString())) + jObj["_rid"] = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).TrimEnd('='); + JsonConvert.PopulateObject(jObj.ToString(), props); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Private helpers — Query execution pipeline + // ═══════════════════════════════════════════════════════════════════════════ + + // Ref: Observed behavior on the Windows Cosmos DB Emulator (priority 6): + // Documents returned by queries without ORDER BY are in insertion order. + private IEnumerable GetAllItemsForPartition(QueryRequestOptions requestOptions) + { + List<(string Id, string PartitionKey)> orderedKeys; + lock (_insertionOrderLock) + { + orderedKeys = _insertionOrder.ToList(); + } + + if (requestOptions?.PartitionKey is not null + && requestOptions.PartitionKey != PartitionKey.None) + { + var pk = PartitionKeyToString(requestOptions.PartitionKey.Value); + + // Hierarchical PK prefix match: when the query PK has fewer components + // than the container's partition key paths, treat it as a prefix filter. + if (PartitionKeyPaths is { Count: > 1 }) + { + var queryComponents = CountPartitionKeyComponents(requestOptions.PartitionKey.Value); + if (queryComponents > 0 && queryComponents < PartitionKeyPaths.Count) + { + var prefix = pk + "|"; + return orderedKeys + .Where(key => (key.PartitionKey?.StartsWith(prefix, StringComparison.Ordinal) ?? false) && !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); + } + } + + return orderedKeys + .Where(key => key.PartitionKey == pk && !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); + } + return orderedKeys + .Where(key => !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); + } + + private static int CountPartitionKeyComponents(PartitionKey partitionKey) + { + try + { + var raw = partitionKey.ToString(); + if (raw.StartsWith("[")) + { + var arr = JArray.Parse(raw); + return arr.Count; + } + return 1; + } + catch { return 1; } + } + + private const string UdfRegistryKey = "__udf_registry__"; + private const string UdfPropertiesKey = "__udf_properties__"; + private const string UdfEngineKey = "__udf_engine__"; + + private (string Name, string FromAlias, SqlExpression Expr)[] GetParsedComputedProperties() + { + var cached = _parsedComputedProperties; + if (cached is not null) return cached; + + var cps = _containerProperties.ComputedProperties; + if (cps is null or { Count: 0 }) + { + _parsedComputedProperties = Array.Empty<(string, string, SqlExpression)>(); + return _parsedComputedProperties; + } + + var result = new (string Name, string FromAlias, SqlExpression Expr)[cps.Count]; + for (var i = 0; i < cps.Count; i++) + { + var parsed = CosmosSqlParser.Parse(cps[i].Query); + if (parsed.IsValueSelect && parsed.SelectFields.Length == 1 && parsed.SelectFields[0].SqlExpr is not null) + { + result[i] = (cps[i].Name, parsed.FromAlias, parsed.SelectFields[0].SqlExpr); + } + else + { + // Fallback: try to evaluate via the expression string + result[i] = (cps[i].Name, parsed.FromAlias, null); + } + } + + _parsedComputedProperties = result; + return result; + } + + private List AugmentWithComputedProperties( + IEnumerable items, IDictionary parameters) + { + var cps = GetParsedComputedProperties(); + if (cps.Length == 0) return items.ToList(); + + return items.Select(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + foreach (var (name, fromAlias, expr) in cps) + { + if (expr is null) continue; + var value = EvaluateSqlExpression(expr, jObj, fromAlias, parameters); + if (value is UndefinedValue) + continue; + jObj[name] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + } + return jObj.ToString(Formatting.None); + }).ToList(); + } + + private static List StripComputedProperties( + IEnumerable items, (string Name, string FromAlias, SqlExpression Expr)[] cps) + { + if (cps.Length == 0) return items.ToList(); + var names = new HashSet(cps.Select(cp => cp.Name)); + return items.Select(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + foreach (var name in names) + jObj.Remove(name); + return jObj.ToString(Formatting.None); + }).ToList(); + } + + private List FilterItemsByQuery( + string queryText, IDictionary parameters, QueryRequestOptions requestOptions, + IEnumerable preFilteredItems = null) + { + // Detect ORDER BY RANK RRF(...) early — the parser may silently drop it on some runtimes + if (System.Text.RegularExpressions.Regex.IsMatch( + queryText, @"\bORDER\s+BY\s+RANK\s+RRF\s*\(", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + throw new NotSupportedException( + "ORDER BY RANK RRF() is not implemented in the in-memory emulator."); + } + + if (_userDefinedFunctions.Count > 0) + { + parameters[UdfRegistryKey] = _userDefinedFunctions; + } + + if (_udfProperties.Count > 0) + parameters[UdfPropertiesKey] = _udfProperties; + if (JsTriggerEngine is IJsUdfEngine udfEngine) + parameters[UdfEngineKey] = udfEngine; + + // Snapshot static datetime values so they remain constant for the entire query + var now = DateTime.UtcNow; + parameters["__staticDateTime"] = now.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + parameters["__staticTicks"] = now.Ticks; + parameters["__staticTimestamp"] = new DateTimeOffset(now).ToUnixTimeMilliseconds(); + + var parsed = CosmosSqlParser.Parse(queryText); + + IEnumerable items = preFilteredItems ?? GetAllItemsForPartition(requestOptions); + + // Computed properties — augment items with virtual properties before any filtering/projection + var computedProps = GetParsedComputedProperties(); + if (computedProps.Length > 0) + { + items = AugmentWithComputedProperties(items, parameters); + } + + // FROM alias IN c.field — top-level array iteration (must come before JOINs/WHERE) + if (parsed.FromSource is not null) + { + items = ExpandFromSource(items, parsed); + } + + // JOIN expansion (supports multiple JOINs) — must come before WHERE + if (parsed.Joins is { Length: > 0 }) + { + items = ExpandAllJoins(items, parsed); + } + else if (parsed.Join is not null) + { + items = ExpandJoinedItems(items, parsed); + } + + // WHERE + if (parsed.Where is not null) + { + items = items.Where(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + return EvaluateWhereExpression(parsed.Where, jObj, parsed.FromAlias, parameters, parsed.Join); + }); + } + + // GROUP BY / HAVING — also handles SELECT projection for grouped results + var groupByApplied = false; + if (parsed.GroupByFields is { Length: > 0 }) + { + items = ApplyGroupBy(items, parsed, parameters); + groupByApplied = true; + } + + // ORDER BY + if (parsed.OrderByFields is { Length: > 0 }) + { + var effectiveOrderBy = parsed.OrderByFields; + // After GROUP BY, aggregate ORDER BY expressions (e.g. ORDER BY COUNT(1)) + // must resolve to the alias of the matching SELECT field because the items + // are already projected group results (e.g. {"cat":"A","cnt":2}). + // EvaluateSqlExpression returns pass-through values for aggregates (COUNT→1), + // which makes sorting non-deterministic when groups share the same count. + if (groupByApplied) + { + effectiveOrderBy = ResolveOrderByAliasesAfterGroupBy(parsed.OrderByFields, parsed.SelectFields); + } + items = ApplyOrderByFields(items, effectiveOrderBy, parsed.FromAlias, parameters); + } + else if (parsed.OrderBy is not null) + { + items = ApplyOrderBy(items, parsed.OrderBy, parsed.FromAlias); + } + else if (parsed.RankExpression is not null) + { + items = ApplyOrderByRank(items, parsed.RankExpression, parsed.FromAlias, parameters); + } + + // TOP — applied before projection only when DISTINCT is not active. + // When DISTINCT is active, TOP is deferred until after deduplication. + if (parsed.TopCount.HasValue && !parsed.IsDistinct) + { + items = items.Take(parsed.TopCount.Value); + } + + // OFFSET / LIMIT — when DISTINCT is active, defer until after dedup + if (!parsed.IsDistinct && parsed.Offset.HasValue) + { + items = items.Skip(parsed.Offset.Value); + } + + if (!parsed.IsDistinct && parsed.Limit.HasValue) + { + items = items.Take(parsed.Limit.Value); + } + + // SELECT projection (skip if GROUP BY already projected) + if (!groupByApplied && !parsed.IsSelectAll && parsed.SelectFields.Length > 0) + { + items = ProjectFields(items, parsed, parameters); + } + + // SELECT * must not include computed properties (they are virtual) + if (parsed.IsSelectAll && computedProps.Length > 0) + { + items = StripComputedProperties(items, computedProps); + } + + // DISTINCT — applied after projection so dedup works on projected shapes + if (parsed.IsDistinct) + { + items = items.Distinct(JsonStructuralStringComparer.Instance); + } + + // OFFSET / LIMIT — deferred application after DISTINCT + if (parsed.IsDistinct && parsed.Offset.HasValue) + { + items = items.Skip(parsed.Offset.Value); + } + + if (parsed.IsDistinct && parsed.Limit.HasValue) + { + items = items.Take(parsed.Limit.Value); + } + + // TOP — deferred application after DISTINCT + if (parsed.TopCount.HasValue && parsed.IsDistinct) + { + items = items.Take(parsed.TopCount.Value); + } + + // VALUE SELECT — unwrap scalar values from projected JObjects + if (parsed.IsValueSelect && parsed.SelectFields.Length == 1) + { + items = UnwrapValueSelect(items); + } + + return items as List ?? items.ToList(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query helpers — ORDER BY + // ═══════════════════════════════════════════════════════════════════════════ + + private static List ApplyOrderBy(IEnumerable items, OrderByClause orderBy, string fromAlias) + => ApplyOrderByFields(items, new[] { new OrderByField(orderBy.Field, orderBy.Ascending) }, fromAlias); + + private static List ApplyOrderByRank( + IEnumerable items, SqlExpression rankExpr, string fromAlias, IDictionary parameters) + { + // ORDER BY RANK sorts by the evaluated expression descending (highest score first). + return items + .OrderByDescending(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + var score = EvaluateSqlExpression(rankExpr, jObj, fromAlias, parameters); + return score switch + { + double d => d, + long l => (double)l, + int i => (double)i, + _ => 0.0 + }; + }) + .ToList(); + } + + // Sentinel for undefined (missing field) — distinct from null + private static readonly object UndefinedSortSentinel = new(); + + private static List ApplyOrderByFields( + IEnumerable items, OrderByField[] orderByFields, string fromAlias, + IDictionary parameters = null) + { + if (orderByFields.Length == 0) + { + return items.ToList(); + } + + IOrderedEnumerable ordered = null; + foreach (var field in orderByFields) + { + object KeySelector(string json) + { + var jObj = JsonParseHelpers.ParseJson(json); + + if (field.Expression is not null) + { + var value = EvaluateSqlExpression(field.Expression, jObj, fromAlias, + parameters ?? new Dictionary()); + return value switch + { + double d => (object)d, + long l => (object)(double)l, + int i => (object)(double)i, + decimal dec => (object)(double)dec, + bool b => (object)b, + string s => (object)s, + UndefinedValue => UndefinedSortSentinel, + null => null, + JToken jt => jt, + _ => (object)value.ToString() + }; + } + + var fieldPath = field.Field; + if (fieldPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + fieldPath = fieldPath[(fromAlias.Length + 1)..]; + } + + var token = jObj.SelectToken(fieldPath); + if (token is null) + { + return UndefinedSortSentinel; // field missing — undefined + } + + return token.Type switch + { + JTokenType.Null => null, // field present but null + JTokenType.Boolean => (object)token.Value(), + JTokenType.Integer => (object)token.Value(), + JTokenType.Float => (object)token.Value(), + JTokenType.String => (object)token.Value(), + _ => (object)token // Return JArray/JObject for element-by-element comparison + }; + } + + var asc = field.Ascending; + var comparer = Comparer.Create((l, r) => + { + var result = CompareValues(l, r); + return asc ? result : -result; + }); + ordered = ordered == null ? items.OrderBy(KeySelector, comparer) : ordered.ThenBy(KeySelector, comparer); + } + return ordered?.ToList() ?? items.ToList(); + } + + /// + /// After GROUP BY projection, ORDER BY aggregate expressions (e.g. COUNT(1), SUM(c.val)) + /// must be resolved to their SELECT alias so sorting works on the projected field value + /// instead of the aggregate passthrough (which would be 1 for COUNT, etc.). + /// + private static OrderByField[] ResolveOrderByAliasesAfterGroupBy( + OrderByField[] orderByFields, SelectField[] selectFields) + { + var resolved = new OrderByField[orderByFields.Length]; + for (var i = 0; i < orderByFields.Length; i++) + { + var obf = orderByFields[i]; + if (obf.Expression is FunctionCallExpression func + && AggregateFunctions.Contains(func.FunctionName)) + { + // Convert both to text form using ExprToString for consistent comparison + var orderByText = CosmosSqlParser.ExprToString(obf.Expression); + foreach (var sf in selectFields) + { + if (sf.Alias is not null && sf.Expression.Trim().Equals( + orderByText, StringComparison.OrdinalIgnoreCase)) + { + // Replace with a simple field lookup on the alias + resolved[i] = new OrderByField(sf.Alias, obf.Ascending); + break; + } + } + + resolved[i] ??= obf; // no matching alias found — keep original + } + else + { + resolved[i] = obf; + } + } + + return resolved; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query helpers — GROUP BY / HAVING + // ═══════════════════════════════════════════════════════════════════════════ + + private List ApplyGroupBy(IEnumerable items, CosmosSqlQuery parsed, IDictionary parameters) + { + var fromAlias = parsed.FromAlias; + + var groups = items.GroupBy(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + var keyParts = new List(); + var groupByExprs = parsed.GroupByExpressions; + for (var i = 0; i < parsed.GroupByFields.Length; i++) + { + // If the GROUP BY field is a function expression (e.g., LOWER(c.name)), + // evaluate it with the expression tree rather than treating it as a path. + if (groupByExprs != null && i < groupByExprs.Length + && groupByExprs[i] is not IdentifierExpression + && groupByExprs[i] is not null) + { + var val = EvaluateSqlExpression(groupByExprs[i], jObj, fromAlias, + parameters ?? new Dictionary()); + keyParts.Add(val is UndefinedValue ? "\x00undefined" : val?.ToString() ?? "null"); + } + else + { + var path = StripAliasPrefix(parsed.GroupByFields[i], fromAlias); + var token = jObj.SelectToken(path); + // Distinguish null (JTokenType.Null) from undefined (missing property) + if (token is null) + keyParts.Add("\x00undefined"); + else if (token.Type == JTokenType.Null) + keyParts.Add("\x00null"); + else + keyParts.Add(token.ToString()); + } + } + return string.Join("\x1F", keyParts.Select(k => k.Replace("\x1F", "\x1F\x1F"))); + }); + + var hasAggregate = parsed.SelectFields.Any(f => + { + var expr = f.Expression.TrimStart(); + if (AggregateFunctions.Any(fn => expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase))) + return true; + // Also detect aggregates nested inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1)}) + if (f.SqlExpr is not null) + return ContainsAggregateCall(f.SqlExpr); + return false; + }); + + if (!hasAggregate) + { + return groups.Select(g => + { + var jObj = JsonParseHelpers.ParseJson(g.First()); + var projected = new JObject(); + foreach (var field in parsed.SelectFields) + { + var outputName = field.Alias ?? field.Expression.Split('.').Last(); + + // If the select field has a non-trivial expression (function call, etc.), + // evaluate it rather than treating it as a property path. + if (field.SqlExpr is not null and not IdentifierExpression) + { + var val = EvaluateSqlExpression(field.SqlExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + projected[outputName] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); + } + else + { + var path = StripAliasPrefix(field.Expression, fromAlias); + projected[outputName] = jObj.SelectToken(path)?.DeepClone(); + } + } + return projected.ToString(Formatting.None); + }).ToList(); + } + + var result = new List(); + foreach (var group in groups) + { + var groupItems = group.ToList(); + var resultObj = new JObject(); + + foreach (var field in parsed.SelectFields) + { + var expr = field.Expression.TrimStart(); + string funcName = null; + string innerArg = null; + + foreach (var fn in AggregateFunctions) + { + if (expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase)) + { + funcName = fn; + var open = expr.IndexOf('('); + var close = expr.LastIndexOf(')'); + if (open >= 0 && close > open) + { + innerArg = expr.Substring(open + 1, close - open - 1).Trim(); + } + + break; + } + } + + var outputName = field.Alias ?? field.Expression; + + if (funcName == "COUNT") + { + if (innerArg is "1" or "*" or null) + { + resultObj[outputName] = groupItems.Count; + } + else + { + var countPath = innerArg; + if (countPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + countPath = countPath[(fromAlias.Length + 1)..]; + resultObj[outputName] = groupItems.Count(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; + }); + } + } + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // COUNTIF() counts items where the expression evaluates to true. + else if (funcName == "COUNTIF" && field.SqlExpr is FunctionCallExpression countIfFunc) + { + resultObj[outputName] = EvaluateCountIf(countIfFunc, groupItems, fromAlias, parameters); + } + else if (funcName is "SUM" or "AVG" && innerArg != null) + { + var values = ExtractNumericValues(groupItems, innerArg, fromAlias, parameters); + if (funcName == "SUM") + { + if (values.Count > 0) + resultObj[outputName] = NormalizeNumericResult(values.Sum()); + // else: omit field entirely (undefined) — matches Cosmos DB + } + else // AVG + { + if (values.Count > 0) + resultObj[outputName] = NormalizeNumericResult(values.Average()); + } + } + else if (funcName is "MIN" or "MAX" && innerArg != null) + { + var tokens = ExtractTokenValues(groupItems, innerArg, fromAlias, parameters); + var minMaxResult = AggregateMinMax(tokens, funcName == "MIN"); + if (minMaxResult is not UndefinedValue) + resultObj[outputName] = JToken.FromObject(minMaxResult); + } + else + { + var jObj = JsonParseHelpers.ParseJson(groupItems[0]); + + // If the select field has a non-trivial expression (function call, etc.), + // evaluate it rather than treating it as a property path. + if (field.SqlExpr is not null and not IdentifierExpression && funcName is null) + { + var fieldOutputName = field.Alias ?? field.Expression; + // Use aggregate-aware evaluation if the expression contains nested aggregates + // (e.g. SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)}) + var val = ContainsAggregateCall(field.SqlExpr) + ? EvaluateGroupByProjectionExpression(field.SqlExpr, groupItems, jObj, fromAlias, parameters) + : EvaluateSqlExpression(field.SqlExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + resultObj[fieldOutputName] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); + } + else + { + var path = StripAliasPrefix(field.Expression, fromAlias); + var fieldOutputName = field.Alias ?? path.Split('.').Last(); + resultObj[fieldOutputName] = jObj.SelectToken(path)?.DeepClone(); + } + } + } + + if (parsed.Having is not null) + { + if (!EvaluateHavingCondition(parsed.Having, groupItems, resultObj, fromAlias, parameters)) + { + continue; + } + } + + result.Add(resultObj.ToString(Formatting.None)); + } + return result; + } + + private static bool IsComplexExpression(string arg) + => arg.Contains('(') || arg.Contains('*') || arg.Contains('+') || + arg.Contains('/') || arg.Contains('%') || + (arg.Contains('-') && !arg.StartsWith("-") && arg.IndexOf('-') != arg.IndexOf(".") + 1); + + /// + /// Counts items for which evaluates to a defined, + /// non-null value. Matches Cosmos DB COUNT(expr) semantics: rows producing + /// undefined or null are excluded. + /// + private static int CountDefinedResults( + SqlExpression innerExpr, List items, string fromAlias, IDictionary parameters) + { + var count = 0; + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + var result = EvaluateSqlExpression(innerExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + if (result is null or UndefinedValue) + continue; + if (result is JToken jt && jt.Type == JTokenType.Null) + continue; + count++; + } + return count; + } + + private static List ExtractNumericValues(IEnumerable items, string innerArg, string fromAlias, IDictionary parameters = null) + { + var values = new List(); + + // Handle numeric literals (e.g., SUM(1), AVG(2.5)) + if (double.TryParse(innerArg, NumberStyles.Any, CultureInfo.InvariantCulture, out var literalValue)) + { + values.AddRange(Enumerable.Repeat(literalValue, items.Count())); + return values; + } + + var isExpr = IsComplexExpression(innerArg); + SqlExpression parsedInnerExpr = null; + if (isExpr) + { + CosmosSqlParser.TryParse($"SELECT VALUE {innerArg} FROM {fromAlias}", out var innerParsed); + if (innerParsed?.SelectFields.Length > 0) + parsedInnerExpr = innerParsed.SelectFields[0].SqlExpr; + } + + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + double? val = null; + + if (parsedInnerExpr is not null) + { + var result = EvaluateSqlExpression(parsedInnerExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + if (result is double d) + val = d; + else if (result is long l) + val = l; + else if (result != null && double.TryParse(result.ToString(), NumberStyles.Any, + CultureInfo.InvariantCulture, out var parsed2)) + val = parsed2; + } + else + { + var path = StripAliasPrefix(innerArg, fromAlias); + var token = jObj.SelectToken(path); + if (token != null && double.TryParse(token.ToString(), NumberStyles.Any, + CultureInfo.InvariantCulture, out var parsed3)) + val = parsed3; + } + + if (val.HasValue) + values.Add(val.Value); + } + return values; + } + + private static List ExtractTokenValues(IEnumerable items, string innerArg, string fromAlias, IDictionary parameters = null) + { + var tokens = new List(); + + // Handle numeric literals (e.g., MIN(1), MAX(2.5)) + if (double.TryParse(innerArg, NumberStyles.Any, CultureInfo.InvariantCulture, out var literalValue)) + { + var token = new JValue(literalValue); + tokens.AddRange(Enumerable.Repeat((JToken)token, items.Count())); + return tokens; + } + + var isExpr = IsComplexExpression(innerArg); + SqlExpression parsedInnerExpr = null; + if (isExpr) + { + CosmosSqlParser.TryParse($"SELECT VALUE {innerArg} FROM {fromAlias}", out var innerParsed); + if (innerParsed?.SelectFields.Length > 0) + parsedInnerExpr = innerParsed.SelectFields[0].SqlExpr; + } + + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + + if (parsedInnerExpr is not null) + { + var result = EvaluateSqlExpression(parsedInnerExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + if (result is not null and not UndefinedValue) + tokens.Add(JToken.FromObject(result)); + } + else + { + var path = StripAliasPrefix(innerArg, fromAlias); + var token = jObj.SelectToken(path); + if (token != null && token.Type != JTokenType.Null) + tokens.Add(token); + } + } + return tokens; + } + + /// + /// Computes MIN or MAX across a list of JTokens, respecting Cosmos DB type ordering: + /// boolean < number < string. Booleans compare as false < true. + /// Returns when is empty. + /// + private static object AggregateMinMax(List tokens, bool isMin) + { + if (tokens.Count == 0) + return UndefinedValue.Instance; + + // Cosmos DB MIN/MAX type rank: boolean(0) < number(1) < string(2) + // MIN returns the value with the lowest rank; within a rank, the smallest value. + // MAX returns the value with the highest rank; within a rank, the largest value. + static int MinMaxTypeRank(JToken t) => t.Type switch + { + JTokenType.Boolean => 0, + JTokenType.Integer or JTokenType.Float => 1, + JTokenType.String => 2, + _ => 3 // other types ignored below + }; + + var eligible = tokens.Where(t => MinMaxTypeRank(t) <= 2).ToList(); + if (eligible.Count == 0) + return UndefinedValue.Instance; + + JToken best = eligible[0]; + foreach (var t in eligible.Skip(1)) + { + var rankBest = MinMaxTypeRank(best); + var rankT = MinMaxTypeRank(t); + bool tIsBetter; + if (rankBest != rankT) + { + tIsBetter = isMin ? rankT < rankBest : rankT > rankBest; + } + else + { + tIsBetter = rankBest switch + { + 0 => isMin + ? !t.Value() && best.Value() // false < true + : t.Value() && !best.Value(), + 1 => isMin + ? t.Value() < best.Value() + : t.Value() > best.Value(), + 2 => isMin + ? string.Compare(t.Value(), best.Value(), StringComparison.Ordinal) < 0 + : string.Compare(t.Value(), best.Value(), StringComparison.Ordinal) > 0, + _ => false + }; + } + if (tIsBetter) best = t; + } + + return best.Type switch + { + JTokenType.Boolean => best.Value(), + JTokenType.Integer => best.Value(), + JTokenType.Float => best.Value(), + JTokenType.String => best.Value(), + _ => UndefinedValue.Instance + }; + } + + // Ref: JavaScript: JSON.stringify(750.0) === "750" + // Real Cosmos DB uses a JavaScript engine which normalises whole-number aggregate results + // to integers. Storing the result as JValue(double) causes platform-dependent serialisation + // ("750.0" on Linux, "750" on Windows). We explicitly emit a long-backed JValue for whole + // numbers to guarantee consistent cross-platform output. + private static JToken NormalizeNumericResult(double value) + { + if (!double.IsNaN(value) && !double.IsInfinity(value) && value == Math.Truncate(value)) + return new JValue((long)value); + return new JValue(value); + } + + private static bool EvaluateHavingCondition( + WhereExpression having, List groupItems, JObject resultObj, + string fromAlias, IDictionary parameters) + { + if (having is SqlExpressionCondition sec) + { + return IsTruthy(EvaluateHavingSqlExpression(sec.Expression, groupItems, resultObj, fromAlias, parameters)); + } + + return EvaluateWhereExpression(having, resultObj, fromAlias, parameters, null); + } + + /// + /// Strips the FROM alias prefix from a property path, handling both + /// dot notation (c.name) and bracket notation (c["name"]) from the LINQ provider. + /// + private static string StripAliasPrefix(string path, string fromAlias) + { + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + return path[(fromAlias.Length + 1)..]; + if (path.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) + { + var bracketPart = path[fromAlias.Length..]; // ["name"] or ['name'] + if (bracketPart.StartsWith("[\"") && bracketPart.EndsWith("\"]")) + return bracketPart[2..^2]; // Extract property name from ["prop"] + if (bracketPart.StartsWith("['") && bracketPart.EndsWith("']")) + return bracketPart[2..^2]; // Extract property name from ['prop'] + return bracketPart; + } + return path; + } + + private static object EvaluateHavingSqlExpression( + SqlExpression expr, List groupItems, JObject resultObj, + string fromAlias, IDictionary parameters) + { + return expr switch + { + FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName) => + EvaluateHavingAggregate(func, groupItems, fromAlias, parameters), + BinaryExpression bin => EvaluateHavingBinaryExpression(bin, groupItems, resultObj, fromAlias, parameters), + LiteralExpression lit => lit.Value, + UndefinedLiteralExpression => UndefinedValue.Instance, + IdentifierExpression ident => ResolveValue(ident.Name, resultObj, fromAlias, parameters), + _ => EvaluateSqlExpression(expr, resultObj, fromAlias, parameters) + }; + } + + private static object EvaluateHavingBinaryExpression( + BinaryExpression bin, List groupItems, JObject resultObj, + string fromAlias, IDictionary parameters) + { + var left = EvaluateHavingSqlExpression(bin.Left, groupItems, resultObj, fromAlias, parameters); + var right = EvaluateHavingSqlExpression(bin.Right, groupItems, resultObj, fromAlias, parameters); + return bin.Operator switch + { + BinaryOp.GreaterThan => CompareValues(left, right) > 0, + BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, + BinaryOp.LessThan => CompareValues(left, right) < 0, + BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, + BinaryOp.Equal => (object)ValuesEqual(left, right), + BinaryOp.NotEqual => !ValuesEqual(left, right), + BinaryOp.And => IsTruthy(left) && IsTruthy(right), + BinaryOp.Or => IsTruthy(left) || IsTruthy(right), + BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), + BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), + BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), + BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), + BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), + _ => null + }; + } + + /// + /// Evaluates COUNTIF() — counts items where the boolean expression evaluates to true. + /// Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + /// "Adds an aggregate operator for CountIf" — undocumented server-side aggregate. + /// + private static int EvaluateCountIf( + FunctionCallExpression func, List items, string fromAlias, + IDictionary parameters) + { + if (func.Arguments.Length < 1) return 0; + var boolExpr = func.Arguments[0]; + return items.Count(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + var val = EvaluateSqlExpression(boolExpr, jObj, fromAlias, + parameters ?? new Dictionary()); + return IsTruthy(val); + }); + } + + private static object EvaluateHavingAggregate( + FunctionCallExpression func, List groupItems, string fromAlias, + IDictionary parameters = null) + { + switch (func.FunctionName) + { + case "COUNT": + { + // COUNT(1) / COUNT(*) — count all items in group + // COUNT(c.field) — count only items where field is defined + if (func.Arguments.Length < 1) + return (double)groupItems.Count; + var countArg = func.Arguments[0] is IdentifierExpression countIdent ? countIdent.Name : "1"; + if (countArg is "1" or "*") + return (double)groupItems.Count; + var countPath = countArg; + if (countPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + countPath = countPath[(fromAlias.Length + 1)..]; + return (double)groupItems.Count(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; + }); + } + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // COUNTIF() counts items where the expression evaluates to true. + case "COUNTIF": + return (double)EvaluateCountIf(func, groupItems, fromAlias, parameters); + case "SUM": + case "AVG": + case "MIN": + case "MAX": + { + if (func.Arguments.Length < 1) + { + return 0.0; + } + + var innerArg = func.Arguments[0] is IdentifierExpression ident ? ident.Name : "1"; + var values = ExtractNumericValues(groupItems, innerArg, fromAlias, parameters); + if (func.FunctionName is "MIN" or "MAX") + { + var tokens = ExtractTokenValues(groupItems, innerArg, fromAlias, parameters); + return AggregateMinMax(tokens, func.FunctionName == "MIN"); + } + return func.FunctionName switch + { + "SUM" => values.Count > 0 ? (object)NormalizeNumericResult(values.Sum()) : UndefinedValue.Instance, + "AVG" => values.Count > 0 ? (object)NormalizeNumericResult(values.Average()) : UndefinedValue.Instance, + _ => 0.0 + }; + } + default: return null; + } + } + + /// + /// Evaluates a SELECT expression within GROUP BY context, resolving aggregate calls + /// (COUNT, COUNTIF, SUM, AVG, MIN, MAX) against the group items rather than treating them as passthroughs. + /// Used for expressions like: SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)} + /// + private static object EvaluateGroupByProjectionExpression( + SqlExpression expr, List groupItems, JObject sampleItem, + string fromAlias, IDictionary parameters) + { + return expr switch + { + FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName) + => EvaluateHavingAggregate(func, groupItems, fromAlias, parameters), + ObjectLiteralExpression obj => EvaluateGroupByObjectLiteral(obj, groupItems, sampleItem, fromAlias, parameters), + ArrayLiteralExpression arr => arr.Elements + .Select(e => EvaluateGroupByProjectionExpression(e, groupItems, sampleItem, fromAlias, parameters)) + .Where(v => v is not UndefinedValue) + .Select(v => v is not null ? JToken.FromObject(v) : JValue.CreateNull()) + .ToArray(), + _ => EvaluateSqlExpression(expr, sampleItem, fromAlias, + parameters ?? new Dictionary()) + }; + } + + private static JObject EvaluateGroupByObjectLiteral( + ObjectLiteralExpression obj, List groupItems, JObject sampleItem, + string fromAlias, IDictionary parameters) + { + var result = new JObject(); + foreach (var prop in obj.Properties) + { + var val = EvaluateGroupByProjectionExpression(prop.Value, groupItems, sampleItem, fromAlias, parameters); + if (val is not UndefinedValue) + result[prop.Key] = val is not null ? JToken.FromObject(val) : JValue.CreateNull(); + } + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query helpers — JOIN expansion + // ═══════════════════════════════════════════════════════════════════════════ + + private static List ExpandJoinedItems(IEnumerable items, CosmosSqlQuery parsed) + { + if (parsed.Join is null) + { + return items.ToList(); + } + + var expanded = new List(); + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + var arrayToken = jObj.SelectToken(parsed.Join.ArrayField); + if (arrayToken is JArray jArray) + { + foreach (var element in jArray) + { + var combined = new JObject(jObj.Properties()) + { + [parsed.Join.Alias] = element + }; + expanded.Add(combined.ToString(Formatting.None)); + } + } + } + return expanded; + } + + private static List ExpandAllJoins(IEnumerable items, CosmosSqlQuery parsed) + { + var current = items; + foreach (var join in parsed.Joins) + { + var expanded = new List(); + foreach (var json in current) + { + var jObj = JsonParseHelpers.ParseJson(json); + // Resolve the array from the correct source alias (e.g. "g.tags" for JOIN t IN g.tags) + var sourcePath = join.SourceAlias == parsed.FromAlias + ? join.ArrayField + : $"{join.SourceAlias}.{join.ArrayField}"; + var arrayToken = jObj.SelectToken(sourcePath); + if (arrayToken is JArray jArray) + { + foreach (var element in jArray) + { + var combined = new JObject(jObj.Properties()) + { + [join.Alias] = element + }; + expanded.Add(combined.ToString(Formatting.None)); + } + } + } + current = expanded; + } + return current as List ?? current.ToList(); + } + + /// + /// Expands top-level FROM alias IN c.field — each array element becomes a result row. + /// + private static List ExpandFromSource(IEnumerable items, CosmosSqlQuery parsed) + { + var sourcePath = parsed.FromSource; + // The FromSource is the full dotted path (e.g. "c.tags"). We need to resolve it + // relative to each document, but the outer alias isn't available here (the FROM clause + // redefines the alias). Use a simple heuristic: strip the first segment if it looks + // like an alias (contains a dot). + var dotIndex = sourcePath.IndexOf('.'); + var arrayPath = dotIndex >= 0 ? sourcePath[(dotIndex + 1)..] : sourcePath; + + var expanded = new List(); + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + var arrayToken = jObj.SelectToken(arrayPath); + if (arrayToken is not JArray jArray) + continue; + + foreach (var element in jArray) + { + // The alias is the range variable (e.g. "item" in FROM item IN c.items). + // - For object elements: spread properties at root so "item.price" → strip alias → "price" resolves. + // - Also keep the element under the alias for "SELECT item" / "SELECT VALUE item". + var combined = element is JObject elementObj + ? new JObject(elementObj.Properties()) { [parsed.FromAlias] = element.DeepClone() } + : new JObject { [parsed.FromAlias] = element }; + expanded.Add(combined.ToString(Formatting.None)); + } + } + return expanded; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query helpers — SELECT projection + // ═══════════════════════════════════════════════════════════════════════════ + + private static List ProjectFields(IEnumerable items, CosmosSqlQuery parsed, IDictionary parameters) + { + var hasAggregate = parsed.SelectFields.Any(f => + { + var expr = f.Expression.TrimStart(); + if (AggregateFunctions.Any(fn => expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase))) + return true; + // Also detect aggregates nested inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1)}) + if (f.SqlExpr is not null) + return ContainsAggregateCall(f.SqlExpr); + return false; + }); + + if (hasAggregate) + { + return ProjectAggregateFields(items, parsed, parameters); + } + + var projected = new List(); + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + var resultObj = new JObject(); + foreach (var field in parsed.SelectFields) + { + // Handle SELECT * combined with other fields (e.g. SELECT *, expr AS alias) + if (field.Expression == "*") + { + foreach (var prop in jObj.Properties()) + resultObj[prop.Name] = prop.Value.DeepClone(); + continue; + } + + var outputName = field.Alias ?? field.Expression.Split('.').Last(); + + if (field.SqlExpr is not null and not IdentifierExpression) + { + var value = EvaluateSqlExpression(field.SqlExpr, jObj, parsed.FromAlias, parameters); + if (value is not UndefinedValue) + resultObj[outputName] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + } + else + { + var path = field.Expression; + + // When the expression is exactly the FROM alias (e.g., SELECT VALUE root), + // return the entire document rather than looking for a property named "root". + // Exception: for FROM alias IN c.field, the alias is a property on the expanded + // JObject — use the alias value (the array element) instead of the whole doc. + if (string.Equals(path, parsed.FromAlias, StringComparison.OrdinalIgnoreCase)) + { + var aliasToken = jObj[parsed.FromAlias]; + if (parsed.FromSource is not null && aliasToken is not null) + { + resultObj[outputName] = aliasToken.DeepClone(); + } + else + { + resultObj[outputName] = jObj.DeepClone(); + } + continue; + } + + if (path.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(parsed.FromAlias.Length + 1)..]; + } + + var token = jObj.SelectToken(path); + outputName = field.Alias ?? path.Split('.').Last(); + if (token is not null) + resultObj[outputName] = token.DeepClone(); + } + } + projected.Add(resultObj.ToString(Formatting.None)); + } + return projected; + } + + private static List UnwrapValueSelect(IEnumerable items) + { + var unwrapped = new List(); + foreach (var json in items) + { + var jObj = JsonParseHelpers.ParseJson(json); + if (!jObj.HasValues) + { + // Empty object means the projected value was undefined — skip (omit from results) + continue; + } + + var first = jObj.Properties().FirstOrDefault(); + if (first?.Value is not null) + { + var val = first.Value; + if (val.Type == JTokenType.String) + { + unwrapped.Add(JsonConvert.SerializeObject(val.Value())); + } + else + { + unwrapped.Add(val.ToString(Formatting.None)); + } + } + else + { + unwrapped.Add("null"); + } + } + return unwrapped; + } + + private static List ProjectAggregateFields(IEnumerable itemsEnumerable, CosmosSqlQuery parsed, IDictionary parameters = null) + { + // Aggregates need multiple passes (Count, Sum, indexing) — materialize once + var items = itemsEnumerable as List ?? itemsEnumerable.ToList(); + var resultObj = new JObject(); + foreach (var field in parsed.SelectFields) + { + var expr = field.Expression.TrimStart(); + var outputName = field.Alias ?? field.Expression; + + // Prefer SqlExpr-based evaluation when the expression contains aggregates + // but is NOT a direct aggregate call (e.g., ternary/binary wrapping an aggregate). + // This prevents string-based matching from incorrectly extracting a partial aggregate. + if (field.SqlExpr is not null && ContainsAggregateCall(field.SqlExpr) + && field.SqlExpr is not FunctionCallExpression directAgg) + { + var val = EvaluateAggregateExpression(field.SqlExpr, items, parsed.FromAlias, parameters ?? new Dictionary()); + if (val is not null and not UndefinedValue) + resultObj[outputName] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); + continue; + } + + string funcName = null; + string innerArg = null; + + foreach (var fn in AggregateFunctions) + { + if (expr.StartsWith(fn + "(", StringComparison.OrdinalIgnoreCase)) + { + funcName = fn.ToUpperInvariant(); + var open = expr.IndexOf('('); + var close = expr.LastIndexOf(')'); + if (open >= 0 && close > open) + { + innerArg = expr.Substring(open + 1, close - open - 1).Trim(); + } + + break; + } + } + + if (funcName == "COUNT") + { + if (innerArg is "1" or "*" or null) + { + resultObj[outputName] = items.Count; + } + else if (field.SqlExpr is FunctionCallExpression countFunc && countFunc.Arguments.Length > 0) + { + resultObj[outputName] = CountDefinedResults( + countFunc.Arguments[0], items, parsed.FromAlias, + parameters ?? new Dictionary()); + } + else + { + var countPath = innerArg; + if (countPath.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) + countPath = countPath[(parsed.FromAlias.Length + 1)..]; + resultObj[outputName] = items.Count(json => + { + var jObj = JsonParseHelpers.ParseJson(json); + return jObj.SelectToken(countPath) is JToken t && t.Type != JTokenType.Null; + }); + } + } + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // COUNTIF() counts items where the expression evaluates to true. + else if (funcName == "COUNTIF" && field.SqlExpr is FunctionCallExpression countIfFunc) + { + resultObj[outputName] = EvaluateCountIf(countIfFunc, items, parsed.FromAlias, parameters); + } + else if (funcName is "SUM" or "AVG" && innerArg != null) + { + var values = ExtractNumericValues(items, innerArg, parsed.FromAlias, parameters); + if (funcName == "SUM") + { + if (values.Count > 0) + resultObj[outputName] = NormalizeNumericResult(values.Sum()); + // else: omit field entirely (undefined) — matches Cosmos DB + } + else // AVG + { + if (values.Count > 0) + resultObj[outputName] = NormalizeNumericResult(values.Average()); + // else: omit field entirely (undefined) + } + } + else if (funcName is "MIN" or "MAX" && innerArg != null) + { + var tokens = ExtractTokenValues(items, innerArg, parsed.FromAlias, parameters); + var minMaxResult = AggregateMinMax(tokens, funcName == "MIN"); + if (minMaxResult is not UndefinedValue) + resultObj[outputName] = JToken.FromObject(minMaxResult); + } + else if (field.SqlExpr is not null && ContainsAggregateCall(field.SqlExpr)) + { + // Handle aggregates inside object/array literals (e.g. SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)}) + var val = EvaluateAggregateExpression(field.SqlExpr, items, parsed.FromAlias, parameters ?? new Dictionary()); + if (val is not null and not UndefinedValue) + resultObj[outputName] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); + } + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/select + // "The SELECT clause supports arbitrary expressions including literal values." + // Handle non-aggregate, non-trivial expressions (literals, function calls, etc.) + // by evaluating the SqlExpr directly instead of treating as a property path. + else if (field.SqlExpr is not null and not IdentifierExpression) + { + var jObj = items.Count > 0 ? JsonParseHelpers.ParseJson(items[0]) : new JObject(); + var val = EvaluateSqlExpression(field.SqlExpr, jObj, parsed.FromAlias, + parameters ?? new Dictionary()); + if (val is not null and not UndefinedValue) + resultObj[outputName] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); + else if (val is null) + resultObj[outputName] = JValue.CreateNull(); + } + else + { + var path = field.Expression; + if (path.StartsWith(parsed.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(parsed.FromAlias.Length + 1)..]; + } + + if (items.Count > 0) + { + var jObj = JsonParseHelpers.ParseJson(items[0]); + resultObj[outputName] = jObj.SelectToken(path)?.DeepClone(); + } + } + } + return new List { resultObj.ToString(Formatting.None) }; + } + + /// + /// Evaluates a SqlExpression that may contain aggregate function calls, + /// resolving them globally across all items. Used for expressions like + /// SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.value)} FROM c. + /// + private static object EvaluateAggregateExpression( + SqlExpression expr, IEnumerable itemsEnumerable, string fromAlias, IDictionary parameters) + { + // Aggregates need multiple passes — materialize once + var items = itemsEnumerable as List ?? itemsEnumerable.ToList(); + switch (expr) + { + case FunctionCallExpression func when AggregateFunctions.Contains(func.FunctionName): + { + var innerArg = func.Arguments.Length > 0 ? CosmosSqlParser.ExprToString(func.Arguments[0]) : "1"; + return func.FunctionName.ToUpperInvariant() switch + { + "COUNT" when innerArg is "1" or "*" => (object)items.Count, + "COUNT" => (object)CountDefinedResults(func.Arguments[0], items, fromAlias, parameters), + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // COUNTIF() counts items where the expression evaluates to true. + "COUNTIF" => (object)EvaluateCountIf(func, items, fromAlias, parameters), + "SUM" => ExtractNumericValues(items, innerArg, fromAlias, parameters) is var sv && sv.Count > 0 ? (object)NormalizeNumericResult(sv.Sum()) : UndefinedValue.Instance, + "AVG" => ExtractNumericValues(items, innerArg, fromAlias, parameters) is var av && av.Count > 0 ? (object)NormalizeNumericResult(av.Average()) : UndefinedValue.Instance, + "MIN" => AggregateMinMax(ExtractTokenValues(items, innerArg, fromAlias, parameters), true), + "MAX" => AggregateMinMax(ExtractTokenValues(items, innerArg, fromAlias, parameters), false), + _ => null + }; + } + case ObjectLiteralExpression obj: + { + var result = new JObject(); + foreach (var prop in obj.Properties) + { + var val = EvaluateAggregateExpression(prop.Value, items, fromAlias, parameters); + if (val is not null and not UndefinedValue) + result[prop.Key] = val is JToken jt ? jt.DeepClone() : JToken.FromObject(val); + } + return result; + } + case ArrayLiteralExpression arr: + { + var result = new JArray(); + foreach (var element in arr.Elements) + { + var val = EvaluateAggregateExpression(element, items, fromAlias, parameters); + result.Add(val is JToken jt ? jt.DeepClone() : val is not null and not UndefinedValue ? JToken.FromObject(val) : JValue.CreateNull()); + } + return result; + } + case BinaryExpression bin: + { + var left = EvaluateAggregateExpression(bin.Left, items, fromAlias, parameters); + var right = EvaluateAggregateExpression(bin.Right, items, fromAlias, parameters); + return bin.Operator switch + { + BinaryOp.GreaterThan => (object)(CompareValues(left, right) > 0), + BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, + BinaryOp.LessThan => CompareValues(left, right) < 0, + BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, + BinaryOp.Equal => ValuesEqual(left, right), + BinaryOp.NotEqual => !ValuesEqual(left, right), + BinaryOp.And => IsTruthy(left) && IsTruthy(right), + BinaryOp.Or => IsTruthy(left) || IsTruthy(right), + BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), + BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), + BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), + BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), + BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), + _ => null + }; + } + case TernaryExpression tern: + { + var condition = EvaluateAggregateExpression(tern.Condition, items, fromAlias, parameters); + return condition is bool b && b + ? EvaluateAggregateExpression(tern.IfTrue, items, fromAlias, parameters) + : EvaluateAggregateExpression(tern.IfFalse, items, fromAlias, parameters); + } + default: + // Non-aggregate expression — evaluate against first item (for non-aggregate fields in mixed projection) + if (items.Count > 0) + { + var jObj = JsonParseHelpers.ParseJson(items[0]); + return EvaluateSqlExpression(expr, jObj, fromAlias, parameters); + } + return null; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Query helpers — Parameter extraction + // ═══════════════════════════════════════════════════════════════════════════ + + private static Dictionary ExtractQueryParameters(QueryDefinition queryDefinition) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var (Name, Value) in queryDefinition.GetQueryParameters()) + { + parameters[Name] = Value; + } + + if (parameters.Count > 0) + { + return parameters; + } + } + catch { /* Fall through to reflection */ } + + try + { + var internalField = typeof(QueryDefinition) + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name.Contains("parameter", StringComparison.OrdinalIgnoreCase)); + + if (internalField?.GetValue(queryDefinition) is System.Collections.IEnumerable enumerable) + { + foreach (var item in enumerable) + { + var itemType = item.GetType(); + var nameProp = itemType.GetProperty("Name") ?? itemType.GetProperty("Item1"); + var valueProp = itemType.GetProperty("Value") ?? itemType.GetProperty("Item2"); + if (nameProp is not null && valueProp is not null) + { + var name = nameProp.GetValue(item)?.ToString(); + if (name is not null) + { + parameters[name] = valueProp.GetValue(item); + } + } + else + { + var nameField = itemType.GetField("Name") ?? itemType.GetField("Item1"); + var valueField = itemType.GetField("Value") ?? itemType.GetField("Item2"); + if (nameField is not null && valueField is not null) + { + var name = nameField.GetValue(item)?.ToString(); + if (name is not null) + { + parameters[name] = valueField.GetValue(item); + } + } + } + } + } + } + catch { /* Parameters cannot be extracted */ } + + return parameters; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WHERE expression evaluation + // ═══════════════════════════════════════════════════════════════════════════ + + private static bool EvaluateWhereExpression( + WhereExpression expression, JObject item, string fromAlias, + IDictionary parameters, JoinClause join, bool treatUndefinedAsNull = false) + { + return expression switch + { + ComparisonCondition c => EvaluateComparison(c, item, fromAlias, parameters, treatUndefinedAsNull), + AndCondition a => EvaluateWhereExpression(a.Left, item, fromAlias, parameters, join, treatUndefinedAsNull) + && EvaluateWhereExpression(a.Right, item, fromAlias, parameters, join, treatUndefinedAsNull), + OrCondition o => EvaluateWhereExpression(o.Left, item, fromAlias, parameters, join, treatUndefinedAsNull) + || EvaluateWhereExpression(o.Right, item, fromAlias, parameters, join, treatUndefinedAsNull), + NotCondition n => !EvaluateWhereExpressionIncludesUndefined(n.Inner, item, fromAlias, parameters, join, treatUndefinedAsNull) + && !EvaluateWhereExpression(n.Inner, item, fromAlias, parameters, join, treatUndefinedAsNull), + FunctionCondition f => EvaluateFunction(f, item, fromAlias, parameters), + ExistsCondition e => EvaluateExists(e, item, fromAlias, parameters, join), + SqlExpressionCondition s => IsTruthy(EvaluateSqlExpression(s.Expression, item, fromAlias, parameters)), + _ => true + }; + } + + private static bool EvaluateComparison( + ComparisonCondition comparison, JObject item, string fromAlias, IDictionary parameters, + bool treatUndefinedAsNull = false) + { + var leftValue = ResolveValue(comparison.Left, item, fromAlias, parameters); + var rightValue = ResolveValue(comparison.Right, item, fromAlias, parameters); + + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update#filter-predicate + // In FilterPredicate context, missing properties are treated as null. + if (treatUndefinedAsNull) + { + if (leftValue is UndefinedValue) leftValue = null; + if (rightValue is UndefinedValue) rightValue = null; + } + + if (leftValue is UndefinedValue || rightValue is UndefinedValue) + return false; + return comparison.Operator switch + { + ComparisonOp.Equal => ValuesEqual(leftValue, rightValue), + ComparisonOp.NotEqual => !ValuesEqual(leftValue, rightValue), + ComparisonOp.LessThan => CompareValues(leftValue, rightValue) < 0, + ComparisonOp.GreaterThan => CompareValues(leftValue, rightValue) > 0, + ComparisonOp.LessThanOrEqual => CompareValues(leftValue, rightValue) <= 0, + ComparisonOp.GreaterThanOrEqual => CompareValues(leftValue, rightValue) >= 0, + ComparisonOp.Like => IsTruthy(EvaluateLike(leftValue, rightValue)), + _ => false + }; + } + + /// + /// Returns true if the inner expression involves undefined operands (three-value logic). + /// Used by NOT to propagate undefined — NOT undefined = undefined (excluded). + /// + private static bool EvaluateWhereExpressionIncludesUndefined( + WhereExpression expression, JObject item, string fromAlias, + IDictionary parameters, JoinClause join, bool treatUndefinedAsNull = false) + { + return expression switch + { + ComparisonCondition c => ComparisonIncludesUndefined(c, item, fromAlias, parameters, treatUndefinedAsNull), + AndCondition a => + EvaluateWhereExpressionIncludesUndefined(a.Left, item, fromAlias, parameters, join, treatUndefinedAsNull) || + EvaluateWhereExpressionIncludesUndefined(a.Right, item, fromAlias, parameters, join, treatUndefinedAsNull), + OrCondition o => + EvaluateWhereExpressionIncludesUndefined(o.Left, item, fromAlias, parameters, join, treatUndefinedAsNull) || + EvaluateWhereExpressionIncludesUndefined(o.Right, item, fromAlias, parameters, join, treatUndefinedAsNull), + NotCondition n => + EvaluateWhereExpressionIncludesUndefined(n.Inner, item, fromAlias, parameters, join, treatUndefinedAsNull), + SqlExpressionCondition s => + EvaluateSqlExpression(s.Expression, item, fromAlias, parameters) is UndefinedValue, + _ => false, + }; + } + + /// + /// Checks if a comparison involves undefined semantics. For most operators, only + /// UndefinedValue counts. For LIKE, null also produces undefined (three-value logic). + /// When treatUndefinedAsNull is true, undefined is treated as null (FilterPredicate context). + /// + private static bool ComparisonIncludesUndefined( + ComparisonCondition c, JObject item, string fromAlias, IDictionary parameters, + bool treatUndefinedAsNull = false) + { + var left = ResolveValue(c.Left, item, fromAlias, parameters); + var right = ResolveValue(c.Right, item, fromAlias, parameters); + + // In FilterPredicate context, undefined is treated as null — not "undefined" + if (treatUndefinedAsNull) + { + if (left is UndefinedValue) left = null; + if (right is UndefinedValue) right = null; + } + + if (left is UndefinedValue || right is UndefinedValue) + return true; + // LIKE with null operand(s) produces undefined per three-value logic + if (c.Operator == ComparisonOp.Like && (left is null || right is null)) + return true; + return false; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Value resolution & comparison + // ═══════════════════════════════════════════════════════════════════════════ + + private static object ResolveValue( + string expression, JObject item, string fromAlias, IDictionary parameters) + { + var trimmed = expression.Trim(); + if (trimmed.StartsWith("@")) + { + return parameters.TryGetValue(trimmed, out var v) ? v : null; + } + + if (trimmed.StartsWith("'") && trimmed.EndsWith("'")) + { + return trimmed[1..^1]; + } + + if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(trimmed, "null", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (double.TryParse(trimmed, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + { + return num; + } + + var jsonPath = trimmed; + if (jsonPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + jsonPath = jsonPath[(fromAlias.Length + 1)..]; + } + else if (jsonPath.StartsWith(fromAlias + "[", StringComparison.OrdinalIgnoreCase)) + { + jsonPath = jsonPath[fromAlias.Length..]; // Keep brackets — SelectToken handles ['prop'] + } + + var token = item.SelectToken(jsonPath); + if (token == null) + { + // Handle string pseudo-properties: SDK LINQ translates d.Name.Length + // to root["name"]["Length"] which SelectToken can't resolve on a string value. + if (TryResolveStringPseudoProperty(jsonPath, item, out var pseudoResult)) + { + return pseudoResult; + } + + return UndefinedValue.Instance; + } + + return token.Type switch + { + JTokenType.String => token.Value(), + JTokenType.Integer => token.Value(), + JTokenType.Float => token.Value(), + JTokenType.Boolean => token.Value(), + JTokenType.Null => null, + JTokenType.Undefined => UndefinedValue.Instance, + JTokenType.Array => (object)token, + JTokenType.Object => (object)token, + _ => token.ToString() + }; + } + + /// + /// Handles string/array pseudo-properties that the SDK LINQ provider generates. + /// For example, d.Name.Length is translated to root["name"]["Length"] + /// which SelectToken cannot resolve on a string JValue. This method detects the + /// pattern and returns the string length (or array count) instead. + /// + private static bool TryResolveStringPseudoProperty(string jsonPath, JObject item, out object result) + { + result = null; + + string parentPath = null; + if (jsonPath.EndsWith("['Length']", StringComparison.OrdinalIgnoreCase)) + parentPath = jsonPath[..^"['Length']".Length]; + else if (jsonPath.EndsWith("[\"Length\"]", StringComparison.OrdinalIgnoreCase)) + parentPath = jsonPath[..^"[\"Length\"]".Length]; + else if (jsonPath.EndsWith(".Length", StringComparison.OrdinalIgnoreCase)) + parentPath = jsonPath[..^".Length".Length]; + + if (parentPath is null || parentPath.Length == 0) return false; + + var parentToken = item.SelectToken(parentPath); + if (parentToken is JValue { Type: JTokenType.String } sv) + { + result = (long)sv.Value()!.Length; + return true; + } + + if (parentToken is JArray arr) + { + result = (long)arr.Count; + return true; + } + + return false; + } + + private static bool ValuesEqual(object left, object right) + { + if (left is UndefinedValue || right is UndefinedValue) + { + return false; + } + + if (left is null && right is null) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + if ((left is double or long) && (right is double or long)) + { + if (double.TryParse(left.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && + double.TryParse(right.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) + { + return l == r; + } + } + + // Normalize non-primitive / non-JToken values (e.g. anonymous objects from parameters) to JToken + var leftJ = left is JToken ? left : NormalizeToJTokenIfComplex(left); + var rightJ = right is JToken ? right : NormalizeToJTokenIfComplex(right); + + // Cosmos DB uses strict type comparison: different type ranks are never equal + // (except numeric types which are handled above) + if (GetTypeRank(leftJ) != GetTypeRank(rightJ)) + { + return false; + } + + // Deep-compare JTokens + if (leftJ is JToken jtLeft && rightJ is JToken jtRight) + { + return JToken.DeepEquals(jtLeft, jtRight); + } + + return string.Equals(leftJ.ToString(), rightJ.ToString(), StringComparison.Ordinal); + } + + private static object NormalizeToJTokenIfComplex(object value) + { + if (value is null or string or bool or int or long or double or float or decimal or UndefinedValue) + return value; + try { return JToken.FromObject(value); } + catch { return value; } + } + + /// + /// Returns the Cosmos DB type rank for ordering: + /// undefined(0) < null(1) < bool(2) < number(3) < string(4) < array(5) < object(6). + /// + private static int GetTypeRank(object value) + { + if (ReferenceEquals(value, UndefinedSortSentinel)) return 0; + if (value is UndefinedValue) return 0; + if (value is null) return 1; + if (value is bool) return 2; + if (value is int or long or double or float or decimal) return 3; + if (value is string) return 4; + if (value is JToken jt) + { + return jt.Type switch + { + JTokenType.Undefined => 0, + JTokenType.Null => 1, + JTokenType.Boolean => 2, + JTokenType.Integer or JTokenType.Float => 3, + JTokenType.String => 4, + JTokenType.Array => 5, + JTokenType.Object => 6, + _ => 7 + }; + } + return 7; + } + + private static int CompareValues(object left, object right) + { + var leftRank = GetTypeRank(left); + var rightRank = GetTypeRank(right); + if (leftRank != rightRank) return leftRank.CompareTo(rightRank); + + // Both undefined or both null + if (leftRank <= 1) return 0; + + if (left is bool lb && right is bool rb) + return lb.CompareTo(rb); // false < true + + if (double.TryParse(left?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && + double.TryParse(right?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) + { + return l.CompareTo(r); + } + + // Element-by-element array comparison (Cosmos DB behavior) + if (leftRank == 5) + { + var leftArr = left is JArray la ? la : (left is JToken lt ? new JArray(lt) : null); + var rightArr = right is JArray ra ? ra : (right is JToken rt ? new JArray(rt) : null); + if (leftArr is not null && rightArr is not null) + { + for (var i = 0; i < Math.Min(leftArr.Count, rightArr.Count); i++) + { + var cmp = CompareValues( + JTokenToObject(leftArr[i]), + JTokenToObject(rightArr[i])); + if (cmp != 0) return cmp; + } + return leftArr.Count.CompareTo(rightArr.Count); + } + } + + // Property-by-property object comparison (sorted by property name) + if (leftRank == 6) + { + var leftObj = left as JObject ?? (left is JToken lk ? lk as JObject : null); + var rightObj = right as JObject ?? (right is JToken rk ? rk as JObject : null); + if (leftObj is not null && rightObj is not null) + { + var leftProps = leftObj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); + var rightProps = rightObj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); + for (var i = 0; i < Math.Min(leftProps.Count, rightProps.Count); i++) + { + var nameCmp = string.Compare(leftProps[i].Name, rightProps[i].Name, StringComparison.Ordinal); + if (nameCmp != 0) return nameCmp; + var valCmp = CompareValues( + JTokenToObject(leftProps[i].Value), + JTokenToObject(rightProps[i].Value)); + if (valCmp != 0) return valCmp; + } + return leftProps.Count.CompareTo(rightProps.Count); + } + } + + return string.Compare(left?.ToString(), right?.ToString(), StringComparison.Ordinal); + } + + private static object JTokenToObject(JToken token) + { + return token.Type switch + { + JTokenType.Integer => (object)token.Value(), + JTokenType.Float => token.Value(), + JTokenType.Boolean => token.Value(), + JTokenType.String => token.Value(), + JTokenType.Null => null, + JTokenType.Undefined => UndefinedValue.Instance, + _ => token // Return JArray/JObject as-is for recursive comparison + }; + } + + private static object EvaluateLike(object left, object right, string escapeChar = null) + { + if (left is UndefinedValue || right is UndefinedValue) + { + return UndefinedValue.Instance; + } + + if (left is null || right is null) + { + return UndefinedValue.Instance; + } + + // Real Cosmos DB: LIKE only operates on strings. Non-string left operand returns undefined. + if (left is not string) + { + return UndefinedValue.Instance; + } + + var patternStr = right.ToString(); + if (escapeChar is { Length: > 0 }) + { + var esc = escapeChar[0]; + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < patternStr.Length; i++) + { + if (patternStr[i] == esc && i + 1 < patternStr.Length) + { + sb.Append(Regex.Escape(patternStr[i + 1].ToString())); + i++; + } + else if (patternStr[i] == '%') + sb.Append(".*"); + else if (patternStr[i] == '_') + sb.Append('.'); + else + sb.Append(Regex.Escape(patternStr[i].ToString())); + } + var pattern = $"^{sb}$"; + return GetOrCreateRegex(pattern, RegexOptions.Singleline).IsMatch(left.ToString()); + } + + var simplePattern = ConvertLikeToRegex(patternStr); + return GetOrCreateRegex(simplePattern, RegexOptions.Singleline).IsMatch(left.ToString()); + } + + private static string ConvertLikeToRegex(string pattern) + { + var sb = new System.Text.StringBuilder("^"); + foreach (var ch in pattern) + { + if (ch == '%') sb.Append(".*"); + else if (ch == '_') sb.Append('.'); + else sb.Append(Regex.Escape(ch.ToString())); + } + sb.Append('$'); + return sb.ToString(); + } + + private static Regex GetOrCreateRegex(string pattern, RegexOptions options) + { + var key = (pattern, options); + if (RegexCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var regex = new Regex(pattern, options | RegexOptions.Compiled); + if (RegexCache.Count < RegexCacheMaxSize) + { + RegexCache.TryAdd(key, regex); + } + + return regex; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Legacy FunctionCondition evaluation (backward compat) + // ═══════════════════════════════════════════════════════════════════════════ + + private static bool EvaluateFunction( + FunctionCondition func, JObject item, string fromAlias, IDictionary parameters) + { + switch (func.FunctionName) + { + case "STARTSWITH": + { + if (func.Arguments.Length < 2) + { + return false; + } + + var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); + var prefix = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); + if (fieldValue is null || prefix is null) + { + return false; + } + + var ignoreCase = func.Arguments.Length >= 3 && + string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return fieldValue.StartsWith(prefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "ENDSWITH": + { + if (func.Arguments.Length < 2) + { + return false; + } + + var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); + var suffix = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); + if (fieldValue is null || suffix is null) + { + return false; + } + + var ignoreCase = func.Arguments.Length >= 3 && + string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return fieldValue.EndsWith(suffix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "CONTAINS": + { + if (func.Arguments.Length < 2) + { + return false; + } + + var fieldValue = ResolveValue(func.Arguments[0], item, fromAlias, parameters)?.ToString(); + var search = ResolveValue(func.Arguments[1], item, fromAlias, parameters)?.ToString(); + if (fieldValue is null || search is null) + { + return false; + } + + var ignoreCase = func.Arguments.Length >= 3 && + string.Equals(ResolveValue(func.Arguments[2], item, fromAlias, parameters)?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return fieldValue.Contains(search, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "ARRAY_CONTAINS": + { + if (func.Arguments.Length < 2) + { + return false; + } + + JArray jArray; + var firstArg = func.Arguments[0].Trim(); + if (firstArg.StartsWith("@") && parameters.TryGetValue(firstArg, out var paramVal)) + { + // First argument is a parameter (e.g. ARRAY_CONTAINS(@names, c.name)) + jArray = paramVal switch + { + JArray ja => ja, + System.Collections.IEnumerable enumerable when paramVal is not string => + new JArray(enumerable.Cast().Select(JToken.FromObject)), + _ => null + }; + } + else + { + var arrayPath = firstArg; + if (arrayPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + arrayPath = arrayPath[(fromAlias.Length + 1)..]; + } + jArray = item.SelectToken(arrayPath) as JArray; + } + + if (jArray is null) + { + return false; + } + + var searchValue = ResolveValue(func.Arguments[1], item, fromAlias, parameters); + if (searchValue is null) + { + return jArray.Any(t => t.Type == JTokenType.Null); + } + + var searchStr = searchValue.ToString(); + var partial = func.Arguments.Length >= 3 && + string.Equals(func.Arguments[2].Trim(), "true", StringComparison.OrdinalIgnoreCase); + return ArrayContainsMatch(jArray, searchStr, partial); + } + case "IS_DEFINED": + { + if (func.Arguments.Length < 1) + { + return false; + } + + var path = func.Arguments[0].Trim(); + + // Resolve parameterized values (e.g. IS_DEFINED(@param)) + // A resolved parameter is always "defined" (even if null). + if (path.StartsWith("@")) + return parameters.ContainsKey(path); + + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + var token = item.SelectToken(path); + return token is not null && token.Type != JTokenType.Undefined; + } + case "IS_NULL": + { + if (func.Arguments.Length < 1) + { + return false; + } + + var path = func.Arguments[0].Trim(); + + // Resolve parameterized values (e.g. IS_NULL(@param)) + if (path.StartsWith("@")) + { + if (parameters.TryGetValue(path, out var paramVal)) + return paramVal is null || (paramVal is JToken jt && jt.Type == JTokenType.Null); + return true; // Unresolved parameter treated as null + } + + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + var token = item.SelectToken(path); + return token is not null && token.Type is JTokenType.Null; + } + default: + return true; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EXISTS subquery evaluation + // ═══════════════════════════════════════════════════════════════════════════ + + private static bool EvaluateExists( + ExistsCondition exists, JObject item, string fromAlias, + IDictionary parameters, JoinClause join) + { + try + { + var raw = exists.RawSubquery.Trim(); + var queryToParse = raw.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) + ? raw + : "SELECT * FROM " + raw; + + CosmosSqlQuery subquery; + try + { + subquery = CosmosSqlParser.Parse(queryToParse); + } + catch (NotSupportedException) + { + // Malformed SQL — throw 400 like real Cosmos DB + throw InMemoryCosmosException.Create( + $"Syntax error in EXISTS subquery: {raw}", + System.Net.HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + + // Handle FROM alias IN source.path (array iteration in EXISTS subquery) + if (subquery.FromSource is not null) + { + var sourcePath = subquery.FromSource; + // Strip outer alias prefix (e.g. "c.tags" → "tags" when fromAlias is "c") + if (sourcePath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + sourcePath = sourcePath[(fromAlias.Length + 1)..]; + } + + var arrayToken = item.SelectToken(sourcePath); + if (arrayToken is not JArray jArray || jArray.Count == 0) + { + return false; + } + + if (subquery.Where is not null) + { + var iterAlias = subquery.FromAlias; + foreach (var element in jArray) + { + // Create a combined object with the array element available under its alias + var combined = new JObject(item.Properties()); + combined[iterAlias] = element is JObject elementObj + ? elementObj.DeepClone() + : new JValue(element.ToObject()); + if (EvaluateWhereExpression(subquery.Where, combined, iterAlias, parameters, null)) + { + return true; + } + } + return false; + } + return true; + } + + if (subquery.Join is not null) + { + var arrayPath = subquery.Join.ArrayField; + var sourceAlias = subquery.Join.SourceAlias; + if (sourceAlias.Equals(fromAlias, StringComparison.OrdinalIgnoreCase)) + { + var arrayToken = item.SelectToken(arrayPath); + if (arrayToken is not JArray jArray || jArray.Count == 0) + { + return false; + } + + if (subquery.Where is not null) + { + foreach (var element in jArray) + { + if (element is JObject elementObj) + { + var combined = new JObject(item.Properties()) + { + [subquery.Join.Alias] = elementObj + }; + if (EvaluateWhereExpression(subquery.Where, combined, subquery.FromAlias, parameters, subquery.Join)) + { + return true; + } + } + } + return false; + } + return true; + } + } + if (subquery.Where is not null) + { + return EvaluateWhereExpression(subquery.Where, item, fromAlias, parameters, join); + } + + return true; + } + catch (CosmosException) + { + throw; // Re-throw parse errors (400 Bad Request) + } + catch + { + return false; // Runtime evaluation errors — EXISTS evaluates to false + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SqlExpression tree evaluation + // ═══════════════════════════════════════════════════════════════════════════ + + private static object EvaluateSqlExpression( + SqlExpression expr, JObject item, string fromAlias, IDictionary parameters) + { + return expr switch + { + LiteralExpression lit => lit.Value, + UndefinedLiteralExpression => UndefinedValue.Instance, + IdentifierExpression ident => ResolveValue(ident.Name, item, fromAlias, parameters), + ParameterExpression param => parameters.TryGetValue(param.Name, out var v) ? UnwrapJToken(v) : null, + FunctionCallExpression func => EvaluateSqlFunction(func, item, fromAlias, parameters), + BinaryExpression bin => EvaluateBinaryExpression(bin, item, fromAlias, parameters), + UnaryExpression unary => EvaluateUnaryExpression(unary, item, fromAlias, parameters), + BetweenExpression between => EvalBetween(between, item, fromAlias, parameters), + InExpression inExpr => EvalIn(inExpr, item, fromAlias, parameters), + LikeExpression like => EvaluateLike( + EvaluateSqlExpression(like.Value, item, fromAlias, parameters), + EvaluateSqlExpression(like.Pattern, item, fromAlias, parameters), + like.EscapeChar), + ExistsExpression exists => EvaluateExists(new ExistsCondition(exists.RawSubquery), item, fromAlias, parameters, null), + CoalesceExpression coal => EvalCoalesce(coal, item, fromAlias, parameters), + TernaryExpression tern => EvaluateSqlExpression(tern.Condition, item, fromAlias, parameters) is bool tb && tb + ? EvaluateSqlExpression(tern.IfTrue, item, fromAlias, parameters) + : EvaluateSqlExpression(tern.IfFalse, item, fromAlias, parameters), + ObjectLiteralExpression obj => EvaluateObjectLiteral(obj, item, fromAlias, parameters), + ArrayLiteralExpression arr => EvaluateArrayLiteral(arr, item, fromAlias, parameters), + PropertyAccessExpression prop => ResolveValue( + $"{CosmosSqlParser.ExprToString(prop.Object)}.{prop.Property}", item, fromAlias, parameters), + IndexAccessExpression idx => ResolveValue( + $"{CosmosSqlParser.ExprToString(idx.Object)}[{CosmosSqlParser.ExprToString(idx.Index)}]", item, fromAlias, parameters), + SubqueryExpression sub => EvaluateSubquery(sub.Subquery, item, fromAlias, parameters), + _ => null + }; + } + + private static object UnwrapJToken(object value) + { + if (value is JValue jv) + { + return jv.Type switch + { + JTokenType.String => jv.Value(), + JTokenType.Integer => jv.Value(), + JTokenType.Float => jv.Value(), + JTokenType.Boolean => jv.Value(), + JTokenType.Null => null, + _ => value + }; + } + return value; + } + + private static object EvalBetween(BetweenExpression b, JObject item, string fromAlias, IDictionary parameters) + { + var value = EvaluateSqlExpression(b.Value, item, fromAlias, parameters); + var low = EvaluateSqlExpression(b.Low, item, fromAlias, parameters); + var high = EvaluateSqlExpression(b.High, item, fromAlias, parameters); + if (value is UndefinedValue || low is UndefinedValue || high is UndefinedValue) + return UndefinedValue.Instance; + return CompareValues(value, low) >= 0 && CompareValues(value, high) <= 0; + } + + private static object EvalCoalesce(CoalesceExpression coal, JObject item, string fromAlias, IDictionary parameters) + { + var left = EvaluateSqlExpression(coal.Left, item, fromAlias, parameters); + return left is not null and not UndefinedValue ? left : EvaluateSqlExpression(coal.Right, item, fromAlias, parameters); + } + + private static object EvalIn(InExpression inExpr, JObject item, string fromAlias, IDictionary parameters) + { + var value = EvaluateSqlExpression(inExpr.Value, item, fromAlias, parameters); + if (value is UndefinedValue) + return UndefinedValue.Instance; + return inExpr.List.Any(li => ValuesEqual(value, EvaluateSqlExpression(li, item, fromAlias, parameters))); + } + + private static object EvaluateBinaryExpression( + BinaryExpression bin, JObject item, string fromAlias, IDictionary parameters) + { + var left = EvaluateSqlExpression(bin.Left, item, fromAlias, parameters); + var right = EvaluateSqlExpression(bin.Right, item, fromAlias, parameters); + + // Three-value logic: comparisons with undefined operand(s) produce undefined + if (bin.Operator is BinaryOp.Equal or BinaryOp.NotEqual or BinaryOp.LessThan or BinaryOp.GreaterThan + or BinaryOp.LessThanOrEqual or BinaryOp.GreaterThanOrEqual or BinaryOp.Like) + { + if (left is UndefinedValue || right is UndefinedValue) + return UndefinedValue.Instance; + } + + return bin.Operator switch + { + BinaryOp.Equal => (object)ValuesEqual(left, right), + BinaryOp.NotEqual => !ValuesEqual(left, right), + BinaryOp.LessThan => CompareValues(left, right) < 0, + BinaryOp.GreaterThan => CompareValues(left, right) > 0, + BinaryOp.LessThanOrEqual => CompareValues(left, right) <= 0, + BinaryOp.GreaterThanOrEqual => CompareValues(left, right) >= 0, + // Three-value AND: false AND undefined = false; true AND undefined = undefined + BinaryOp.And => left is UndefinedValue + ? (IsTruthy(right) ? UndefinedValue.Instance : (object)false) + : right is UndefinedValue + ? (IsTruthy(left) ? UndefinedValue.Instance : (object)false) + : IsTruthy(left) && IsTruthy(right), + // Three-value OR: true OR undefined = true; false OR undefined = undefined + BinaryOp.Or => left is UndefinedValue + ? (IsTruthy(right) ? (object)true : UndefinedValue.Instance) + : right is UndefinedValue + ? (IsTruthy(left) ? (object)true : UndefinedValue.Instance) + : IsTruthy(left) || IsTruthy(right), + BinaryOp.Like => EvaluateLike(left, right), + BinaryOp.Add => ArithmeticOp(left, right, (a, b) => a + b), + BinaryOp.Subtract => ArithmeticOp(left, right, (a, b) => a - b), + BinaryOp.Multiply => ArithmeticOp(left, right, (a, b) => a * b), + BinaryOp.Divide => ArithmeticOp(left, right, (a, b) => b != 0 ? a / b : double.NaN), + BinaryOp.Modulo => ArithmeticOp(left, right, (a, b) => b != 0 ? a % b : double.NaN), + BinaryOp.StringConcat => left is null or UndefinedValue || right is null or UndefinedValue ? UndefinedValue.Instance : left.ToString() + right.ToString(), + BinaryOp.BitwiseAnd => BitwiseOp(left, right, (a, b) => a & b), + BinaryOp.BitwiseOr => BitwiseOp(left, right, (a, b) => a | b), + BinaryOp.BitwiseXor => BitwiseOp(left, right, (a, b) => a ^ b), + _ => null + }; + } + + private static object EvaluateUnaryExpression( + UnaryExpression unary, JObject item, string fromAlias, IDictionary parameters) + { + var operand = EvaluateSqlExpression(unary.Operand, item, fromAlias, parameters); + return unary.Operator switch + { + UnaryOp.Not => operand is UndefinedValue or null ? UndefinedValue.Instance : (object)!IsTruthy(operand), + UnaryOp.Negate => operand is double d ? (object)(-d) : operand is long l ? (object)(-l) : UndefinedValue.Instance, + UnaryOp.BitwiseNot => operand is long lng ? (object)(~lng) : UndefinedValue.Instance, + _ => UndefinedValue.Instance + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SQL function evaluation + // ═══════════════════════════════════════════════════════════════════════════ + + private static object EvaluateSqlFunction( + FunctionCallExpression func, JObject item, string fromAlias, IDictionary parameters) + { + // ARRAY(subquery) — evaluate subquery and collect results into a JArray + if (string.Equals(func.FunctionName, "ARRAY", StringComparison.OrdinalIgnoreCase) && + func.Arguments.Length == 1 && func.Arguments[0] is SubqueryExpression subExpr) + { + return EvaluateArraySubquery(subExpr.Subquery, item, fromAlias, parameters); + } + + var args = func.Arguments.Select(a => EvaluateSqlExpression(a, item, fromAlias, parameters)).ToArray(); + + // Undefined propagation: most scalar functions return undefined when any + // argument is undefined (missing property). Type-checking functions (IS_*), + // emulator-specific functions that handle missing values with special + // semantics, and a few other functions are excluded. + if (args.Any(a => a is UndefinedValue)) + { + var name = func.FunctionName.ToUpperInvariant(); + if (!name.StartsWith("IS_") && name is not "COALESCE" and not "IIF" + and not "ARRAY_CONTAINS" and not "ARRAY_CONTAINS_ANY" and not "ARRAY_CONTAINS_ALL" + and not "DOCUMENTID" and not "VECTORDISTANCE" + and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" and not "FULLTEXTCONTAINSANY" + and not "INDEX_OF" and not "TYPE") + { + return UndefinedValue.Instance; + } + } + + switch (func.FunctionName) + { + // ── Type checking ── + case "IS_DEFINED": + { + if (func.Arguments.Length < 1) + { + return false; + } + + if (func.Arguments[0] is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + return item.SelectToken(path) != null; + } + // A parameter is always "defined" if it exists in the dictionary + // (even when its value is null). + if (func.Arguments[0] is ParameterExpression param) + return parameters.ContainsKey(param.Name); + return args[0] != null; + } + case "IS_NULL": return args.Length > 0 && args[0] is null; + case "IS_ARRAY": + return args.Length > 0 && (ResolveTokenType(func.Arguments, item, fromAlias) is JArray || args[0] is JArray); + case "IS_BOOL": return args.Length > 0 && args[0] is bool; + case "IS_NUMBER": return args.Length > 0 && args[0] is long or double or int or float or decimal; + case "IS_STRING": return args.Length > 0 && args[0] is string; + case "IS_OBJECT": + return args.Length > 0 && (ResolveTokenType(func.Arguments, item, fromAlias) is JObject || args[0] is JObject); + case "IS_PRIMITIVE": + { + if (args.Length == 0) + { + return false; + } + + if (args[0] is null) + { + return true; + } + + var tokenForPrimitive = ResolveTokenType(func.Arguments, item, fromAlias); + if (tokenForPrimitive is JArray or JObject) + { + return false; + } + + return args[0] is string or bool or long or double; + } + case "IS_FINITE_NUMBER": + { + if (args.Length == 0) + { + return false; + } + + return args[0] switch + { + long => true, + int => true, + double d => !double.IsInfinity(d) && !double.IsNaN(d), + float f => !float.IsInfinity(f) && !float.IsNaN(f), + decimal => true, + _ => false, + }; + } + case "IS_INTEGER": + { + if (args.Length == 0) + { + return false; + } + + return args[0] is long or int; + } + case "IS_NAN": + { + if (args.Length == 0) + { + return false; + } + + return args[0] is double d && double.IsNaN(d); + } + case "TYPE": + { + if (args.Length == 0) + { + return UndefinedValue.Instance; + } + + if (args[0] is UndefinedValue) + { + return UndefinedValue.Instance; + } + + var tokenForType = ResolveTokenType(func.Arguments, item, fromAlias); + if (tokenForType is JArray) return "array"; + if (tokenForType is JObject) return "object"; + + return args[0] switch + { + null => "null", + bool => "boolean", + long or int or double or float or decimal => "number", + string => "string", + _ => "undefined" + }; + } + + // ── String functions ── + case "STARTSWITH": + { + if (args.Length < 2) + { + return false; + } + + if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; + if (args[1] is not string p) return UndefinedValue.Instance; + + var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return s.StartsWith(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "ENDSWITH": + { + if (args.Length < 2) + { + return false; + } + + if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; + if (args[1] is not string p) return UndefinedValue.Instance; + + var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return s.EndsWith(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "CONTAINS": + { + if (args.Length < 2) + { + return false; + } + + if (args[0] is not string s || args[1] is UndefinedValue) return UndefinedValue.Instance; + if (args[1] is not string p) return UndefinedValue.Instance; + + var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return s.Contains(p, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "CONCAT": + { + // Cosmos DB: if any arg is null, undefined, or not a string, CONCAT returns undefined + if (args.Any(a => a is null or UndefinedValue)) return UndefinedValue.Instance; + if (args.Any(a => a is not string)) return UndefinedValue.Instance; + return string.Concat(args.Cast()); + } + case "LENGTH": + { + if (args.Length == 0) return null; + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + if (args[0] is not string s) return UndefinedValue.Instance; + return (object)(long)s.Length; + } + case "LOWER": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string sl ? (object)sl.ToLowerInvariant() : UndefinedValue.Instance) : null; + case "UPPER": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string su ? (object)su.ToUpperInvariant() : UndefinedValue.Instance) : null; + case "TRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string st ? (object)st.Trim() : UndefinedValue.Instance) : null; + case "LTRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string slt ? (object)slt.TrimStart() : UndefinedValue.Instance) : null; + case "RTRIM": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string srt ? (object)srt.TrimEnd() : UndefinedValue.Instance) : null; + case "REVERSE": return args.Length > 0 ? (args[0] is null or UndefinedValue ? UndefinedValue.Instance : args[0] is string rs ? (object)new string(rs.Reverse().ToArray()) : UndefinedValue.Instance) : null; + case "LEFT": + { + if (args.Length < 2) + { + return null; + } + + if (args[0] is not string s) return UndefinedValue.Instance; + var c = ToLong(args[1]); + if (!c.HasValue) return UndefinedValue.Instance; + if (c.Value < 0) return UndefinedValue.Instance; + return s[..(int)Math.Min(c.Value, s.Length)]; + } + case "RIGHT": + { + if (args.Length < 2) + { + return null; + } + + if (args[0] is not string s) return UndefinedValue.Instance; + var c = ToLong(args[1]); + if (!c.HasValue) return UndefinedValue.Instance; + if (c.Value < 0) return UndefinedValue.Instance; + return s[Math.Max(0, s.Length - (int)c.Value)..]; + } + case "SUBSTRING": + { + if (args.Length < 3) + { + return UndefinedValue.Instance; + } + + if (args[0] is UndefinedValue || args[1] is UndefinedValue || args[2] is UndefinedValue) + { + return UndefinedValue.Instance; + } + + if (args[0] is not string s) return UndefinedValue.Instance; + var start = ToLong(args[1]); var len = ToLong(args[2]); + if (!start.HasValue || !len.HasValue) + { + return UndefinedValue.Instance; + } + + var si = (int)Math.Max(0, Math.Min(start.Value, s.Length)); + var li = (int)Math.Max(0, Math.Min(len.Value, s.Length - si)); + return s.Substring(si, li); + } + case "REPLACE": + { + if (args.Length < 3) + { + return null; + } + + var s = args[0] is string ss ? ss : null; + var find = args[1] is string fs ? fs : null; + var rep = args[2] is string rps ? rps : null; + if (s is null || find is null) return UndefinedValue.Instance; + if (args[2] is not (null or UndefinedValue or string)) return UndefinedValue.Instance; + if (find.Length == 0) return s; + return s.Replace(find, rep ?? ""); + } + case "INDEX_OF": + { + if (args.Length < 2) + { + return UndefinedValue.Instance; + } + + if (args[0] is not string s || args[1] is not string sub) + { + return UndefinedValue.Instance; + } + + if (args.Length >= 3 && double.TryParse(args[2]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var startPos)) + { + var sp = (int)startPos; + if (sp < 0 || sp > s.Length) return UndefinedValue.Instance; + return (object)(long)s.IndexOf(sub, sp, StringComparison.Ordinal); + } + return (object)(long)s.IndexOf(sub, StringComparison.Ordinal); + } + case "REGEXMATCH": + { + if (args.Length < 2) + { + return false; + } + + if (args[0] is not string input || args[1] is not string pattern) + { + return UndefinedValue.Instance; + } + + var options = RegexOptions.None; + if (args.Length >= 3) + { + var modifiers = args[2]?.ToString() ?? ""; + foreach (var ch in modifiers) + { + options |= ch switch + { + 'i' => RegexOptions.IgnoreCase, + 'm' => RegexOptions.Multiline, + 's' => RegexOptions.Singleline, + 'x' => RegexOptions.IgnorePatternWhitespace, + _ => RegexOptions.None, + }; + } + } + try + { + return GetOrCreateRegex(pattern, options).IsMatch(input); + } + catch (ArgumentException) + { + return UndefinedValue.Instance; + } + } + case "REPLICATE": + { + if (args.Length < 2) + { + return null; + } + + if (args[0] is not string s) + { + return UndefinedValue.Instance; + } + + var count = ToLong(args[1]); + if (!count.HasValue || count.Value < 0 || count.Value > 10000) + { + return UndefinedValue.Instance; + } + + return count.Value == 0 ? "" : string.Concat(Enumerable.Repeat(s, (int)count.Value)); + } + case "STRING_EQUALS": + case "STRINGEQUALS": + { + if (args.Length < 2) + { + return UndefinedValue.Instance; + } + + if (args[0] is not string s1) + { + return UndefinedValue.Instance; + } + + if (args[1] is not string s2) + { + return UndefinedValue.Instance; + } + + var ic = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + return string.Equals(s1, s2, ic ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + case "STRINGTOARRAY": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + var s = args[0].ToString(); + if (s is null) + { + return UndefinedValue.Instance; + } + + try + { + var token = JToken.Parse(s); + return token is JArray ? token : UndefinedValue.Instance; + } + catch + { + return UndefinedValue.Instance; + } + } + case "STRINGTOBOOLEAN": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + var s = args[0].ToString()?.Trim(); + return s switch + { + "true" => true, + "false" => (object)false, + _ => UndefinedValue.Instance, + }; + } + case "STRINGTONULL": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + return args[0].ToString()?.Trim() == "null" ? null : UndefinedValue.Instance; + } + case "STRINGTONUMBER": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + var s = args[0].ToString()?.Trim(); + if (s is null) + { + return UndefinedValue.Instance; + } + + if (long.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var longVal)) + { + return longVal; + } + + if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var doubleVal)) + { + if (double.IsNaN(doubleVal) || double.IsInfinity(doubleVal)) + return UndefinedValue.Instance; + return doubleVal; + } + + return UndefinedValue.Instance; + } + case "STRINGTOOBJECT": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + var s = args[0].ToString(); + if (s is null) + { + return UndefinedValue.Instance; + } + + try + { + var token = JToken.Parse(s); + return token is JObject ? token : UndefinedValue.Instance; + } + catch + { + return UndefinedValue.Instance; + } + } + case "TOSTRING" or "ToString": + if (args.Length == 0) return null; + if (args[0] is null or UndefinedValue) return UndefinedValue.Instance; + if (args[0] is bool boolVal) return boolVal ? "true" : "false"; + if (args[0] is string or long or int or double or float or decimal) return args[0].ToString(); + if (args[0] is JArray or JObject) return ((JToken)args[0]).ToString(Newtonsoft.Json.Formatting.None); + if (args[0] is JValue jv && jv.Type == JTokenType.Null) return UndefinedValue.Instance; + return args[0].ToString(); + case "TONUMBER" or "ToNumber": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is null) + { + return UndefinedValue.Instance; + } + + if (args[0] is long or double) + { + return args[0]; + } + + if (double.TryParse(args[0].ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) + { + return n; + } + + return UndefinedValue.Instance; + } + case "TOBOOLEAN" or "ToBoolean": + { + if (args.Length == 0) + { + return null; + } + + if (args[0] is bool) + { + return args[0]; + } + + if (args[0] != null && bool.TryParse(args[0].ToString(), out var b)) + { + return b; + } + + return UndefinedValue.Instance; + } + + // ── Array functions ── + case "ARRAY_CONTAINS": + { + if (func.Arguments.Length < 2) + { + return false; + } + + JArray jArray = null; + if (func.Arguments[0] is IdentifierExpression ident) + { + var arrayPath = ident.Name; + if (arrayPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + arrayPath = arrayPath[(fromAlias.Length + 1)..]; + } + + var arrayToken = item.SelectToken(arrayPath); + jArray = arrayToken as JArray; + } + else if (args[0] is JArray evalArr) + { + jArray = evalArr; + } + + if (jArray is null) + { + return false; + } + + { + var partial = args.Length >= 3 && string.Equals(args[2]?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + var searchValue = args[1]; + if (searchValue is JObject searchObj) + { + return ArrayContainsMatchJObject(jArray, searchObj, partial); + } + + if (searchValue is null) + { + return jArray.Any(t => t.Type == JTokenType.Null); + } + + var searchStr = searchValue.ToString(); + return ArrayContainsMatch(jArray, searchStr, partial); + } + } + case "ARRAY_LENGTH": + { + if (func.Arguments.Length < 1) + { + return null; + } + + if (func.Arguments[0] is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + var token = item.SelectToken(path); + return token is JArray arr ? (object)(long)arr.Count : UndefinedValue.Instance; + } + + // Support nested function calls like ARRAY_LENGTH(SetIntersect(...)) + var evaluated = EvaluateSqlExpression(func.Arguments[0], item, fromAlias, parameters); + return evaluated is JArray evalArr ? (object)(long)evalArr.Count : UndefinedValue.Instance; + } + case "ARRAY_SLICE": + { + if (func.Arguments.Length < 2) + { + return null; + } + + if (func.Arguments[0] is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + var token = item.SelectToken(path); + if (token is not JArray arr) + { + return null; + } + + var start = (int)(ToLong(args[1]) ?? 0); + if (start < 0) + { + start = Math.Max(0, arr.Count + start); + } + + var length = args.Length >= 3 ? (int)(ToLong(args[2]) ?? arr.Count) : arr.Count; + return new JArray(arr.Skip(start).Take(length)); + } + + // Support literal arrays and nested expressions like ARRAY_SLICE([1,2,3], 0, 2) + var sliceEval = EvaluateSqlExpression(func.Arguments[0], item, fromAlias, parameters); + if (sliceEval is JArray evalArr2) + { + var start2 = (int)(ToLong(args[1]) ?? 0); + if (start2 < 0) start2 = Math.Max(0, evalArr2.Count + start2); + var length2 = args.Length >= 3 ? (int)(ToLong(args[2]) ?? evalArr2.Count) : evalArr2.Count; + return new JArray(evalArr2.Skip(start2).Take(length2)); + } + return null; + } + case "ARRAY_CONCAT": + { + var result = new JArray(); + foreach (var argExpr in func.Arguments) + { + JArray ja = null; + if (argExpr is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + ja = item.SelectToken(path) as JArray; + } + else + { + var evaluated = EvaluateSqlExpression(argExpr, item, fromAlias, parameters); + ja = evaluated as JArray; + } + + if (ja is null) + { + return UndefinedValue.Instance; + } + + foreach (var el in ja) + { + result.Add(el.DeepClone()); + } + } + return result; + } + + // ── Math functions ── + case "ABS": return args.Length > 0 ? MathOp(args[0], Math.Abs) : null; + case "CEILING": return args.Length > 0 ? MathOp(args[0], Math.Ceiling) : null; + case "FLOOR": return args.Length > 0 ? MathOp(args[0], Math.Floor) : null; + case "ROUND": + if (args.Length >= 2 && double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var roundVal) + && double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var roundPrec)) + { + var prec = (int)roundPrec; + if (prec < 0) + { + var factor = Math.Pow(10, -prec); + return Math.Round(roundVal / factor, MidpointRounding.AwayFromZero) * factor; + } + return Math.Round(roundVal, prec, MidpointRounding.AwayFromZero); + } + return args.Length > 0 ? MathOp(args[0], v => Math.Round(v, MidpointRounding.AwayFromZero)) : null; + case "SQRT": return args.Length > 0 ? MathOp(args[0], Math.Sqrt) : null; + case "SQUARE": return args.Length > 0 ? MathOp(args[0], v => v * v) : null; + case "POWER": return args.Length >= 2 ? ArithmeticOp(args[0], args[1], Math.Pow) : null; + case "EXP": return args.Length > 0 ? MathOp(args[0], Math.Exp) : null; + case "LOG": + if (args.Length >= 2 && double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var logVal) + && double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var logBase)) + { + var logResult = Math.Log(logVal, logBase); + if (double.IsNaN(logResult) || double.IsInfinity(logResult)) + return UndefinedValue.Instance; + return logResult; + } + return args.Length > 0 ? MathOp(args[0], Math.Log) : null; + case "LOG10": return args.Length > 0 ? MathOp(args[0], Math.Log10) : null; + case "SIGN": return args.Length > 0 ? MathOp(args[0], v => Math.Sign(v)) : null; + case "TRUNC": return args.Length > 0 ? MathOp(args[0], Math.Truncate) : null; + case "PI": return Math.PI; + case "SIN": return args.Length > 0 ? MathOp(args[0], Math.Sin) : null; + case "COS": return args.Length > 0 ? MathOp(args[0], Math.Cos) : null; + case "TAN": return args.Length > 0 ? MathOp(args[0], Math.Tan) : null; + case "COT": return args.Length > 0 ? MathOp(args[0], v => 1.0 / Math.Tan(v)) : null; + case "ASIN": return args.Length > 0 ? MathOp(args[0], Math.Asin) : null; + case "ACOS": return args.Length > 0 ? MathOp(args[0], Math.Acos) : null; + case "ATAN": return args.Length > 0 ? MathOp(args[0], Math.Atan) : null; + case "ATN2": return args.Length >= 2 ? ArithmeticOp(args[0], args[1], Math.Atan2) : null; + case "DEGREES": return args.Length > 0 ? MathOp(args[0], v => v * (180.0 / Math.PI)) : null; + case "RADIANS": return args.Length > 0 ? MathOp(args[0], v => v * (Math.PI / 180.0)) : null; + case "RAND": return Random.Shared.NextDouble(); + + // ── Integer math functions ── + case "NUMBERBIN": + { + if (args.Length < 2) + { + return null; + } + + if (double.TryParse(args[0]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var val) && + double.TryParse(args[1]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var binSize) && + binSize > 0) + { + return Math.Floor(val / binSize) * binSize; + } + + return UndefinedValue.Instance; + } + case "INTADD": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a + b) : null; + case "INTSUB": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a - b) : null; + case "INTMUL": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a * b) : null; + case "INTDIV": + { + if (args.Length < 2) + { + return UndefinedValue.Instance; + } + + var dividend = ToLong(args[0]); + var divisor = ToLong(args[1]); + if (!dividend.HasValue || !divisor.HasValue || divisor.Value == 0) + { + return UndefinedValue.Instance; + } + + return dividend.Value / divisor.Value; + } + case "INTMOD": + { + if (args.Length < 2) + { + return UndefinedValue.Instance; + } + + var dividend = ToLong(args[0]); + var divisor = ToLong(args[1]); + if (!dividend.HasValue || !divisor.HasValue || divisor.Value == 0) + { + return UndefinedValue.Instance; + } + + return dividend.Value % divisor.Value; + } + case "INTBITAND": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a & b) : null; + case "INTBITOR": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a | b) : null; + case "INTBITXOR": return args.Length >= 2 ? BitwiseOp(args[0], args[1], (a, b) => a ^ b) : null; + case "INTBITNOT": + { + if (args.Length == 0) + { + return null; + } + + var value = ToLong(args[0]); + return value.HasValue ? ~value.Value : null; + } + case "INTBITLEFTSHIFT": return args.Length >= 2 ? BitwiseShiftOp(args[0], args[1], isLeft: true) : null; + case "INTBITRIGHTSHIFT": return args.Length >= 2 ? BitwiseShiftOp(args[0], args[1], isLeft: false) : null; + + // ── Aggregates (passthrough for non-GROUP-BY contexts) ── + // When there is no GROUP BY, each document is emitted individually and the + // SDK accumulates partial results client-side (cross-partition fan-out model). + // COUNT always emits 1 per document (each document counts as one row). + // SUM/MIN/MAX emit the field value so the SDK can aggregate across partitions. + // AVG is handled by the SDK via sum+count, so SUM and COUNT passthroughs suffice. + case "COUNT": + return 1L; + case "SUM": + case "AVG": + case "MIN": + case "MAX": + return args.Length > 0 ? args[0] : null; + + // ── Conditional functions ── + case "IIF": + { + if (args.Length < 3) + { + return null; + } + + // Real Cosmos DB IIF only treats boolean true as truthy. + // Non-boolean values (numbers, strings, arrays, objects) always yield the false branch. + return args[0] is bool b && b ? args[1] : args[2]; + } + + // ── Extended array functions ── + case "ARRAY_CONTAINS_ANY": + { + if (func.Arguments.Length < 2) + { + return false; + } + + var sourceArray = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); + if (sourceArray is null || sourceArray.Count == 0) + { + return false; + } + + // Support both array form: ARRAY_CONTAINS_ANY(c.tags, ['a','b']) + // and variadic form: ARRAY_CONTAINS_ANY(c.tags, 'a', 'b') + var searchArray = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); + if (searchArray is null && func.Arguments.Length >= 2) + { + searchArray = new JArray(); + for (int i = 1; i < func.Arguments.Length; i++) + { + var val = EvaluateSqlExpression(func.Arguments[i], item, fromAlias, parameters); + if (val is UndefinedValue) continue; + searchArray.Add(val is JToken jt ? jt : (val is null ? JValue.CreateNull() : JToken.FromObject(val))); + } + } + + if (searchArray is null || searchArray.Count == 0) + { + return false; + } + + var sourceValues = new HashSet(sourceArray, JTokenValueComparer.Instance); + return searchArray.Any(t => sourceValues.Contains(t)); + } + case "ARRAY_CONTAINS_ALL": + { + if (func.Arguments.Length < 2) + { + return false; + } + + var sourceArray = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); + if (sourceArray is null) + { + return false; + } + + // Support both array form: ARRAY_CONTAINS_ALL(c.tags, ['a','b']) + // and variadic form: ARRAY_CONTAINS_ALL(c.tags, 'a', 'b') + var searchArray = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); + if (searchArray is null && func.Arguments.Length >= 2) + { + searchArray = new JArray(); + for (int i = 1; i < func.Arguments.Length; i++) + { + var val = EvaluateSqlExpression(func.Arguments[i], item, fromAlias, parameters); + if (val is UndefinedValue) continue; + searchArray.Add(val is JToken jt ? jt : (val is null ? JValue.CreateNull() : JToken.FromObject(val))); + } + } + + if (searchArray is null || searchArray.Count == 0) + { + return true; + } + + var sourceValues = new HashSet(sourceArray, JTokenValueComparer.Instance); + return searchArray.All(t => sourceValues.Contains(t)); + } + case "SETINTERSECT": + { + if (func.Arguments.Length < 2) + { + return new JArray(); + } + + var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); + var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); + if (arr1 is null || arr2 is null) + { + return UndefinedValue.Instance; + } + + var set2 = new HashSet(arr2, JTokenValueComparer.Instance); + var result = new JArray(); + var seen = new HashSet(JTokenValueComparer.Instance); + foreach (var element in arr1) + { + if (set2.Contains(element) && seen.Add(element)) + { + result.Add(element.DeepClone()); + } + } + return result; + } + case "SETUNION": + { + if (func.Arguments.Length < 2) + { + return new JArray(); + } + + var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); + var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); + if (arr1 is null || arr2 is null) + { + return UndefinedValue.Instance; + } + var result = new JArray(); + var seen = new HashSet(JTokenValueComparer.Instance); + foreach (var element in arr1) + { + if (seen.Add(element)) + { + result.Add(element.DeepClone()); + } + } + foreach (var element in arr2) + { + if (seen.Add(element)) + { + result.Add(element.DeepClone()); + } + } + return result; + } + case "SETDIFFERENCE": + { + if (func.Arguments.Length < 2) + { + return new JArray(); + } + + var arr1 = ResolveJArray(func.Arguments[0], item, fromAlias, parameters); + var arr2 = ResolveJArray(func.Arguments[1], item, fromAlias, parameters); + if (arr1 is null || arr2 is null) + { + return UndefinedValue.Instance; + } + + var exclude = new HashSet(arr2, JTokenValueComparer.Instance); + var result = new JArray(); + var seen = new HashSet(JTokenValueComparer.Instance); + foreach (var element in arr1) + { + if (!exclude.Contains(element) && seen.Add(element)) + { + result.Add(element.DeepClone()); + } + } + return result; + } + + // ── Array/Object utility functions ── + case "CHOOSE": + { + if (args.Length < 2) return UndefinedValue.Instance; + var idx = ToLong(args[0]); + if (!idx.HasValue || idx.Value < 1 || idx.Value >= args.Length) return UndefinedValue.Instance; + return args[(int)idx.Value]; + } + case "OBJECTTOARRAY": + { + if (args.Length < 1) return null; + JObject obj; + if (args[0] is JObject jo) obj = jo; + else if (args[0] is string s) { try { obj = JObject.Parse(s); } catch { return UndefinedValue.Instance; } } + else return UndefinedValue.Instance; + var result = new JArray(); + foreach (var prop in obj.Properties()) + { + result.Add(new JObject { ["k"] = prop.Name, ["v"] = prop.Value.DeepClone() }); + } + return result; + } + case "ARRAYTOOBJECT": + { + if (args.Length < 1) return UndefinedValue.Instance; + JArray arr; + if (args[0] is JArray ja) arr = ja; + else if (args[0] is string s) { try { arr = JArray.Parse(s); } catch { return UndefinedValue.Instance; } } + else return UndefinedValue.Instance; + var result = new JObject(); + foreach (var element in arr) + { + if (element is JObject kvObj && kvObj["k"] != null && kvObj["v"] != null) + { + result[kvObj["k"]!.Value()] = kvObj["v"]!.DeepClone(); + } + else + { + return UndefinedValue.Instance; + } + } + return result; + } + + // ── String utility functions ── + case "STRINGJOIN": + { + if (args.Length < 2) return null; + var separator = args[1]?.ToString(); + if (separator is null) return null; + JArray joinArr; + if (args[0] is JArray ja) joinArr = ja; + else if (args[0] is string s) { try { joinArr = JArray.Parse(s); } catch { return null; } } + else return null; + return string.Join(separator, joinArr.Select(t => t.Value())); + } + case "STRINGSPLIT": + { + if (args.Length < 2) return null; + var input = args[0]?.ToString(); + var delimiter = args[1]?.ToString(); + if (input is null || delimiter is null) return UndefinedValue.Instance; + if (delimiter.Length == 0) return UndefinedValue.Instance; + var parts = input.Split(delimiter); + return new JArray(parts.Select(p => (JToken)p)); + } + + // ── Item functions ── + case "DOCUMENTID": + { + // DOCUMENTID returns the _rid system property of the current document. + // The emulator synthesises from id since it doesn't generate _rid. + return item["_rid"]?.Value() ?? item["id"]?.Value(); + } + + // ── Full-text search functions (approximate) ── + // These use case-insensitive substring matching instead of real NLP tokenization. + case "FULLTEXTCONTAINS": + { + if (args.Length < 2) return false; + var text = args[0]?.ToString(); + var term = args[1]?.ToString(); + if (text is null || term is null) return false; + return text.Contains(term, StringComparison.OrdinalIgnoreCase); + } + case "FULLTEXTCONTAINSALL": + { + if (args.Length < 2) return false; + var text = args[0]?.ToString(); + if (text is null) return false; + for (var i = 1; i < args.Length; i++) + { + var term = args[i]?.ToString(); + if (term is null || !text.Contains(term, StringComparison.OrdinalIgnoreCase)) + return false; + } + return true; + } + case "FULLTEXTCONTAINSANY": + { + if (args.Length < 2) return false; + var text = args[0]?.ToString(); + if (text is null) return false; + for (var i = 1; i < args.Length; i++) + { + var term = args[i]?.ToString(); + if (term is not null && text.Contains(term, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + case "FULLTEXTSCORE": + { + // Naive term-frequency scoring: count occurrences of each search term + // in the field text. Returns the total count as a double. + // Real Cosmos DB uses BM25 with IDF and length normalization. + if (args.Length < 2) return 0.0; + var text = args[0]?.ToString(); + if (text is null) return 0.0; + + var score = 0.0; + // arg[1] may be a JArray (from [...] literal) or individual terms + if (args[1] is JArray searchTerms) + { + foreach (var termToken in searchTerms) + { + var term = termToken.Value(); + if (term is not null) + score += CountOccurrences(text, term); + } + } + else + { + for (var i = 1; i < args.Length; i++) + { + var term = args[i]?.ToString(); + if (term is not null) + score += CountOccurrences(text, term); + } + } + return score; + } + + // ── Spatial functions ── + case "ST_DISTANCE": + return args.Length >= 2 ? StDistance(args[0], args[1]) : null; + case "ST_WITHIN": + return args.Length >= 2 ? (object)StWithin(args[0], args[1]) : null; + case "ST_INTERSECTS": + return args.Length >= 2 ? (object)StIntersects(args[0], args[1]) : null; + case "ST_ISVALID": + return args.Length >= 1 ? (object)StIsValid(args[0]) : false; + case "ST_ISVALIDDETAILED": + return args.Length >= 1 ? StIsValidDetailed(args[0]) : null; + case "ST_AREA": + return args.Length >= 1 ? StArea(args[0]) : null; + + // ── Vector functions ── + case "VECTORDISTANCE": + return args.Length >= 2 ? VectorDistanceFunc(args) : null; + + // ── Date/time functions ── + case "GETCURRENTDATETIME": + case "GETCURRENTDATETIMESTATIC": return parameters.TryGetValue("__staticDateTime", out var sdt) ? sdt : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + case "GETCURRENTTIMESTAMP": + case "GETCURRENTTIMESTAMPSTATIC": return parameters.TryGetValue("__staticTimestamp", out var sts) ? sts : (object)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + case "GETCURRENTTICKS": + case "GETCURRENTTICKSSTATIC": return parameters.TryGetValue("__staticTicks", out var stk) ? stk : (object)DateTime.UtcNow.Ticks; + case "DATETIMEADD": + { + if (args.Length < 3) + { + return UndefinedValue.Instance; + } + + var part = args[0]?.ToString()?.ToLowerInvariant(); + var number = ToLong(args[1]); + var dateTime = args[2] is not null and not UndefinedValue ? args[2].ToString() : null; + if (part is null || !number.HasValue || dateTime is null) + { + return UndefinedValue.Instance; + } + + if (!DateTime.TryParse(dateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) + { + return UndefinedValue.Instance; + } + + var n = (int)number.Value; + try + { + DateTime? result = part switch + { + "year" or "yyyy" or "yy" => dt.AddYears(n), + "month" or "mm" or "m" => dt.AddMonths(n), + "day" or "dd" or "d" => dt.AddDays(n), + "hour" or "hh" => dt.AddHours(n), + "minute" or "mi" or "n" => dt.AddMinutes(n), + "second" or "ss" or "s" => dt.AddSeconds(n), + "millisecond" or "ms" => dt.AddMilliseconds(n), + "microsecond" or "mcs" => dt.AddTicks(n * 10L), + "nanosecond" or "ns" => dt.AddTicks(n / 100L), + _ => null, + }; + if (result is null) return UndefinedValue.Instance; + return result.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + catch (ArgumentOutOfRangeException) + { + return UndefinedValue.Instance; + } + } + case "DATETIMEPART": + { + if (args.Length < 2) + { + return UndefinedValue.Instance; + } + + var part = args[0]?.ToString()?.ToLowerInvariant(); + var dateTime = args[1] is not null and not UndefinedValue ? args[1].ToString() : null; + if (part is null || dateTime is null) + { + return UndefinedValue.Instance; + } + + if (!DateTime.TryParse(dateTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) + { + return UndefinedValue.Instance; + } + + return part switch + { + "year" or "yyyy" or "yy" => (object)(long)dt.Year, + "month" or "mm" or "m" => (long)dt.Month, + "day" or "dd" or "d" => (long)dt.Day, + "hour" or "hh" => (long)dt.Hour, + "minute" or "mi" or "n" => (long)dt.Minute, + "second" or "ss" or "s" => (long)dt.Second, + "millisecond" or "ms" => (long)dt.Millisecond, + "microsecond" or "mcs" => (long)(dt.Ticks % TimeSpan.TicksPerSecond / 10), + "nanosecond" or "ns" => (long)(dt.Ticks % TimeSpan.TicksPerSecond * 100), + "weekday" or "dw" or "w" => (long)(dt.DayOfWeek + 1), // Sunday=1..Saturday=7 + _ => null, + }; + } + case "DATETIMEDIFF": + { + if (args.Length < 3) return UndefinedValue.Instance; + var part = args[0]?.ToString()?.ToLowerInvariant(); + var startStr = args[1]?.ToString(); + var endStr = args[2]?.ToString(); + if (part is null || startStr is null || endStr is null) return UndefinedValue.Instance; + if (!DateTime.TryParse(startStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dtStart)) return UndefinedValue.Instance; + if (!DateTime.TryParse(endStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dtEnd)) return UndefinedValue.Instance; + + return part switch + { + "year" or "yyyy" or "yy" => (object)(long)(dtEnd.Year - dtStart.Year), + "month" or "mm" or "m" => (long)((dtEnd.Year - dtStart.Year) * 12 + dtEnd.Month - dtStart.Month), + "day" or "dd" or "d" => (long)(dtEnd.Date - dtStart.Date).TotalDays, + "hour" or "hh" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerHour) - FloorToUnit(dtStart, TimeSpan.TicksPerHour)), + "minute" or "mi" or "n" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerMinute) - FloorToUnit(dtStart, TimeSpan.TicksPerMinute)), + "second" or "ss" or "s" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerSecond) - FloorToUnit(dtStart, TimeSpan.TicksPerSecond)), + "millisecond" or "ms" => (long)(FloorToUnit(dtEnd, TimeSpan.TicksPerMillisecond) - FloorToUnit(dtStart, TimeSpan.TicksPerMillisecond)), + "microsecond" or "mcs" => (long)((dtEnd - dtStart).Ticks / 10), + "nanosecond" or "ns" => (long)((dtEnd - dtStart).Ticks * 100), + _ => UndefinedValue.Instance, + }; + } + case "DATETIMEFROMPARTS": + { + if (args.Length < 3) return UndefinedValue.Instance; + var y = ToLong(args[0]); + var mo = ToLong(args[1]); + var d = ToLong(args[2]); + var h = args.Length > 3 ? ToLong(args[3]) : 0; + var mi = args.Length > 4 ? ToLong(args[4]) : 0; + var s = args.Length > 5 ? ToLong(args[5]) : 0; + var fraction = args.Length > 6 ? ToLong(args[6]) : 0; + if (!y.HasValue || !mo.HasValue || !d.HasValue || !h.HasValue || !mi.HasValue || !s.HasValue || !fraction.HasValue) return UndefinedValue.Instance; + if (fraction.Value < 0 || fraction.Value > 9999999) return UndefinedValue.Instance; + try + { + var dt = new DateTime((int)y.Value, (int)mo.Value, (int)d.Value, + (int)h.Value, (int)mi.Value, (int)s.Value, DateTimeKind.Utc).AddTicks(fraction.Value); + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + catch (ArgumentOutOfRangeException) + { + return UndefinedValue.Instance; + } + } + case "DATETIMEBIN": + { + if (args.Length < 2) return UndefinedValue.Instance; + var dtStr = args[0] is not null and not UndefinedValue ? args[0].ToString() : null; + var part = args[1]?.ToString()?.ToLowerInvariant(); + var binSize = args.Length >= 3 ? ToLong(args[2]) : 1; + if (dtStr is null || part is null || !binSize.HasValue) return UndefinedValue.Instance; + if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; + + var bs = (int)binSize.Value; + if (bs <= 0) return UndefinedValue.Instance; + + DateTime origin; + if (args.Length >= 4) + { + if (args[3] is not string originStr || !DateTime.TryParse(originStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out origin)) + return UndefinedValue.Instance; + } + else + { + origin = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + if (origin.Year < 1601) return UndefinedValue.Instance; + + if (part is "year" or "yyyy" or "yy") + { + var yearBin = (int)(Math.Floor((double)(dt.Year - origin.Year) / bs) * bs); + dt = new DateTime(origin.Year + yearBin, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + else if (part is "month" or "mm" or "m") + { + var totalMonths = (dt.Year - origin.Year) * 12 + (dt.Month - origin.Month); + var binned = (int)(Math.Floor((double)totalMonths / bs) * bs); + dt = new DateTime(origin.Year, origin.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(binned); + } + else + { + var ticksPerUnit = part switch + { + "day" or "dd" or "d" => TimeSpan.TicksPerDay, + "hour" or "hh" => TimeSpan.TicksPerHour, + "minute" or "mi" or "n" => TimeSpan.TicksPerMinute, + "second" or "ss" or "s" => TimeSpan.TicksPerSecond, + "millisecond" or "ms" => TimeSpan.TicksPerMillisecond, + "microsecond" or "mcs" => 10L, + "nanosecond" or "ns" => 1L, + _ => -1L, + }; + if (ticksPerUnit < 0) return UndefinedValue.Instance; + var tickSpan = (dt - origin).Ticks; + var binTicks = ticksPerUnit * bs; + var binnedTicks = (long)Math.Floor((double)tickSpan / binTicks) * binTicks; + dt = origin.AddTicks(binnedTicks); + } + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + case "DATETIMETOTICKS": + { + if (args.Length < 1) return null; + var dtStr = args[0]?.ToString(); + if (dtStr is null) return UndefinedValue.Instance; + if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; + if (dt.Kind == DateTimeKind.Unspecified) dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); + return (object)dt.ToUniversalTime().Ticks; + } + case "TICKSTODATETIME": + { + if (args.Length < 1) return UndefinedValue.Instance; + var ticks = ToLong(args[0]); + if (!ticks.HasValue) return UndefinedValue.Instance; + try + { + var dt = new DateTime(ticks.Value, DateTimeKind.Utc); + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + catch (ArgumentOutOfRangeException) + { + return UndefinedValue.Instance; + } + } + case "DATETIMETOTIMESTAMP": + { + if (args.Length < 1) return UndefinedValue.Instance; + var dtStr = args[0]?.ToString(); + if (dtStr is null) return UndefinedValue.Instance; + if (!DateTime.TryParse(dtStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) return UndefinedValue.Instance; + if (dt.Kind == DateTimeKind.Unspecified) dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); + return (object)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds(); + } + case "TIMESTAMPTODATETIME": + { + if (args.Length < 1) return UndefinedValue.Instance; + var ms = ToLong(args[0]); + if (!ms.HasValue) return UndefinedValue.Instance; + var dt = DateTimeOffset.FromUnixTimeMilliseconds(ms.Value).UtcDateTime; + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + // ── COALESCE function ── + // COALESCE skips null and undefined, returning the first concrete value. + // When all args are exhausted: returns null if any arg was null, + // otherwise returns undefined (omitted from results). + case "COALESCE": + { + // COALESCE returns the first expression that is NOT undefined. + // null is a defined value and should be returned (unlike ?? which skips both null and undefined). + foreach (var arg in args) + { + if (arg is UndefinedValue) + continue; + return arg; // returns first non-undefined value (including null) + } + + return UndefinedValue.Instance; + } + + default: + if (func.FunctionName.StartsWith("UDF.", StringComparison.OrdinalIgnoreCase)) + { + var udfName = func.FunctionName.Substring(4); // strip "UDF." prefix + + // JavaScript has no int/float distinction — all numbers are IEEE 754 doubles. + // Normalise any long values to double so C# UDF handlers can safely cast args to double. + var jsArgs = args.Select(a => a is long l ? (object)(double)l : a).ToArray(); + + // Priority 1: C# handler (skip placeholder delegates) + if (parameters.TryGetValue(UdfRegistryKey, out var registry) && + registry is Dictionary> udfs && + udfs.TryGetValue(func.FunctionName, out var udfImpl) && + !ReferenceEquals(udfImpl, UdfPlaceholder)) + { + return udfImpl(jsArgs); + } + + // Priority 2: JS body via engine + if (parameters.TryGetValue(UdfPropertiesKey, out var propsObj) && + propsObj is Dictionary udfProps && + udfProps.TryGetValue(udfName, out var udfProp) && + udfProp.Body is not null && + parameters.TryGetValue(UdfEngineKey, out var engineObj) && + engineObj is IJsUdfEngine jsUdfEngine) + { + return jsUdfEngine.ExecuteUdf(udfProp.Body, jsArgs); + } + + throw new NotSupportedException( + $"Unregistered user-defined function: {func.FunctionName}. " + + "Call RegisterUdf() on the InMemoryContainer to register it before querying."); + } + + throw new NotSupportedException($"Unsupported Cosmos SQL function: {func.FunctionName}"); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Utility helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static bool IsTruthy(object value) => value switch + { + null => false, + UndefinedValue => false, + bool b => b, + // Cosmos DB: WHERE requires strict boolean — non-boolean values evaluate to false + _ => false + }; + + private static int CountOccurrences(string text, string term) + { + if (term.Length == 0) return 0; + var count = 0; + var idx = 0; + while ((idx = text.IndexOf(term, idx, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + idx += term.Length; + } + return count; + } + + private static JObject EvaluateObjectLiteral( + ObjectLiteralExpression obj, JObject item, string fromAlias, IDictionary parameters) + { + var result = new JObject(); + foreach (var prop in obj.Properties) + { + var value = EvaluateSqlExpression(prop.Value, item, fromAlias, parameters); + if (value is not UndefinedValue) + result[prop.Key] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + } + return result; + } + + private static JArray EvaluateArrayLiteral( + ArrayLiteralExpression arr, JObject item, string fromAlias, IDictionary parameters) + { + var result = new JArray(); + foreach (var element in arr.Elements) + { + var value = EvaluateSqlExpression(element, item, fromAlias, parameters); + result.Add(value is not null and not UndefinedValue ? JToken.FromObject(value) : JValue.CreateNull()); + } + return result; + } + + private static object EvaluateSubquery( + CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) + { + var results = ExecuteSubqueryAgainstItem(subquery, item, fromAlias, parameters); + if (subquery.IsValueSelect && results.Count > 0) + { + return JsonParseHelpers.ParseJsonToken(results[0]).ToObject(); + } + + return results.Count > 0 ? JsonParseHelpers.ParseJsonToken(results[0]).ToObject() : null; + } + + private static object EvaluateSubqueryAggregate( + FunctionCallExpression func, List sourceItems, string fromAlias, IDictionary parameters) + { + var name = func.FunctionName.ToUpperInvariant(); + if (name is "COUNT") + { + // COUNT(1) / COUNT(*) → count all items; COUNT(c.field) → count defined only + if (func.Arguments.Length == 1 && func.Arguments[0] is IdentifierExpression ident) + { + var fieldPath = ident.Name; + if (fieldPath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + fieldPath = fieldPath[(fromAlias.Length + 1)..]; + return (double)sourceItems.Count(si => si.SelectToken(fieldPath) is not null); + } + return (double)sourceItems.Count; + } + + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // COUNTIF() counts items where the expression evaluates to true. + if (name is "COUNTIF") + { + if (func.Arguments.Length < 1) return 0.0; + var boolExpr = func.Arguments[0]; + return (double)sourceItems.Count(si => + { + var val = EvaluateSqlExpression(boolExpr, si, fromAlias, parameters); + return IsTruthy(val); + }); + } + + // For SUM/AVG/MIN/MAX, extract numeric values from the first argument + var argExpr = func.Arguments.Length > 0 ? func.Arguments[0] : null; + if (argExpr is null) return null; + + var values = new List(); + foreach (var si in sourceItems) + { + var val = EvaluateSqlExpression(argExpr, si, fromAlias, parameters); + if (val is not null and not UndefinedValue) + values.Add(JToken.FromObject(val)); + } + + return name switch + { + "SUM" => values.Count > 0 ? values.Sum(v => v.Value()) : (object)UndefinedValue.Instance, + "AVG" => values.Count > 0 ? values.Average(v => v.Value()) : (object)UndefinedValue.Instance, + "MIN" => AggregateMinMax(values, true), + "MAX" => AggregateMinMax(values, false), + _ => null + }; + } + + private static object EvaluateArraySubquery( + CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) + { + var results = ExecuteSubqueryAgainstItem(subquery, item, fromAlias, parameters); + var jArray = new JArray(); + foreach (var resultJson in results) + { + jArray.Add(JsonParseHelpers.ParseJsonToken(resultJson)); + } + return jArray; + } + + private static List ExecuteSubqueryAgainstItem( + CosmosSqlQuery subquery, JObject item, string fromAlias, IDictionary parameters) + { + // Determine the data to iterate over + List sourceItems; + + if (subquery.FromSource is not null) + { + // FROM alias IN path — expand the array at path, aliasing each element + var sourcePath = subquery.FromSource; + if (sourcePath.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + sourcePath = sourcePath[(fromAlias.Length + 1)..]; + } + + var arrayToken = item.SelectToken(sourcePath); + if (arrayToken is not JArray sourceArray) + { + return []; + } + + sourceItems = []; + foreach (var element in sourceArray) + { + var combined = new JObject(item.Properties()) + { + [subquery.FromAlias] = element + }; + sourceItems.Add(combined); + } + } + else if (subquery.Join is not null) + { + // Correlated subquery with JOIN — expand the join array from the parent item + var arrayPath = subquery.Join.ArrayField; + var sourceAlias = subquery.Join.SourceAlias; + JToken arrayToken; + + if (sourceAlias.Equals(fromAlias, StringComparison.OrdinalIgnoreCase) || + sourceAlias.Equals(subquery.FromAlias, StringComparison.OrdinalIgnoreCase)) + { + arrayToken = item.SelectToken(arrayPath); + } + else + { + arrayToken = item.SelectToken($"{sourceAlias}.{arrayPath}"); + } + + if (arrayToken is not JArray jArray) + { + return []; + } + + sourceItems = []; + foreach (var element in jArray) + { + var combined = new JObject(item.Properties()) + { + [subquery.Join.Alias] = element + }; + sourceItems.Add(combined); + } + } + else + { + // Non-correlated — treat the current item as the sole source + sourceItems = [item]; + } + + // Apply WHERE filter + if (subquery.Where is not null) + { + sourceItems = sourceItems.Where(sourceItem => + EvaluateWhereExpression(subquery.Where, sourceItem, subquery.FromAlias, parameters, subquery.Join) + ).ToList(); + } + + // Apply SELECT projection + // Check if any SELECT field contains an aggregate function — if so, collapse into a single row + var hasAggregateSelect = !subquery.IsSelectAll && + subquery.SelectFields.Any(f => f.SqlExpr is not null && ContainsAggregateCall(f.SqlExpr)); + + var results = new List(); + if (hasAggregateSelect) + { + // Aggregate subquery: compute aggregates over all sourceItems and return one row + var projected = new JObject(); + foreach (var field in subquery.SelectFields) + { + var outputName = field.Alias ?? field.Expression.Split('.').Last(); + if (field.SqlExpr is FunctionCallExpression func && AggregateFunctions.Contains(func.FunctionName)) + { + var aggValue = EvaluateSubqueryAggregate(func, sourceItems, subquery.FromAlias, parameters); + if (aggValue is not null and not UndefinedValue) + projected[outputName] = JToken.FromObject(aggValue); + } + else if (field.SqlExpr is not null) + { + // Non-aggregate expression in an aggregate query — evaluate against first item + if (sourceItems.Count > 0) + { + var value = EvaluateSqlExpression(field.SqlExpr, sourceItems[0], subquery.FromAlias, parameters); + if (value is not null and not UndefinedValue) + projected[outputName] = JToken.FromObject(value); + } + } + } + + if (subquery.IsValueSelect) + { + var first = projected.Properties().FirstOrDefault(); + if (first?.Value is not null) + { + results.Add(first.Value.Type == JTokenType.String + ? JsonConvert.SerializeObject(first.Value.Value()) + : first.Value.ToString(Formatting.None)); + } + } + else + { + results.Add(projected.ToString(Formatting.None)); + } + } + else + { + foreach (var sourceItem in sourceItems) + { + if (subquery.IsSelectAll) + { + results.Add(sourceItem.ToString(Formatting.None)); + } + else + { + var projected = new JObject(); + foreach (var field in subquery.SelectFields) + { + var outputName = field.Alias ?? field.Expression.Split('.').Last(); + if (field.SqlExpr is not null and not IdentifierExpression) + { + var value = EvaluateSqlExpression(field.SqlExpr, sourceItem, subquery.FromAlias, parameters); + if (value is not UndefinedValue) + projected[outputName] = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + } + else + { + var path = field.Expression; + if (string.Equals(path, subquery.FromAlias, StringComparison.OrdinalIgnoreCase)) + { + // For range variables (FROM t IN c.tags), use the aliased element + var aliasToken = sourceItem[subquery.FromAlias]; + projected[outputName] = aliasToken is not null + ? aliasToken.DeepClone() + : sourceItem.DeepClone(); + continue; + } + if (path.StartsWith(subquery.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(subquery.FromAlias.Length + 1)..]; + } + var token = sourceItem.SelectToken(path); + outputName = field.Alias ?? path.Split('.').Last(); + projected[outputName] = token?.DeepClone(); + } + } + + if (subquery.IsValueSelect) + { + var first = projected.Properties().FirstOrDefault(); + if (first?.Value is not null) + { + results.Add(first.Value.Type == JTokenType.String + ? JsonConvert.SerializeObject(first.Value.Value()) + : first.Value.ToString(Formatting.None)); + } + } + else + { + results.Add(projected.ToString(Formatting.None)); + } + } + } + } // end else (non-aggregate) + + // Apply DISTINCT + if (subquery.IsDistinct) + { + results = results.Distinct(JsonStructuralStringComparer.Instance).ToList(); + } + + // Apply ORDER BY (must happen before TOP so TOP takes from sorted results) + if (subquery.OrderByFields is { Length: > 0 }) + { + IOrderedEnumerable ordered = null; + foreach (var field in subquery.OrderByFields) + { + var fieldPath = field.Field; + if (fieldPath.StartsWith(subquery.FromAlias + ".", StringComparison.OrdinalIgnoreCase)) + fieldPath = fieldPath[(subquery.FromAlias.Length + 1)..]; + + object KeySelector(string json) + { + var token = JsonParseHelpers.ParseJsonToken(json); + // For SELECT VALUE results, the token is the scalar value itself + if (token is not JObject obj) + { + // If the ORDER BY field matches the FROM alias, use the raw value + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => token.Value(), + _ => (object)token.ToString() + }; + } + var selected = obj.SelectToken(fieldPath); + if (selected is null) return null; + return selected.Type switch + { + JTokenType.Integer => selected.Value(), + JTokenType.Float => selected.Value(), + _ => (object)selected.ToString() + }; + } + + var asc = field.Ascending; + var comparer = Comparer.Create((l, r) => + { + var result = CompareValues(l, r); + return asc ? result : -result; + }); + ordered = ordered == null ? results.OrderBy(KeySelector, comparer) : ordered.ThenBy(KeySelector, comparer); + } + results = ordered?.ToList() ?? results; + } + + // Apply TOP (after ORDER BY so we take from sorted results) + if (subquery.TopCount.HasValue) + { + results = results.Take(subquery.TopCount.Value).ToList(); + } + + // Apply OFFSET + if (subquery.Offset.HasValue) + { + results = results.Skip(subquery.Offset.Value).ToList(); + } + + // Apply LIMIT + if (subquery.Limit.HasValue) + { + results = results.Take(subquery.Limit.Value).ToList(); + } + + return results; + } + + private static object ArithmeticOp(object left, object right, Func op) + { + if (left is null or UndefinedValue || right is null or UndefinedValue) + { + return UndefinedValue.Instance; + } + + if (double.TryParse(left.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var l) && + double.TryParse(right.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var r)) + { + var result = op(l, r); + // Cosmos DB treats NaN and Infinity as undefined + if (double.IsNaN(result) || double.IsInfinity(result)) + return UndefinedValue.Instance; + // Only convert back to long when both operands were integers and the result + // fits within long range. We use strict < for MaxValue because (double)long.MaxValue + // rounds up beyond the actual max, causing (long) cast to overflow. + if (left is long or int && right is long or int && result == Math.Floor(result) + && result >= long.MinValue && result < 9.2233720368547758E+18) + { + return (long)result; + } + + return result; + } + + return null; + } + + private static object BitwiseOp(object left, object right, Func op) + { + if (left is null or UndefinedValue || right is null or UndefinedValue) + { + return UndefinedValue.Instance; + } + + if (long.TryParse(left.ToString(), out var l) && long.TryParse(right.ToString(), out var r)) + { + return op(l, r); + } + + return UndefinedValue.Instance; + } + + private static object BitwiseShiftOp(object left, object right, bool isLeft) + { + if (left is null or UndefinedValue || right is null or UndefinedValue) + { + return UndefinedValue.Instance; + } + + if (long.TryParse(left.ToString(), out var l) && long.TryParse(right.ToString(), out var r)) + { + if (r < 0 || r >= 64) return UndefinedValue.Instance; + return isLeft ? l << (int)r : l >> (int)r; + } + + return UndefinedValue.Instance; + } + + private static object MathOp(object value, Func op) + { + if (value is null or UndefinedValue) + { + return UndefinedValue.Instance; + } + + var isIntegerInput = value is long or int; + + if (double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) + { + var result = op(n); + // Cosmos DB treats NaN and Infinity as undefined + if (double.IsNaN(result) || double.IsInfinity(result)) + return UndefinedValue.Instance; + // Preserve integer type when input was integer and result is a whole number + if (isIntegerInput && result == Math.Floor(result) && result is >= long.MinValue and <= long.MaxValue) + return (long)result; + return result; + } + + return UndefinedValue.Instance; + } + + private static long? ToLong(object value) => value switch + { + long l => l, + double d => (long)d, + int i => i, + _ when value != null && long.TryParse(value.ToString(), out var p) => p, + _ => null + }; + + private static long FloorToUnit(DateTime dt, long ticksPerUnit) + => dt.Ticks / ticksPerUnit; + + private static JToken ResolveTokenType(SqlExpression[] arguments, JObject item, string fromAlias) + { + if (arguments.Length < 1) + { + return null; + } + + if (arguments[0] is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + return item.SelectToken(path); + } + return null; + } + + private static JArray ResolveJArray( + SqlExpression argument, JObject item, string fromAlias, IDictionary parameters) + { + if (argument is IdentifierExpression ident) + { + var path = ident.Name; + if (path.StartsWith(fromAlias + ".", StringComparison.OrdinalIgnoreCase)) + { + path = path[(fromAlias.Length + 1)..]; + } + + return item.SelectToken(path) as JArray; + } + + var evaluated = EvaluateSqlExpression(argument, item, fromAlias, parameters); + return evaluated as JArray; + } + + private static bool ArrayContainsMatch(JArray jArray, string searchStr, bool partial) + { + foreach (var element in jArray) + { + if (element.Type == JTokenType.Object && searchStr.StartsWith("{")) + { + var searchObj = JsonParseHelpers.ParseJson(searchStr); + if (partial) + { + var allMatch = searchObj.Properties().All(prop => + { + var elementValue = element[prop.Name]; + return elementValue is not null && JToken.DeepEquals(elementValue, prop.Value); + }); + if (allMatch) + { + return true; + } + } + else if (JToken.DeepEquals(element, searchObj)) + { + return true; + } + } + else if (string.Equals(element.ToString(), searchStr, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + private static bool ArrayContainsMatchJObject(JArray jArray, JObject searchObj, bool partial) + { + foreach (var element in jArray) + { + if (element.Type != JTokenType.Object) + { + continue; + } + + if (partial) + { + var allMatch = searchObj.Properties().All(prop => + { + var elementValue = element[prop.Name]; + return elementValue is not null && JToken.DeepEquals(elementValue, prop.Value); + }); + if (allMatch) + { + return true; + } + } + else if (JToken.DeepEquals(element, searchObj)) + { + return true; + } + } + return false; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Patch operations + // ═══════════════════════════════════════════════════════════════════════════ + + private static readonly HashSet SystemProperties = new(StringComparer.OrdinalIgnoreCase) + { "/_ts", "/_etag", "/_rid", "/_self", "/_attachments" }; + + private static void ApplyPatchOperations(JObject jObj, IReadOnlyList patchOperations) + { + foreach (var operation in patchOperations) + { + var path = GetPatchPath(operation); + + // Reject patches to system-generated properties + if (SystemProperties.Contains(path)) + { + throw InMemoryCosmosException.Create( + $"Cannot patch system property '{path}'.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + var segments = path.TrimStart('/').Split('/'); + var propertyName = segments.Last(); + var parentPath = BuildSelectTokenPath(segments.Take(segments.Length - 1)); + var rawParent = segments.Length > 1 ? jObj.SelectToken(parentPath) : (JToken)jObj; + + switch (operation.OperationType) + { + case PatchOperationType.Add: + { + var value = GetPatchValue(operation); + var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + + if (propertyName == "-" && rawParent is JArray appendArray) + { + appendArray.Add(newToken); + } + else if (int.TryParse(propertyName, out var insertIdx) && rawParent is JArray insertArray) + { + if (insertIdx < 0 || insertIdx > insertArray.Count) + { + throw InMemoryCosmosException.Create("Array index out of bounds.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + insertArray.Insert(insertIdx, newToken); + } + else + { + var parent = rawParent as JObject ?? jObj; + parent[propertyName] = newToken; + } + + break; + } + case PatchOperationType.Set: + { + var value = GetPatchValue(operation); + var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) + { + if (idx < 0 || idx >= arr.Count) + { + throw InMemoryCosmosException.Create("Array index out of bounds.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + arr[idx] = newToken; + } + else + { + var parent = rawParent as JObject ?? jObj; + parent[propertyName] = newToken; + } + break; + } + case PatchOperationType.Replace: + { + var value = GetPatchValue(operation); + var newToken = value is not null ? JToken.FromObject(value) : JValue.CreateNull(); + if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) + { + if (idx < 0 || idx >= arr.Count) + { + throw InMemoryCosmosException.Create("Array index out of bounds.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + arr[idx] = newToken; + } + else + { + var parent = rawParent as JObject ?? jObj; + if (parent[propertyName] is null) + { + throw InMemoryCosmosException.Create("Replace target does not exist.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + parent[propertyName] = newToken; + } + break; + } + case PatchOperationType.Remove: + { + if (int.TryParse(propertyName, out var idx) && rawParent is JArray arr) + { + if (idx < 0 || idx >= arr.Count) + { + throw InMemoryCosmosException.Create("Array index out of bounds.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + arr.RemoveAt(idx); + } + else + { + var parent = rawParent as JObject ?? jObj; + if (parent[propertyName] is null) + { + throw InMemoryCosmosException.Create("Remove target does not exist.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + parent.Remove(propertyName); + } + break; + } + case PatchOperationType.Move: + { + var sourcePath = GetPatchSourcePath(operation); + if (sourcePath is not null) + { + // Reject when destination is a child of source (e.g. move /nested → /nested/child) + if (path.StartsWith(sourcePath + "/", StringComparison.Ordinal)) + { + throw InMemoryCosmosException.Create( + "The 'path' attribute can't be a JSON child of the 'from' JSON location.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + + var sourceSegments = sourcePath.TrimStart('/').Split('/'); + var sourcePropertyName = sourceSegments.Last(); + var sourceParentPath = BuildSelectTokenPath(sourceSegments.Take(sourceSegments.Length - 1)); + var sourceParent = sourceSegments.Length > 1 + ? jObj.SelectToken(sourceParentPath) as JObject ?? jObj + : jObj; + var sourceValue = sourceParent[sourcePropertyName]; + if (sourceValue is null) + { + throw InMemoryCosmosException.Create("Move source does not exist.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + sourceParent.Remove(sourcePropertyName); + var parent = rawParent as JObject ?? jObj; + parent[propertyName] = sourceValue; + } + + break; + } + case PatchOperationType.Increment: + { + var incrementValue = GetPatchValue(operation); + if (incrementValue is not null) + { + var parent = rawParent as JObject ?? jObj; + var existingToken = parent[propertyName]; + if (existingToken is not null) + { + if (existingToken.Type is not (JTokenType.Integer or JTokenType.Float)) + { + throw InMemoryCosmosException.Create( + $"Cannot increment non-numeric field '{path}'. Field type is {existingToken.Type}.", + HttpStatusCode.BadRequest, 0, Guid.NewGuid().ToString(), SyntheticRequestCharge); + } + var existingDouble = existingToken.Value(); + var incrementDouble = Convert.ToDouble(incrementValue); + var result = existingDouble + incrementDouble; + if (existingToken.Type == JTokenType.Integer && result == Math.Floor(result)) + { + parent[propertyName] = (long)result; + } + else + { + parent[propertyName] = result; + } + } + else + { + var incrementDouble = Convert.ToDouble(incrementValue); + if (incrementDouble == Math.Floor(incrementDouble)) + { + parent[propertyName] = (long)incrementDouble; + } + else + { + parent[propertyName] = incrementDouble; + } + } + } + break; + } + } + } + } + + private static string GetPatchPath(PatchOperation operation) => operation.Path; + + /// + /// Converts path segments to a Newtonsoft.Json SelectToken-compatible path. + /// Numeric segments become array indexers (e.g., ["runs","0"] → "runs[0]"). + /// + internal static string BuildSelectTokenPath(IEnumerable segments) + { + var sb = new System.Text.StringBuilder(); + foreach (var segment in segments) + { + if (int.TryParse(segment, out _)) + { + sb.Append('[').Append(segment).Append(']'); + } + else + { + if (sb.Length > 0) sb.Append('.'); + sb.Append(segment); + } + } + return sb.ToString(); + } + + private static string GetPatchSourcePath(PatchOperation operation) + { + var fromProp = operation.GetType() + .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + return fromProp?.GetValue(operation)?.ToString(); + } + + private static object GetPatchValue(PatchOperation operation) + { + var valueProp = operation.GetType() + .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + return valueProp?.GetValue(operation); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Stream FeedIterator factory + // ═══════════════════════════════════════════════════════════════════════════ + + private FeedIterator CreateStreamFeedIterator(List items, int initialOffset = 0, int? maxItemCount = null) + { + return CreateStreamFeedIteratorFromFactory(() => items, initialOffset, maxItemCount ?? items.Count); + } + + private FeedIterator CreateStreamFeedIterator(Func> itemsFactory) + { + return CreateStreamFeedIteratorFromFactory(() => itemsFactory().Select(o => o?.ToString() ?? "").ToList(), 0, null); + } + + private FeedIterator CreateStreamFeedIteratorFromFactory(Func> itemsFactory, int initialOffset, int? maxItemCount) + { + var offset = initialOffset; + var done = false; + + var feedIterator = Substitute.For(); + feedIterator.HasMoreResults.Returns(_ => !done); + feedIterator.ReadNextAsync(Arg.Any()).Returns(callInfo => + { + var ct = callInfo.Arg(); + ct.ThrowIfCancellationRequested(); + var items = itemsFactory(); + var pageSize = maxItemCount ?? items.Count; + if (pageSize <= 0) pageSize = items.Count; + var page = items.Skip(offset).Take(pageSize).ToList(); + offset += page.Count; + if (offset >= items.Count) + done = true; + var documentsArray = new JArray(page.Select(JsonParseHelpers.ParseJson)); + var envelope = new JObject + { + ["Documents"] = documentsArray, + ["_count"] = documentsArray.Count, + ["_rid"] = string.Empty + }; + var stream = ToStream(envelope.ToString(Formatting.None)); + var response = new ResponseMessage(HttpStatusCode.OK) { Content = stream }; + response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + response.Headers["x-ms-request-charge"] = "1"; + response.Headers["x-ms-session-token"] = CurrentSessionToken; + response.Headers["x-ms-item-count"] = documentsArray.Count.ToString(); + if (!done) + response.Headers.Add("x-ms-continuation", offset.ToString()); + return Task.FromResult(response); + }); + return feedIterator; + } + + + + // ═══════════════════════════════════════════════════════════════════════════ + // InMemoryFeedResponse (for ReadManyItemsAsync) + // ═══════════════════════════════════════════════════════════════════════════ + + private sealed class InMemoryFeedResponse : FeedResponse + { + private readonly IReadOnlyList _items; + private readonly HttpStatusCode _statusCode; + private readonly string _etag; + + public InMemoryFeedResponse(IReadOnlyList items, HttpStatusCode statusCode = HttpStatusCode.OK, string etag = null) + { + _items = items; + _statusCode = statusCode; + _etag = etag; + Headers["x-ms-request-charge"] = SyntheticRequestCharge.ToString(); + Headers["x-ms-item-count"] = items.Count.ToString(); + } + public override Headers Headers { get; } = new(); + public override IEnumerable Resource => _items; + public override HttpStatusCode StatusCode => _statusCode; + public override CosmosDiagnostics Diagnostics => FakeDiagnostics; + public override int Count => _items.Count; + public override string IndexMetrics => null; + public override string ContinuationToken => null; + public override double RequestCharge => SyntheticRequestCharge; + public override string ActivityId { get; } = Guid.NewGuid().ToString(); + public override string ETag => _etag; + public override IEnumerator GetEnumerator() => _items.GetEnumerator(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Vector distance helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static object VectorDistanceFunc(object[] args) + { + // VECTORDISTANCE(vector1, vector2 [, bool_bruteForce] [, {distanceFunction:'cosine'|'dotproduct'|'euclidean'}) + if (args.Length > 4) + throw InMemoryCosmosException.Create("VECTORDISTANCE accepts at most 4 arguments.", HttpStatusCode.BadRequest, 0, string.Empty, 0); + + var vec1 = ToDoubleArray(args[0]); + var vec2 = ToDoubleArray(args[1]); + if (vec1 is null || vec2 is null || vec1.Length != vec2.Length || vec1.Length == 0) + return null; + + // 3rd arg (bool bruteForce) is accepted but ignored in the emulator + // 4th arg (object options) may contain distanceFunction override + var distanceFunction = "cosine"; + if (args.Length > 3) + { + var options = args[3] switch + { + JObject jo => jo, + string s when s.TrimStart().StartsWith("{") => JObject.Parse(s), + string s => new JObject { ["distanceFunction"] = s }, + _ => null, + }; + var df = options?["distanceFunction"]?.ToString(); + if (df is not null) distanceFunction = df; + } + + var result = distanceFunction.ToLowerInvariant() switch + { + "cosine" => CosineSimilarity(vec1, vec2), + "dotproduct" => (object)DotProduct(vec1, vec2), + "euclidean" => (object)EuclideanDistance(vec1, vec2), + _ => throw InMemoryCosmosException.Create($"Unknown distanceFunction '{distanceFunction}'. Supported values: 'cosine', 'dotproduct', 'euclidean'.", HttpStatusCode.BadRequest, 0, string.Empty, 0), + }; + + // Guard against Infinity/NaN which are not valid JSON numbers + if (result is double d && (double.IsInfinity(d) || double.IsNaN(d))) + return null; + + return result; + } + + private static object CosineSimilarity(double[] a, double[] b) + { + double dot = 0, magA = 0, magB = 0; + for (var i = 0; i < a.Length; i++) + { + dot += a[i] * b[i]; + magA += a[i] * a[i]; + magB += b[i] * b[i]; + } + var denominator = Math.Sqrt(magA) * Math.Sqrt(magB); + return denominator == 0 ? null : (object)(dot / denominator); + } + + private static double DotProduct(double[] a, double[] b) + { + double sum = 0; + for (var i = 0; i < a.Length; i++) + sum += a[i] * b[i]; + return sum; + } + + private static double EuclideanDistance(double[] a, double[] b) + { + double sum = 0; + for (var i = 0; i < a.Length; i++) + { + var diff = a[i] - b[i]; + sum += diff * diff; + } + return Math.Sqrt(sum); + } + + private static double[] ToDoubleArray(object value) + { + if (value is double[] dArr) return dArr; + if (value is float[] fArr) return Array.ConvertAll(fArr, f => (double)f); + if (value is IEnumerable dEnum) return dEnum.ToArray(); + + JArray ja = value switch + { + JArray arr => arr, + string s when s.TrimStart().StartsWith("[") => JArray.Parse(s), + _ => null, + }; + if (ja is null) return null; + + var result = new double[ja.Count]; + for (var i = 0; i < ja.Count; i++) + { + if (ja[i].Type is not (JTokenType.Float or JTokenType.Integer)) + return null; + result[i] = ja[i].Value(); + } + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Spatial function helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static object StDistance(object left, object right) + { + var p1 = ExtractPoint(left); + var p2 = ExtractPoint(right); + if (p1 is null || p2 is null) + { + return null; + } + + return HaversineDistanceMeters(p1.Value.Lat, p1.Value.Lon, p2.Value.Lat, p2.Value.Lon); + } + + private static bool StWithin(object point, object region) + { + var p = ExtractPoint(point); + if (p is null) + { + return false; + } + + var polygon = ExtractPolygonRings(region); + if (polygon is not null) + { + return PointInPolygon(p.Value, polygon); + } + + var circle = ExtractCircle(region); + if (circle is not null) + { + var dist = HaversineDistanceMeters(p.Value.Lat, p.Value.Lon, circle.Value.Center.Lat, circle.Value.Center.Lon); + return dist <= circle.Value.RadiusMeters; + } + + return false; + } + + private static bool StIntersects(object geo1, object geo2) + { + var p1 = ExtractPoint(geo1); + var p2 = ExtractPoint(geo2); + + if (p1 is not null && p2 is not null) + { + return Math.Abs(p1.Value.Lat - p2.Value.Lat) < 1e-10 && + Math.Abs(p1.Value.Lon - p2.Value.Lon) < 1e-10; + } + + if (p1 is not null) + { + return StWithin(geo1, geo2); + } + + if (p2 is not null) + { + return StWithin(geo2, geo1); + } + + var poly1 = ExtractPolygonRings(geo1); + var poly2 = ExtractPolygonRings(geo2); + if (poly1 is not null && poly2 is not null) + { + return PolygonsShareAnyPoint(poly1, poly2); + } + + return false; + } + + private static bool StIsValid(object geo) + { + var obj = AsJObject(geo); + if (obj is null) + { + return false; + } + + var type = obj["type"]?.ToString(); + return type switch + { + "Point" => ExtractPoint(geo) is not null, + "Polygon" => ExtractPolygonRings(geo) is not null && ValidatePolygonRings(ExtractPolygonRings(geo)), + "LineString" => ExtractCoordinateArray(obj["coordinates"]) is { Count: >= 2 }, + "MultiPoint" => ExtractCoordinateArray(obj["coordinates"]) is { Count: >= 1 }, + _ => false + }; + } + + private static JObject StIsValidDetailed(object geo) + { + var obj = AsJObject(geo); + if (obj is null) + { + return JObject.FromObject(new { valid = false, reason = "Not a valid GeoJSON object." }); + } + + var type = obj["type"]?.ToString(); + if (string.IsNullOrEmpty(type)) + { + return JObject.FromObject(new { valid = false, reason = "GeoJSON object missing 'type' property." }); + } + + switch (type) + { + case "Point": + if (ExtractPoint(geo) is null) + { + return JObject.FromObject(new { valid = false, reason = "Point must have coordinates [longitude, latitude]." }); + } + + return JObject.FromObject(new { valid = true, reason = "" }); + + case "Polygon": + var rings = ExtractPolygonRings(geo); + if (rings is null) + { + return JObject.FromObject(new { valid = false, reason = "Polygon coordinates must be an array of linear rings." }); + } + + if (!ValidatePolygonRings(rings)) + { + return JObject.FromObject(new { valid = false, reason = "Polygon rings must be closed (first and last position must be identical) and have at least 4 positions." }); + } + + return JObject.FromObject(new { valid = true, reason = "" }); + + case "LineString": + if (ExtractCoordinateArray(obj["coordinates"]) is not { Count: >= 2 }) + { + return JObject.FromObject(new { valid = false, reason = "LineString must have at least 2 positions." }); + } + + return JObject.FromObject(new { valid = true, reason = "" }); + + default: + return JObject.FromObject(new { valid = false, reason = $"Unsupported GeoJSON type: {type}." }); + } + } + + private readonly record struct GeoPoint(double Lat, double Lon); + + private readonly record struct GeoCircle(GeoPoint Center, double RadiusMeters); + + private static object StArea(object geo) + { + var rings = ExtractPolygonRings(geo); + if (rings is null || rings.Count == 0) return null; + // Approximate area using spherical excess formula for the outer ring, minus holes + var area = SphericalPolygonArea(rings[0]); + for (var i = 1; i < rings.Count; i++) + area -= SphericalPolygonArea(rings[i]); + return Math.Abs(area); + } + + private static double SphericalPolygonArea(List ring) + { + // Spherical excess method (Girard's theorem) for area on a sphere + double sum = 0; + for (var i = 0; i < ring.Count - 1; i++) + { + var lon1 = ring[i].Lon * Math.PI / 180; + var lat1 = ring[i].Lat * Math.PI / 180; + var lon2 = ring[(i + 1) % (ring.Count - 1)].Lon * Math.PI / 180; + var lat2 = ring[(i + 1) % (ring.Count - 1)].Lat * Math.PI / 180; + sum += (lon2 - lon1) * (2 + Math.Sin(lat1) + Math.Sin(lat2)); + } + return Math.Abs(sum * EarthRadiusMeters * EarthRadiusMeters / 2.0); + } + + private static JObject AsJObject(object value) + { + return value switch + { + JObject jo => jo, + string s when s.TrimStart().StartsWith("{") => JsonParseHelpers.ParseJson(s), + _ => null + }; + } + + private static GeoPoint? ExtractPoint(object value) + { + var obj = AsJObject(value); + if (obj is null) + { + return null; + } + + if (!string.Equals(obj["type"]?.ToString(), "Point", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (obj["coordinates"] is not JArray coords || coords.Count < 2) + { + return null; + } + + var lon = coords[0].Value(); + var lat = coords[1].Value(); + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) + { + return null; + } + + return new GeoPoint(lat, lon); + } + + private static List> ExtractPolygonRings(object value) + { + var obj = AsJObject(value); + if (obj is null) + { + return null; + } + + if (!string.Equals(obj["type"]?.ToString(), "Polygon", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (obj["coordinates"] is not JArray coords || coords.Count == 0) + { + return null; + } + + var rings = new List>(); + foreach (var ring in coords) + { + var points = ExtractCoordinateArray(ring); + if (points is null || points.Count < 4) + { + return null; + } + + rings.Add(points); + } + + return rings; + } + + private static GeoCircle? ExtractCircle(object value) + { + var obj = AsJObject(value); + if (obj is null) + { + return null; + } + + var center = obj["center"]; + var radius = obj["radius"]; + if (center is null || radius is null) + { + return null; + } + + var centerPoint = ExtractPoint(center); + if (centerPoint is null) + { + return null; + } + + if (!double.TryParse(radius.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var radiusMeters)) + { + return null; + } + + return new GeoCircle(centerPoint.Value, radiusMeters); + } + + private static List ExtractCoordinateArray(JToken token) + { + if (token is not JArray arr) + { + return null; + } + + var points = new List(); + foreach (var item in arr) + { + if (item is JArray coord && coord.Count >= 2) + { + var lon = coord[0].Value(); + var lat = coord[1].Value(); + points.Add(new GeoPoint(lat, lon)); + } + else + { + return null; + } + } + + return points; + } + + private static bool ValidatePolygonRings(List> rings) + { + foreach (var ring in rings) + { + if (ring.Count < 4) + { + return false; + } + + var first = ring[0]; + var last = ring[^1]; + if (Math.Abs(first.Lat - last.Lat) > 1e-10 || Math.Abs(first.Lon - last.Lon) > 1e-10) + { + return false; + } + } + + return true; + } + + private static double HaversineDistanceMeters(double lat1, double lon1, double lat2, double lon2) + { + var dLat = DegreesToRadians(lat2 - lat1); + var dLon = DegreesToRadians(lon2 - lon1); + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(DegreesToRadians(lat1)) * Math.Cos(DegreesToRadians(lat2)) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return EarthRadiusMeters * c; + } + + private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180.0); + + private static bool PointInPolygon(GeoPoint point, List> rings) + { + if (rings.Count == 0) + { + return false; + } + + var inOuter = PointInRing(point, rings[0]); + if (!inOuter) + { + return false; + } + + for (var i = 1; i < rings.Count; i++) + { + if (PointInRing(point, rings[i])) + { + return false; + } + } + + return true; + } + + private static bool PointInRing(GeoPoint point, List ring) + { + var inside = false; + for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++) + { + if ((ring[i].Lat > point.Lat) != (ring[j].Lat > point.Lat) && + point.Lon < (ring[j].Lon - ring[i].Lon) * (point.Lat - ring[i].Lat) / (ring[j].Lat - ring[i].Lat) + ring[i].Lon) + { + inside = !inside; + } + } + + return inside; + } + + private static bool PolygonsShareAnyPoint(List> poly1, List> poly2) + { + foreach (var point in poly1[0]) + { + if (PointInPolygon(point, poly2)) + { + return true; + } + } + + foreach (var point in poly2[0]) + { + if (PointInPolygon(point, poly1)) + { + return true; + } + } + + return false; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainerOptions.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainerOptions.cs index 0df9d89..e1e70ed 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainerOptions.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainerOptions.cs @@ -10,97 +10,97 @@ namespace CosmosDB.InMemoryEmulator; /// public class InMemoryContainerOptions { - /// - /// Container configurations. If empty, a single default container - /// with partition key "/id" is registered. - /// - public List Containers { get; } = new(); + /// + /// Container configurations. If empty, a single default container + /// with partition key "/id" is registered. + /// + public List Containers { get; } = new(); - /// - /// The database name to use for the hidden internal . - /// Falls back to "in-memory-db" if not specified. - /// - public string? DatabaseName { get; set; } + /// + /// The database name to use for the hidden internal . + /// Falls back to "in-memory-db" if not specified. + /// + public string? DatabaseName { get; set; } - /// - /// When true (default), calls . - /// - [Obsolete("FakeCosmosHandler handles .ToFeedIterator() natively. This property is ignored.")] - public bool RegisterFeedIteratorSetup { get; set; } = true; + /// + /// When true (default), calls . + /// + [Obsolete("FakeCosmosHandler handles .ToFeedIterator() natively. This property is ignored.")] + public bool RegisterFeedIteratorSetup { get; set; } = true; - /// - /// Callback invoked with each container's after it is created. - /// Use this to configure container properties (TTL, feed ranges), seed data, or capture references. - /// - public Action? OnContainerCreated { get; set; } + /// + /// Callback invoked with each container's after it is created. + /// Use this to configure container properties (TTL, feed ranges), seed data, or capture references. + /// + public Action? OnContainerCreated { get; set; } - /// - /// Callback invoked with each after it is created. - /// Use this to capture handler references for fault injection, request logging, - /// or to access the backing via - /// . - /// - public Action? OnHandlerCreated { get; set; } + /// + /// Callback invoked with each after it is created. + /// Use this to capture handler references for fault injection, request logging, + /// or to access the backing via + /// . + /// + public Action? OnHandlerCreated { get; set; } - /// - /// Optional function that wraps the final - /// (the or multi-container router) before it is - /// passed to the internal . - /// - /// Use this to insert a into the pipeline. - /// The input is the handler that serves in-memory responses; the return value - /// replaces it as the outermost handler in the . - /// - /// - public Func? HttpMessageHandlerWrapper { get; private set; } + /// + /// Optional function that wraps the final + /// (the or multi-container router) before it is + /// passed to the internal . + /// + /// Use this to insert a into the pipeline. + /// The input is the handler that serves in-memory responses; the return value + /// replaces it as the outermost handler in the . + /// + /// + public Func? HttpMessageHandlerWrapper { get; private set; } - /// - /// When set, each container automatically loads its state from this directory on creation - /// and saves its state back on disposal. Files are named {ContainerName}.json. - /// If the directory or file does not exist, the container starts empty—state is saved on first disposal. - /// - /// This enables persisting container data between test runs without any manual - /// / calls. - /// - /// - public string? StatePersistenceDirectory { get; set; } + /// + /// When set, each container automatically loads its state from this directory on creation + /// and saves its state back on disposal. Files are named {ContainerName}.json. + /// If the directory or file does not exist, the container starts empty—state is saved on first disposal. + /// + /// This enables persisting container data between test runs without any manual + /// / calls. + /// + /// + public string? StatePersistenceDirectory { get; set; } - /// - /// Sets a function that wraps the final before it is - /// passed to the internal . - /// - public InMemoryContainerOptions WithHttpMessageHandlerWrapper( - Func wrapper) - { - HttpMessageHandlerWrapper = wrapper; - return this; - } + /// + /// Sets a function that wraps the final before it is + /// passed to the internal . + /// + public InMemoryContainerOptions WithHttpMessageHandlerWrapper( + Func wrapper) + { + HttpMessageHandlerWrapper = wrapper; + return this; + } - /// - /// Adds a container configuration. - /// - public InMemoryContainerOptions AddContainer( - string containerName = "in-memory-container", - string partitionKeyPath = "/id") - { - Containers.Add(new ContainerConfig(containerName, partitionKeyPath)); - return this; - } + /// + /// Adds a container configuration. + /// + public InMemoryContainerOptions AddContainer( + string containerName = "in-memory-container", + string partitionKeyPath = "/id") + { + Containers.Add(new ContainerConfig(containerName, partitionKeyPath)); + return this; + } - /// - /// Adds a container configuration using full , - /// which supports UniqueKeyPolicy, DefaultTimeToLive, hierarchical partition keys, etc. - /// - public InMemoryContainerOptions AddContainer(ContainerProperties containerProperties) - { - string pkPath; - try { pkPath = containerProperties.PartitionKeyPath; } - catch (NotImplementedException) { pkPath = containerProperties.PartitionKeyPaths[0]; } + /// + /// Adds a container configuration using full , + /// which supports UniqueKeyPolicy, DefaultTimeToLive, hierarchical partition keys, etc. + /// + public InMemoryContainerOptions AddContainer(ContainerProperties containerProperties) + { + string pkPath; + try { pkPath = containerProperties.PartitionKeyPath; } + catch (NotImplementedException) { pkPath = containerProperties.PartitionKeyPaths[0]; } - Containers.Add(new ContainerConfig( - containerProperties.Id, - pkPath, - ContainerProperties: containerProperties)); - return this; - } + Containers.Add(new ContainerConfig( + containerProperties.Id, + pkPath, + ContainerProperties: containerProperties)); + return this; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryCosmos.cs b/src/CosmosDB.InMemoryEmulator/InMemoryCosmos.cs index e14e5d6..611c0e1 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryCosmos.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryCosmos.cs @@ -12,73 +12,73 @@ namespace CosmosDB.InMemoryEmulator; /// public static class InMemoryCosmos { - /// - /// The default database name used by and . - /// Value is "default". - /// - public const string DefaultDatabaseName = "default"; - - /// - /// Creates a single-container in-memory Cosmos setup with a single partition key path. - /// - public static InMemoryCosmosResult Create( - string containerName, - string partitionKeyPath = "/id", - Func? wrapHandler = null, - Action? configureOptions = null, - Action? configureContainer = null) - { - ValidateContainerName(containerName); - ValidatePartitionKeyPath(partitionKeyPath); - - return Builder() - .AddContainer(containerName, partitionKeyPath, configureContainer) - .ApplyWrapHandler(wrapHandler) - .ApplyConfigureOptions(configureOptions) - .Build(singleContainerName: containerName); - } - - /// - /// Creates a single-container in-memory Cosmos setup with hierarchical (composite) partition key paths. - /// - public static InMemoryCosmosResult Create( - string containerName, - string[] partitionKeyPaths, - Func? wrapHandler = null, - Action? configureOptions = null, - Action? configureContainer = null) - { - ValidateContainerName(containerName); - if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) - throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); - foreach (var path in partitionKeyPaths) - ValidatePartitionKeyPath(path); - - return Builder() - .AddContainer(containerName, partitionKeyPaths, configureContainer) - .ApplyWrapHandler(wrapHandler) - .ApplyConfigureOptions(configureOptions) - .Build(singleContainerName: containerName); - } - - /// - /// Returns a builder for multi-container or advanced configuration. - /// - public static InMemoryCosmosBuilder Builder() => new(); - - internal static void ValidateContainerName(string containerName) - { - if (string.IsNullOrWhiteSpace(containerName)) - throw new ArgumentException("Container name must not be null, empty, or whitespace.", nameof(containerName)); - } - - internal static void ValidatePartitionKeyPath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException("Partition key path must not be null or empty.", nameof(path)); - if (!path.StartsWith('/')) - throw new ArgumentException($"Partition key path must start with '/'. Got: '{path}'.", nameof(path)); - } + /// + /// The default database name used by and . + /// Value is "default". + /// + public const string DefaultDatabaseName = "default"; + + /// + /// Creates a single-container in-memory Cosmos setup with a single partition key path. + /// + public static InMemoryCosmosResult Create( + string containerName, + string partitionKeyPath = "/id", + Func? wrapHandler = null, + Action? configureOptions = null, + Action? configureContainer = null) + { + ValidateContainerName(containerName); + ValidatePartitionKeyPath(partitionKeyPath); + + return Builder() + .AddContainer(containerName, partitionKeyPath, configureContainer) + .ApplyWrapHandler(wrapHandler) + .ApplyConfigureOptions(configureOptions) + .Build(singleContainerName: containerName); + } + + /// + /// Creates a single-container in-memory Cosmos setup with hierarchical (composite) partition key paths. + /// + public static InMemoryCosmosResult Create( + string containerName, + string[] partitionKeyPaths, + Func? wrapHandler = null, + Action? configureOptions = null, + Action? configureContainer = null) + { + ValidateContainerName(containerName); + if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) + throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); + foreach (var path in partitionKeyPaths) + ValidatePartitionKeyPath(path); + + return Builder() + .AddContainer(containerName, partitionKeyPaths, configureContainer) + .ApplyWrapHandler(wrapHandler) + .ApplyConfigureOptions(configureOptions) + .Build(singleContainerName: containerName); + } + + /// + /// Returns a builder for multi-container or advanced configuration. + /// + public static InMemoryCosmosBuilder Builder() => new(); + + internal static void ValidateContainerName(string containerName) + { + if (string.IsNullOrWhiteSpace(containerName)) + throw new ArgumentException("Container name must not be null, empty, or whitespace.", nameof(containerName)); + } + + internal static void ValidatePartitionKeyPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Partition key path must not be null or empty.", nameof(path)); + if (!path.StartsWith('/')) + throw new ArgumentException($"Partition key path must start with '/'. Got: '{path}'.", nameof(path)); + } } /// @@ -86,250 +86,250 @@ internal static void ValidatePartitionKeyPath(string path) /// public sealed class InMemoryCosmosBuilder { - private readonly List _containers = new(); - private readonly List _databases = new(); - private readonly List> _wrapHandlers = new(); - private readonly List> _configureOptions = new(); - - internal InMemoryCosmosBuilder() { } - - /// - /// Adds a container with a single partition key path. - /// - public InMemoryCosmosBuilder AddContainer(string name, string partitionKeyPath, - Action? configure = null) - { - InMemoryCosmos.ValidateContainerName(name); - InMemoryCosmos.ValidatePartitionKeyPath(partitionKeyPath); - ValidateNoDuplicate(name); - _containers.Add(new ContainerSpec(name, new[] { partitionKeyPath }, configure)); - return this; - } - - /// - /// Adds a container with hierarchical (composite) partition key paths. - /// - public InMemoryCosmosBuilder AddContainer(string name, string[] partitionKeyPaths, - Action? configure = null) - { - InMemoryCosmos.ValidateContainerName(name); - if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) - throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); - foreach (var path in partitionKeyPaths) - InMemoryCosmos.ValidatePartitionKeyPath(path); - ValidateNoDuplicate(name); - _containers.Add(new ContainerSpec(name, partitionKeyPaths, configure)); - return this; - } - - /// - /// Adds a named database with its own containers. Use this when a single - /// talks to multiple databases that may have - /// overlapping container names (e.g. users-db/events and orders-db/events). - /// - public InMemoryCosmosBuilder AddDatabase(string databaseName, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - if (string.IsNullOrWhiteSpace(databaseName)) - throw new ArgumentException("Database name must not be null, empty, or whitespace.", nameof(databaseName)); - if (_databases.Any(d => d.Name == databaseName)) - throw new InvalidOperationException($"Database '{databaseName}' has already been added."); - - var builder = new InMemoryDatabaseBuilder(); - configure(builder); - - if (builder.Containers.Count == 0) - throw new InvalidOperationException($"Database '{databaseName}' must have at least one container."); - - _databases.Add(new DatabaseSpec(databaseName, builder.Containers.ToList())); - return this; - } - - /// - /// Adds an HTTP handler wrapper. Multiple calls compose: WrapHandler(a).WrapHandler(b) - /// produces b(a(handler)). - /// - public InMemoryCosmosBuilder WrapHandler(Func wrapper) - { - ArgumentNullException.ThrowIfNull(wrapper); - _wrapHandlers.Add(wrapper); - return this; - } - - /// - /// Configures . Multiple calls run sequentially. - /// Do not set — use - /// instead. - /// - public InMemoryCosmosBuilder ConfigureOptions(Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - _configureOptions.Add(configure); - return this; - } - - internal InMemoryCosmosBuilder ApplyWrapHandler(Func? wrapper) - { - if (wrapper is not null) - _wrapHandlers.Add(wrapper); - return this; - } - - internal InMemoryCosmosBuilder ApplyConfigureOptions(Action? configure) - { - if (configure is not null) - _configureOptions.Add(configure); - return this; - } - - /// - /// Builds the in-memory Cosmos setup. Can be called multiple times — each call - /// produces an independent result with fresh containers and handlers. - /// - public InMemoryCosmosResult Build() => Build(singleContainerName: null); - - internal InMemoryCosmosResult Build(string? singleContainerName) - { - var totalContainers = _containers.Count + _databases.Sum(d => d.Containers.Count); - if (totalContainers == 0) - throw new InvalidOperationException("At least one container must be added before calling Build()."); - - var registry = new FakeCosmosHandler.DynamicContainerRegistry - { - DatabaseName = InMemoryCosmos.DefaultDatabaseName - }; - - var handlers = new Dictionary(StringComparer.Ordinal); - var configureCallbacks = new List<(string RegistryKey, Action? Configure)>(); - - // Process flat containers → default database, plain keys - if (_containers.Count > 0) - { - var defaultDbMap = registry.DatabaseContainerKeys.GetOrAdd( - InMemoryCosmos.DefaultDatabaseName, _ => new(StringComparer.Ordinal)); - - foreach (var spec in _containers) - { - var container = CreateContainer(spec); - var handler = new FakeCosmosHandler(container); - handlers[spec.Name] = handler; - registry.BackingContainers[spec.Name] = container; - defaultDbMap[spec.Name] = spec.Name; - configureCallbacks.Add((spec.Name, spec.Configure)); - } - } - - // Process explicit databases → compound keys - foreach (var dbSpec in _databases) - { - var dbMap = registry.DatabaseContainerKeys.GetOrAdd( - dbSpec.Name, _ => new(StringComparer.Ordinal)); - registry.CompoundKeyDatabases.Add(dbSpec.Name); - - foreach (var spec in dbSpec.Containers) - { - var registryKey = $"{dbSpec.Name}/{spec.Name}"; - var container = CreateContainer(spec); - var handler = new FakeCosmosHandler(container); - handlers[registryKey] = handler; - registry.BackingContainers[registryKey] = container; - dbMap[spec.Name] = registryKey; - configureCallbacks.Add((registryKey, spec.Configure)); - } - } - - // Always use a RoutingHandler (even for single container) to enable dynamic container management - HttpMessageHandler httpHandler = new FakeCosmosHandler.RoutingHandler(handlers, registry); - - // Apply handler wrappers in order - foreach (var wrapper in _wrapHandlers) - httpHandler = wrapper(httpHandler); - - // Build CosmosClientOptions - var options = new CosmosClientOptions(); - foreach (var configure in _configureOptions) - configure(options); - - // Validate user didn't set HttpClientFactory - if (options.HttpClientFactory is not null) - throw new InvalidOperationException( - "Do not set HttpClientFactory directly — use the wrapHandler parameter to customize " + - "the HTTP pipeline. HttpClientFactory is managed internally by InMemoryCosmos."); - - // Force required settings - options.ConnectionMode = ConnectionMode.Gateway; - options.LimitToEndpoint = true; - var capturedHandler = httpHandler; - options.HttpClientFactory = () => new HttpClient(capturedHandler); - - var innerClient = new CosmosClient( - FakeCosmosHandler.FakeConnectionString, - options); - - var client = FakeCosmosHandler.WrapClient(innerClient, handlers); - - // Set client on registry so dynamic container creation can register SDK containers - registry.Client = client; - - // Run configure callbacks after wiring is complete - foreach (var (registryKey, configure) in configureCallbacks) - { - configure?.Invoke(registry.BackingContainers[registryKey]); - } - - // Build SDK Container references - // Flat containers → default database - foreach (var spec in _containers) - registry.SdkContainers[spec.Name] = client.GetContainer(InMemoryCosmos.DefaultDatabaseName, spec.Name); - - // Explicit databases → use database name - foreach (var dbSpec in _databases) - { - foreach (var spec in dbSpec.Containers) - { - var registryKey = $"{dbSpec.Name}/{spec.Name}"; - registry.SdkContainers[registryKey] = client.GetContainer(dbSpec.Name, spec.Name); - } - } - - // Determine single-container name - var isSingle = singleContainerName is not null || totalContainers == 1; - var singleName = singleContainerName ?? (isSingle ? GetSingleContainerRegistryKey() : null); - - return new InMemoryCosmosResult(client, registry, singleName); - - string? GetSingleContainerRegistryKey() - { - if (_containers.Count == 1 && _databases.Count == 0) - return _containers[0].Name; - if (_containers.Count == 0 && _databases.Count == 1 && _databases[0].Containers.Count == 1) - return $"{_databases[0].Name}/{_databases[0].Containers[0].Name}"; - return null; - } - } - - private static InMemoryContainer CreateContainer(ContainerSpec spec) - { - return spec.PartitionKeyPaths.Length == 1 - ? new InMemoryContainer(spec.Name, spec.PartitionKeyPaths[0]) - : new InMemoryContainer(spec.Name, spec.PartitionKeyPaths); - } - - private void ValidateNoDuplicate(string name) - { - if (_containers.Any(c => c.Name == name)) - throw new InvalidOperationException($"Container '{name}' has already been added."); - } - - internal sealed record ContainerSpec( - string Name, - string[] PartitionKeyPaths, - Action? Configure); - - private sealed record DatabaseSpec( - string Name, - List Containers); + private readonly List _containers = new(); + private readonly List _databases = new(); + private readonly List> _wrapHandlers = new(); + private readonly List> _configureOptions = new(); + + internal InMemoryCosmosBuilder() { } + + /// + /// Adds a container with a single partition key path. + /// + public InMemoryCosmosBuilder AddContainer(string name, string partitionKeyPath, + Action? configure = null) + { + InMemoryCosmos.ValidateContainerName(name); + InMemoryCosmos.ValidatePartitionKeyPath(partitionKeyPath); + ValidateNoDuplicate(name); + _containers.Add(new ContainerSpec(name, new[] { partitionKeyPath }, configure)); + return this; + } + + /// + /// Adds a container with hierarchical (composite) partition key paths. + /// + public InMemoryCosmosBuilder AddContainer(string name, string[] partitionKeyPaths, + Action? configure = null) + { + InMemoryCosmos.ValidateContainerName(name); + if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) + throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); + foreach (var path in partitionKeyPaths) + InMemoryCosmos.ValidatePartitionKeyPath(path); + ValidateNoDuplicate(name); + _containers.Add(new ContainerSpec(name, partitionKeyPaths, configure)); + return this; + } + + /// + /// Adds a named database with its own containers. Use this when a single + /// talks to multiple databases that may have + /// overlapping container names (e.g. users-db/events and orders-db/events). + /// + public InMemoryCosmosBuilder AddDatabase(string databaseName, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + if (string.IsNullOrWhiteSpace(databaseName)) + throw new ArgumentException("Database name must not be null, empty, or whitespace.", nameof(databaseName)); + if (_databases.Any(d => d.Name == databaseName)) + throw new InvalidOperationException($"Database '{databaseName}' has already been added."); + + var builder = new InMemoryDatabaseBuilder(); + configure(builder); + + if (builder.Containers.Count == 0) + throw new InvalidOperationException($"Database '{databaseName}' must have at least one container."); + + _databases.Add(new DatabaseSpec(databaseName, builder.Containers.ToList())); + return this; + } + + /// + /// Adds an HTTP handler wrapper. Multiple calls compose: WrapHandler(a).WrapHandler(b) + /// produces b(a(handler)). + /// + public InMemoryCosmosBuilder WrapHandler(Func wrapper) + { + ArgumentNullException.ThrowIfNull(wrapper); + _wrapHandlers.Add(wrapper); + return this; + } + + /// + /// Configures . Multiple calls run sequentially. + /// Do not set — use + /// instead. + /// + public InMemoryCosmosBuilder ConfigureOptions(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + _configureOptions.Add(configure); + return this; + } + + internal InMemoryCosmosBuilder ApplyWrapHandler(Func? wrapper) + { + if (wrapper is not null) + _wrapHandlers.Add(wrapper); + return this; + } + + internal InMemoryCosmosBuilder ApplyConfigureOptions(Action? configure) + { + if (configure is not null) + _configureOptions.Add(configure); + return this; + } + + /// + /// Builds the in-memory Cosmos setup. Can be called multiple times — each call + /// produces an independent result with fresh containers and handlers. + /// + public InMemoryCosmosResult Build() => Build(singleContainerName: null); + + internal InMemoryCosmosResult Build(string? singleContainerName) + { + var totalContainers = _containers.Count + _databases.Sum(d => d.Containers.Count); + if (totalContainers == 0) + throw new InvalidOperationException("At least one container must be added before calling Build()."); + + var registry = new FakeCosmosHandler.DynamicContainerRegistry + { + DatabaseName = InMemoryCosmos.DefaultDatabaseName + }; + + var handlers = new Dictionary(StringComparer.Ordinal); + var configureCallbacks = new List<(string RegistryKey, Action? Configure)>(); + + // Process flat containers → default database, plain keys + if (_containers.Count > 0) + { + var defaultDbMap = registry.DatabaseContainerKeys.GetOrAdd( + InMemoryCosmos.DefaultDatabaseName, _ => new(StringComparer.Ordinal)); + + foreach (var spec in _containers) + { + var container = CreateContainer(spec); + var handler = new FakeCosmosHandler(container); + handlers[spec.Name] = handler; + registry.BackingContainers[spec.Name] = container; + defaultDbMap[spec.Name] = spec.Name; + configureCallbacks.Add((spec.Name, spec.Configure)); + } + } + + // Process explicit databases → compound keys + foreach (var dbSpec in _databases) + { + var dbMap = registry.DatabaseContainerKeys.GetOrAdd( + dbSpec.Name, _ => new(StringComparer.Ordinal)); + registry.CompoundKeyDatabases.Add(dbSpec.Name); + + foreach (var spec in dbSpec.Containers) + { + var registryKey = $"{dbSpec.Name}/{spec.Name}"; + var container = CreateContainer(spec); + var handler = new FakeCosmosHandler(container); + handlers[registryKey] = handler; + registry.BackingContainers[registryKey] = container; + dbMap[spec.Name] = registryKey; + configureCallbacks.Add((registryKey, spec.Configure)); + } + } + + // Always use a RoutingHandler (even for single container) to enable dynamic container management + HttpMessageHandler httpHandler = new FakeCosmosHandler.RoutingHandler(handlers, registry); + + // Apply handler wrappers in order + foreach (var wrapper in _wrapHandlers) + httpHandler = wrapper(httpHandler); + + // Build CosmosClientOptions + var options = new CosmosClientOptions(); + foreach (var configure in _configureOptions) + configure(options); + + // Validate user didn't set HttpClientFactory + if (options.HttpClientFactory is not null) + throw new InvalidOperationException( + "Do not set HttpClientFactory directly — use the wrapHandler parameter to customize " + + "the HTTP pipeline. HttpClientFactory is managed internally by InMemoryCosmos."); + + // Force required settings + options.ConnectionMode = ConnectionMode.Gateway; + options.LimitToEndpoint = true; + var capturedHandler = httpHandler; + options.HttpClientFactory = () => new HttpClient(capturedHandler); + + var innerClient = new CosmosClient( + FakeCosmosHandler.FakeConnectionString, + options); + + var client = FakeCosmosHandler.WrapClient(innerClient, handlers); + + // Set client on registry so dynamic container creation can register SDK containers + registry.Client = client; + + // Run configure callbacks after wiring is complete + foreach (var (registryKey, configure) in configureCallbacks) + { + configure?.Invoke(registry.BackingContainers[registryKey]); + } + + // Build SDK Container references + // Flat containers → default database + foreach (var spec in _containers) + registry.SdkContainers[spec.Name] = client.GetContainer(InMemoryCosmos.DefaultDatabaseName, spec.Name); + + // Explicit databases → use database name + foreach (var dbSpec in _databases) + { + foreach (var spec in dbSpec.Containers) + { + var registryKey = $"{dbSpec.Name}/{spec.Name}"; + registry.SdkContainers[registryKey] = client.GetContainer(dbSpec.Name, spec.Name); + } + } + + // Determine single-container name + var isSingle = singleContainerName is not null || totalContainers == 1; + var singleName = singleContainerName ?? (isSingle ? GetSingleContainerRegistryKey() : null); + + return new InMemoryCosmosResult(client, registry, singleName); + + string? GetSingleContainerRegistryKey() + { + if (_containers.Count == 1 && _databases.Count == 0) + return _containers[0].Name; + if (_containers.Count == 0 && _databases.Count == 1 && _databases[0].Containers.Count == 1) + return $"{_databases[0].Name}/{_databases[0].Containers[0].Name}"; + return null; + } + } + + private static InMemoryContainer CreateContainer(ContainerSpec spec) + { + return spec.PartitionKeyPaths.Length == 1 + ? new InMemoryContainer(spec.Name, spec.PartitionKeyPaths[0]) + : new InMemoryContainer(spec.Name, spec.PartitionKeyPaths); + } + + private void ValidateNoDuplicate(string name) + { + if (_containers.Any(c => c.Name == name)) + throw new InvalidOperationException($"Container '{name}' has already been added."); + } + + internal sealed record ContainerSpec( + string Name, + string[] PartitionKeyPaths, + Action? Configure); + + private sealed record DatabaseSpec( + string Name, + List Containers); } /// @@ -339,265 +339,265 @@ private sealed record DatabaseSpec( /// public sealed class InMemoryCosmosResult : IDisposable, IAsyncDisposable { - private readonly FakeCosmosHandler.DynamicContainerRegistry _registry; - private readonly string? _singleContainerName; - - internal InMemoryCosmosResult( - CosmosClient client, - FakeCosmosHandler.DynamicContainerRegistry registry, - string? singleContainerName) - { - Client = client; - _registry = registry; - _singleContainerName = singleContainerName; - } - - // ─── Tier 1: Production-like ────────────────────────────────────────────── - - /// The backed by in-memory handlers. - public CosmosClient Client { get; } - - /// - /// The SDK for single-container setups. - /// Throws if multiple containers are registered. - /// - public Container Container - { - get - { - if (_singleContainerName is null) - throw new InvalidOperationException( - "Container property requires a single-container setup. " + - $"Use Containers[\"name\"] instead. Available containers: {AvailableNames}."); - return _registry.SdkContainers[_singleContainerName]; - } - } - - /// - /// All SDK references keyed by container name. - /// Case-sensitive (matching real Cosmos DB). Contents may change when containers - /// are dynamically created or deleted via the SDK. - /// When container names are unique across databases, flat access works. - /// When names collide, throws with guidance. - /// - public IReadOnlyDictionary Containers => - BuildFlatDictionary( - (dbName, containerName, registryKey) => - _registry.SdkContainers.TryGetValue(registryKey, out var c) ? c : null!); - - // ─── Tier 2: Test setup ─────────────────────────────────────────────────── - - /// - /// Returns an for the specified container. - /// For single-container setups, can be omitted. - /// - public IContainerTestSetup SetupContainer(string? containerName = null) - { - if (containerName is null) - { - if (_singleContainerName is null) - throw new InvalidOperationException( - "SetupContainer() with no name requires a single-container setup. " + - $"Use SetupContainer(\"name\") instead. Available containers: {AvailableNames}."); - return _registry.BackingContainers[_singleContainerName]; - } - - var registryKey = ResolveContainerRegistryKey(containerName, "SetupContainer"); - return _registry.BackingContainers[registryKey]; - } - - // ─── State management convenience methods ───────────────────────────────── - - /// Exports the current container state as a JSON string. - public string ExportState(string? containerName = null) => - SetupContainer(containerName).ExportState(); - - /// Exports the current container state to a file. - public void ExportStateToFile(string path, string? containerName = null) => - SetupContainer(containerName).ExportStateToFile(path); - - /// Imports container state from a JSON string, replacing all existing data. - public void ImportState(string json, string? containerName = null) => - SetupContainer(containerName).ImportState(json); - - /// Imports container state from a file, replacing all existing data. - public void ImportStateFromFile(string filePath, string? containerName = null) => - SetupContainer(containerName).ImportStateFromFile(filePath); - - /// - /// Restores the container to its state at the specified point in time - /// by replaying the change feed. - /// - public void RestoreToPointInTime(DateTimeOffset timestamp, string? containerName = null) => - SetupContainer(containerName).RestoreToPointInTime(timestamp); - - /// Removes all items, ETags, timestamps, and change feed entries. - public void ClearItems(string? containerName = null) => - SetupContainer(containerName).ClearItems(); - - // ─── Tier 3: Fault injection & diagnostics ──────────────────────────────── - - /// - /// The for single-container setups. - /// Throws if multiple containers are registered. - /// - public FakeCosmosHandler Handler - { - get - { - if (_singleContainerName is null) - throw new InvalidOperationException( - "Handler property requires a single-container setup. " + - $"Use Handlers[\"name\"] or GetHandler(\"name\") instead. Available containers: {AvailableNames}."); - return _registry.Handlers[_singleContainerName]; - } - } - - /// - /// All instances keyed by container name. - /// Contents may change when containers are dynamically created or deleted. - /// When container names collide across databases, throws with guidance. - /// - public IReadOnlyDictionary Handlers => - BuildFlatDictionary( - (dbName, containerName, registryKey) => - _registry.Handlers.TryGetValue(registryKey, out var h) ? h : null!); - - /// - /// Returns the for the specified container. - /// - public FakeCosmosHandler GetHandler(string name) - { - var registryKey = ResolveContainerRegistryKey(name, "GetHandler"); - return _registry.Handlers[registryKey]; - } - - /// - /// Sets or clears the fault injector on all handlers. - /// Pass null to clear. - /// - public void SetFaultInjector(Func? injector) - { - foreach (var handler in _registry.Handlers.Values) - handler.FaultInjector = injector; - } - - // ─── Tier 4: Multi-database ─────────────────────────────────────────────── - - /// - /// Returns an for the specified database. - /// - public InMemoryDatabaseResult Database(string databaseName) - { - if (!_registry.DatabaseContainerKeys.TryGetValue(databaseName, out var containerKeys)) - throw new InvalidOperationException( - $"Database '{databaseName}' not found. Available databases: " + - string.Join(", ", _registry.DatabaseContainerKeys.Keys.OrderBy(k => k)) + "."); - return new InMemoryDatabaseResult(databaseName, _registry, containerKeys); - } - - /// - /// All databases keyed by name. - /// - public IReadOnlyDictionary Databases - { - get - { - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (dbName, containerKeys) in _registry.DatabaseContainerKeys) - dict[dbName] = new InMemoryDatabaseResult(dbName, _registry, containerKeys); - return new ReadOnlyDictionary(dict); - } - } - - // ─── Dispose ────────────────────────────────────────────────────────────── - - /// - public void Dispose() - { - Client.Dispose(); - } - - /// - public ValueTask DisposeAsync() - { - Dispose(); - return ValueTask.CompletedTask; - } - - private string AvailableNames => string.Join(", ", - _registry.DatabaseContainerKeys.Values - .SelectMany(m => m.Keys) - .Distinct(StringComparer.Ordinal) - .OrderBy(k => k)); - - /// - /// Resolves a plain container name to its registry key, throwing for ambiguous - /// or missing containers. - /// - private string ResolveContainerRegistryKey(string containerName, string methodName) - { - var matches = new List<(string DatabaseName, string RegistryKey)>(); - foreach (var (dbName, containers) in _registry.DatabaseContainerKeys) - { - if (containers.TryGetValue(containerName, out var registryKey)) - matches.Add((dbName, registryKey)); - } - - return matches.Count switch - { - 0 => throw new InvalidOperationException( - $"Container '{containerName}' not found. Available containers: {AvailableNames}."), - 1 => matches[0].RegistryKey, - _ => throw new InvalidOperationException( - $"Container '{containerName}' exists in multiple databases: " + - string.Join(", ", matches.Select(m => m.DatabaseName).OrderBy(n => n)) + ". " + - $"Use cosmos.Database(\"...\").{methodName}(\"{containerName}\") instead.") - }; - } - - /// - /// Builds a flat dictionary from container names to values, with ambiguity detection. - /// - private IReadOnlyDictionary BuildFlatDictionary( - Func valueSelector) where T : class - { - var unambiguous = new Dictionary(StringComparer.Ordinal); - var ambiguous = new Dictionary>(StringComparer.Ordinal); - - foreach (var (dbName, containers) in _registry.DatabaseContainerKeys) - { - foreach (var (containerName, registryKey) in containers) - { - var value = valueSelector(dbName, containerName, registryKey); - if (value is null) continue; - - if (ambiguous.ContainsKey(containerName)) - { - ambiguous[containerName].Add(dbName); - } - else if (unambiguous.ContainsKey(containerName)) - { - // Move from unambiguous to ambiguous - var existingDb = _registry.DatabaseContainerKeys - .Where(d => d.Value.ContainsKey(containerName)) - .Select(d => d.Key) - .First(d => d != dbName); - ambiguous[containerName] = new List { existingDb, dbName }; - unambiguous.Remove(containerName); - } - else - { - unambiguous[containerName] = value; - } - } - } - - if (ambiguous.Count == 0) - return new ReadOnlyDictionary(unambiguous); - - return new AmbiguityDetectingDictionary(unambiguous, ambiguous); - } + private readonly FakeCosmosHandler.DynamicContainerRegistry _registry; + private readonly string? _singleContainerName; + + internal InMemoryCosmosResult( + CosmosClient client, + FakeCosmosHandler.DynamicContainerRegistry registry, + string? singleContainerName) + { + Client = client; + _registry = registry; + _singleContainerName = singleContainerName; + } + + // ─── Tier 1: Production-like ────────────────────────────────────────────── + + /// The backed by in-memory handlers. + public CosmosClient Client { get; } + + /// + /// The SDK for single-container setups. + /// Throws if multiple containers are registered. + /// + public Container Container + { + get + { + if (_singleContainerName is null) + throw new InvalidOperationException( + "Container property requires a single-container setup. " + + $"Use Containers[\"name\"] instead. Available containers: {AvailableNames}."); + return _registry.SdkContainers[_singleContainerName]; + } + } + + /// + /// All SDK references keyed by container name. + /// Case-sensitive (matching real Cosmos DB). Contents may change when containers + /// are dynamically created or deleted via the SDK. + /// When container names are unique across databases, flat access works. + /// When names collide, throws with guidance. + /// + public IReadOnlyDictionary Containers => + BuildFlatDictionary( + (dbName, containerName, registryKey) => + _registry.SdkContainers.TryGetValue(registryKey, out var c) ? c : null!); + + // ─── Tier 2: Test setup ─────────────────────────────────────────────────── + + /// + /// Returns an for the specified container. + /// For single-container setups, can be omitted. + /// + public IContainerTestSetup SetupContainer(string? containerName = null) + { + if (containerName is null) + { + if (_singleContainerName is null) + throw new InvalidOperationException( + "SetupContainer() with no name requires a single-container setup. " + + $"Use SetupContainer(\"name\") instead. Available containers: {AvailableNames}."); + return _registry.BackingContainers[_singleContainerName]; + } + + var registryKey = ResolveContainerRegistryKey(containerName, "SetupContainer"); + return _registry.BackingContainers[registryKey]; + } + + // ─── State management convenience methods ───────────────────────────────── + + /// Exports the current container state as a JSON string. + public string ExportState(string? containerName = null) => + SetupContainer(containerName).ExportState(); + + /// Exports the current container state to a file. + public void ExportStateToFile(string path, string? containerName = null) => + SetupContainer(containerName).ExportStateToFile(path); + + /// Imports container state from a JSON string, replacing all existing data. + public void ImportState(string json, string? containerName = null) => + SetupContainer(containerName).ImportState(json); + + /// Imports container state from a file, replacing all existing data. + public void ImportStateFromFile(string filePath, string? containerName = null) => + SetupContainer(containerName).ImportStateFromFile(filePath); + + /// + /// Restores the container to its state at the specified point in time + /// by replaying the change feed. + /// + public void RestoreToPointInTime(DateTimeOffset timestamp, string? containerName = null) => + SetupContainer(containerName).RestoreToPointInTime(timestamp); + + /// Removes all items, ETags, timestamps, and change feed entries. + public void ClearItems(string? containerName = null) => + SetupContainer(containerName).ClearItems(); + + // ─── Tier 3: Fault injection & diagnostics ──────────────────────────────── + + /// + /// The for single-container setups. + /// Throws if multiple containers are registered. + /// + public FakeCosmosHandler Handler + { + get + { + if (_singleContainerName is null) + throw new InvalidOperationException( + "Handler property requires a single-container setup. " + + $"Use Handlers[\"name\"] or GetHandler(\"name\") instead. Available containers: {AvailableNames}."); + return _registry.Handlers[_singleContainerName]; + } + } + + /// + /// All instances keyed by container name. + /// Contents may change when containers are dynamically created or deleted. + /// When container names collide across databases, throws with guidance. + /// + public IReadOnlyDictionary Handlers => + BuildFlatDictionary( + (dbName, containerName, registryKey) => + _registry.Handlers.TryGetValue(registryKey, out var h) ? h : null!); + + /// + /// Returns the for the specified container. + /// + public FakeCosmosHandler GetHandler(string name) + { + var registryKey = ResolveContainerRegistryKey(name, "GetHandler"); + return _registry.Handlers[registryKey]; + } + + /// + /// Sets or clears the fault injector on all handlers. + /// Pass null to clear. + /// + public void SetFaultInjector(Func? injector) + { + foreach (var handler in _registry.Handlers.Values) + handler.FaultInjector = injector; + } + + // ─── Tier 4: Multi-database ─────────────────────────────────────────────── + + /// + /// Returns an for the specified database. + /// + public InMemoryDatabaseResult Database(string databaseName) + { + if (!_registry.DatabaseContainerKeys.TryGetValue(databaseName, out var containerKeys)) + throw new InvalidOperationException( + $"Database '{databaseName}' not found. Available databases: " + + string.Join(", ", _registry.DatabaseContainerKeys.Keys.OrderBy(k => k)) + "."); + return new InMemoryDatabaseResult(databaseName, _registry, containerKeys); + } + + /// + /// All databases keyed by name. + /// + public IReadOnlyDictionary Databases + { + get + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (dbName, containerKeys) in _registry.DatabaseContainerKeys) + dict[dbName] = new InMemoryDatabaseResult(dbName, _registry, containerKeys); + return new ReadOnlyDictionary(dict); + } + } + + // ─── Dispose ────────────────────────────────────────────────────────────── + + /// + public void Dispose() + { + Client.Dispose(); + } + + /// + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private string AvailableNames => string.Join(", ", + _registry.DatabaseContainerKeys.Values + .SelectMany(m => m.Keys) + .Distinct(StringComparer.Ordinal) + .OrderBy(k => k)); + + /// + /// Resolves a plain container name to its registry key, throwing for ambiguous + /// or missing containers. + /// + private string ResolveContainerRegistryKey(string containerName, string methodName) + { + var matches = new List<(string DatabaseName, string RegistryKey)>(); + foreach (var (dbName, containers) in _registry.DatabaseContainerKeys) + { + if (containers.TryGetValue(containerName, out var registryKey)) + matches.Add((dbName, registryKey)); + } + + return matches.Count switch + { + 0 => throw new InvalidOperationException( + $"Container '{containerName}' not found. Available containers: {AvailableNames}."), + 1 => matches[0].RegistryKey, + _ => throw new InvalidOperationException( + $"Container '{containerName}' exists in multiple databases: " + + string.Join(", ", matches.Select(m => m.DatabaseName).OrderBy(n => n)) + ". " + + $"Use cosmos.Database(\"...\").{methodName}(\"{containerName}\") instead.") + }; + } + + /// + /// Builds a flat dictionary from container names to values, with ambiguity detection. + /// + private IReadOnlyDictionary BuildFlatDictionary( + Func valueSelector) where T : class + { + var unambiguous = new Dictionary(StringComparer.Ordinal); + var ambiguous = new Dictionary>(StringComparer.Ordinal); + + foreach (var (dbName, containers) in _registry.DatabaseContainerKeys) + { + foreach (var (containerName, registryKey) in containers) + { + var value = valueSelector(dbName, containerName, registryKey); + if (value is null) continue; + + if (ambiguous.ContainsKey(containerName)) + { + ambiguous[containerName].Add(dbName); + } + else if (unambiguous.ContainsKey(containerName)) + { + // Move from unambiguous to ambiguous + var existingDb = _registry.DatabaseContainerKeys + .Where(d => d.Value.ContainsKey(containerName)) + .Select(d => d.Key) + .First(d => d != dbName); + ambiguous[containerName] = new List { existingDb, dbName }; + unambiguous.Remove(containerName); + } + else + { + unambiguous[containerName] = value; + } + } + } + + if (ambiguous.Count == 0) + return new ReadOnlyDictionary(unambiguous); + + return new AmbiguityDetectingDictionary(unambiguous, ambiguous); + } } /// @@ -605,42 +605,42 @@ private IReadOnlyDictionary BuildFlatDictionary( /// public sealed class InMemoryDatabaseBuilder { - internal List Containers { get; } = new(); - - /// - /// Adds a container with a single partition key path. - /// - public InMemoryDatabaseBuilder AddContainer(string name, string partitionKeyPath, - Action? configure = null) - { - InMemoryCosmos.ValidateContainerName(name); - InMemoryCosmos.ValidatePartitionKeyPath(partitionKeyPath); - ValidateNoDuplicate(name); - Containers.Add(new InMemoryCosmosBuilder.ContainerSpec(name, new[] { partitionKeyPath }, configure)); - return this; - } - - /// - /// Adds a container with hierarchical (composite) partition key paths. - /// - public InMemoryDatabaseBuilder AddContainer(string name, string[] partitionKeyPaths, - Action? configure = null) - { - InMemoryCosmos.ValidateContainerName(name); - if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) - throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); - foreach (var path in partitionKeyPaths) - InMemoryCosmos.ValidatePartitionKeyPath(path); - ValidateNoDuplicate(name); - Containers.Add(new InMemoryCosmosBuilder.ContainerSpec(name, partitionKeyPaths, configure)); - return this; - } - - private void ValidateNoDuplicate(string name) - { - if (Containers.Any(c => c.Name == name)) - throw new InvalidOperationException($"Container '{name}' has already been added."); - } + internal List Containers { get; } = new(); + + /// + /// Adds a container with a single partition key path. + /// + public InMemoryDatabaseBuilder AddContainer(string name, string partitionKeyPath, + Action? configure = null) + { + InMemoryCosmos.ValidateContainerName(name); + InMemoryCosmos.ValidatePartitionKeyPath(partitionKeyPath); + ValidateNoDuplicate(name); + Containers.Add(new InMemoryCosmosBuilder.ContainerSpec(name, new[] { partitionKeyPath }, configure)); + return this; + } + + /// + /// Adds a container with hierarchical (composite) partition key paths. + /// + public InMemoryDatabaseBuilder AddContainer(string name, string[] partitionKeyPaths, + Action? configure = null) + { + InMemoryCosmos.ValidateContainerName(name); + if (partitionKeyPaths is null || partitionKeyPaths.Length == 0) + throw new ArgumentException("At least one partition key path must be provided.", nameof(partitionKeyPaths)); + foreach (var path in partitionKeyPaths) + InMemoryCosmos.ValidatePartitionKeyPath(path); + ValidateNoDuplicate(name); + Containers.Add(new InMemoryCosmosBuilder.ContainerSpec(name, partitionKeyPaths, configure)); + return this; + } + + private void ValidateNoDuplicate(string name) + { + if (Containers.Any(c => c.Name == name)) + throw new InvalidOperationException($"Container '{name}' has already been added."); + } } /// @@ -648,118 +648,118 @@ private void ValidateNoDuplicate(string name) /// public sealed class InMemoryDatabaseResult { - private readonly FakeCosmosHandler.DynamicContainerRegistry _registry; - private readonly ConcurrentDictionary _containerKeys; - - internal InMemoryDatabaseResult( - string databaseName, - FakeCosmosHandler.DynamicContainerRegistry registry, - ConcurrentDictionary containerKeys) - { - DatabaseName = databaseName; - _registry = registry; - _containerKeys = containerKeys; - } - - /// The database name. - public string DatabaseName { get; } - - /// - /// All SDK references in this database, keyed by container name. - /// - public IReadOnlyDictionary Containers - { - get - { - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (name, registryKey) in _containerKeys) - { - if (_registry.SdkContainers.TryGetValue(registryKey, out var container)) - dict[name] = container; - } - return new ReadOnlyDictionary(dict); - } - } - - /// - /// Returns an for the specified container in this database. - /// - public IContainerTestSetup SetupContainer(string containerName) - { - if (!_containerKeys.TryGetValue(containerName, out var registryKey)) - throw new InvalidOperationException( - $"Container '{containerName}' not found in database '{DatabaseName}'."); - return _registry.BackingContainers[registryKey]; - } - - // ─── State management convenience methods ───────────────────────────────── - - /// Exports the current container state as a JSON string. - public string ExportState(string containerName) => - SetupContainer(containerName).ExportState(); - - /// Exports the current container state to a file. - public void ExportStateToFile(string containerName, string path) => - SetupContainer(containerName).ExportStateToFile(path); - - /// Imports container state from a JSON string, replacing all existing data. - public void ImportState(string containerName, string json) => - SetupContainer(containerName).ImportState(json); - - /// Imports container state from a file, replacing all existing data. - public void ImportStateFromFile(string containerName, string filePath) => - SetupContainer(containerName).ImportStateFromFile(filePath); - - /// - /// Restores the container to its state at the specified point in time - /// by replaying the change feed. - /// - public void RestoreToPointInTime(string containerName, DateTimeOffset timestamp) => - SetupContainer(containerName).RestoreToPointInTime(timestamp); - - /// Removes all items, ETags, timestamps, and change feed entries. - public void ClearItems(string containerName) => - SetupContainer(containerName).ClearItems(); - - /// - /// Returns the for the specified container in this database. - /// - public FakeCosmosHandler GetHandler(string containerName) - { - if (!_containerKeys.TryGetValue(containerName, out var registryKey)) - throw new InvalidOperationException( - $"Container '{containerName}' not found in database '{DatabaseName}'."); - return _registry.Handlers[registryKey]; - } - - /// - /// All instances in this database, keyed by container name. - /// - public IReadOnlyDictionary Handlers - { - get - { - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (name, registryKey) in _containerKeys) - { - if (_registry.Handlers.TryGetValue(registryKey, out var handler)) - dict[name] = handler; - } - return new ReadOnlyDictionary(dict); - } - } - - /// - /// Sets or clears the fault injector on all handlers in this database. - /// - public void SetFaultInjector(Func? injector) - { - foreach (var (_, registryKey) in _containerKeys) - { - if (_registry.Handlers.TryGetValue(registryKey, out var handler)) - handler.FaultInjector = injector; - } - } + private readonly FakeCosmosHandler.DynamicContainerRegistry _registry; + private readonly ConcurrentDictionary _containerKeys; + + internal InMemoryDatabaseResult( + string databaseName, + FakeCosmosHandler.DynamicContainerRegistry registry, + ConcurrentDictionary containerKeys) + { + DatabaseName = databaseName; + _registry = registry; + _containerKeys = containerKeys; + } + + /// The database name. + public string DatabaseName { get; } + + /// + /// All SDK references in this database, keyed by container name. + /// + public IReadOnlyDictionary Containers + { + get + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (name, registryKey) in _containerKeys) + { + if (_registry.SdkContainers.TryGetValue(registryKey, out var container)) + dict[name] = container; + } + return new ReadOnlyDictionary(dict); + } + } + + /// + /// Returns an for the specified container in this database. + /// + public IContainerTestSetup SetupContainer(string containerName) + { + if (!_containerKeys.TryGetValue(containerName, out var registryKey)) + throw new InvalidOperationException( + $"Container '{containerName}' not found in database '{DatabaseName}'."); + return _registry.BackingContainers[registryKey]; + } + + // ─── State management convenience methods ───────────────────────────────── + + /// Exports the current container state as a JSON string. + public string ExportState(string containerName) => + SetupContainer(containerName).ExportState(); + + /// Exports the current container state to a file. + public void ExportStateToFile(string containerName, string path) => + SetupContainer(containerName).ExportStateToFile(path); + + /// Imports container state from a JSON string, replacing all existing data. + public void ImportState(string containerName, string json) => + SetupContainer(containerName).ImportState(json); + + /// Imports container state from a file, replacing all existing data. + public void ImportStateFromFile(string containerName, string filePath) => + SetupContainer(containerName).ImportStateFromFile(filePath); + + /// + /// Restores the container to its state at the specified point in time + /// by replaying the change feed. + /// + public void RestoreToPointInTime(string containerName, DateTimeOffset timestamp) => + SetupContainer(containerName).RestoreToPointInTime(timestamp); + + /// Removes all items, ETags, timestamps, and change feed entries. + public void ClearItems(string containerName) => + SetupContainer(containerName).ClearItems(); + + /// + /// Returns the for the specified container in this database. + /// + public FakeCosmosHandler GetHandler(string containerName) + { + if (!_containerKeys.TryGetValue(containerName, out var registryKey)) + throw new InvalidOperationException( + $"Container '{containerName}' not found in database '{DatabaseName}'."); + return _registry.Handlers[registryKey]; + } + + /// + /// All instances in this database, keyed by container name. + /// + public IReadOnlyDictionary Handlers + { + get + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (name, registryKey) in _containerKeys) + { + if (_registry.Handlers.TryGetValue(registryKey, out var handler)) + dict[name] = handler; + } + return new ReadOnlyDictionary(dict); + } + } + + /// + /// Sets or clears the fault injector on all handlers in this database. + /// + public void SetFaultInjector(Func? injector) + { + foreach (var (_, registryKey) in _containerKeys) + { + if (_registry.Handlers.TryGetValue(registryKey, out var handler)) + handler.FaultInjector = injector; + } + } } /// @@ -768,43 +768,43 @@ public void SetFaultInjector(Func? inj /// internal sealed class AmbiguityDetectingDictionary : IReadOnlyDictionary { - private readonly Dictionary _unambiguous; - private readonly Dictionary> _ambiguous; + private readonly Dictionary _unambiguous; + private readonly Dictionary> _ambiguous; - internal AmbiguityDetectingDictionary( - Dictionary unambiguous, - Dictionary> ambiguous) - { - _unambiguous = unambiguous; - _ambiguous = ambiguous; - } + internal AmbiguityDetectingDictionary( + Dictionary unambiguous, + Dictionary> ambiguous) + { + _unambiguous = unambiguous; + _ambiguous = ambiguous; + } - public TValue this[string key] - { - get - { - if (_ambiguous.TryGetValue(key, out var databases)) - throw new KeyNotFoundException( - $"Container '{key}' exists in multiple databases: " + - string.Join(", ", databases.OrderBy(d => d)) + ". " + - $"Use cosmos.Database(\"...\").Containers[\"{key}\"] instead."); - if (!_unambiguous.TryGetValue(key, out var value)) - throw new KeyNotFoundException($"Container '{key}' not found."); - return value; - } - } + public TValue this[string key] + { + get + { + if (_ambiguous.TryGetValue(key, out var databases)) + throw new KeyNotFoundException( + $"Container '{key}' exists in multiple databases: " + + string.Join(", ", databases.OrderBy(d => d)) + ". " + + $"Use cosmos.Database(\"...\").Containers[\"{key}\"] instead."); + if (!_unambiguous.TryGetValue(key, out var value)) + throw new KeyNotFoundException($"Container '{key}' not found."); + return value; + } + } - public bool ContainsKey(string key) => _unambiguous.ContainsKey(key); + public bool ContainsKey(string key) => _unambiguous.ContainsKey(key); - public bool TryGetValue(string key, out TValue value) => _unambiguous.TryGetValue(key, out value!); + public bool TryGetValue(string key, out TValue value) => _unambiguous.TryGetValue(key, out value!); - public int Count => _unambiguous.Count; + public int Count => _unambiguous.Count; - public IEnumerable Keys => _unambiguous.Keys; + public IEnumerable Keys => _unambiguous.Keys; - public IEnumerable Values => _unambiguous.Values; + public IEnumerable Values => _unambiguous.Values; - public IEnumerator> GetEnumerator() => _unambiguous.GetEnumerator(); + public IEnumerator> GetEnumerator() => _unambiguous.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosClient.cs b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosClient.cs index b809db0..8b46af6 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosClient.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosClient.cs @@ -39,300 +39,300 @@ namespace CosmosDB.InMemoryEmulator; /// /// [Obsolete("Use UseInMemoryCosmosDB() or UseInMemoryCosmosContainers() instead. " + - "InMemoryCosmosClient bypasses the SDK HTTP pipeline. " + - "FakeCosmosHandler-based methods provide higher fidelity and native .ToFeedIterator() support.")] + "InMemoryCosmosClient bypasses the SDK HTTP pipeline. " + + "FakeCosmosHandler-based methods provide higher fidelity and native .ToFeedIterator() support.")] internal class InMemoryCosmosClient : CosmosClient { - private readonly ConcurrentDictionary _databases = new(); - private readonly ConcurrentDictionary _explicitlyCreatedDatabases = new(); - private bool _disposed; + private readonly ConcurrentDictionary _databases = new(); + private readonly ConcurrentDictionary _explicitlyCreatedDatabases = new(); + private bool _disposed; - private static readonly Uri EmulatorEndpoint = new("https://localhost:8081/"); - private readonly CosmosClientOptions _clientOptions = new(); - private readonly CosmosResponseFactory _responseFactory = Substitute.For(); + private static readonly Uri EmulatorEndpoint = new("https://localhost:8081/"); + private readonly CosmosClientOptions _clientOptions = new(); + private readonly CosmosResponseFactory _responseFactory = Substitute.For(); - /// Returns https://localhost:8081/. - public override Uri Endpoint => EmulatorEndpoint; + /// Returns https://localhost:8081/. + public override Uri Endpoint => EmulatorEndpoint; - /// Returns a default instance. - public override CosmosClientOptions ClientOptions => _clientOptions; + /// Returns a default instance. + public override CosmosClientOptions ClientOptions => _clientOptions; - /// Returns a stubbed . - public override CosmosResponseFactory ResponseFactory => _responseFactory; + /// Returns a stubbed . + public override CosmosResponseFactory ResponseFactory => _responseFactory; - /// - /// Creates a database if it does not already exist. Returns - /// for new databases or for existing ones. - /// - /// The database identifier. - /// Ignored. Throughput is not enforced in-memory. - /// Ignored. - /// Ignored. - public override Task CreateDatabaseIfNotExistsAsync( - string id, int? throughput = null, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - ThrowIfDisposed(); - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(id); - ArgumentException.ThrowIfNullOrEmpty(id); - ValidateResourceName(id, "Database"); - var created = false; - var database = _databases.GetOrAdd(id, name => { created = true; return new InMemoryDatabase(name, this); }); - _explicitlyCreatedDatabases.TryAdd(id, true); - var response = BuildDatabaseResponse(database, created ? HttpStatusCode.Created : HttpStatusCode.OK); - return Task.FromResult(response); - } + /// + /// Creates a database if it does not already exist. Returns + /// for new databases or for existing ones. + /// + /// The database identifier. + /// Ignored. Throughput is not enforced in-memory. + /// Ignored. + /// Ignored. + public override Task CreateDatabaseIfNotExistsAsync( + string id, int? throughput = null, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(id); + ArgumentException.ThrowIfNullOrEmpty(id); + ValidateResourceName(id, "Database"); + var created = false; + var database = _databases.GetOrAdd(id, name => { created = true; return new InMemoryDatabase(name, this); }); + _explicitlyCreatedDatabases.TryAdd(id, true); + var response = BuildDatabaseResponse(database, created ? HttpStatusCode.Created : HttpStatusCode.OK); + return Task.FromResult(response); + } - public override Task CreateDatabaseIfNotExistsAsync( - string id, ThroughputProperties throughputProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - return CreateDatabaseIfNotExistsAsync(id, (int?)null, requestOptions, cancellationToken); - } + public override Task CreateDatabaseIfNotExistsAsync( + string id, ThroughputProperties throughputProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + return CreateDatabaseIfNotExistsAsync(id, (int?)null, requestOptions, cancellationToken); + } - /// - /// Creates a new database. Throws with - /// if a database with the same already exists. - /// - /// The database identifier. - /// Ignored. Throughput is not enforced in-memory. - /// Ignored. - /// Ignored. - public override Task CreateDatabaseAsync( - string id, int? throughput = null, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - ThrowIfDisposed(); - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(id); - ArgumentException.ThrowIfNullOrEmpty(id); - ValidateResourceName(id, "Database"); - var database = new InMemoryDatabase(id, this); - if (!_databases.TryAdd(id, database)) - { - throw InMemoryCosmosException.Create("Database already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); - } - _explicitlyCreatedDatabases.TryAdd(id, true); - var response = BuildDatabaseResponse(database, HttpStatusCode.Created); - return Task.FromResult(response); - } + /// + /// Creates a new database. Throws with + /// if a database with the same already exists. + /// + /// The database identifier. + /// Ignored. Throughput is not enforced in-memory. + /// Ignored. + /// Ignored. + public override Task CreateDatabaseAsync( + string id, int? throughput = null, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(id); + ArgumentException.ThrowIfNullOrEmpty(id); + ValidateResourceName(id, "Database"); + var database = new InMemoryDatabase(id, this); + if (!_databases.TryAdd(id, database)) + { + throw InMemoryCosmosException.Create("Database already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); + } + _explicitlyCreatedDatabases.TryAdd(id, true); + var response = BuildDatabaseResponse(database, HttpStatusCode.Created); + return Task.FromResult(response); + } - public override Task CreateDatabaseAsync( - string id, ThroughputProperties throughputProperties, RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - return CreateDatabaseAsync(id, (int?)null, requestOptions, cancellationToken); - } + public override Task CreateDatabaseAsync( + string id, ThroughputProperties throughputProperties, RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + return CreateDatabaseAsync(id, (int?)null, requestOptions, cancellationToken); + } - /// - /// Creates a new database using stream semantics. Returns - /// on success or if the database already exists. - /// - public override Task CreateDatabaseStreamAsync( - DatabaseProperties databaseProperties, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - ThrowIfDisposed(); - cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(databaseProperties); - var id = databaseProperties.Id; - ArgumentNullException.ThrowIfNull(id); - ArgumentException.ThrowIfNullOrEmpty(id); - ValidateResourceName(id, "Database"); - var database = new InMemoryDatabase(id, this); - if (!_databases.TryAdd(id, database)) - { - return Task.FromResult(CreateStreamResponse(HttpStatusCode.Conflict)); - } - _explicitlyCreatedDatabases.TryAdd(id, true); - return Task.FromResult(CreateStreamResponse(HttpStatusCode.Created, databaseProperties)); - } + /// + /// Creates a new database using stream semantics. Returns + /// on success or if the database already exists. + /// + public override Task CreateDatabaseStreamAsync( + DatabaseProperties databaseProperties, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(databaseProperties); + var id = databaseProperties.Id; + ArgumentNullException.ThrowIfNull(id); + ArgumentException.ThrowIfNullOrEmpty(id); + ValidateResourceName(id, "Database"); + var database = new InMemoryDatabase(id, this); + if (!_databases.TryAdd(id, database)) + { + return Task.FromResult(CreateStreamResponse(HttpStatusCode.Conflict)); + } + _explicitlyCreatedDatabases.TryAdd(id, true); + return Task.FromResult(CreateStreamResponse(HttpStatusCode.Created, databaseProperties)); + } - private static ResponseMessage CreateStreamResponse(HttpStatusCode statusCode, DatabaseProperties databaseProperties = null) - { - var msg = new ResponseMessage(statusCode); - msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - msg.Headers["x-ms-request-charge"] = "1"; - msg.Headers["x-ms-session-token"] = "0:0#0"; - if (databaseProperties != null) - { - var json = Newtonsoft.Json.JsonConvert.SerializeObject(databaseProperties); - msg.Content = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - } - return msg; - } + private static ResponseMessage CreateStreamResponse(HttpStatusCode statusCode, DatabaseProperties databaseProperties = null) + { + var msg = new ResponseMessage(statusCode); + msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + msg.Headers["x-ms-request-charge"] = "1"; + msg.Headers["x-ms-session-token"] = "0:0#0"; + if (databaseProperties != null) + { + var json = Newtonsoft.Json.JsonConvert.SerializeObject(databaseProperties); + msg.Content = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + return msg; + } - public Task CreateDatabaseStreamAsync( - DatabaseProperties databaseProperties, ThroughputProperties throughputProperties, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return CreateDatabaseStreamAsync(databaseProperties, (int?)null, requestOptions, cancellationToken); - } + public Task CreateDatabaseStreamAsync( + DatabaseProperties databaseProperties, ThroughputProperties throughputProperties, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return CreateDatabaseStreamAsync(databaseProperties, (int?)null, requestOptions, cancellationToken); + } - /// - /// Gets or creates an with the given . - /// The database is created lazily on first access. - /// - /// The database identifier. - public override Database GetDatabase(string id) - { - ThrowIfDisposed(); - ArgumentNullException.ThrowIfNull(id); - ArgumentException.ThrowIfNullOrEmpty(id); - return _databases.GetOrAdd(id, name => new InMemoryDatabase(name, this)); - } + /// + /// Gets or creates an with the given . + /// The database is created lazily on first access. + /// + /// The database identifier. + public override Database GetDatabase(string id) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(id); + ArgumentException.ThrowIfNullOrEmpty(id); + return _databases.GetOrAdd(id, name => new InMemoryDatabase(name, this)); + } - /// - /// Gets or creates a container within the specified database. Both the database and - /// container are created lazily on first access. - /// - /// The database identifier. - /// The container identifier. - public override Container GetContainer(string databaseId, string containerId) - { - ThrowIfDisposed(); - ArgumentNullException.ThrowIfNull(databaseId); - ArgumentException.ThrowIfNullOrEmpty(databaseId); - ArgumentNullException.ThrowIfNull(containerId); - ArgumentException.ThrowIfNullOrEmpty(containerId); - var database = _databases.GetOrAdd(databaseId, name => new InMemoryDatabase(name, this)); - return database.GetOrCreateContainer(containerId); - } + /// + /// Gets or creates a container within the specified database. Both the database and + /// container are created lazily on first access. + /// + /// The database identifier. + /// The container identifier. + public override Container GetContainer(string databaseId, string containerId) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(databaseId); + ArgumentException.ThrowIfNullOrEmpty(databaseId); + ArgumentNullException.ThrowIfNull(containerId); + ArgumentException.ThrowIfNullOrEmpty(containerId); + var database = _databases.GetOrAdd(databaseId, name => new InMemoryDatabase(name, this)); + return database.GetOrCreateContainer(containerId); + } - /// - /// Returns a feed iterator over all databases. The query text is ignored; - /// all databases are returned as . - /// - public override FeedIterator GetDatabaseQueryIterator( - string queryText = null, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - var offset = int.TryParse(continuationToken, out var o) ? o : 0; - IEnumerable items = _databases.Values - .Select(db => new DatabaseProperties(db.Id)); - var idFilter = ExtractIdFilter(queryText); - if (idFilter is not null) - items = items.Where(d => string.Equals(d.Id, idFilter, StringComparison.Ordinal)); - return new InMemoryFeedIterator(items.Cast().ToList(), requestOptions?.MaxItemCount, offset); - } + /// + /// Returns a feed iterator over all databases. The query text is ignored; + /// all databases are returned as . + /// + public override FeedIterator GetDatabaseQueryIterator( + string queryText = null, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + var offset = int.TryParse(continuationToken, out var o) ? o : 0; + IEnumerable items = _databases.Values + .Select(db => new DatabaseProperties(db.Id)); + var idFilter = ExtractIdFilter(queryText); + if (idFilter is not null) + items = items.Where(d => string.Equals(d.Id, idFilter, StringComparison.Ordinal)); + return new InMemoryFeedIterator(items.Cast().ToList(), requestOptions?.MaxItemCount, offset); + } - public override FeedIterator GetDatabaseQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return GetDatabaseQueryIterator(queryDefinition?.QueryText, continuationToken, requestOptions); - } + public override FeedIterator GetDatabaseQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return GetDatabaseQueryIterator(queryDefinition?.QueryText, continuationToken, requestOptions); + } - public override FeedIterator GetDatabaseQueryStreamIterator( - string queryText = null, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return new InMemoryStreamFeedIterator( - () => _databases.Values - .Select(db => new { id = db.Id }) - .ToList(), - "Databases"); - } + public override FeedIterator GetDatabaseQueryStreamIterator( + string queryText = null, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return new InMemoryStreamFeedIterator( + () => _databases.Values + .Select(db => new { id = db.Id }) + .ToList(), + "Databases"); + } - public override FeedIterator GetDatabaseQueryStreamIterator( - QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return GetDatabaseQueryStreamIterator((string)null, continuationToken, requestOptions); - } + public override FeedIterator GetDatabaseQueryStreamIterator( + QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return GetDatabaseQueryStreamIterator((string)null, continuationToken, requestOptions); + } - /// - /// Returns synthetic with Id "in-memory-emulator". - /// Falls back to an NSubstitute stub if reflection fails. - /// - public override Task ReadAccountAsync() - { - try - { - var account = (AccountProperties)Activator.CreateInstance( - typeof(AccountProperties), - nonPublic: true); - var idProp = typeof(AccountProperties).GetProperty(nameof(AccountProperties.Id)); - idProp?.SetValue(account, "in-memory-emulator"); - return Task.FromResult(account); - } - catch (Exception ex) when (ex is MissingMethodException or MissingMemberException or System.Reflection.TargetInvocationException) - { - Trace.TraceWarning( - "InMemoryCosmosClient: AccountProperties reflection failed " + - $"({ex.GetType().Name}: {ex.Message}). " + - "Falling back to NSubstitute stub."); - var stub = Substitute.For(); - return Task.FromResult(stub); - } - } + /// + /// Returns synthetic with Id "in-memory-emulator". + /// Falls back to an NSubstitute stub if reflection fails. + /// + public override Task ReadAccountAsync() + { + try + { + var account = (AccountProperties)Activator.CreateInstance( + typeof(AccountProperties), + nonPublic: true); + var idProp = typeof(AccountProperties).GetProperty(nameof(AccountProperties.Id)); + idProp?.SetValue(account, "in-memory-emulator"); + return Task.FromResult(account); + } + catch (Exception ex) when (ex is MissingMethodException or MissingMemberException or System.Reflection.TargetInvocationException) + { + Trace.TraceWarning( + "InMemoryCosmosClient: AccountProperties reflection failed " + + $"({ex.GetType().Name}: {ex.Message}). " + + "Falling back to NSubstitute stub."); + var stub = Substitute.For(); + return Task.FromResult(stub); + } + } - internal bool RemoveDatabase(string id) - { - _explicitlyCreatedDatabases.TryRemove(id, out _); - return _databases.TryRemove(id, out _); - } + internal bool RemoveDatabase(string id) + { + _explicitlyCreatedDatabases.TryRemove(id, out _); + return _databases.TryRemove(id, out _); + } - internal bool IsDatabaseExplicitlyCreated(string id) - { - return _explicitlyCreatedDatabases.ContainsKey(id); - } + internal bool IsDatabaseExplicitlyCreated(string id) + { + return _explicitlyCreatedDatabases.ContainsKey(id); + } - /// - /// Disposes the client and cascades disposal to all containers, triggering - /// state persistence for any container with set. - /// - protected override void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - foreach (var db in _databases.Values) - foreach (var container in db.GetAllContainers()) - container.Dispose(); - } - _disposed = true; - } + /// + /// Disposes the client and cascades disposal to all containers, triggering + /// state persistence for any container with set. + /// + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + foreach (var db in _databases.Values) + foreach (var container in db.GetAllContainers()) + container.Dispose(); + } + _disposed = true; + } - private void ThrowIfDisposed() - { - ObjectDisposedException.ThrowIf(_disposed, this); - } + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } - internal static void ValidateResourceName(string name, string resourceType) - { - if (name.Length > 255) - { - throw InMemoryCosmosException.Create( - $"{resourceType} name must not exceed 255 characters.", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - if (name.IndexOfAny(['/', '\\', '#', '?']) >= 0) - { - throw InMemoryCosmosException.Create( - $"{resourceType} name must not contain '/', '\\', '#', or '?' characters.", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - } + internal static void ValidateResourceName(string name, string resourceType) + { + if (name.Length > 255) + { + throw InMemoryCosmosException.Create( + $"{resourceType} name must not exceed 255 characters.", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + if (name.IndexOfAny(['/', '\\', '#', '?']) >= 0) + { + throw InMemoryCosmosException.Create( + $"{resourceType} name must not contain '/', '\\', '#', or '?' characters.", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + } - private static DatabaseResponse BuildDatabaseResponse(Database database, HttpStatusCode statusCode) - { - var response = Substitute.For(); - response.Database.Returns(database); - response.StatusCode.Returns(statusCode); - response.Resource.Returns(new DatabaseProperties(database.Id)); - return response; - } + private static DatabaseResponse BuildDatabaseResponse(Database database, HttpStatusCode statusCode) + { + var response = Substitute.For(); + response.Database.Returns(database); + response.StatusCode.Returns(statusCode); + response.Resource.Returns(new DatabaseProperties(database.Id)); + return response; + } - /// - /// Extracts a simple WHERE c.id = 'value' filter from SQL text. - /// Returns the extracted id value, or null if no simple id filter is found. - /// - internal static string ExtractIdFilter(string queryText) - { - if (string.IsNullOrWhiteSpace(queryText)) return null; - var match = System.Text.RegularExpressions.Regex.Match(queryText, - @"WHERE\s+\w+\.id\s*=\s*'([^']*)'", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - return match.Success ? match.Groups[1].Value : null; - } + /// + /// Extracts a simple WHERE c.id = 'value' filter from SQL text. + /// Returns the extracted id value, or null if no simple id filter is found. + /// + internal static string ExtractIdFilter(string queryText) + { + if (string.IsNullOrWhiteSpace(queryText)) return null; + var match = System.Text.RegularExpressions.Regex.Match(queryText, + @"WHERE\s+\w+\.id\s*=\s*'([^']*)'", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return match.Success ? match.Groups[1].Value : null; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosException.cs b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosException.cs index 1940ebe..c8871fa 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosException.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosException.cs @@ -22,15 +22,15 @@ namespace CosmosDB.InMemoryEmulator; /// public static class InMemoryCosmosException { - /// - /// Creates a new . - /// - /// will be null because the SDK - /// does not provide a public constructor or setter that accepts diagnostics. - /// - /// - public static CosmosException Create(string message, HttpStatusCode statusCode, int subStatusCode, string activityId, double requestCharge) - { - return new CosmosException(message, statusCode, subStatusCode, activityId, requestCharge); - } + /// + /// Creates a new . + /// + /// will be null because the SDK + /// does not provide a public constructor or setter that accepts diagnostics. + /// + /// + public static CosmosException Create(string message, HttpStatusCode statusCode, int subStatusCode, string activityId, double requestCharge) + { + return new CosmosException(message, statusCode, subStatusCode, activityId, requestCharge); + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosOptions.cs b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosOptions.cs index 25b051b..93c9c7c 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryCosmosOptions.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryCosmosOptions.cs @@ -8,110 +8,110 @@ namespace CosmosDB.InMemoryEmulator; /// public class InMemoryCosmosOptions { - /// - /// Container configurations. If empty, the method registers a single default - /// container with partition key "/id". - /// - public List Containers { get; } = new(); + /// + /// Container configurations. If empty, the method registers a single default + /// container with partition key "/id". + /// + public List Containers { get; } = new(); - /// - /// The database name to use. Falls back to "in-memory-db" if not specified. - /// - public string? DatabaseName { get; set; } + /// + /// The database name to use. Falls back to "in-memory-db" if not specified. + /// + public string? DatabaseName { get; set; } - /// - /// When true (default), calls so that - /// .ToFeedIteratorOverridable() works for LINQ queries. - /// - public bool RegisterFeedIteratorSetup { get; set; } = true; + /// + /// When true (default), calls so that + /// .ToFeedIteratorOverridable() works for LINQ queries. + /// + public bool RegisterFeedIteratorSetup { get; set; } = true; - /// - /// Callback invoked with the after it is created. - /// Use this to capture the client reference for test assertions, etc. - /// - public Action? OnClientCreated { get; set; } + /// + /// Callback invoked with the after it is created. + /// Use this to capture the client reference for test assertions, etc. + /// + public Action? OnClientCreated { get; set; } - /// - /// Callback invoked with each after it is created. - /// Use this to capture handler references for fault injection, request logging, - /// or to access the backing via - /// . - /// - public Action? OnHandlerCreated { get; set; } + /// + /// Callback invoked with each after it is created. + /// Use this to capture handler references for fault injection, request logging, + /// or to access the backing via + /// . + /// + public Action? OnHandlerCreated { get; set; } - /// - /// Optional function that wraps the final - /// (the or multi-container router) before it is - /// passed to . - /// - /// Use this to insert a into the pipeline. - /// The input is the handler that serves in-memory responses; the return value - /// replaces it as the outermost handler in the . - /// - /// - /// When null (the default), the handler is used as-is — no behaviour change - /// for existing consumers. - /// - /// - /// Only applies to . - /// Ignored by UseInMemoryCosmosDB<TClient>() (no HTTP pipeline). - /// - /// - public Func? HttpMessageHandlerWrapper { get; set; } + /// + /// Optional function that wraps the final + /// (the or multi-container router) before it is + /// passed to . + /// + /// Use this to insert a into the pipeline. + /// The input is the handler that serves in-memory responses; the return value + /// replaces it as the outermost handler in the . + /// + /// + /// When null (the default), the handler is used as-is — no behaviour change + /// for existing consumers. + /// + /// + /// Only applies to . + /// Ignored by UseInMemoryCosmosDB<TClient>() (no HTTP pipeline). + /// + /// + public Func? HttpMessageHandlerWrapper { get; set; } - /// - /// When set, each container automatically loads its state from this directory on creation - /// and saves its state back on disposal. Files are named {DatabaseName}_{ContainerName}.json. - /// If the directory or file does not exist, the container starts empty—state is saved on first disposal. - /// - /// This enables persisting container data between test runs without any manual - /// / calls. - /// - /// - public string? StatePersistenceDirectory { get; set; } + /// + /// When set, each container automatically loads its state from this directory on creation + /// and saves its state back on disposal. Files are named {DatabaseName}_{ContainerName}.json. + /// If the directory or file does not exist, the container starts empty—state is saved on first disposal. + /// + /// This enables persisting container data between test runs without any manual + /// / calls. + /// + /// + public string? StatePersistenceDirectory { get; set; } - /// - /// Adds a container configuration. - /// - public InMemoryCosmosOptions AddContainer( - string containerName, - string partitionKeyPath = "/id", - string? databaseName = null) - { - Containers.Add(new ContainerConfig(containerName, partitionKeyPath, databaseName)); - return this; - } + /// + /// Adds a container configuration. + /// + public InMemoryCosmosOptions AddContainer( + string containerName, + string partitionKeyPath = "/id", + string? databaseName = null) + { + Containers.Add(new ContainerConfig(containerName, partitionKeyPath, databaseName)); + return this; + } - /// - /// Adds a container configuration using full , - /// which supports UniqueKeyPolicy, DefaultTimeToLive, hierarchical partition keys, etc. - /// - public InMemoryCosmosOptions AddContainer( - ContainerProperties containerProperties, - string? databaseName = null) - { - string pkPath; - try { pkPath = containerProperties.PartitionKeyPath; } - catch (NotImplementedException) { pkPath = containerProperties.PartitionKeyPaths[0]; } + /// + /// Adds a container configuration using full , + /// which supports UniqueKeyPolicy, DefaultTimeToLive, hierarchical partition keys, etc. + /// + public InMemoryCosmosOptions AddContainer( + ContainerProperties containerProperties, + string? databaseName = null) + { + string pkPath; + try { pkPath = containerProperties.PartitionKeyPath; } + catch (NotImplementedException) { pkPath = containerProperties.PartitionKeyPaths[0]; } - Containers.Add(new ContainerConfig( - containerProperties.Id, - pkPath, - databaseName, - containerProperties)); - return this; - } + Containers.Add(new ContainerConfig( + containerProperties.Id, + pkPath, + databaseName, + containerProperties)); + return this; + } - /// - /// Sets to the specified function. - /// The function receives the (or multi-container - /// router) and must return the handler to use as the outermost handler in - /// the . - /// - public InMemoryCosmosOptions WithHttpMessageHandlerWrapper( - Func wrapper) - { - HttpMessageHandlerWrapper = wrapper; - return this; - } + /// + /// Sets to the specified function. + /// The function receives the (or multi-container + /// router) and must return the handler to use as the outermost handler in + /// the . + /// + public InMemoryCosmosOptions WithHttpMessageHandlerWrapper( + Func wrapper) + { + HttpMessageHandlerWrapper = wrapper; + return this; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryDatabase.cs b/src/CosmosDB.InMemoryEmulator/InMemoryDatabase.cs index 4d74642..7097681 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryDatabase.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryDatabase.cs @@ -25,492 +25,492 @@ namespace CosmosDB.InMemoryEmulator; /// internal class InMemoryDatabase : Database { - private readonly ConcurrentDictionary _containers = new(); - private readonly ConcurrentDictionary _explicitlyCreatedContainers = new(); - private readonly ConcurrentDictionary _users = new(); - private readonly InMemoryCosmosClient _client; - private int _throughput = 400; - - /// - /// Creates a new with no parent client. - /// - /// The database identifier. - public InMemoryDatabase(string id) : this(id, null) { } - - /// - /// Creates a new owned by the given . - /// - /// The database identifier. - /// The owning , or null. - public InMemoryDatabase(string id, InMemoryCosmosClient client) - { - Id = id; - _client = client; - } - - /// The database identifier. - public override string Id { get; } - - /// The owning , or null. - public override CosmosClient Client => _client; - - /// - /// Gets or creates an with the given identifier and partition key path. - /// Used internally by DI extensions and . - /// - /// The container identifier. - /// The JSON path to the partition key field (e.g. /partitionKey). - internal InMemoryContainer GetOrCreateContainer(string containerId, string partitionKeyPath = "/id") - { - var isNew = false; - var container = _containers.GetOrAdd(containerId, name => { isNew = true; return new InMemoryContainer(name, partitionKeyPath); }); - if (isNew) - container.ExplicitlyCreated = false; - container.OnDeleted ??= () => _containers.TryRemove(containerId, out _); - container.SetParentDatabase(Id); - return container; - } - - internal InMemoryContainer GetOrCreateContainer(ContainerProperties containerProperties) - { - var isNew = false; - var container = _containers.GetOrAdd(containerProperties.Id, _ => { isNew = true; return new InMemoryContainer(containerProperties); }); - if (isNew) - container.ExplicitlyCreated = false; - container.OnDeleted ??= () => _containers.TryRemove(containerProperties.Id, out _); - container.SetParentDatabase(Id); - return container; - } - - // ── CreateContainerIfNotExistsAsync ───────────────────────────────────── - - public override Task CreateContainerIfNotExistsAsync( - string id, string partitionKeyPath, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var created = false; - var container = _containers.GetOrAdd(id, name => { created = true; return new InMemoryContainer(name, partitionKeyPath); }); - // If the container was lazily created by GetContainer() with a default PK path, - // replace it with one that has the correct partition key path. - if (!created && !container.ExplicitlyCreated) - { - var replacement = new InMemoryContainer(id, partitionKeyPath); - replacement.OnDeleted = () => _containers.TryRemove(id, out _); - replacement.SetParentDatabase(Id); - replacement.ExplicitlyCreated = true; - _containers[id] = replacement; - container = replacement; - created = true; - } - container.OnDeleted ??= () => _containers.TryRemove(id, out _); - container.SetParentDatabase(Id); - container.ExplicitlyCreated = true; - if (throughput.HasValue) - container._throughput = throughput.Value; - _explicitlyCreatedContainers.TryAdd(id, true); - var response = BuildContainerResponse(container, partitionKeyPath, created ? HttpStatusCode.Created : HttpStatusCode.OK); - return Task.FromResult(response); - } - - public override Task CreateContainerIfNotExistsAsync( - ContainerProperties containerProperties, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var id = containerProperties.Id; - if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) - containerProperties.PartitionKeyPath = "/id"; - var created = false; - var container = _containers.GetOrAdd(id, _ => { created = true; return new InMemoryContainer(containerProperties); }); - // If the container was lazily created by GetContainer() with a default PK path, - // replace it with one that has the correct properties. - if (!created && !container.ExplicitlyCreated) - { - var replacement = new InMemoryContainer(containerProperties); - replacement.OnDeleted = () => _containers.TryRemove(id, out _); - replacement.SetParentDatabase(Id); - replacement.ExplicitlyCreated = true; - replacement.DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (containerProperties.IndexingPolicy is not null) - replacement.IndexingPolicy = containerProperties.IndexingPolicy; - _containers[id] = replacement; - container = replacement; - created = true; - } - container.OnDeleted ??= () => _containers.TryRemove(id, out _); - container.SetParentDatabase(Id); - container.ExplicitlyCreated = true; - if (throughput.HasValue) - container._throughput = throughput.Value; - _explicitlyCreatedContainers.TryAdd(id, true); - if (created) - { - container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (containerProperties.IndexingPolicy is not null) - container.IndexingPolicy = containerProperties.IndexingPolicy; - } - var response = BuildContainerResponse(container, containerProperties, created ? HttpStatusCode.Created : HttpStatusCode.OK); - return Task.FromResult(response); - } - - public override Task CreateContainerIfNotExistsAsync( - ContainerProperties containerProperties, ThroughputProperties throughputProperties, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return CreateContainerIfNotExistsAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); - } - - // ── CreateContainerAsync ──────────────────────────────────────────────── - - public override Task CreateContainerAsync( - string id, string partitionKeyPath, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(id); - ArgumentException.ThrowIfNullOrEmpty(id); - InMemoryCosmosClient.ValidateResourceName(id, "Container"); - ArgumentNullException.ThrowIfNull(partitionKeyPath); - var container = new InMemoryContainer(id, partitionKeyPath); - container.OnDeleted = () => _containers.TryRemove(id, out _); - container.SetParentDatabase(Id); - container.ExplicitlyCreated = true; - if (throughput.HasValue) - container._throughput = throughput.Value; - if (!_containers.TryAdd(id, container)) - { - // If the existing container was lazily created by GetContainer(), replace it. - // Real Cosmos DB: GetContainer() returns a lightweight proxy that doesn't create anything. - if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) - { - _containers[id] = container; - } - else - { - throw InMemoryCosmosException.Create("Container already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); - } - } - _explicitlyCreatedContainers.TryAdd(id, true); - var response = BuildContainerResponse(container, partitionKeyPath, HttpStatusCode.Created); - return Task.FromResult(response); - } - - public override Task CreateContainerAsync( - ContainerProperties containerProperties, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var id = containerProperties.Id; - InMemoryCosmosClient.ValidateResourceName(id, "Container"); - if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) - containerProperties.PartitionKeyPath = "/id"; - var container = new InMemoryContainer(containerProperties); - container.OnDeleted = () => _containers.TryRemove(id, out _); - container.SetParentDatabase(Id); - container.ExplicitlyCreated = true; - container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (throughput.HasValue) - container._throughput = throughput.Value; - if (containerProperties.IndexingPolicy is not null) - container.IndexingPolicy = containerProperties.IndexingPolicy; - if (!_containers.TryAdd(id, container)) - { - // If the existing container was lazily created by GetContainer(), replace it. - if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) - { - _containers[id] = container; - } - else - { - throw InMemoryCosmosException.Create("Container already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); - } - } - _explicitlyCreatedContainers.TryAdd(id, true); - var response = BuildContainerResponse(container, containerProperties, HttpStatusCode.Created); - return Task.FromResult(response); - } - - public override Task CreateContainerAsync( - ContainerProperties containerProperties, ThroughputProperties throughputProperties, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-collection - // Throughput can be specified via ThroughputProperties; extract the value. - return CreateContainerAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); - } - - // ── CreateContainerStreamAsync ────────────────────────────────────────── - - public override Task CreateContainerStreamAsync( - ContainerProperties containerProperties, int? throughput = null, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var id = containerProperties.Id; - if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) - containerProperties.PartitionKeyPath = "/id"; - var container = new InMemoryContainer(containerProperties); - container.OnDeleted = () => _containers.TryRemove(id, out _); - container.SetParentDatabase(Id); - container.ExplicitlyCreated = true; - container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; - if (throughput.HasValue) - container._throughput = throughput.Value; - if (containerProperties.IndexingPolicy is not null) - container.IndexingPolicy = containerProperties.IndexingPolicy; - if (!_containers.TryAdd(id, container)) - { - if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) - { - _containers[id] = container; - } - else - { - return Task.FromResult(CreateStreamResponse(HttpStatusCode.Conflict)); - } - } - _explicitlyCreatedContainers.TryAdd(id, true); - return Task.FromResult(CreateStreamResponse(HttpStatusCode.Created)); - } - - public override Task CreateContainerStreamAsync( - ContainerProperties containerProperties, ThroughputProperties throughputProperties, - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return CreateContainerStreamAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); - } - - // ── GetContainer ──────────────────────────────────────────────────────── - - public override Container GetContainer(string id) - { - return GetOrCreateContainer(id); - } - - internal bool IsContainerExplicitlyCreated(string id) - { - return _explicitlyCreatedContainers.ContainsKey(id); - } - - internal IEnumerable GetAllContainers() => _containers.Values; - - // ── GetContainerQueryIterator ─────────────────────────────────────────── - - public override FeedIterator GetContainerQueryIterator( - string queryText = null, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - var offset = int.TryParse(continuationToken, out var o) ? o : 0; - IEnumerable items = _containers.Values - .Select(c => new ContainerProperties(c.Id, c.PartitionKeyPaths)); - var idFilter = InMemoryCosmosClient.ExtractIdFilter(queryText); - if (idFilter is not null) - items = items.Where(cp => string.Equals(cp.Id, idFilter, StringComparison.Ordinal)); - - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-collections - // SELECT VALUE queries project scalar values (e.g. container IDs as strings). - if (typeof(T) != typeof(ContainerProperties) && IsSelectValueIdQuery(queryText)) - { - var ids = items.Select(cp => (T)(object)cp.Id).ToList(); - return new InMemoryFeedIterator(ids, requestOptions?.MaxItemCount, offset); - } - - return new InMemoryFeedIterator(items.Select(cp => (T)(object)cp).ToList(), requestOptions?.MaxItemCount, offset); - } - - private static bool IsSelectValueIdQuery(string queryText) - { - if (string.IsNullOrWhiteSpace(queryText)) - return false; - // Matches patterns like: SELECT VALUE c.id, SELECT VALUE(c.id), SELECT VALUE c["id"] - return Regex.IsMatch(queryText, @"SELECT\s+VALUE\s*\(?.*\.id\)?", RegexOptions.IgnoreCase); - } - - public override FeedIterator GetContainerQueryIterator( - QueryDefinition queryDefinition, string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return GetContainerQueryIterator(queryDefinition?.QueryText, continuationToken, requestOptions); - } - - // ── Read / Delete ─────────────────────────────────────────────────────── - - public override Task ReadAsync( - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (_client != null && !_client.IsDatabaseExplicitlyCreated(Id)) - { - throw InMemoryCosmosException.Create($"Database '{Id}' not found.", HttpStatusCode.NotFound, 1003, Guid.NewGuid().ToString(), 0); - } - var response = Substitute.For(); - response.Database.Returns(this); - response.StatusCode.Returns(HttpStatusCode.OK); - response.Resource.Returns(new DatabaseProperties(Id)); - return Task.FromResult(response); - } - - public override Task ReadStreamAsync( - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(CreateStreamResponse(HttpStatusCode.OK)); - } - - public override Task DeleteAsync( - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - foreach (var container in _containers.Values) - container.DeleteContainerAsync().GetAwaiter().GetResult(); - _containers.Clear(); - _explicitlyCreatedContainers.Clear(); - _users.Clear(); - _client?.RemoveDatabase(Id); - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.NoContent); - return Task.FromResult(response); - } - - public override Task DeleteStreamAsync( - RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - foreach (var container in _containers.Values) - container.DeleteContainerAsync().GetAwaiter().GetResult(); - _containers.Clear(); - _explicitlyCreatedContainers.Clear(); - _users.Clear(); - _client?.RemoveDatabase(Id); - return Task.FromResult(CreateStreamResponse(HttpStatusCode.NoContent)); - } - - private static ResponseMessage CreateStreamResponse(HttpStatusCode statusCode) - { - var msg = new ResponseMessage(statusCode); - msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - msg.Headers["x-ms-request-charge"] = "1"; - msg.Headers["x-ms-session-token"] = "0:0#0"; - return msg; - } - - // ── Response builder (reuses NSubstitute pattern from BuildDatabaseResponse) ─ - - private static ContainerResponse BuildContainerResponse(Container container, string partitionKeyPath, HttpStatusCode statusCode) - { - var response = Substitute.For(); - response.Container.Returns(container); - response.StatusCode.Returns(statusCode); - response.Resource.Returns(new ContainerProperties(container.Id, partitionKeyPath ?? "/id")); - return response; - } - - private static ContainerResponse BuildContainerResponse(Container container, ContainerProperties properties, HttpStatusCode statusCode) - { - var response = Substitute.For(); - response.Container.Returns(container); - response.StatusCode.Returns(statusCode); - response.Resource.Returns(properties); - return response; - } - - // ── Throughput (not meaningful for in-memory, but returns sensible defaults) ─ - - public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) - => Task.FromResult(_throughput); - - public override Task ReadThroughputAsync(RequestOptions requestOptions, CancellationToken cancellationToken = default) - { - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.OK); - response.Resource.Returns(ThroughputProperties.CreateManualThroughput(_throughput)); - return Task.FromResult(response); - } - - public override Task ReplaceThroughputAsync(int throughput, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - _throughput = throughput; - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.OK); - response.Resource.Returns(ThroughputProperties.CreateManualThroughput(throughput)); - return Task.FromResult(response); - } - - public override Task ReplaceThroughputAsync(ThroughputProperties throughputProperties, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - if (throughputProperties?.Throughput.HasValue == true) - _throughput = throughputProperties.Throughput.Value; - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.OK); - response.Resource.Returns(throughputProperties); - return Task.FromResult(response); - } - - // ── Stream query iterators ────────────────────────────────────────────── - - public override FeedIterator GetContainerQueryStreamIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => GetContainerQueryStreamIterator((string)null, continuationToken, requestOptions); - - public override FeedIterator GetContainerQueryStreamIterator(string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - { - return new InMemoryStreamFeedIterator( - () => _containers.Values - .Select(c => (object)new { id = c.Id }) - .ToList(), - "DocumentCollections"); - } - - // ── DefineContainer (fluent builder) ──────────────────────────────────── - - public override ContainerBuilder DefineContainer(string name, string partitionKeyPath) - => new ContainerBuilder(this, name, partitionKeyPath); - - // ── User management (stub store — no authorization enforced) ─────────── - - public override User GetUser(string id) - { - if (_users.TryGetValue(id, out var existing)) - return existing; - - // Return a proxy that is NOT registered in _users. - // ReadAsync will check _users and throw 404 if not explicitly created. - return new InMemoryUser(id, () => _users.TryRemove(id, out _), _users); - } - - public override Task CreateUserAsync(string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var user = new InMemoryUser(id, () => _users.TryRemove(id, out _)); - if (!_users.TryAdd(id, user)) - throw InMemoryCosmosException.Create($"User '{id}' already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); - - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.Created); - response.Resource.Returns(new UserProperties(id)); - response.User.Returns(user); - return Task.FromResult(response); - } - - public override Task UpsertUserAsync(string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - { - var created = false; - var user = _users.GetOrAdd(id, uid => { created = true; return new InMemoryUser(uid, () => _users.TryRemove(uid, out _)); }); - - var response = Substitute.For(); - response.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK); - response.Resource.Returns(new UserProperties(id)); - response.User.Returns(user); - return Task.FromResult(response); - } - - public override FeedIterator GetUserQueryIterator(string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) - { - return new InMemoryFeedIterator( - () => _users.Values - .Select(u => (T)(object)new UserProperties(u.Id)) - .ToList()); - } - - public override FeedIterator GetUserQueryIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - { - return GetUserQueryIterator((string)null, continuationToken, requestOptions); - } - - // ── Not implemented overrides (encryption) ────────────────────────────── - public override ClientEncryptionKey GetClientEncryptionKey(string id) => throw new System.NotImplementedException(); - public override FeedIterator GetClientEncryptionKeyQueryIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) - => throw new System.NotImplementedException(); - public override Task CreateClientEncryptionKeyAsync(ClientEncryptionKeyProperties clientEncryptionKeyProperties, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) - => throw new System.NotImplementedException(); + private readonly ConcurrentDictionary _containers = new(); + private readonly ConcurrentDictionary _explicitlyCreatedContainers = new(); + private readonly ConcurrentDictionary _users = new(); + private readonly InMemoryCosmosClient _client; + private int _throughput = 400; + + /// + /// Creates a new with no parent client. + /// + /// The database identifier. + public InMemoryDatabase(string id) : this(id, null) { } + + /// + /// Creates a new owned by the given . + /// + /// The database identifier. + /// The owning , or null. + public InMemoryDatabase(string id, InMemoryCosmosClient client) + { + Id = id; + _client = client; + } + + /// The database identifier. + public override string Id { get; } + + /// The owning , or null. + public override CosmosClient Client => _client; + + /// + /// Gets or creates an with the given identifier and partition key path. + /// Used internally by DI extensions and . + /// + /// The container identifier. + /// The JSON path to the partition key field (e.g. /partitionKey). + internal InMemoryContainer GetOrCreateContainer(string containerId, string partitionKeyPath = "/id") + { + var isNew = false; + var container = _containers.GetOrAdd(containerId, name => { isNew = true; return new InMemoryContainer(name, partitionKeyPath); }); + if (isNew) + container.ExplicitlyCreated = false; + container.OnDeleted ??= () => _containers.TryRemove(containerId, out _); + container.SetParentDatabase(Id); + return container; + } + + internal InMemoryContainer GetOrCreateContainer(ContainerProperties containerProperties) + { + var isNew = false; + var container = _containers.GetOrAdd(containerProperties.Id, _ => { isNew = true; return new InMemoryContainer(containerProperties); }); + if (isNew) + container.ExplicitlyCreated = false; + container.OnDeleted ??= () => _containers.TryRemove(containerProperties.Id, out _); + container.SetParentDatabase(Id); + return container; + } + + // ── CreateContainerIfNotExistsAsync ───────────────────────────────────── + + public override Task CreateContainerIfNotExistsAsync( + string id, string partitionKeyPath, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var created = false; + var container = _containers.GetOrAdd(id, name => { created = true; return new InMemoryContainer(name, partitionKeyPath); }); + // If the container was lazily created by GetContainer() with a default PK path, + // replace it with one that has the correct partition key path. + if (!created && !container.ExplicitlyCreated) + { + var replacement = new InMemoryContainer(id, partitionKeyPath); + replacement.OnDeleted = () => _containers.TryRemove(id, out _); + replacement.SetParentDatabase(Id); + replacement.ExplicitlyCreated = true; + _containers[id] = replacement; + container = replacement; + created = true; + } + container.OnDeleted ??= () => _containers.TryRemove(id, out _); + container.SetParentDatabase(Id); + container.ExplicitlyCreated = true; + if (throughput.HasValue) + container._throughput = throughput.Value; + _explicitlyCreatedContainers.TryAdd(id, true); + var response = BuildContainerResponse(container, partitionKeyPath, created ? HttpStatusCode.Created : HttpStatusCode.OK); + return Task.FromResult(response); + } + + public override Task CreateContainerIfNotExistsAsync( + ContainerProperties containerProperties, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var id = containerProperties.Id; + if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) + containerProperties.PartitionKeyPath = "/id"; + var created = false; + var container = _containers.GetOrAdd(id, _ => { created = true; return new InMemoryContainer(containerProperties); }); + // If the container was lazily created by GetContainer() with a default PK path, + // replace it with one that has the correct properties. + if (!created && !container.ExplicitlyCreated) + { + var replacement = new InMemoryContainer(containerProperties); + replacement.OnDeleted = () => _containers.TryRemove(id, out _); + replacement.SetParentDatabase(Id); + replacement.ExplicitlyCreated = true; + replacement.DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (containerProperties.IndexingPolicy is not null) + replacement.IndexingPolicy = containerProperties.IndexingPolicy; + _containers[id] = replacement; + container = replacement; + created = true; + } + container.OnDeleted ??= () => _containers.TryRemove(id, out _); + container.SetParentDatabase(Id); + container.ExplicitlyCreated = true; + if (throughput.HasValue) + container._throughput = throughput.Value; + _explicitlyCreatedContainers.TryAdd(id, true); + if (created) + { + container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (containerProperties.IndexingPolicy is not null) + container.IndexingPolicy = containerProperties.IndexingPolicy; + } + var response = BuildContainerResponse(container, containerProperties, created ? HttpStatusCode.Created : HttpStatusCode.OK); + return Task.FromResult(response); + } + + public override Task CreateContainerIfNotExistsAsync( + ContainerProperties containerProperties, ThroughputProperties throughputProperties, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return CreateContainerIfNotExistsAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); + } + + // ── CreateContainerAsync ──────────────────────────────────────────────── + + public override Task CreateContainerAsync( + string id, string partitionKeyPath, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(id); + ArgumentException.ThrowIfNullOrEmpty(id); + InMemoryCosmosClient.ValidateResourceName(id, "Container"); + ArgumentNullException.ThrowIfNull(partitionKeyPath); + var container = new InMemoryContainer(id, partitionKeyPath); + container.OnDeleted = () => _containers.TryRemove(id, out _); + container.SetParentDatabase(Id); + container.ExplicitlyCreated = true; + if (throughput.HasValue) + container._throughput = throughput.Value; + if (!_containers.TryAdd(id, container)) + { + // If the existing container was lazily created by GetContainer(), replace it. + // Real Cosmos DB: GetContainer() returns a lightweight proxy that doesn't create anything. + if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) + { + _containers[id] = container; + } + else + { + throw InMemoryCosmosException.Create("Container already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); + } + } + _explicitlyCreatedContainers.TryAdd(id, true); + var response = BuildContainerResponse(container, partitionKeyPath, HttpStatusCode.Created); + return Task.FromResult(response); + } + + public override Task CreateContainerAsync( + ContainerProperties containerProperties, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var id = containerProperties.Id; + InMemoryCosmosClient.ValidateResourceName(id, "Container"); + if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) + containerProperties.PartitionKeyPath = "/id"; + var container = new InMemoryContainer(containerProperties); + container.OnDeleted = () => _containers.TryRemove(id, out _); + container.SetParentDatabase(Id); + container.ExplicitlyCreated = true; + container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (throughput.HasValue) + container._throughput = throughput.Value; + if (containerProperties.IndexingPolicy is not null) + container.IndexingPolicy = containerProperties.IndexingPolicy; + if (!_containers.TryAdd(id, container)) + { + // If the existing container was lazily created by GetContainer(), replace it. + if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) + { + _containers[id] = container; + } + else + { + throw InMemoryCosmosException.Create("Container already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); + } + } + _explicitlyCreatedContainers.TryAdd(id, true); + var response = BuildContainerResponse(container, containerProperties, HttpStatusCode.Created); + return Task.FromResult(response); + } + + public override Task CreateContainerAsync( + ContainerProperties containerProperties, ThroughputProperties throughputProperties, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-collection + // Throughput can be specified via ThroughputProperties; extract the value. + return CreateContainerAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); + } + + // ── CreateContainerStreamAsync ────────────────────────────────────────── + + public override Task CreateContainerStreamAsync( + ContainerProperties containerProperties, int? throughput = null, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var id = containerProperties.Id; + if (string.IsNullOrEmpty(containerProperties.PartitionKeyPath) && containerProperties.PartitionKeyPaths is null) + containerProperties.PartitionKeyPath = "/id"; + var container = new InMemoryContainer(containerProperties); + container.OnDeleted = () => _containers.TryRemove(id, out _); + container.SetParentDatabase(Id); + container.ExplicitlyCreated = true; + container.DefaultTimeToLive = containerProperties.DefaultTimeToLive; + if (throughput.HasValue) + container._throughput = throughput.Value; + if (containerProperties.IndexingPolicy is not null) + container.IndexingPolicy = containerProperties.IndexingPolicy; + if (!_containers.TryAdd(id, container)) + { + if (_containers.TryGetValue(id, out var existing) && !existing.ExplicitlyCreated) + { + _containers[id] = container; + } + else + { + return Task.FromResult(CreateStreamResponse(HttpStatusCode.Conflict)); + } + } + _explicitlyCreatedContainers.TryAdd(id, true); + return Task.FromResult(CreateStreamResponse(HttpStatusCode.Created)); + } + + public override Task CreateContainerStreamAsync( + ContainerProperties containerProperties, ThroughputProperties throughputProperties, + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return CreateContainerStreamAsync(containerProperties, throughputProperties?.Throughput, requestOptions, cancellationToken); + } + + // ── GetContainer ──────────────────────────────────────────────────────── + + public override Container GetContainer(string id) + { + return GetOrCreateContainer(id); + } + + internal bool IsContainerExplicitlyCreated(string id) + { + return _explicitlyCreatedContainers.ContainsKey(id); + } + + internal IEnumerable GetAllContainers() => _containers.Values; + + // ── GetContainerQueryIterator ─────────────────────────────────────────── + + public override FeedIterator GetContainerQueryIterator( + string queryText = null, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + var offset = int.TryParse(continuationToken, out var o) ? o : 0; + IEnumerable items = _containers.Values + .Select(c => new ContainerProperties(c.Id, c.PartitionKeyPaths)); + var idFilter = InMemoryCosmosClient.ExtractIdFilter(queryText); + if (idFilter is not null) + items = items.Where(cp => string.Equals(cp.Id, idFilter, StringComparison.Ordinal)); + + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/list-collections + // SELECT VALUE queries project scalar values (e.g. container IDs as strings). + if (typeof(T) != typeof(ContainerProperties) && IsSelectValueIdQuery(queryText)) + { + var ids = items.Select(cp => (T)(object)cp.Id).ToList(); + return new InMemoryFeedIterator(ids, requestOptions?.MaxItemCount, offset); + } + + return new InMemoryFeedIterator(items.Select(cp => (T)(object)cp).ToList(), requestOptions?.MaxItemCount, offset); + } + + private static bool IsSelectValueIdQuery(string queryText) + { + if (string.IsNullOrWhiteSpace(queryText)) + return false; + // Matches patterns like: SELECT VALUE c.id, SELECT VALUE(c.id), SELECT VALUE c["id"] + return Regex.IsMatch(queryText, @"SELECT\s+VALUE\s*\(?.*\.id\)?", RegexOptions.IgnoreCase); + } + + public override FeedIterator GetContainerQueryIterator( + QueryDefinition queryDefinition, string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return GetContainerQueryIterator(queryDefinition?.QueryText, continuationToken, requestOptions); + } + + // ── Read / Delete ─────────────────────────────────────────────────────── + + public override Task ReadAsync( + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (_client != null && !_client.IsDatabaseExplicitlyCreated(Id)) + { + throw InMemoryCosmosException.Create($"Database '{Id}' not found.", HttpStatusCode.NotFound, 1003, Guid.NewGuid().ToString(), 0); + } + var response = Substitute.For(); + response.Database.Returns(this); + response.StatusCode.Returns(HttpStatusCode.OK); + response.Resource.Returns(new DatabaseProperties(Id)); + return Task.FromResult(response); + } + + public override Task ReadStreamAsync( + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(CreateStreamResponse(HttpStatusCode.OK)); + } + + public override Task DeleteAsync( + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + foreach (var container in _containers.Values) + container.DeleteContainerAsync().GetAwaiter().GetResult(); + _containers.Clear(); + _explicitlyCreatedContainers.Clear(); + _users.Clear(); + _client?.RemoveDatabase(Id); + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.NoContent); + return Task.FromResult(response); + } + + public override Task DeleteStreamAsync( + RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + foreach (var container in _containers.Values) + container.DeleteContainerAsync().GetAwaiter().GetResult(); + _containers.Clear(); + _explicitlyCreatedContainers.Clear(); + _users.Clear(); + _client?.RemoveDatabase(Id); + return Task.FromResult(CreateStreamResponse(HttpStatusCode.NoContent)); + } + + private static ResponseMessage CreateStreamResponse(HttpStatusCode statusCode) + { + var msg = new ResponseMessage(statusCode); + msg.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + msg.Headers["x-ms-request-charge"] = "1"; + msg.Headers["x-ms-session-token"] = "0:0#0"; + return msg; + } + + // ── Response builder (reuses NSubstitute pattern from BuildDatabaseResponse) ─ + + private static ContainerResponse BuildContainerResponse(Container container, string partitionKeyPath, HttpStatusCode statusCode) + { + var response = Substitute.For(); + response.Container.Returns(container); + response.StatusCode.Returns(statusCode); + response.Resource.Returns(new ContainerProperties(container.Id, partitionKeyPath ?? "/id")); + return response; + } + + private static ContainerResponse BuildContainerResponse(Container container, ContainerProperties properties, HttpStatusCode statusCode) + { + var response = Substitute.For(); + response.Container.Returns(container); + response.StatusCode.Returns(statusCode); + response.Resource.Returns(properties); + return response; + } + + // ── Throughput (not meaningful for in-memory, but returns sensible defaults) ─ + + public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) + => Task.FromResult(_throughput); + + public override Task ReadThroughputAsync(RequestOptions requestOptions, CancellationToken cancellationToken = default) + { + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.OK); + response.Resource.Returns(ThroughputProperties.CreateManualThroughput(_throughput)); + return Task.FromResult(response); + } + + public override Task ReplaceThroughputAsync(int throughput, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + _throughput = throughput; + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.OK); + response.Resource.Returns(ThroughputProperties.CreateManualThroughput(throughput)); + return Task.FromResult(response); + } + + public override Task ReplaceThroughputAsync(ThroughputProperties throughputProperties, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + if (throughputProperties?.Throughput.HasValue == true) + _throughput = throughputProperties.Throughput.Value; + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.OK); + response.Resource.Returns(throughputProperties); + return Task.FromResult(response); + } + + // ── Stream query iterators ────────────────────────────────────────────── + + public override FeedIterator GetContainerQueryStreamIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => GetContainerQueryStreamIterator((string)null, continuationToken, requestOptions); + + public override FeedIterator GetContainerQueryStreamIterator(string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + { + return new InMemoryStreamFeedIterator( + () => _containers.Values + .Select(c => (object)new { id = c.Id }) + .ToList(), + "DocumentCollections"); + } + + // ── DefineContainer (fluent builder) ──────────────────────────────────── + + public override ContainerBuilder DefineContainer(string name, string partitionKeyPath) + => new ContainerBuilder(this, name, partitionKeyPath); + + // ── User management (stub store — no authorization enforced) ─────────── + + public override User GetUser(string id) + { + if (_users.TryGetValue(id, out var existing)) + return existing; + + // Return a proxy that is NOT registered in _users. + // ReadAsync will check _users and throw 404 if not explicitly created. + return new InMemoryUser(id, () => _users.TryRemove(id, out _), _users); + } + + public override Task CreateUserAsync(string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var user = new InMemoryUser(id, () => _users.TryRemove(id, out _)); + if (!_users.TryAdd(id, user)) + throw InMemoryCosmosException.Create($"User '{id}' already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); + + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.Created); + response.Resource.Returns(new UserProperties(id)); + response.User.Returns(user); + return Task.FromResult(response); + } + + public override Task UpsertUserAsync(string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + { + var created = false; + var user = _users.GetOrAdd(id, uid => { created = true; return new InMemoryUser(uid, () => _users.TryRemove(uid, out _)); }); + + var response = Substitute.For(); + response.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK); + response.Resource.Returns(new UserProperties(id)); + response.User.Returns(user); + return Task.FromResult(response); + } + + public override FeedIterator GetUserQueryIterator(string queryText = null, string continuationToken = null, QueryRequestOptions requestOptions = null) + { + return new InMemoryFeedIterator( + () => _users.Values + .Select(u => (T)(object)new UserProperties(u.Id)) + .ToList()); + } + + public override FeedIterator GetUserQueryIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + { + return GetUserQueryIterator((string)null, continuationToken, requestOptions); + } + + // ── Not implemented overrides (encryption) ────────────────────────────── + public override ClientEncryptionKey GetClientEncryptionKey(string id) => throw new System.NotImplementedException(); + public override FeedIterator GetClientEncryptionKeyQueryIterator(QueryDefinition queryDefinition, string continuationToken = null, QueryRequestOptions requestOptions = null) + => throw new System.NotImplementedException(); + public override Task CreateClientEncryptionKeyAsync(ClientEncryptionKeyProperties clientEncryptionKeyProperties, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) + => throw new System.NotImplementedException(); } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryFeedIterator.cs b/src/CosmosDB.InMemoryEmulator/InMemoryFeedIterator.cs index 4effcdd..2ed99b1 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryFeedIterator.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryFeedIterator.cs @@ -19,144 +19,144 @@ namespace CosmosDB.InMemoryEmulator; /// The item type returned by each page. public class InMemoryFeedIterator : FeedIterator { - private IReadOnlyList _items; - private readonly Func> _factory; - private readonly int? _maxItemCount; - private int _offset; - private bool _hasReadFirstPage; - - /// - /// Creates a feed iterator from a pre-computed list of items. - /// - /// The complete list of items to paginate. - /// Maximum items per page. If null, all items are returned in one page. - /// The starting offset (for continuation token support). - public InMemoryFeedIterator(IReadOnlyList items, int? maxItemCount = null, int initialOffset = 0) - { - _items = items; - _maxItemCount = maxItemCount; - _offset = initialOffset; - } - - /// - /// Creates a feed iterator from an enumerable source, materialising it eagerly. - /// - /// The items to paginate. - /// Maximum items per page. - public InMemoryFeedIterator(IEnumerable source, int? maxItemCount = null) - { - _items = Materialize(source); - _maxItemCount = maxItemCount; - } - - /// - /// Creates a feed iterator with deferred evaluation. The factory is called - /// on the first access to produce the list of items. - /// - /// A factory that produces the items on first access. - /// Maximum items per page. - public InMemoryFeedIterator(Func> factory, int? maxItemCount = null) - { - _factory = factory; - _maxItemCount = maxItemCount; - } - - private static IReadOnlyList Materialize(IEnumerable source) - { - try - { - return (source as IReadOnlyList) ?? source.ToList(); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to materialize InMemoryFeedIterator<{typeof(T).Name}> source enumerable. " + - "This may indicate an error in the LINQ query or the underlying data source.", ex); - } - } - - private int PageSize => _maxItemCount is > 0 ? _maxItemCount.Value : EnsureItems().Count; - - /// - /// When true, returns a synthetic stub - /// instead of null, matching real Cosmos DB behaviour when PopulateIndexMetrics is requested. - /// - public bool PopulateIndexMetrics { get; set; } - - /// - /// When true, returns true before the first page is read, - /// even when the result set is empty. This matches real Cosmos DB query iterator behavior. - /// - public bool GuaranteeFirstPage { get; set; } - - public override bool HasMoreResults => (GuaranteeFirstPage && !_hasReadFirstPage) || _offset < EnsureItems().Count; - - public override Task> ReadNextAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - _hasReadFirstPage = true; - var items = EnsureItems(); - var page = items.Skip(_offset).Take(PageSize).ToList(); - _offset += page.Count; - if (_offset >= items.Count) - { - _offset = items.Count; - } - - var continuationToken = _offset < items.Count ? _offset.ToString() : null; - return Task.FromResult>(new InMemoryFeedResponse(page, continuationToken, PopulateIndexMetrics)); - } - - private IReadOnlyList EnsureItems() - { - if (_items == null && _factory != null) - { - _items = _factory(); - } - return _items ?? Array.Empty(); - } - - private static readonly CosmosDiagnostics FakeDiagnostics = new InMemoryFeedDiagnostics(); - - private sealed class InMemoryFeedDiagnostics : CosmosDiagnostics - { - public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; - public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); - public override string ToString() => "{}"; - } - - private sealed class InMemoryFeedResponse : FeedResponse - { - private readonly IReadOnlyList _items; - private readonly bool _populateIndexMetrics; - - public InMemoryFeedResponse(IReadOnlyList items, string continuationToken = null, bool populateIndexMetrics = false) - { - _items = items; - _populateIndexMetrics = populateIndexMetrics; - ContinuationToken = continuationToken; - ActivityId = Guid.NewGuid().ToString(); - var headers = new Headers(); - headers["x-ms-activity-id"] = ActivityId; - headers["x-ms-request-charge"] = RequestCharge.ToString(System.Globalization.CultureInfo.InvariantCulture); - headers["x-ms-item-count"] = items.Count.ToString(); - if (continuationToken != null) headers["x-ms-continuation"] = continuationToken; - Headers = headers; - } - - public override Headers Headers { get; } - public override IEnumerable Resource => _items; - public override HttpStatusCode StatusCode => HttpStatusCode.OK; - public override CosmosDiagnostics Diagnostics => FakeDiagnostics; - public override int Count => _items.Count; - public override string IndexMetrics => _populateIndexMetrics - ? "{\"UtilizedSingleIndexes\":[],\"PotentialSingleIndexes\":[],\"UtilizedCompositeIndexes\":[],\"PotentialCompositeIndexes\":[]}" - : null!; - public override string ContinuationToken { get; } - public override double RequestCharge => 1; - public override string ActivityId { get; } - public override string ETag => null!; - - public override IEnumerator GetEnumerator() => _items.GetEnumerator(); - } + private IReadOnlyList _items; + private readonly Func> _factory; + private readonly int? _maxItemCount; + private int _offset; + private bool _hasReadFirstPage; + + /// + /// Creates a feed iterator from a pre-computed list of items. + /// + /// The complete list of items to paginate. + /// Maximum items per page. If null, all items are returned in one page. + /// The starting offset (for continuation token support). + public InMemoryFeedIterator(IReadOnlyList items, int? maxItemCount = null, int initialOffset = 0) + { + _items = items; + _maxItemCount = maxItemCount; + _offset = initialOffset; + } + + /// + /// Creates a feed iterator from an enumerable source, materialising it eagerly. + /// + /// The items to paginate. + /// Maximum items per page. + public InMemoryFeedIterator(IEnumerable source, int? maxItemCount = null) + { + _items = Materialize(source); + _maxItemCount = maxItemCount; + } + + /// + /// Creates a feed iterator with deferred evaluation. The factory is called + /// on the first access to produce the list of items. + /// + /// A factory that produces the items on first access. + /// Maximum items per page. + public InMemoryFeedIterator(Func> factory, int? maxItemCount = null) + { + _factory = factory; + _maxItemCount = maxItemCount; + } + + private static IReadOnlyList Materialize(IEnumerable source) + { + try + { + return (source as IReadOnlyList) ?? source.ToList(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to materialize InMemoryFeedIterator<{typeof(T).Name}> source enumerable. " + + "This may indicate an error in the LINQ query or the underlying data source.", ex); + } + } + + private int PageSize => _maxItemCount is > 0 ? _maxItemCount.Value : EnsureItems().Count; + + /// + /// When true, returns a synthetic stub + /// instead of null, matching real Cosmos DB behaviour when PopulateIndexMetrics is requested. + /// + public bool PopulateIndexMetrics { get; set; } + + /// + /// When true, returns true before the first page is read, + /// even when the result set is empty. This matches real Cosmos DB query iterator behavior. + /// + public bool GuaranteeFirstPage { get; set; } + + public override bool HasMoreResults => (GuaranteeFirstPage && !_hasReadFirstPage) || _offset < EnsureItems().Count; + + public override Task> ReadNextAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _hasReadFirstPage = true; + var items = EnsureItems(); + var page = items.Skip(_offset).Take(PageSize).ToList(); + _offset += page.Count; + if (_offset >= items.Count) + { + _offset = items.Count; + } + + var continuationToken = _offset < items.Count ? _offset.ToString() : null; + return Task.FromResult>(new InMemoryFeedResponse(page, continuationToken, PopulateIndexMetrics)); + } + + private IReadOnlyList EnsureItems() + { + if (_items == null && _factory != null) + { + _items = _factory(); + } + return _items ?? Array.Empty(); + } + + private static readonly CosmosDiagnostics FakeDiagnostics = new InMemoryFeedDiagnostics(); + + private sealed class InMemoryFeedDiagnostics : CosmosDiagnostics + { + public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; + public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); + public override string ToString() => "{}"; + } + + private sealed class InMemoryFeedResponse : FeedResponse + { + private readonly IReadOnlyList _items; + private readonly bool _populateIndexMetrics; + + public InMemoryFeedResponse(IReadOnlyList items, string continuationToken = null, bool populateIndexMetrics = false) + { + _items = items; + _populateIndexMetrics = populateIndexMetrics; + ContinuationToken = continuationToken; + ActivityId = Guid.NewGuid().ToString(); + var headers = new Headers(); + headers["x-ms-activity-id"] = ActivityId; + headers["x-ms-request-charge"] = RequestCharge.ToString(System.Globalization.CultureInfo.InvariantCulture); + headers["x-ms-item-count"] = items.Count.ToString(); + if (continuationToken != null) headers["x-ms-continuation"] = continuationToken; + Headers = headers; + } + + public override Headers Headers { get; } + public override IEnumerable Resource => _items; + public override HttpStatusCode StatusCode => HttpStatusCode.OK; + public override CosmosDiagnostics Diagnostics => FakeDiagnostics; + public override int Count => _items.Count; + public override string IndexMetrics => _populateIndexMetrics + ? "{\"UtilizedSingleIndexes\":[],\"PotentialSingleIndexes\":[],\"UtilizedCompositeIndexes\":[],\"PotentialCompositeIndexes\":[]}" + : null!; + public override string ContinuationToken { get; } + public override double RequestCharge => 1; + public override string ActivityId { get; } + public override string ETag => null!; + + public override IEnumerator GetEnumerator() => _items.GetEnumerator(); + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryFeedIteratorSetup.cs b/src/CosmosDB.InMemoryEmulator/InMemoryFeedIteratorSetup.cs index dc47daf..b74b09d 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryFeedIteratorSetup.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryFeedIteratorSetup.cs @@ -33,72 +33,72 @@ namespace CosmosDB.InMemoryEmulator; /// public static class InMemoryFeedIteratorSetup { - private static readonly MethodInfo CreateMethod = typeof(InMemoryFeedIteratorSetup) - .GetMethod(nameof(CreateInMemoryFeedIterator), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo CreateMethod = typeof(InMemoryFeedIteratorSetup) + .GetMethod(nameof(CreateInMemoryFeedIterator), BindingFlags.NonPublic | BindingFlags.Static)!; - private static readonly ConcurrentDictionary MethodCache = new(); + private static readonly ConcurrentDictionary MethodCache = new(); - /// - /// Registers the in-memory feed iterator factory so that - /// .ToFeedIteratorOverridable() returns an - /// backed by LINQ-to-Objects rather than requiring a real Cosmos connection. - /// Call this once during test fixture initialisation. - /// - public static void Register() - { - Func factory = queryable => - { - var queryableType = queryable.GetType(); - var elementType = queryableType - .GetInterfaces() - .Concat([queryableType]) - .Where(interfaceType => interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IQueryable<>)) - .Select(interfaceType => interfaceType.GetGenericArguments()[0]) - .FirstOrDefault(); + /// + /// Registers the in-memory feed iterator factory so that + /// .ToFeedIteratorOverridable() returns an + /// backed by LINQ-to-Objects rather than requiring a real Cosmos connection. + /// Call this once during test fixture initialisation. + /// + public static void Register() + { + Func factory = queryable => + { + var queryableType = queryable.GetType(); + var elementType = queryableType + .GetInterfaces() + .Concat([queryableType]) + .Where(interfaceType => interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IQueryable<>)) + .Select(interfaceType => interfaceType.GetGenericArguments()[0]) + .FirstOrDefault(); - if (elementType is null) - { - throw new InvalidOperationException( - $"Cannot create InMemoryFeedIterator: the queryable type '{queryableType.FullName}' " + - "does not implement IQueryable. Ensure you are passing a valid LINQ queryable."); - } + if (elementType is null) + { + throw new InvalidOperationException( + $"Cannot create InMemoryFeedIterator: the queryable type '{queryableType.FullName}' " + + "does not implement IQueryable. Ensure you are passing a valid LINQ queryable."); + } - var method = MethodCache.GetOrAdd(elementType, type => CreateMethod.MakeGenericMethod(type)); - return method.Invoke(null, [queryable])!; - }; + var method = MethodCache.GetOrAdd(elementType, type => CreateMethod.MakeGenericMethod(type)); + return method.Invoke(null, [queryable])!; + }; - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory = factory; - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory = factory; - } + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory = factory; + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory = factory; + } - /// - /// Clears both the AsyncLocal and static fallback factories, reverting - /// .ToFeedIteratorOverridable() to its default production behaviour - /// (delegating to the real Cosmos SDK's .ToFeedIterator()). - /// - public static void Deregister() - { - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory = null; - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory = null; - MethodCache.Clear(); - } + /// + /// Clears both the AsyncLocal and static fallback factories, reverting + /// .ToFeedIteratorOverridable() to its default production behaviour + /// (delegating to the real Cosmos SDK's .ToFeedIterator()). + /// + public static void Deregister() + { + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory = null; + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory = null; + MethodCache.Clear(); + } - /// - /// Side-channel for passing MaxItemCount from GetItemLinqQueryable to the feed iterator factory. - /// Set by InMemoryContainer.GetItemLinqQueryable, consumed by CreateInMemoryFeedIterator. - /// - internal static readonly AsyncLocal MaxItemCountLocal = new(); + /// + /// Side-channel for passing MaxItemCount from GetItemLinqQueryable to the feed iterator factory. + /// Set by InMemoryContainer.GetItemLinqQueryable, consumed by CreateInMemoryFeedIterator. + /// + internal static readonly AsyncLocal MaxItemCountLocal = new(); - internal static int? LastMaxItemCount - { - get => MaxItemCountLocal.Value; - set => MaxItemCountLocal.Value = value; - } + internal static int? LastMaxItemCount + { + get => MaxItemCountLocal.Value; + set => MaxItemCountLocal.Value = value; + } - private static InMemoryFeedIterator CreateInMemoryFeedIterator(IQueryable queryable) - { - var maxItemCount = MaxItemCountLocal.Value; - MaxItemCountLocal.Value = null; - return new InMemoryFeedIterator(queryable.AsEnumerable(), maxItemCount) { GuaranteeFirstPage = true }; - } + private static InMemoryFeedIterator CreateInMemoryFeedIterator(IQueryable queryable) + { + var maxItemCount = MaxItemCountLocal.Value; + MaxItemCountLocal.Value = null; + return new InMemoryFeedIterator(queryable.AsEnumerable(), maxItemCount) { GuaranteeFirstPage = true }; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryPermission.cs b/src/CosmosDB.InMemoryEmulator/InMemoryPermission.cs index 4cb18be..f55101f 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryPermission.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryPermission.cs @@ -20,68 +20,68 @@ namespace CosmosDB.InMemoryEmulator; /// public sealed class InMemoryPermission : Permission { - private readonly ConcurrentDictionary _parentPermissions; + private readonly ConcurrentDictionary _parentPermissions; - public InMemoryPermission(string id, ConcurrentDictionary parentPermissions) - { - Id = id; - _parentPermissions = parentPermissions; - } + public InMemoryPermission(string id, ConcurrentDictionary parentPermissions) + { + Id = id; + _parentPermissions = parentPermissions; + } - public override string Id { get; } + public override string Id { get; } - public override Task ReadAsync( - int? tokenExpiryInSeconds = null, - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_parentPermissions.TryGetValue(Id, out var props)) - throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); + public override Task ReadAsync( + int? tokenExpiryInSeconds = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_parentPermissions.TryGetValue(Id, out var props)) + throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); - return Task.FromResult(BuildPermissionResponse(props, HttpStatusCode.OK)); - } + return Task.FromResult(BuildPermissionResponse(props, HttpStatusCode.OK)); + } - public override Task ReplaceAsync( - PermissionProperties permissionProperties, - int? tokenExpiryInSeconds = null, - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_parentPermissions.ContainsKey(Id)) - throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); + public override Task ReplaceAsync( + PermissionProperties permissionProperties, + int? tokenExpiryInSeconds = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_parentPermissions.ContainsKey(Id)) + throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); - var updated = WithSyntheticMetadata(permissionProperties); - _parentPermissions[Id] = updated; - return Task.FromResult(BuildPermissionResponse(updated, HttpStatusCode.OK)); - } + var updated = WithSyntheticMetadata(permissionProperties); + _parentPermissions[Id] = updated; + return Task.FromResult(BuildPermissionResponse(updated, HttpStatusCode.OK)); + } - public override Task DeleteAsync( - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (!_parentPermissions.TryRemove(Id, out _)) - throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); + public override Task DeleteAsync( + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (!_parentPermissions.TryRemove(Id, out _)) + throw InMemoryCosmosException.Create($"Permission '{Id}' not found.", HttpStatusCode.NotFound, 0, string.Empty, 0); - return Task.FromResult(BuildPermissionResponse(null, HttpStatusCode.NoContent)); - } + return Task.FromResult(BuildPermissionResponse(null, HttpStatusCode.NoContent)); + } - internal static PermissionProperties WithSyntheticMetadata(PermissionProperties source) - { - // PermissionProperties has private setters for ETag, Token, LastModified. - // Roundtrip through JSON to set them. - var json = JObject.FromObject(source); - json["_etag"] = $"\"{Guid.NewGuid()}\""; - json["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - json["_token"] = $"type=resource&ver=1&sig=stub_{source.Id}"; - return JsonConvert.DeserializeObject(json.ToString()); - } + internal static PermissionProperties WithSyntheticMetadata(PermissionProperties source) + { + // PermissionProperties has private setters for ETag, Token, LastModified. + // Roundtrip through JSON to set them. + var json = JObject.FromObject(source); + json["_etag"] = $"\"{Guid.NewGuid()}\""; + json["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + json["_token"] = $"type=resource&ver=1&sig=stub_{source.Id}"; + return JsonConvert.DeserializeObject(json.ToString()); + } - private PermissionResponse BuildPermissionResponse(PermissionProperties props, HttpStatusCode statusCode) - { - var response = Substitute.For(); - response.StatusCode.Returns(statusCode); - response.Resource.Returns(props); - response.Permission.Returns(this); - return response; - } + private PermissionResponse BuildPermissionResponse(PermissionProperties props, HttpStatusCode statusCode) + { + var response = Substitute.For(); + response.StatusCode.Returns(statusCode); + response.Resource.Returns(props); + response.Permission.Returns(this); + return response; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryStreamFeedIterator.cs b/src/CosmosDB.InMemoryEmulator/InMemoryStreamFeedIterator.cs index 66cbc1d..c463bd6 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryStreamFeedIterator.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryStreamFeedIterator.cs @@ -7,36 +7,36 @@ namespace CosmosDB.InMemoryEmulator; internal sealed class InMemoryStreamFeedIterator : FeedIterator { - private readonly Func> _itemsFactory; - private readonly string _wrapperProperty; - private readonly Func _sessionTokenFactory; - private bool _hasMoreResults = true; + private readonly Func> _itemsFactory; + private readonly string _wrapperProperty; + private readonly Func _sessionTokenFactory; + private bool _hasMoreResults = true; - public InMemoryStreamFeedIterator(Func> itemsFactory, string wrapperProperty, Func sessionTokenFactory = null) - { - _itemsFactory = itemsFactory; - _wrapperProperty = wrapperProperty; - _sessionTokenFactory = sessionTokenFactory; - } + public InMemoryStreamFeedIterator(Func> itemsFactory, string wrapperProperty, Func sessionTokenFactory = null) + { + _itemsFactory = itemsFactory; + _wrapperProperty = wrapperProperty; + _sessionTokenFactory = sessionTokenFactory; + } - public override bool HasMoreResults => _hasMoreResults; + public override bool HasMoreResults => _hasMoreResults; - public override Task ReadNextAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - _hasMoreResults = false; + public override Task ReadNextAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _hasMoreResults = false; - var items = _itemsFactory(); - var json = JsonSerializer.Serialize( - new Dictionary { [_wrapperProperty] = items }, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var items = _itemsFactory(); + var json = JsonSerializer.Serialize( + new Dictionary { [_wrapperProperty] = items }, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var response = new ResponseMessage(System.Net.HttpStatusCode.OK) { Content = stream }; - response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); - response.Headers["x-ms-request-charge"] = "1"; - response.Headers["x-ms-session-token"] = _sessionTokenFactory?.Invoke() ?? "0:0#0"; - response.Headers["x-ms-item-count"] = items.Count.ToString(); - return Task.FromResult(response); - } + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var response = new ResponseMessage(System.Net.HttpStatusCode.OK) { Content = stream }; + response.Headers["x-ms-activity-id"] = Guid.NewGuid().ToString(); + response.Headers["x-ms-request-charge"] = "1"; + response.Headers["x-ms-session-token"] = _sessionTokenFactory?.Invoke() ?? "0:0#0"; + response.Headers["x-ms-item-count"] = items.Count.ToString(); + return Task.FromResult(response); + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryTransactionalBatch.cs b/src/CosmosDB.InMemoryEmulator/InMemoryTransactionalBatch.cs index f0892c9..fe2c6f9 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryTransactionalBatch.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryTransactionalBatch.cs @@ -23,409 +23,409 @@ namespace CosmosDB.InMemoryEmulator; /// internal class InMemoryTransactionalBatch : TransactionalBatch { - private static readonly JsonSerializerSettings JsonSettings = new() - { - TypeNameHandling = TypeNameHandling.None, - DateParseHandling = DateParseHandling.None, - ContractResolver = new DefaultContractResolver(), - Converters = { new StringEnumConverter { AllowIntegerValues = true } } - }; - - private const int MaxBatchOperations = 100; - private const int MaxBatchSizeBytes = 2 * 1024 * 1024; - - private enum BatchOpType { Create, Read, Upsert, Replace, Delete, Patch, CreateStream, UpsertStream, ReplaceStream } - - private readonly InMemoryContainer _container; - private readonly PartitionKey _partitionKey; - private readonly List<(Func Execute, BatchOpType Type)> _operations = new(); - private long _estimatedBatchSize; - private readonly Dictionary _readResults = new(); - private readonly Dictionary _writeEtags = new(); - private readonly Dictionary _opStatusCodes = new(); - - public InMemoryTransactionalBatch(InMemoryContainer container, PartitionKey partitionKey) - { - _container = container; - _partitionKey = partitionKey; - } - - public override TransactionalBatch CreateItem(T item, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = JsonConvert.SerializeObject(item, JsonSettings); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var result = await _container.CreateItemAsync(item, _partitionKey, ToItemRequestOptions(requestOptions)); - _writeEtags[opIndex] = result.ETag; - _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); - }, BatchOpType.Create)); - return this; - } - - public override TransactionalBatch UpsertItem(T item, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = JsonConvert.SerializeObject(item, JsonSettings); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var result = await _container.UpsertItemAsync(item, _partitionKey, ToItemRequestOptions(requestOptions)); - _writeEtags[opIndex] = result.ETag; - _opStatusCodes[opIndex] = result.StatusCode; - _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); - }, BatchOpType.Upsert)); - return this; - } - - public override TransactionalBatch ReplaceItem(string id, T item, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = JsonConvert.SerializeObject(item, JsonSettings); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var result = await _container.ReplaceItemAsync(item, id, _partitionKey, ToItemRequestOptions(requestOptions)); - _writeEtags[opIndex] = result.ETag; - _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); - }, BatchOpType.Replace)); - return this; - } - - public override TransactionalBatch DeleteItem(string id, TransactionalBatchItemRequestOptions? requestOptions = null) - { - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(id) + 128; - _operations.Add((async () => await _container.DeleteItemAsync(id, _partitionKey, ToItemRequestOptions(requestOptions)), BatchOpType.Delete)); - return this; - } - - public override TransactionalBatch ReadItem(string id, TransactionalBatchItemRequestOptions? requestOptions = null) - { - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(id) + 128; - var operationIndex = _operations.Count; - _operations.Add((async () => - { - var result = await _container.ReadItemAsync(id, _partitionKey, ToItemRequestOptions(requestOptions)); - _readResults[operationIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); - }, BatchOpType.Read)); - return this; - } - - private static ItemRequestOptions? ToItemRequestOptions(TransactionalBatchItemRequestOptions? options) - { - if (options is null) - { - return null; - } - - return new ItemRequestOptions - { - IfMatchEtag = options.IfMatchEtag, - IfNoneMatchEtag = options.IfNoneMatchEtag, - EnableContentResponseOnWrite = options.EnableContentResponseOnWrite, - PreTriggers = ExtractTriggerHeader(options.Properties, "x-ms-pre-trigger-include"), - PostTriggers = ExtractTriggerHeader(options.Properties, "x-ms-post-trigger-include"), - }; - } - - private static IEnumerable? ExtractTriggerHeader(IReadOnlyDictionary? properties, string headerName) - { - if (properties is not null - && properties.TryGetValue(headerName, out var value) - && value is IEnumerable triggers) - return triggers; - return null; - } - - public override async Task ExecuteAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (_operations.Count > MaxBatchOperations) - { - throw InMemoryCosmosException.Create("Batch request has more operations than what is supported.", - HttpStatusCode.BadRequest, 0, string.Empty, 0); - } - - if (_estimatedBatchSize > MaxBatchSizeBytes) - { - var overSizeResults = new List<(HttpStatusCode status, bool isSuccess)>(); - for (var i = 0; i < _operations.Count; i++) - overSizeResults.Add((HttpStatusCode.FailedDependency, false)); - return new InMemoryBatchResponse(HttpStatusCode.RequestEntityTooLarge, false, overSizeResults, _readResults, _writeEtags, - "Request size is too large.", _container.CurrentSessionToken); - } - - var itemsSnapshot = _container.SnapshotItems(); - var etagsSnapshot = _container.SnapshotEtags(); - var timestampsSnapshot = _container.SnapshotTimestamps(); - var changeFeedCount = _container.GetChangeFeedCount(); - _container.BeginBatchTracking(); - - var operationResults = new List<(HttpStatusCode status, bool isSuccess)>(); - var failedIndex = -1; - HttpStatusCode failedStatusCode = default; - - for (var i = 0; i < _operations.Count; i++) - { - try - { - var (execute, opType) = _operations[i]; - await execute(); - var statusCode = opType switch - { - BatchOpType.Read => HttpStatusCode.OK, - BatchOpType.Delete => HttpStatusCode.NoContent, - BatchOpType.Replace => HttpStatusCode.OK, - BatchOpType.Patch => HttpStatusCode.OK, - BatchOpType.Upsert => _opStatusCodes.TryGetValue(i, out var sc) ? sc : HttpStatusCode.Created, - _ => HttpStatusCode.Created - }; - operationResults.Add((statusCode, true)); - } - catch (CosmosException ex) - { - failedIndex = i; - failedStatusCode = ex.StatusCode; - operationResults.Add((ex.StatusCode, false)); - break; - } - } - - if (failedIndex >= 0) - { - var touchedKeys = _container.EndBatchTracking(); - _container.RestoreSnapshot(itemsSnapshot, etagsSnapshot, timestampsSnapshot, changeFeedCount, touchedKeys); - for (var i = 0; i < failedIndex; i++) - { - operationResults[i] = (HttpStatusCode.FailedDependency, false); - } - - for (var i = failedIndex + 1; i < _operations.Count; i++) - { - operationResults.Add((HttpStatusCode.FailedDependency, false)); - } - - return new InMemoryBatchResponse(failedStatusCode, false, operationResults, _readResults, _writeEtags, - $"Batch operation at index {failedIndex} failed with status code {(int)failedStatusCode}.", _container.CurrentSessionToken); - } - - _container.EndBatchTracking(); - return new InMemoryBatchResponse(HttpStatusCode.OK, true, operationResults, _readResults, _writeEtags, sessionToken: _container.CurrentSessionToken); - } - - public override Task ExecuteAsync(TransactionalBatchRequestOptions requestOptions, CancellationToken cancellationToken = default) - => ExecuteAsync(cancellationToken); - - #region Unimplemented overrides - - public override TransactionalBatch CreateItemStream(Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = new StreamReader(streamPayload).ReadToEnd(); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var jObj = JsonParseHelpers.ParseJson(json); - var id = jObj["id"]?.ToString() ?? Guid.NewGuid().ToString(); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await _container.CreateItemStreamAsync(stream, _partitionKey, ToItemRequestOptions(requestOptions)); - if (!response.IsSuccessStatusCode) - throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); - _writeEtags[opIndex] = response.Headers.ETag; - if (response.Content != null) - { - using var responseReader = new StreamReader(response.Content); - _readResults[opIndex] = await responseReader.ReadToEndAsync(); - } - else - { - _readResults[opIndex] = json; - } - }, BatchOpType.Create)); - return this; - } - - public override TransactionalBatch UpsertItemStream(Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = new StreamReader(streamPayload).ReadToEnd(); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await _container.UpsertItemStreamAsync(stream, _partitionKey, ToItemRequestOptions(requestOptions)); - if (!response.IsSuccessStatusCode) - throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); - _writeEtags[opIndex] = response.Headers.ETag; - _opStatusCodes[opIndex] = response.StatusCode; - if (response.Content != null) - { - using var responseReader = new StreamReader(response.Content); - _readResults[opIndex] = await responseReader.ReadToEndAsync(); - } - else - { - _readResults[opIndex] = json; - } - }, BatchOpType.Upsert)); - return this; - } - - public override TransactionalBatch ReplaceItemStream(string id, Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) - { - var json = new StreamReader(streamPayload).ReadToEnd(); - _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); - var opIndex = _operations.Count; - _operations.Add((async () => - { - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await _container.ReplaceItemStreamAsync(stream, id, _partitionKey, ToItemRequestOptions(requestOptions)); - if (!response.IsSuccessStatusCode) - throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); - _writeEtags[opIndex] = response.Headers.ETag; - if (response.Content != null) - { - using var responseReader = new StreamReader(response.Content); - _readResults[opIndex] = await responseReader.ReadToEndAsync(); - } - else - { - _readResults[opIndex] = json; - } - }, BatchOpType.Replace)); - return this; - } - - public override TransactionalBatch PatchItem(string id, IReadOnlyList patchOperations, TransactionalBatchPatchItemRequestOptions? requestOptions = null) - { - var estimatedSize = patchOperations.Sum(op => - { - var json = JsonConvert.SerializeObject(op, JsonSettings); - return System.Text.Encoding.UTF8.GetByteCount(json); - }); - _estimatedBatchSize += estimatedSize; - var opIndex = _operations.Count; - _operations.Add((async () => - { - PatchItemRequestOptions? patchOptions = null; - if (requestOptions is not null) - { - patchOptions = new PatchItemRequestOptions - { - IfMatchEtag = requestOptions.IfMatchEtag, - IfNoneMatchEtag = requestOptions.IfNoneMatchEtag, - FilterPredicate = requestOptions.FilterPredicate, - PreTriggers = ExtractTriggerHeader(requestOptions.Properties, "x-ms-pre-trigger-include"), - PostTriggers = ExtractTriggerHeader(requestOptions.Properties, "x-ms-post-trigger-include"), - }; - } - var result = await _container.PatchItemAsync(id, _partitionKey, patchOperations, patchOptions); - _writeEtags[opIndex] = result.ETag; - _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); - }, BatchOpType.Patch)); - return this; - } - - #endregion - - private sealed class InMemoryBatchResponse : TransactionalBatchResponse - { - private readonly HttpStatusCode _statusCode; - private readonly bool _isSuccess; - private readonly List<(HttpStatusCode status, bool isSuccess)> _operationResults; - private readonly Dictionary _readResults; - private readonly Dictionary _writeEtags; - private readonly string? _errorMessage; - - public InMemoryBatchResponse( - HttpStatusCode statusCode, - bool isSuccess, - List<(HttpStatusCode status, bool isSuccess)> operationResults, - Dictionary readResults, - Dictionary writeEtags, - string? errorMessage = null, - string? sessionToken = null) - { - _statusCode = statusCode; - _isSuccess = isSuccess; - _operationResults = operationResults; - _readResults = readResults; - _writeEtags = writeEtags; - _errorMessage = errorMessage; - _headers["x-ms-activity-id"] = ActivityId; - _headers["x-ms-request-charge"] = RequestCharge.ToString(System.Globalization.CultureInfo.InvariantCulture); - _headers["x-ms-session-token"] = sessionToken ?? "0:0#0"; - } - - public override HttpStatusCode StatusCode => _statusCode; - public override bool IsSuccessStatusCode => _isSuccess; - public override int Count => _operationResults.Count; - public override double RequestCharge => 1d; - public override string ActivityId { get; } = Guid.NewGuid().ToString(); - public override CosmosDiagnostics Diagnostics => new InMemoryBatchDiagnostics(); - public override Headers Headers => _headers; - private readonly Headers _headers = new Headers(); - public override string? ErrorMessage => _errorMessage; - public override TimeSpan? RetryAfter => TimeSpan.Zero; - - public override TransactionalBatchOperationResult this[int index] - { - get - { - if (index < 0 || index >= _operationResults.Count) return null!; - var (status, isSuccess) = _operationResults[index]; - var result = Substitute.For(); - result.StatusCode.Returns(status); - result.IsSuccessStatusCode.Returns(isSuccess); - if (_writeEtags.TryGetValue(index, out var etag)) - { - result.ETag.Returns(etag); - } - if (_readResults.TryGetValue(index, out var json)) - { - result.ResourceStream.Returns(_ => new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); - } - return result; - } - } - - public override TransactionalBatchOperationResult GetOperationResultAtIndex(int index) - { - var (status, isSuccess) = _operationResults[index]; - var result = Substitute.For>(); - result.StatusCode.Returns(status); - result.IsSuccessStatusCode.Returns(isSuccess); - - if (_readResults.TryGetValue(index, out var json)) - { - result.Resource.Returns(JsonConvert.DeserializeObject(json, JsonSettings)!); - } - - if (_writeEtags.TryGetValue(index, out var etag)) - { - result.ETag.Returns(etag); - } - - return result; - } - - public override IEnumerator GetEnumerator() - { - for (var i = 0; i < _operationResults.Count; i++) - { - yield return this[i]; - } - } - - protected override void Dispose(bool disposing) { } - } - - private sealed class InMemoryBatchDiagnostics : CosmosDiagnostics - { - public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; - public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); - public override string ToString() => "{}"; - } + private static readonly JsonSerializerSettings JsonSettings = new() + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + ContractResolver = new DefaultContractResolver(), + Converters = { new StringEnumConverter { AllowIntegerValues = true } } + }; + + private const int MaxBatchOperations = 100; + private const int MaxBatchSizeBytes = 2 * 1024 * 1024; + + private enum BatchOpType { Create, Read, Upsert, Replace, Delete, Patch, CreateStream, UpsertStream, ReplaceStream } + + private readonly InMemoryContainer _container; + private readonly PartitionKey _partitionKey; + private readonly List<(Func Execute, BatchOpType Type)> _operations = new(); + private long _estimatedBatchSize; + private readonly Dictionary _readResults = new(); + private readonly Dictionary _writeEtags = new(); + private readonly Dictionary _opStatusCodes = new(); + + public InMemoryTransactionalBatch(InMemoryContainer container, PartitionKey partitionKey) + { + _container = container; + _partitionKey = partitionKey; + } + + public override TransactionalBatch CreateItem(T item, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = JsonConvert.SerializeObject(item, JsonSettings); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var result = await _container.CreateItemAsync(item, _partitionKey, ToItemRequestOptions(requestOptions)); + _writeEtags[opIndex] = result.ETag; + _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); + }, BatchOpType.Create)); + return this; + } + + public override TransactionalBatch UpsertItem(T item, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = JsonConvert.SerializeObject(item, JsonSettings); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var result = await _container.UpsertItemAsync(item, _partitionKey, ToItemRequestOptions(requestOptions)); + _writeEtags[opIndex] = result.ETag; + _opStatusCodes[opIndex] = result.StatusCode; + _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); + }, BatchOpType.Upsert)); + return this; + } + + public override TransactionalBatch ReplaceItem(string id, T item, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = JsonConvert.SerializeObject(item, JsonSettings); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var result = await _container.ReplaceItemAsync(item, id, _partitionKey, ToItemRequestOptions(requestOptions)); + _writeEtags[opIndex] = result.ETag; + _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); + }, BatchOpType.Replace)); + return this; + } + + public override TransactionalBatch DeleteItem(string id, TransactionalBatchItemRequestOptions? requestOptions = null) + { + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(id) + 128; + _operations.Add((async () => await _container.DeleteItemAsync(id, _partitionKey, ToItemRequestOptions(requestOptions)), BatchOpType.Delete)); + return this; + } + + public override TransactionalBatch ReadItem(string id, TransactionalBatchItemRequestOptions? requestOptions = null) + { + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(id) + 128; + var operationIndex = _operations.Count; + _operations.Add((async () => + { + var result = await _container.ReadItemAsync(id, _partitionKey, ToItemRequestOptions(requestOptions)); + _readResults[operationIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); + }, BatchOpType.Read)); + return this; + } + + private static ItemRequestOptions? ToItemRequestOptions(TransactionalBatchItemRequestOptions? options) + { + if (options is null) + { + return null; + } + + return new ItemRequestOptions + { + IfMatchEtag = options.IfMatchEtag, + IfNoneMatchEtag = options.IfNoneMatchEtag, + EnableContentResponseOnWrite = options.EnableContentResponseOnWrite, + PreTriggers = ExtractTriggerHeader(options.Properties, "x-ms-pre-trigger-include"), + PostTriggers = ExtractTriggerHeader(options.Properties, "x-ms-post-trigger-include"), + }; + } + + private static IEnumerable? ExtractTriggerHeader(IReadOnlyDictionary? properties, string headerName) + { + if (properties is not null + && properties.TryGetValue(headerName, out var value) + && value is IEnumerable triggers) + return triggers; + return null; + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_operations.Count > MaxBatchOperations) + { + throw InMemoryCosmosException.Create("Batch request has more operations than what is supported.", + HttpStatusCode.BadRequest, 0, string.Empty, 0); + } + + if (_estimatedBatchSize > MaxBatchSizeBytes) + { + var overSizeResults = new List<(HttpStatusCode status, bool isSuccess)>(); + for (var i = 0; i < _operations.Count; i++) + overSizeResults.Add((HttpStatusCode.FailedDependency, false)); + return new InMemoryBatchResponse(HttpStatusCode.RequestEntityTooLarge, false, overSizeResults, _readResults, _writeEtags, + "Request size is too large.", _container.CurrentSessionToken); + } + + var itemsSnapshot = _container.SnapshotItems(); + var etagsSnapshot = _container.SnapshotEtags(); + var timestampsSnapshot = _container.SnapshotTimestamps(); + var changeFeedCount = _container.GetChangeFeedCount(); + _container.BeginBatchTracking(); + + var operationResults = new List<(HttpStatusCode status, bool isSuccess)>(); + var failedIndex = -1; + HttpStatusCode failedStatusCode = default; + + for (var i = 0; i < _operations.Count; i++) + { + try + { + var (execute, opType) = _operations[i]; + await execute(); + var statusCode = opType switch + { + BatchOpType.Read => HttpStatusCode.OK, + BatchOpType.Delete => HttpStatusCode.NoContent, + BatchOpType.Replace => HttpStatusCode.OK, + BatchOpType.Patch => HttpStatusCode.OK, + BatchOpType.Upsert => _opStatusCodes.TryGetValue(i, out var sc) ? sc : HttpStatusCode.Created, + _ => HttpStatusCode.Created + }; + operationResults.Add((statusCode, true)); + } + catch (CosmosException ex) + { + failedIndex = i; + failedStatusCode = ex.StatusCode; + operationResults.Add((ex.StatusCode, false)); + break; + } + } + + if (failedIndex >= 0) + { + var touchedKeys = _container.EndBatchTracking(); + _container.RestoreSnapshot(itemsSnapshot, etagsSnapshot, timestampsSnapshot, changeFeedCount, touchedKeys); + for (var i = 0; i < failedIndex; i++) + { + operationResults[i] = (HttpStatusCode.FailedDependency, false); + } + + for (var i = failedIndex + 1; i < _operations.Count; i++) + { + operationResults.Add((HttpStatusCode.FailedDependency, false)); + } + + return new InMemoryBatchResponse(failedStatusCode, false, operationResults, _readResults, _writeEtags, + $"Batch operation at index {failedIndex} failed with status code {(int)failedStatusCode}.", _container.CurrentSessionToken); + } + + _container.EndBatchTracking(); + return new InMemoryBatchResponse(HttpStatusCode.OK, true, operationResults, _readResults, _writeEtags, sessionToken: _container.CurrentSessionToken); + } + + public override Task ExecuteAsync(TransactionalBatchRequestOptions requestOptions, CancellationToken cancellationToken = default) + => ExecuteAsync(cancellationToken); + + #region Unimplemented overrides + + public override TransactionalBatch CreateItemStream(Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = new StreamReader(streamPayload).ReadToEnd(); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var jObj = JsonParseHelpers.ParseJson(json); + var id = jObj["id"]?.ToString() ?? Guid.NewGuid().ToString(); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await _container.CreateItemStreamAsync(stream, _partitionKey, ToItemRequestOptions(requestOptions)); + if (!response.IsSuccessStatusCode) + throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); + _writeEtags[opIndex] = response.Headers.ETag; + if (response.Content != null) + { + using var responseReader = new StreamReader(response.Content); + _readResults[opIndex] = await responseReader.ReadToEndAsync(); + } + else + { + _readResults[opIndex] = json; + } + }, BatchOpType.Create)); + return this; + } + + public override TransactionalBatch UpsertItemStream(Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = new StreamReader(streamPayload).ReadToEnd(); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await _container.UpsertItemStreamAsync(stream, _partitionKey, ToItemRequestOptions(requestOptions)); + if (!response.IsSuccessStatusCode) + throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); + _writeEtags[opIndex] = response.Headers.ETag; + _opStatusCodes[opIndex] = response.StatusCode; + if (response.Content != null) + { + using var responseReader = new StreamReader(response.Content); + _readResults[opIndex] = await responseReader.ReadToEndAsync(); + } + else + { + _readResults[opIndex] = json; + } + }, BatchOpType.Upsert)); + return this; + } + + public override TransactionalBatch ReplaceItemStream(string id, Stream streamPayload, TransactionalBatchItemRequestOptions? requestOptions = null) + { + var json = new StreamReader(streamPayload).ReadToEnd(); + _estimatedBatchSize += System.Text.Encoding.UTF8.GetByteCount(json); + var opIndex = _operations.Count; + _operations.Add((async () => + { + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await _container.ReplaceItemStreamAsync(stream, id, _partitionKey, ToItemRequestOptions(requestOptions)); + if (!response.IsSuccessStatusCode) + throw InMemoryCosmosException.Create(response.ErrorMessage ?? "Stream operation failed.", response.StatusCode, 0, string.Empty, 0); + _writeEtags[opIndex] = response.Headers.ETag; + if (response.Content != null) + { + using var responseReader = new StreamReader(response.Content); + _readResults[opIndex] = await responseReader.ReadToEndAsync(); + } + else + { + _readResults[opIndex] = json; + } + }, BatchOpType.Replace)); + return this; + } + + public override TransactionalBatch PatchItem(string id, IReadOnlyList patchOperations, TransactionalBatchPatchItemRequestOptions? requestOptions = null) + { + var estimatedSize = patchOperations.Sum(op => + { + var json = JsonConvert.SerializeObject(op, JsonSettings); + return System.Text.Encoding.UTF8.GetByteCount(json); + }); + _estimatedBatchSize += estimatedSize; + var opIndex = _operations.Count; + _operations.Add((async () => + { + PatchItemRequestOptions? patchOptions = null; + if (requestOptions is not null) + { + patchOptions = new PatchItemRequestOptions + { + IfMatchEtag = requestOptions.IfMatchEtag, + IfNoneMatchEtag = requestOptions.IfNoneMatchEtag, + FilterPredicate = requestOptions.FilterPredicate, + PreTriggers = ExtractTriggerHeader(requestOptions.Properties, "x-ms-pre-trigger-include"), + PostTriggers = ExtractTriggerHeader(requestOptions.Properties, "x-ms-post-trigger-include"), + }; + } + var result = await _container.PatchItemAsync(id, _partitionKey, patchOperations, patchOptions); + _writeEtags[opIndex] = result.ETag; + _readResults[opIndex] = JsonConvert.SerializeObject(result.Resource, JsonSettings); + }, BatchOpType.Patch)); + return this; + } + + #endregion + + private sealed class InMemoryBatchResponse : TransactionalBatchResponse + { + private readonly HttpStatusCode _statusCode; + private readonly bool _isSuccess; + private readonly List<(HttpStatusCode status, bool isSuccess)> _operationResults; + private readonly Dictionary _readResults; + private readonly Dictionary _writeEtags; + private readonly string? _errorMessage; + + public InMemoryBatchResponse( + HttpStatusCode statusCode, + bool isSuccess, + List<(HttpStatusCode status, bool isSuccess)> operationResults, + Dictionary readResults, + Dictionary writeEtags, + string? errorMessage = null, + string? sessionToken = null) + { + _statusCode = statusCode; + _isSuccess = isSuccess; + _operationResults = operationResults; + _readResults = readResults; + _writeEtags = writeEtags; + _errorMessage = errorMessage; + _headers["x-ms-activity-id"] = ActivityId; + _headers["x-ms-request-charge"] = RequestCharge.ToString(System.Globalization.CultureInfo.InvariantCulture); + _headers["x-ms-session-token"] = sessionToken ?? "0:0#0"; + } + + public override HttpStatusCode StatusCode => _statusCode; + public override bool IsSuccessStatusCode => _isSuccess; + public override int Count => _operationResults.Count; + public override double RequestCharge => 1d; + public override string ActivityId { get; } = Guid.NewGuid().ToString(); + public override CosmosDiagnostics Diagnostics => new InMemoryBatchDiagnostics(); + public override Headers Headers => _headers; + private readonly Headers _headers = new Headers(); + public override string? ErrorMessage => _errorMessage; + public override TimeSpan? RetryAfter => TimeSpan.Zero; + + public override TransactionalBatchOperationResult this[int index] + { + get + { + if (index < 0 || index >= _operationResults.Count) return null!; + var (status, isSuccess) = _operationResults[index]; + var result = Substitute.For(); + result.StatusCode.Returns(status); + result.IsSuccessStatusCode.Returns(isSuccess); + if (_writeEtags.TryGetValue(index, out var etag)) + { + result.ETag.Returns(etag); + } + if (_readResults.TryGetValue(index, out var json)) + { + result.ResourceStream.Returns(_ => new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + return result; + } + } + + public override TransactionalBatchOperationResult GetOperationResultAtIndex(int index) + { + var (status, isSuccess) = _operationResults[index]; + var result = Substitute.For>(); + result.StatusCode.Returns(status); + result.IsSuccessStatusCode.Returns(isSuccess); + + if (_readResults.TryGetValue(index, out var json)) + { + result.Resource.Returns(JsonConvert.DeserializeObject(json, JsonSettings)!); + } + + if (_writeEtags.TryGetValue(index, out var etag)) + { + result.ETag.Returns(etag); + } + + return result; + } + + public override IEnumerator GetEnumerator() + { + for (var i = 0; i < _operationResults.Count; i++) + { + yield return this[i]; + } + } + + protected override void Dispose(bool disposing) { } + } + + private sealed class InMemoryBatchDiagnostics : CosmosDiagnostics + { + public override TimeSpan GetClientElapsedTime() => TimeSpan.Zero; + public override IReadOnlyList<(string regionName, Uri uri)> GetContactedRegions() => Array.Empty<(string, Uri)>(); + public override string ToString() => "{}"; + } } diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryUser.cs b/src/CosmosDB.InMemoryEmulator/InMemoryUser.cs index c9996f0..c2dc714 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryUser.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryUser.cs @@ -20,139 +20,139 @@ namespace CosmosDB.InMemoryEmulator; /// public sealed class InMemoryUser : User { - private readonly ConcurrentDictionary _permissions = new(); - private readonly Action _onDeleted; - private readonly ConcurrentDictionary _parentUsers; - private string _id; - - public InMemoryUser(string id, Action onDeleted = null) - { - _id = id; - _onDeleted = onDeleted; - } - - /// - /// Creates a proxy user that checks for existence on ReadAsync. - /// If the user is not in the dictionary, ReadAsync throws 404 NotFound. - /// - internal InMemoryUser(string id, Action onDeleted, ConcurrentDictionary parentUsers) - { - _id = id; - _onDeleted = onDeleted; - _parentUsers = parentUsers; - } - - public override string Id => _id; - - public override Task ReadAsync( - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (_parentUsers is not null && !_parentUsers.ContainsKey(_id)) - { - throw InMemoryCosmosException.Create( - $"User with id '{_id}' not found.", - HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), 0); - } - - return Task.FromResult(BuildUserResponse(CreateUserProperties(_id), HttpStatusCode.OK)); - } - - public override Task ReplaceAsync( - UserProperties userProperties, - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - if (userProperties.Id != _id) - throw InMemoryCosmosException.Create($"Replacing user id is not allowed.", HttpStatusCode.BadRequest, 0, string.Empty, 0); - - _id = userProperties.Id; - return Task.FromResult(BuildUserResponse(CreateUserProperties(_id), HttpStatusCode.OK)); - } - - public override Task DeleteAsync( - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - _onDeleted?.Invoke(); - return Task.FromResult(BuildUserResponse(null, HttpStatusCode.NoContent)); - } - - public override Permission GetPermission(string id) - { - return new InMemoryPermission(id, _permissions); - } - - public override Task CreatePermissionAsync( - PermissionProperties permissionProperties, - int? tokenExpiryInSeconds = null, - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - var props = InMemoryPermission.WithSyntheticMetadata(permissionProperties); - if (!_permissions.TryAdd(props.Id, props)) - throw InMemoryCosmosException.Create($"Permission '{props.Id}' already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); - - var perm = new InMemoryPermission(props.Id, _permissions); - var response = Substitute.For(); - response.StatusCode.Returns(HttpStatusCode.Created); - response.Resource.Returns(props); - response.Permission.Returns(perm); - return Task.FromResult(response); - } - - public override Task UpsertPermissionAsync( - PermissionProperties permissionProperties, - int? tokenExpiryInSeconds = null, - RequestOptions requestOptions = null, - CancellationToken cancellationToken = default) - { - var props = InMemoryPermission.WithSyntheticMetadata(permissionProperties); - var isNew = !_permissions.ContainsKey(props.Id); - _permissions[props.Id] = props; - - var perm = new InMemoryPermission(props.Id, _permissions); - var response = Substitute.For(); - response.StatusCode.Returns(isNew ? HttpStatusCode.Created : HttpStatusCode.OK); - response.Resource.Returns(props); - response.Permission.Returns(perm); - return Task.FromResult(response); - } - - public override FeedIterator GetPermissionQueryIterator( - string queryText = null, - string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return new InMemoryFeedIterator( - () => _permissions.Values.Cast().ToList()); - } - - public override FeedIterator GetPermissionQueryIterator( - QueryDefinition queryDefinition, - string continuationToken = null, - QueryRequestOptions requestOptions = null) - { - return GetPermissionQueryIterator((string)null, continuationToken, requestOptions); - } - - internal static UserProperties CreateUserProperties(string id) - { - var json = new JObject - { - ["id"] = id, - ["_etag"] = $"\"{Guid.NewGuid()}\"", - ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - }; - return JsonConvert.DeserializeObject(json.ToString()); - } - - private UserResponse BuildUserResponse(UserProperties props, HttpStatusCode statusCode) - { - var response = Substitute.For(); - response.StatusCode.Returns(statusCode); - response.Resource.Returns(props); - response.User.Returns(this); - return response; - } + private readonly ConcurrentDictionary _permissions = new(); + private readonly Action _onDeleted; + private readonly ConcurrentDictionary _parentUsers; + private string _id; + + public InMemoryUser(string id, Action onDeleted = null) + { + _id = id; + _onDeleted = onDeleted; + } + + /// + /// Creates a proxy user that checks for existence on ReadAsync. + /// If the user is not in the dictionary, ReadAsync throws 404 NotFound. + /// + internal InMemoryUser(string id, Action onDeleted, ConcurrentDictionary parentUsers) + { + _id = id; + _onDeleted = onDeleted; + _parentUsers = parentUsers; + } + + public override string Id => _id; + + public override Task ReadAsync( + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (_parentUsers is not null && !_parentUsers.ContainsKey(_id)) + { + throw InMemoryCosmosException.Create( + $"User with id '{_id}' not found.", + HttpStatusCode.NotFound, 0, Guid.NewGuid().ToString(), 0); + } + + return Task.FromResult(BuildUserResponse(CreateUserProperties(_id), HttpStatusCode.OK)); + } + + public override Task ReplaceAsync( + UserProperties userProperties, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + if (userProperties.Id != _id) + throw InMemoryCosmosException.Create($"Replacing user id is not allowed.", HttpStatusCode.BadRequest, 0, string.Empty, 0); + + _id = userProperties.Id; + return Task.FromResult(BuildUserResponse(CreateUserProperties(_id), HttpStatusCode.OK)); + } + + public override Task DeleteAsync( + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + _onDeleted?.Invoke(); + return Task.FromResult(BuildUserResponse(null, HttpStatusCode.NoContent)); + } + + public override Permission GetPermission(string id) + { + return new InMemoryPermission(id, _permissions); + } + + public override Task CreatePermissionAsync( + PermissionProperties permissionProperties, + int? tokenExpiryInSeconds = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + var props = InMemoryPermission.WithSyntheticMetadata(permissionProperties); + if (!_permissions.TryAdd(props.Id, props)) + throw InMemoryCosmosException.Create($"Permission '{props.Id}' already exists.", HttpStatusCode.Conflict, 0, string.Empty, 0); + + var perm = new InMemoryPermission(props.Id, _permissions); + var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.Created); + response.Resource.Returns(props); + response.Permission.Returns(perm); + return Task.FromResult(response); + } + + public override Task UpsertPermissionAsync( + PermissionProperties permissionProperties, + int? tokenExpiryInSeconds = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + { + var props = InMemoryPermission.WithSyntheticMetadata(permissionProperties); + var isNew = !_permissions.ContainsKey(props.Id); + _permissions[props.Id] = props; + + var perm = new InMemoryPermission(props.Id, _permissions); + var response = Substitute.For(); + response.StatusCode.Returns(isNew ? HttpStatusCode.Created : HttpStatusCode.OK); + response.Resource.Returns(props); + response.Permission.Returns(perm); + return Task.FromResult(response); + } + + public override FeedIterator GetPermissionQueryIterator( + string queryText = null, + string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return new InMemoryFeedIterator( + () => _permissions.Values.Cast().ToList()); + } + + public override FeedIterator GetPermissionQueryIterator( + QueryDefinition queryDefinition, + string continuationToken = null, + QueryRequestOptions requestOptions = null) + { + return GetPermissionQueryIterator((string)null, continuationToken, requestOptions); + } + + internal static UserProperties CreateUserProperties(string id) + { + var json = new JObject + { + ["id"] = id, + ["_etag"] = $"\"{Guid.NewGuid()}\"", + ["_ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + return JsonConvert.DeserializeObject(json.ToString()); + } + + private UserResponse BuildUserResponse(UserProperties props, HttpStatusCode statusCode) + { + var response = Substitute.For(); + response.StatusCode.Returns(statusCode); + response.Resource.Returns(props); + response.User.Returns(this); + return response; + } } diff --git a/src/CosmosDB.InMemoryEmulator/JsonParseHelpers.cs b/src/CosmosDB.InMemoryEmulator/JsonParseHelpers.cs index 1a4c24e..e0a5737 100644 --- a/src/CosmosDB.InMemoryEmulator/JsonParseHelpers.cs +++ b/src/CosmosDB.InMemoryEmulator/JsonParseHelpers.cs @@ -5,21 +5,51 @@ namespace CosmosDB.InMemoryEmulator; internal static class JsonParseHelpers { - internal static JObject ParseJson(string json) - { - using var reader = new JsonTextReader(new StringReader(json)) - { - DateParseHandling = DateParseHandling.None - }; - return JObject.Load(reader); - } + internal static JObject ParseJson(string json) + { + using var reader = new JsonTextReader(new StringReader(json)) + { + DateParseHandling = DateParseHandling.None + }; + return (JObject)NormalizeNumbers(JObject.Load(reader)); + } - internal static JToken ParseJsonToken(string json) - { - using var reader = new JsonTextReader(new StringReader(json)) - { - DateParseHandling = DateParseHandling.None - }; - return JToken.Load(reader); - } + internal static JToken ParseJsonToken(string json) + { + using var reader = new JsonTextReader(new StringReader(json)) + { + DateParseHandling = DateParseHandling.None + }; + return NormalizeNumbers(JToken.Load(reader)); + } + + // Ref: JavaScript: JSON.stringify(JSON.parse("1500.0")) === "1500" + // Real Cosmos DB uses a JavaScript engine (IEEE 754 double) which normalises whole-number + // JSON floats to integers. e.g. "1500.0" → 1500, "0.0" → 0. + // Fractional values (e.g. "100.50" → double 100.5) already round-trip correctly because + // doubles do not preserve trailing zeros, so no special handling is needed for them. + private static JToken NormalizeNumbers(JToken token) + { + switch (token.Type) + { + case JTokenType.Float: + var d = token.Value(); + if (!double.IsNaN(d) && !double.IsInfinity(d) && d == Math.Truncate(d) + && d >= long.MinValue && d <= (double)long.MaxValue) + return new JValue((long)d); + return token; + case JTokenType.Object: + var obj = (JObject)token; + foreach (var prop in obj.Properties().ToList()) + obj[prop.Name] = NormalizeNumbers(prop.Value); + return obj; + case JTokenType.Array: + var arr = (JArray)token; + for (var i = 0; i < arr.Count; i++) + arr[i] = NormalizeNumbers(arr[i]); + return arr; + default: + return token; + } + } } diff --git a/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs b/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs index 76f54a2..f3e4922 100644 --- a/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs @@ -14,261 +14,261 @@ namespace CosmosDB.InMemoryEmulator; /// internal sealed class PartitionKeyCapturingContainer : Container { - private readonly Container _inner; - private readonly FakeCosmosHandler _handler; - - internal PartitionKeyCapturingContainer(Container inner, FakeCosmosHandler handler) - { - _inner = inner; - _handler = handler; - } - - // ── Properties ────────────────────────────────────────────────── - - public override string Id => _inner.Id; - public override Database Database => _inner.Database; - public override Conflicts Conflicts => _inner.Conflicts; - public override Scripts Scripts => _inner.Scripts; - - // ── CRUD (typed) ──────────────────────────────────────────────── - - public override Task> CreateItemAsync( - T item, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateItemAsync(item, partitionKey, requestOptions, cancellationToken); - - public override Task> ReadItemAsync( - string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReadItemAsync(id, partitionKey, requestOptions, cancellationToken); - - public override Task> UpsertItemAsync( - T item, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.UpsertItemAsync(item, partitionKey, requestOptions, cancellationToken); - - public override Task> ReplaceItemAsync( - T item, string id, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReplaceItemAsync(item, id, partitionKey, requestOptions, cancellationToken); - - public override Task> DeleteItemAsync( - string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.DeleteItemAsync(id, partitionKey, requestOptions, cancellationToken); - - public override Task> PatchItemAsync( - string id, PartitionKey partitionKey, IReadOnlyList patchOperations, - PatchItemRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.PatchItemAsync(id, partitionKey, patchOperations, requestOptions, cancellationToken); - - // ── CRUD (stream) ─────────────────────────────────────────────── - - public override Task CreateItemStreamAsync( - Stream streamPayload, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateItemStreamAsync(streamPayload, partitionKey, requestOptions, cancellationToken); - - public override Task ReadItemStreamAsync( - string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReadItemStreamAsync(id, partitionKey, requestOptions, cancellationToken); - - public override Task UpsertItemStreamAsync( - Stream streamPayload, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.UpsertItemStreamAsync(streamPayload, partitionKey, requestOptions, cancellationToken); - - public override Task ReplaceItemStreamAsync( - Stream streamPayload, string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReplaceItemStreamAsync(streamPayload, id, partitionKey, requestOptions, cancellationToken); - - public override Task DeleteItemStreamAsync( - string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.DeleteItemStreamAsync(id, partitionKey, requestOptions, cancellationToken); - - public override Task PatchItemStreamAsync( - string id, PartitionKey partitionKey, IReadOnlyList patchOperations, - PatchItemRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.PatchItemStreamAsync(id, partitionKey, patchOperations, requestOptions, cancellationToken); - - // ── ReadMany ──────────────────────────────────────────────────── - - public override Task> ReadManyItemsAsync( - IReadOnlyList<(string id, PartitionKey partitionKey)> items, - ReadManyRequestOptions? readManyRequestOptions = null, CancellationToken cancellationToken = default) - => _inner.ReadManyItemsAsync(items, readManyRequestOptions, cancellationToken); - - public override Task ReadManyItemsStreamAsync( - IReadOnlyList<(string id, PartitionKey partitionKey)> items, - ReadManyRequestOptions? readManyRequestOptions = null, CancellationToken cancellationToken = default) - => _inner.ReadManyItemsStreamAsync(items, readManyRequestOptions, cancellationToken); - - // ── Query (typed) — INTERCEPTED ───────────────────────────────── - - public override FeedIterator GetItemQueryIterator( - QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryIterator(queryDefinition, continuationToken, requestOptions), requestOptions); - - public override FeedIterator GetItemQueryIterator( - string? queryText = null, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryIterator(queryText, continuationToken, requestOptions), requestOptions); - - public override FeedIterator GetItemQueryIterator( - FeedRange feedRange, QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryIterator(feedRange, queryDefinition, continuationToken, requestOptions), requestOptions); - - // ── Query (stream) — INTERCEPTED ──────────────────────────────── - - public override FeedIterator GetItemQueryStreamIterator( - QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryStreamIterator(queryDefinition, continuationToken, requestOptions), requestOptions); - - public override FeedIterator GetItemQueryStreamIterator( - string? queryText = null, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryStreamIterator(queryText, continuationToken, requestOptions), requestOptions); - - public override FeedIterator GetItemQueryStreamIterator( - FeedRange feedRange, QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => MaybeWrap(_inner.GetItemQueryStreamIterator(feedRange, queryDefinition, continuationToken, requestOptions), requestOptions); - - // ── LINQ ──────────────────────────────────────────────────────── - // Sets a partition key hint on the handler so that prefix PK scoping - // works even through .ToFeedIterator() (which creates an SDK-internal - // FeedIterator we cannot wrap). - - public override IOrderedQueryable GetItemLinqQueryable( - bool allowSynchronousQueryExecution = false, string? continuationToken = null, - QueryRequestOptions? requestOptions = null, CosmosLinqSerializerOptions? linqSerializerOptions = null) - { - _handler.SetPartitionKeyHint(requestOptions?.PartitionKey); - return _inner.GetItemLinqQueryable(allowSynchronousQueryExecution, continuationToken, requestOptions, linqSerializerOptions); - } + private readonly Container _inner; + private readonly FakeCosmosHandler _handler; + + internal PartitionKeyCapturingContainer(Container inner, FakeCosmosHandler handler) + { + _inner = inner; + _handler = handler; + } + + // ── Properties ────────────────────────────────────────────────── + + public override string Id => _inner.Id; + public override Database Database => _inner.Database; + public override Conflicts Conflicts => _inner.Conflicts; + public override Scripts Scripts => _inner.Scripts; + + // ── CRUD (typed) ──────────────────────────────────────────────── + + public override Task> CreateItemAsync( + T item, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateItemAsync(item, partitionKey, requestOptions, cancellationToken); + + public override Task> ReadItemAsync( + string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReadItemAsync(id, partitionKey, requestOptions, cancellationToken); + + public override Task> UpsertItemAsync( + T item, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.UpsertItemAsync(item, partitionKey, requestOptions, cancellationToken); + + public override Task> ReplaceItemAsync( + T item, string id, PartitionKey? partitionKey = null, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReplaceItemAsync(item, id, partitionKey, requestOptions, cancellationToken); + + public override Task> DeleteItemAsync( + string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.DeleteItemAsync(id, partitionKey, requestOptions, cancellationToken); + + public override Task> PatchItemAsync( + string id, PartitionKey partitionKey, IReadOnlyList patchOperations, + PatchItemRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.PatchItemAsync(id, partitionKey, patchOperations, requestOptions, cancellationToken); + + // ── CRUD (stream) ─────────────────────────────────────────────── + + public override Task CreateItemStreamAsync( + Stream streamPayload, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateItemStreamAsync(streamPayload, partitionKey, requestOptions, cancellationToken); + + public override Task ReadItemStreamAsync( + string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReadItemStreamAsync(id, partitionKey, requestOptions, cancellationToken); + + public override Task UpsertItemStreamAsync( + Stream streamPayload, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.UpsertItemStreamAsync(streamPayload, partitionKey, requestOptions, cancellationToken); + + public override Task ReplaceItemStreamAsync( + Stream streamPayload, string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReplaceItemStreamAsync(streamPayload, id, partitionKey, requestOptions, cancellationToken); + + public override Task DeleteItemStreamAsync( + string id, PartitionKey partitionKey, ItemRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.DeleteItemStreamAsync(id, partitionKey, requestOptions, cancellationToken); + + public override Task PatchItemStreamAsync( + string id, PartitionKey partitionKey, IReadOnlyList patchOperations, + PatchItemRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.PatchItemStreamAsync(id, partitionKey, patchOperations, requestOptions, cancellationToken); + + // ── ReadMany ──────────────────────────────────────────────────── + + public override Task> ReadManyItemsAsync( + IReadOnlyList<(string id, PartitionKey partitionKey)> items, + ReadManyRequestOptions? readManyRequestOptions = null, CancellationToken cancellationToken = default) + => _inner.ReadManyItemsAsync(items, readManyRequestOptions, cancellationToken); + + public override Task ReadManyItemsStreamAsync( + IReadOnlyList<(string id, PartitionKey partitionKey)> items, + ReadManyRequestOptions? readManyRequestOptions = null, CancellationToken cancellationToken = default) + => _inner.ReadManyItemsStreamAsync(items, readManyRequestOptions, cancellationToken); + + // ── Query (typed) — INTERCEPTED ───────────────────────────────── + + public override FeedIterator GetItemQueryIterator( + QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryIterator(queryDefinition, continuationToken, requestOptions), requestOptions); + + public override FeedIterator GetItemQueryIterator( + string? queryText = null, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryIterator(queryText, continuationToken, requestOptions), requestOptions); + + public override FeedIterator GetItemQueryIterator( + FeedRange feedRange, QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryIterator(feedRange, queryDefinition, continuationToken, requestOptions), requestOptions); + + // ── Query (stream) — INTERCEPTED ──────────────────────────────── + + public override FeedIterator GetItemQueryStreamIterator( + QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryStreamIterator(queryDefinition, continuationToken, requestOptions), requestOptions); + + public override FeedIterator GetItemQueryStreamIterator( + string? queryText = null, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryStreamIterator(queryText, continuationToken, requestOptions), requestOptions); + + public override FeedIterator GetItemQueryStreamIterator( + FeedRange feedRange, QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => MaybeWrap(_inner.GetItemQueryStreamIterator(feedRange, queryDefinition, continuationToken, requestOptions), requestOptions); + + // ── LINQ ──────────────────────────────────────────────────────── + // Sets a partition key hint on the handler so that prefix PK scoping + // works even through .ToFeedIterator() (which creates an SDK-internal + // FeedIterator we cannot wrap). + + public override IOrderedQueryable GetItemLinqQueryable( + bool allowSynchronousQueryExecution = false, string? continuationToken = null, + QueryRequestOptions? requestOptions = null, CosmosLinqSerializerOptions? linqSerializerOptions = null) + { + _handler.SetPartitionKeyHint(requestOptions?.PartitionKey); + return _inner.GetItemLinqQueryable(allowSynchronousQueryExecution, continuationToken, requestOptions, linqSerializerOptions); + } - // ── Container management ──────────────────────────────────────── + // ── Container management ──────────────────────────────────────── - public override Task ReadContainerAsync( - ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.ReadContainerAsync(requestOptions, cancellationToken); + public override Task ReadContainerAsync( + ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.ReadContainerAsync(requestOptions, cancellationToken); - public override Task ReadContainerStreamAsync( - ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.ReadContainerStreamAsync(requestOptions, cancellationToken); + public override Task ReadContainerStreamAsync( + ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.ReadContainerStreamAsync(requestOptions, cancellationToken); - public override Task ReplaceContainerAsync( - ContainerProperties containerProperties, ContainerRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReplaceContainerAsync(containerProperties, requestOptions, cancellationToken); + public override Task ReplaceContainerAsync( + ContainerProperties containerProperties, ContainerRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReplaceContainerAsync(containerProperties, requestOptions, cancellationToken); - public override Task ReplaceContainerStreamAsync( - ContainerProperties containerProperties, ContainerRequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReplaceContainerStreamAsync(containerProperties, requestOptions, cancellationToken); + public override Task ReplaceContainerStreamAsync( + ContainerProperties containerProperties, ContainerRequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReplaceContainerStreamAsync(containerProperties, requestOptions, cancellationToken); - public override Task DeleteContainerAsync( - ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.DeleteContainerAsync(requestOptions, cancellationToken); + public override Task DeleteContainerAsync( + ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.DeleteContainerAsync(requestOptions, cancellationToken); - public override Task DeleteContainerStreamAsync( - ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.DeleteContainerStreamAsync(requestOptions, cancellationToken); + public override Task DeleteContainerStreamAsync( + ContainerRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.DeleteContainerStreamAsync(requestOptions, cancellationToken); - // ── Throughput ────────────────────────────────────────────────── + // ── Throughput ────────────────────────────────────────────────── - public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) - => _inner.ReadThroughputAsync(cancellationToken); + public override Task ReadThroughputAsync(CancellationToken cancellationToken = default) + => _inner.ReadThroughputAsync(cancellationToken); - public override Task ReadThroughputAsync( - RequestOptions requestOptions, CancellationToken cancellationToken = default) - => _inner.ReadThroughputAsync(requestOptions, cancellationToken); + public override Task ReadThroughputAsync( + RequestOptions requestOptions, CancellationToken cancellationToken = default) + => _inner.ReadThroughputAsync(requestOptions, cancellationToken); - public override Task ReplaceThroughputAsync( - int throughput, RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.ReplaceThroughputAsync(throughput, requestOptions, cancellationToken); + public override Task ReplaceThroughputAsync( + int throughput, RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.ReplaceThroughputAsync(throughput, requestOptions, cancellationToken); - public override Task ReplaceThroughputAsync( - ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.ReplaceThroughputAsync(throughputProperties, requestOptions, cancellationToken); + public override Task ReplaceThroughputAsync( + ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.ReplaceThroughputAsync(throughputProperties, requestOptions, cancellationToken); - // ── Change feed iterators ─────────────────────────────────────── + // ── Change feed iterators ─────────────────────────────────────── - public override FeedIterator GetChangeFeedIterator( - ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, - ChangeFeedRequestOptions? changeFeedRequestOptions = null) - => _inner.GetChangeFeedIterator(changeFeedStartFrom, changeFeedMode, changeFeedRequestOptions); + public override FeedIterator GetChangeFeedIterator( + ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, + ChangeFeedRequestOptions? changeFeedRequestOptions = null) + => _inner.GetChangeFeedIterator(changeFeedStartFrom, changeFeedMode, changeFeedRequestOptions); - public override FeedIterator GetChangeFeedStreamIterator( - ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, - ChangeFeedRequestOptions? changeFeedRequestOptions = null) - => _inner.GetChangeFeedStreamIterator(changeFeedStartFrom, changeFeedMode, changeFeedRequestOptions); + public override FeedIterator GetChangeFeedStreamIterator( + ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode, + ChangeFeedRequestOptions? changeFeedRequestOptions = null) + => _inner.GetChangeFeedStreamIterator(changeFeedStartFrom, changeFeedMode, changeFeedRequestOptions); - // ── Change feed processors ────────────────────────────────────── + // ── Change feed processors ────────────────────────────────────── - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, ChangesHandler onChangesDelegate) - => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, ChangesHandler onChangesDelegate) + => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, ChangeFeedHandler onChangesDelegate) - => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, ChangeFeedHandler onChangesDelegate) + => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( - string processorName, ChangeFeedStreamHandler onChangesDelegate) - => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilder( + string processorName, ChangeFeedStreamHandler onChangesDelegate) + => _inner.GetChangeFeedProcessorBuilder(processorName, onChangesDelegate); - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( - string processorName, ChangeFeedHandlerWithManualCheckpoint onChangesDelegate) - => _inner.GetChangeFeedProcessorBuilderWithManualCheckpoint(processorName, onChangesDelegate); + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( + string processorName, ChangeFeedHandlerWithManualCheckpoint onChangesDelegate) + => _inner.GetChangeFeedProcessorBuilderWithManualCheckpoint(processorName, onChangesDelegate); - public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( - string processorName, ChangeFeedStreamHandlerWithManualCheckpoint onChangesDelegate) - => _inner.GetChangeFeedProcessorBuilderWithManualCheckpoint(processorName, onChangesDelegate); + public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithManualCheckpoint( + string processorName, ChangeFeedStreamHandlerWithManualCheckpoint onChangesDelegate) + => _inner.GetChangeFeedProcessorBuilderWithManualCheckpoint(processorName, onChangesDelegate); - public override ChangeFeedEstimator GetChangeFeedEstimator( - string processorName, Container leaseContainer) - => _inner.GetChangeFeedEstimator(processorName, leaseContainer); + public override ChangeFeedEstimator GetChangeFeedEstimator( + string processorName, Container leaseContainer) + => _inner.GetChangeFeedEstimator(processorName, leaseContainer); - public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder( - string processorName, ChangesEstimationHandler estimationDelegate, TimeSpan? estimationPeriod = null) - => _inner.GetChangeFeedEstimatorBuilder(processorName, estimationDelegate, estimationPeriod); + public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder( + string processorName, ChangesEstimationHandler estimationDelegate, TimeSpan? estimationPeriod = null) + => _inner.GetChangeFeedEstimatorBuilder(processorName, estimationDelegate, estimationPeriod); - // ── Feed ranges ───────────────────────────────────────────────── + // ── Feed ranges ───────────────────────────────────────────────── - public override Task> GetFeedRangesAsync(CancellationToken cancellationToken = default) - => _inner.GetFeedRangesAsync(cancellationToken); + public override Task> GetFeedRangesAsync(CancellationToken cancellationToken = default) + => _inner.GetFeedRangesAsync(cancellationToken); - // ── Batch ─────────────────────────────────────────────────────── + // ── Batch ─────────────────────────────────────────────────────── - public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey) - => _inner.CreateTransactionalBatch(partitionKey); + public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey) + => _inner.CreateTransactionalBatch(partitionKey); - // ── Delete all by PK ──────────────────────────────────────────── + // ── Delete all by PK ──────────────────────────────────────────── - public override Task DeleteAllItemsByPartitionKeyStreamAsync( - PartitionKey partitionKey, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.DeleteAllItemsByPartitionKeyStreamAsync(partitionKey, requestOptions, cancellationToken); + public override Task DeleteAllItemsByPartitionKeyStreamAsync( + PartitionKey partitionKey, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.DeleteAllItemsByPartitionKeyStreamAsync(partitionKey, requestOptions, cancellationToken); - // ── Wrapping helpers ──────────────────────────────────────────── + // ── Wrapping helpers ──────────────────────────────────────────── - private FeedIterator MaybeWrap(FeedIterator inner, QueryRequestOptions? requestOptions) - => requestOptions?.PartitionKey is { } pk - ? new PkCapturingFeedIterator(inner, _handler, pk) - : inner; + private FeedIterator MaybeWrap(FeedIterator inner, QueryRequestOptions? requestOptions) + => requestOptions?.PartitionKey is { } pk + ? new PkCapturingFeedIterator(inner, _handler, pk) + : inner; - private FeedIterator MaybeWrap(FeedIterator inner, QueryRequestOptions? requestOptions) - => requestOptions?.PartitionKey is { } pk - ? new PkCapturingStreamFeedIterator(inner, _handler, pk) - : inner; + private FeedIterator MaybeWrap(FeedIterator inner, QueryRequestOptions? requestOptions) + => requestOptions?.PartitionKey is { } pk + ? new PkCapturingStreamFeedIterator(inner, _handler, pk) + : inner; } /// @@ -277,32 +277,32 @@ private FeedIterator MaybeWrap(FeedIterator inner, QueryRequestOptions? requestO /// internal sealed class PkCapturingFeedIterator : FeedIterator { - private readonly FeedIterator _inner; - private readonly FakeCosmosHandler _handler; - private readonly PartitionKey _pk; - - internal PkCapturingFeedIterator(FeedIterator inner, FakeCosmosHandler handler, PartitionKey pk) - { - _inner = inner; - _handler = handler; - _pk = pk; - } - - public override bool HasMoreResults => _inner.HasMoreResults; - - public override async Task> ReadNextAsync(CancellationToken cancellationToken = default) - { - using (_handler.WithPartitionKey(_pk)) - { - return await _inner.ReadNextAsync(cancellationToken); - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) _inner.Dispose(); - base.Dispose(disposing); - } + private readonly FeedIterator _inner; + private readonly FakeCosmosHandler _handler; + private readonly PartitionKey _pk; + + internal PkCapturingFeedIterator(FeedIterator inner, FakeCosmosHandler handler, PartitionKey pk) + { + _inner = inner; + _handler = handler; + _pk = pk; + } + + public override bool HasMoreResults => _inner.HasMoreResults; + + public override async Task> ReadNextAsync(CancellationToken cancellationToken = default) + { + using (_handler.WithPartitionKey(_pk)) + { + return await _inner.ReadNextAsync(cancellationToken); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } } /// @@ -311,32 +311,32 @@ protected override void Dispose(bool disposing) /// internal sealed class PkCapturingStreamFeedIterator : FeedIterator { - private readonly FeedIterator _inner; - private readonly FakeCosmosHandler _handler; - private readonly PartitionKey _pk; - - internal PkCapturingStreamFeedIterator(FeedIterator inner, FakeCosmosHandler handler, PartitionKey pk) - { - _inner = inner; - _handler = handler; - _pk = pk; - } - - public override bool HasMoreResults => _inner.HasMoreResults; - - public override async Task ReadNextAsync(CancellationToken cancellationToken = default) - { - using (_handler.WithPartitionKey(_pk)) - { - return await _inner.ReadNextAsync(cancellationToken); - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) _inner.Dispose(); - base.Dispose(disposing); - } + private readonly FeedIterator _inner; + private readonly FakeCosmosHandler _handler; + private readonly PartitionKey _pk; + + internal PkCapturingStreamFeedIterator(FeedIterator inner, FakeCosmosHandler handler, PartitionKey pk) + { + _inner = inner; + _handler = handler; + _pk = pk; + } + + public override bool HasMoreResults => _inner.HasMoreResults; + + public override async Task ReadNextAsync(CancellationToken cancellationToken = default) + { + using (_handler.WithPartitionKey(_pk)) + { + return await _inner.ReadNextAsync(cancellationToken); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } } /// @@ -346,80 +346,80 @@ protected override void Dispose(bool disposing) /// internal sealed class PkAwareCosmosClient : CosmosClient { - private readonly CosmosClient _inner; - private readonly IReadOnlyDictionary _handlers; - - internal PkAwareCosmosClient( - CosmosClient inner, - IReadOnlyDictionary handlers) : base() - { - _inner = inner; - _handlers = handlers; - } - - public override Container GetContainer(string databaseId, string containerId) - { - var inner = _inner.GetContainer(databaseId, containerId); - if (_handlers.TryGetValue(containerId, out var handler) || - _handlers.TryGetValue($"{databaseId}/{containerId}", out handler)) - return new PartitionKeyCapturingContainer(inner, handler); - return inner; - } - - public override Database GetDatabase(string id) => _inner.GetDatabase(id); - public override Uri Endpoint => _inner.Endpoint; - public override CosmosClientOptions ClientOptions => _inner.ClientOptions; - - public override Task CreateDatabaseAsync( - string id, int? throughput = null, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateDatabaseAsync(id, throughput, requestOptions, cancellationToken); - - public override Task CreateDatabaseAsync( - string id, ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateDatabaseAsync(id, throughputProperties, requestOptions, cancellationToken); - - public override Task CreateDatabaseIfNotExistsAsync( - string id, int? throughput = null, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateDatabaseIfNotExistsAsync(id, throughput, requestOptions, cancellationToken); - - public override Task CreateDatabaseIfNotExistsAsync( - string id, ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, - CancellationToken cancellationToken = default) - => _inner.CreateDatabaseIfNotExistsAsync(id, throughputProperties, requestOptions, cancellationToken); - - public override Task CreateDatabaseStreamAsync( - DatabaseProperties databaseProperties, int? throughput = null, - RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - => _inner.CreateDatabaseStreamAsync(databaseProperties, throughput, requestOptions, cancellationToken); - - public override FeedIterator GetDatabaseQueryIterator( - QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => _inner.GetDatabaseQueryIterator(queryDefinition, continuationToken, requestOptions); - - public override FeedIterator GetDatabaseQueryIterator( - string? queryText = null, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => _inner.GetDatabaseQueryIterator(queryText, continuationToken, requestOptions); - - public override FeedIterator GetDatabaseQueryStreamIterator( - QueryDefinition queryDefinition, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => _inner.GetDatabaseQueryStreamIterator(queryDefinition, continuationToken, requestOptions); - - public override FeedIterator GetDatabaseQueryStreamIterator( - string? queryText = null, string? continuationToken = null, - QueryRequestOptions? requestOptions = null) - => _inner.GetDatabaseQueryStreamIterator(queryText, continuationToken, requestOptions); - - public override Task ReadAccountAsync() - => _inner.ReadAccountAsync(); - - protected override void Dispose(bool disposing) - { - if (disposing) _inner.Dispose(); - } + private readonly CosmosClient _inner; + private readonly IReadOnlyDictionary _handlers; + + internal PkAwareCosmosClient( + CosmosClient inner, + IReadOnlyDictionary handlers) : base() + { + _inner = inner; + _handlers = handlers; + } + + public override Container GetContainer(string databaseId, string containerId) + { + var inner = _inner.GetContainer(databaseId, containerId); + if (_handlers.TryGetValue(containerId, out var handler) || + _handlers.TryGetValue($"{databaseId}/{containerId}", out handler)) + return new PartitionKeyCapturingContainer(inner, handler); + return inner; + } + + public override Database GetDatabase(string id) => _inner.GetDatabase(id); + public override Uri Endpoint => _inner.Endpoint; + public override CosmosClientOptions ClientOptions => _inner.ClientOptions; + + public override Task CreateDatabaseAsync( + string id, int? throughput = null, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateDatabaseAsync(id, throughput, requestOptions, cancellationToken); + + public override Task CreateDatabaseAsync( + string id, ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateDatabaseAsync(id, throughputProperties, requestOptions, cancellationToken); + + public override Task CreateDatabaseIfNotExistsAsync( + string id, int? throughput = null, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateDatabaseIfNotExistsAsync(id, throughput, requestOptions, cancellationToken); + + public override Task CreateDatabaseIfNotExistsAsync( + string id, ThroughputProperties throughputProperties, RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + => _inner.CreateDatabaseIfNotExistsAsync(id, throughputProperties, requestOptions, cancellationToken); + + public override Task CreateDatabaseStreamAsync( + DatabaseProperties databaseProperties, int? throughput = null, + RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + => _inner.CreateDatabaseStreamAsync(databaseProperties, throughput, requestOptions, cancellationToken); + + public override FeedIterator GetDatabaseQueryIterator( + QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => _inner.GetDatabaseQueryIterator(queryDefinition, continuationToken, requestOptions); + + public override FeedIterator GetDatabaseQueryIterator( + string? queryText = null, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => _inner.GetDatabaseQueryIterator(queryText, continuationToken, requestOptions); + + public override FeedIterator GetDatabaseQueryStreamIterator( + QueryDefinition queryDefinition, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => _inner.GetDatabaseQueryStreamIterator(queryDefinition, continuationToken, requestOptions); + + public override FeedIterator GetDatabaseQueryStreamIterator( + string? queryText = null, string? continuationToken = null, + QueryRequestOptions? requestOptions = null) + => _inner.GetDatabaseQueryStreamIterator(queryText, continuationToken, requestOptions); + + public override Task ReadAccountAsync() + => _inner.ReadAccountAsync(); + + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + } } diff --git a/src/CosmosDB.InMemoryEmulator/PartitionKeyHash.cs b/src/CosmosDB.InMemoryEmulator/PartitionKeyHash.cs index 2c3e97f..fe322ef 100644 --- a/src/CosmosDB.InMemoryEmulator/PartitionKeyHash.cs +++ b/src/CosmosDB.InMemoryEmulator/PartitionKeyHash.cs @@ -8,81 +8,81 @@ namespace CosmosDB.InMemoryEmulator; /// internal static class PartitionKeyHash { - internal static uint MurmurHash3(string value) - { - var data = Encoding.UTF8.GetBytes(value); - const uint seed = 0; - const uint c1 = 0xcc9e2d51; - const uint c2 = 0x1b873593; + internal static uint MurmurHash3(string value) + { + var data = Encoding.UTF8.GetBytes(value); + const uint seed = 0; + const uint c1 = 0xcc9e2d51; + const uint c2 = 0x1b873593; - var hash = seed; - var nblocks = data.Length / 4; + var hash = seed; + var nblocks = data.Length / 4; - for (var i = 0; i < nblocks; i++) - { - var k = BitConverter.ToUInt32(data, i * 4); - k *= c1; - k = RotateLeft(k, 15); - k *= c2; - hash ^= k; - hash = RotateLeft(hash, 13); - hash = hash * 5 + 0xe6546b64; - } + for (var i = 0; i < nblocks; i++) + { + var k = BitConverter.ToUInt32(data, i * 4); + k *= c1; + k = RotateLeft(k, 15); + k *= c2; + hash ^= k; + hash = RotateLeft(hash, 13); + hash = hash * 5 + 0xe6546b64; + } - uint tail = 0; - var tailStart = nblocks * 4; - switch (data.Length & 3) - { - case 3: tail ^= (uint)data[tailStart + 2] << 16; goto case 2; - case 2: tail ^= (uint)data[tailStart + 1] << 8; goto case 1; - case 1: - tail ^= data[tailStart]; - tail *= c1; - tail = RotateLeft(tail, 15); - tail *= c2; - hash ^= tail; - break; - } + uint tail = 0; + var tailStart = nblocks * 4; + switch (data.Length & 3) + { + case 3: tail ^= (uint)data[tailStart + 2] << 16; goto case 2; + case 2: tail ^= (uint)data[tailStart + 1] << 8; goto case 1; + case 1: + tail ^= data[tailStart]; + tail *= c1; + tail = RotateLeft(tail, 15); + tail *= c2; + hash ^= tail; + break; + } - hash ^= (uint)data.Length; - hash = FMix(hash); - return hash; - } + hash ^= (uint)data.Length; + hash = FMix(hash); + return hash; + } - /// - /// Returns the 0-based range index for a partition key value given N total ranges. - /// - internal static int GetRangeIndex(string partitionKeyValue, int rangeCount) - { - if (rangeCount <= 1) return 0; - var hash = MurmurHash3(partitionKeyValue); - // Use interval-based assignment matching FeedRange boundaries - // which divide the uint32 hash space [0, 0x100000000) into N equal intervals - var step = 0x1_0000_0000L / rangeCount; - var index = (int)(hash / step); - return Math.Min(index, rangeCount - 1); - } + /// + /// Returns the 0-based range index for a partition key value given N total ranges. + /// + internal static int GetRangeIndex(string partitionKeyValue, int rangeCount) + { + if (rangeCount <= 1) return 0; + var hash = MurmurHash3(partitionKeyValue); + // Use interval-based assignment matching FeedRange boundaries + // which divide the uint32 hash space [0, 0x100000000) into N equal intervals + var step = 0x1_0000_0000L / rangeCount; + var index = (int)(hash / step); + return Math.Min(index, rangeCount - 1); + } - /// - /// Converts a 0-based range index to a hex boundary string for FeedRangeEpk. - /// Range boundaries divide the uint32 hash space evenly and are encoded as uppercase hex. - /// - internal static string RangeBoundaryToHex(long boundary) - { - if (boundary <= 0) return ""; - if (boundary >= 0x1_0000_0000L) return "FF"; - return ((uint)boundary).ToString("X8"); - } + /// + /// Converts a 0-based range index to a hex boundary string for FeedRangeEpk. + /// Range boundaries divide the uint32 hash space evenly and are encoded as uppercase hex. + /// + internal static string RangeBoundaryToHex(long boundary) + { + if (boundary <= 0) return ""; + if (boundary >= 0x1_0000_0000L) return "FF"; + return ((uint)boundary).ToString("X8"); + } - private static uint RotateLeft(uint x, int r) => (x << r) | (x >> (32 - r)); + private static uint RotateLeft(uint x, int r) => (x << r) | (x >> (32 - r)); - private static uint FMix(uint h) - { - h ^= h >> 16; - h *= 0x85ebca6b; - h ^= h >> 13; - h *= 0xc2b2ae35; - h ^= h >> 16; - return h; - } + private static uint FMix(uint h) + { + h ^= h >> 16; + h *= 0x85ebca6b; + h ^= h >> 13; + h *= 0xc2b2ae35; + h ^= h >> 16; + return h; + } } diff --git a/src/CosmosDB.InMemoryEmulator/PartitionScopedCollectionContext.cs b/src/CosmosDB.InMemoryEmulator/PartitionScopedCollectionContext.cs index 5e7f670..abe85dd 100644 --- a/src/CosmosDB.InMemoryEmulator/PartitionScopedCollectionContext.cs +++ b/src/CosmosDB.InMemoryEmulator/PartitionScopedCollectionContext.cs @@ -7,53 +7,53 @@ namespace CosmosDB.InMemoryEmulator; internal sealed class PartitionScopedCollectionContext : ICollectionContext { - private readonly InMemoryContainer _container; - private readonly PartitionKey _partitionKey; - - public PartitionScopedCollectionContext(InMemoryContainer container, PartitionKey partitionKey) - { - _container = container; - _partitionKey = partitionKey; - } - - public string SelfLink => $"dbs/db/colls/{_container.Id}"; - - public JObject CreateDocument(JObject document) - { - var response = _container.CreateItemAsync(document, _partitionKey).GetAwaiter().GetResult(); - var json = JsonConvert.SerializeObject(response.Resource); - return JObject.Parse(json); - } - - public JObject ReadDocument(string id) - { - var response = _container.ReadItemAsync(id, _partitionKey).GetAwaiter().GetResult(); - return response.Resource; - } - - public IReadOnlyList QueryDocuments(string sql) - { - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = _partitionKey }); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - results.AddRange(page); - } - return results; - } - - public JObject ReplaceDocument(string id, JObject document) - { - var response = _container.ReplaceItemAsync(document, id, _partitionKey).GetAwaiter().GetResult(); - var json = JsonConvert.SerializeObject(response.Resource); - return JObject.Parse(json); - } - - public void DeleteDocument(string id) - { - _container.DeleteItemAsync(id, _partitionKey).GetAwaiter().GetResult(); - } + private readonly InMemoryContainer _container; + private readonly PartitionKey _partitionKey; + + public PartitionScopedCollectionContext(InMemoryContainer container, PartitionKey partitionKey) + { + _container = container; + _partitionKey = partitionKey; + } + + public string SelfLink => $"dbs/db/colls/{_container.Id}"; + + public JObject CreateDocument(JObject document) + { + var response = _container.CreateItemAsync(document, _partitionKey).GetAwaiter().GetResult(); + var json = JsonConvert.SerializeObject(response.Resource); + return JObject.Parse(json); + } + + public JObject ReadDocument(string id) + { + var response = _container.ReadItemAsync(id, _partitionKey).GetAwaiter().GetResult(); + return response.Resource; + } + + public IReadOnlyList QueryDocuments(string sql) + { + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = _partitionKey }); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + results.AddRange(page); + } + return results; + } + + public JObject ReplaceDocument(string id, JObject document) + { + var response = _container.ReplaceItemAsync(document, id, _partitionKey).GetAwaiter().GetResult(); + var json = JsonConvert.SerializeObject(response.Resource); + return JObject.Parse(json); + } + + public void DeleteDocument(string id) + { + _container.DeleteItemAsync(id, _partitionKey).GetAwaiter().GetResult(); + } } diff --git a/src/CosmosDB.InMemoryEmulator/SdkVersionDriftDetector.cs b/src/CosmosDB.InMemoryEmulator/SdkVersionDriftDetector.cs index 0399734..33e953f 100644 --- a/src/CosmosDB.InMemoryEmulator/SdkVersionDriftDetector.cs +++ b/src/CosmosDB.InMemoryEmulator/SdkVersionDriftDetector.cs @@ -10,55 +10,55 @@ namespace CosmosDB.InMemoryEmulator; /// public static class SdkVersionDriftDetector { - public static async Task RunAsync() - { - var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; - var report = new DriftReport - { - SdkVersion = sdkVersion, - TestSuiteVersion = typeof(FakeCosmosHandler).Assembly.GetName().Version?.ToString() ?? "unknown", - MinTestedVersion = FakeCosmosHandler.MinTestedSdkVersion.ToString(), - MaxTestedVersion = FakeCosmosHandler.MaxTestedSdkVersion.ToString(), - IsWithinTestedRange = IsWithinRange(sdkVersion), - }; - - try - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - report.CompatibilityPassed = true; - } - catch (Exception ex) - { - report.CompatibilityPassed = false; - report.CompatibilityError = ex.Message; - } - - // Run a handler to collect unrecognised headers across multiple code paths - var container = new InMemoryContainer("drift-check", "/pk"); - await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - using var handler = new FakeCosmosHandler(container); - using var client = handler.CreateClient(); - var cosmosContainer = client.GetContainer("db", "drift-check"); - - // Exercise read path - await cosmosContainer.ReadItemAsync("1", new PartitionKey("a")); - - // Exercise query path - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - - report.UnrecognisedHeaders = handler.UnrecognisedHeaders.Distinct().ToList(); - return report; - } - - private static bool IsWithinRange(string sdkVersionString) - { - if (!Version.TryParse(sdkVersionString, out var sdkVersion)) - return false; - - return sdkVersion >= FakeCosmosHandler.MinTestedSdkVersion - && sdkVersion <= FakeCosmosHandler.MaxTestedSdkVersion; - } + public static async Task RunAsync() + { + var sdkVersion = typeof(CosmosClient).Assembly.GetName().Version?.ToString() ?? "unknown"; + var report = new DriftReport + { + SdkVersion = sdkVersion, + TestSuiteVersion = typeof(FakeCosmosHandler).Assembly.GetName().Version?.ToString() ?? "unknown", + MinTestedVersion = FakeCosmosHandler.MinTestedSdkVersion.ToString(), + MaxTestedVersion = FakeCosmosHandler.MaxTestedSdkVersion.ToString(), + IsWithinTestedRange = IsWithinRange(sdkVersion), + }; + + try + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + report.CompatibilityPassed = true; + } + catch (Exception ex) + { + report.CompatibilityPassed = false; + report.CompatibilityError = ex.Message; + } + + // Run a handler to collect unrecognised headers across multiple code paths + var container = new InMemoryContainer("drift-check", "/pk"); + await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + using var handler = new FakeCosmosHandler(container); + using var client = handler.CreateClient(); + var cosmosContainer = client.GetContainer("db", "drift-check"); + + // Exercise read path + await cosmosContainer.ReadItemAsync("1", new PartitionKey("a")); + + // Exercise query path + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + + report.UnrecognisedHeaders = handler.UnrecognisedHeaders.Distinct().ToList(); + return report; + } + + private static bool IsWithinRange(string sdkVersionString) + { + if (!Version.TryParse(sdkVersionString, out var sdkVersion)) + return false; + + return sdkVersion >= FakeCosmosHandler.MinTestedSdkVersion + && sdkVersion <= FakeCosmosHandler.MaxTestedSdkVersion; + } } /// @@ -67,27 +67,27 @@ private static bool IsWithinRange(string sdkVersionString) /// public sealed class DriftReport { - [JsonPropertyName("sdkVersion")] - public string SdkVersion { get; set; } = ""; + [JsonPropertyName("sdkVersion")] + public string SdkVersion { get; set; } = ""; - [JsonPropertyName("testSuiteVersion")] - public string TestSuiteVersion { get; set; } = ""; + [JsonPropertyName("testSuiteVersion")] + public string TestSuiteVersion { get; set; } = ""; - [JsonPropertyName("minTestedVersion")] - public string MinTestedVersion { get; set; } = ""; + [JsonPropertyName("minTestedVersion")] + public string MinTestedVersion { get; set; } = ""; - [JsonPropertyName("maxTestedVersion")] - public string MaxTestedVersion { get; set; } = ""; + [JsonPropertyName("maxTestedVersion")] + public string MaxTestedVersion { get; set; } = ""; - [JsonPropertyName("isWithinTestedRange")] - public bool IsWithinTestedRange { get; set; } + [JsonPropertyName("isWithinTestedRange")] + public bool IsWithinTestedRange { get; set; } - [JsonPropertyName("compatibilityPassed")] - public bool CompatibilityPassed { get; set; } + [JsonPropertyName("compatibilityPassed")] + public bool CompatibilityPassed { get; set; } - [JsonPropertyName("compatibilityError")] - public string? CompatibilityError { get; set; } + [JsonPropertyName("compatibilityError")] + public string? CompatibilityError { get; set; } - [JsonPropertyName("unrecognisedHeaders")] - public List UnrecognisedHeaders { get; set; } = []; + [JsonPropertyName("unrecognisedHeaders")] + public List UnrecognisedHeaders { get; set; } = []; } diff --git a/src/CosmosDB.InMemoryEmulator/ServiceCollectionExtensions.cs b/src/CosmosDB.InMemoryEmulator/ServiceCollectionExtensions.cs index ac23379..691ab0e 100644 --- a/src/CosmosDB.InMemoryEmulator/ServiceCollectionExtensions.cs +++ b/src/CosmosDB.InMemoryEmulator/ServiceCollectionExtensions.cs @@ -12,371 +12,371 @@ namespace CosmosDB.InMemoryEmulator; /// public static class ServiceCollectionExtensions { - private const string DefaultDatabaseName = "in-memory-db"; - private const string FakeConnectionString = "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;"; - - /// - /// Replaces all registered and - /// instances in the service collection with in-memory equivalents backed by - /// . This provides the highest fidelity: a real - /// exercises the full SDK HTTP pipeline, with - /// intercepting requests in-process. - /// LINQ .ToFeedIterator() works without any production code changes. - /// - public static IServiceCollection UseInMemoryCosmosDB( - this IServiceCollection services, - Action? configure = null) - { - var options = new InMemoryCosmosOptions(); - configure?.Invoke(options); - - var databaseName = options.DatabaseName ?? DefaultDatabaseName; - - // Determine how many Container registrations existed and their lifetime - var existingContainerDescriptors = services - .Where(d => d.ServiceType == typeof(Container)) - .ToList(); - var containerLifetime = existingContainerDescriptors.FirstOrDefault()?.Lifetime - ?? ServiceLifetime.Singleton; - - // Always replace CosmosClient - services.RemoveAll(); - - // Determine container configs - List containerConfigs; - if (options.Containers.Count > 0) - { - containerConfigs = options.Containers; - // Explicit mode: remove existing Container registrations - services.RemoveAll(); - } - else if (existingContainerDescriptors.Count == 0) - { - // No existing registrations and no explicit config: create a default container - containerConfigs = [new ContainerConfig("in-memory-container")]; - } - else - { - // Auto-detect mode: no explicit containers, but there are existing registrations. - // We still need to create a handler. Use a default container. - containerConfigs = [new ContainerConfig("in-memory-container")]; - } - - // Create InMemoryContainers and FakeCosmosHandlers - var handlers = new Dictionary(); - foreach (var config in containerConfigs) - { - var container = config.ContainerProperties != null - ? new InMemoryContainer(config.ContainerProperties) - : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); - - if (options.StatePersistenceDirectory is not null) - { - var dbName = config.DatabaseName ?? databaseName; - var fileName = $"{dbName}_{config.ContainerName}.json"; - container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); - container.LoadPersistedState(); - } - - var handler = new FakeCosmosHandler(container); - // Use compound key (db/container) when databaseName is specified - if (config.DatabaseName is not null) - { - handlers[$"{config.DatabaseName}/{config.ContainerName}"] = handler; - } - // Also register container-only key (for backward compat and single-db scenarios), - // but only if no other container with the same name but different database exists. - if (!handlers.ContainsKey(config.ContainerName)) - { - handlers[config.ContainerName] = handler; - } - options.OnHandlerCreated?.Invoke(config.ContainerName, handler); - } - - // Build the HTTP handler (single or router) - HttpMessageHandler httpHandler = handlers.Count == 1 - ? handlers.Values.First() - : FakeCosmosHandler.CreateRouter(handlers); - - // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) - var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; - - // Create a real CosmosClient with the FakeCosmosHandler, wrapped for prefix PK support - var innerClient = new CosmosClient( - FakeConnectionString, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(finalHandler) - }); - var client = FakeCosmosHandler.WrapClient(innerClient, handlers); - - options.OnClientCreated?.Invoke(client); - - // Register Container(s) in DI - if (options.Containers.Count > 0 || existingContainerDescriptors.Count == 0) - { - foreach (var config in containerConfigs) - { - var dbName = config.DatabaseName ?? databaseName; - var containerName = config.ContainerName; - services.Add(new ServiceDescriptor( - typeof(Container), - _ => client.GetContainer(dbName, containerName), - containerLifetime)); - } - } - // else: auto-detect mode — keep existing Container registrations as-is - - // Register the real CosmosClient backed by FakeCosmosHandler - services.AddSingleton(client); - - // No need for InMemoryFeedIteratorSetup — FakeCosmosHandler handles .ToFeedIterator() natively - - return services; - } - - /// - /// Replaces all registered instances in the service collection - /// with in-memory equivalents backed by . - /// Does NOT replace — any existing client registration is preserved. - /// A hidden internal is created for the in-memory containers - /// so that .ToFeedIterator() works without any production code changes. - /// - public static IServiceCollection UseInMemoryCosmosContainers( - this IServiceCollection services, - Action? configure = null) - { - var options = new InMemoryContainerOptions(); - configure?.Invoke(options); - - var databaseName = options.DatabaseName ?? DefaultDatabaseName; - - // Determine existing lifetime - var existingDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(Container)); - var lifetime = existingDescriptor?.Lifetime ?? ServiceLifetime.Singleton; - - // Remove existing Container registrations - services.RemoveAll(); - - // Determine container configs - var containerConfigs = options.Containers.Count > 0 - ? options.Containers - : [new ContainerConfig("in-memory-container")]; - - // Create InMemoryContainers and FakeCosmosHandlers - var handlers = new Dictionary(); - foreach (var config in containerConfigs) - { - var container = config.ContainerProperties != null - ? new InMemoryContainer(config.ContainerProperties) - : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); - - if (options.StatePersistenceDirectory is not null) - { - var fileName = $"{config.ContainerName}.json"; - container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); - container.LoadPersistedState(); - } - - options.OnContainerCreated?.Invoke(container); - - var handler = new FakeCosmosHandler(container); - if (!handlers.ContainsKey(config.ContainerName)) - { - handlers[config.ContainerName] = handler; - } - options.OnHandlerCreated?.Invoke(config.ContainerName, handler); - } - - // Build the HTTP handler (single or router) - HttpMessageHandler httpHandler = handlers.Count == 1 - ? handlers.Values.First() - : FakeCosmosHandler.CreateRouter(handlers); - - // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) - var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; - - // Create a hidden internal CosmosClient with the FakeCosmosHandler - var innerClient = new CosmosClient( - FakeConnectionString, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(finalHandler) - }); - var client = FakeCosmosHandler.WrapClient(innerClient, handlers); - - // Register Container(s) in DI — but NOT the CosmosClient - foreach (var config in containerConfigs) - { - var containerName = config.ContainerName; - services.Add(new ServiceDescriptor( - typeof(Container), - _ => client.GetContainer(databaseName, containerName), - lifetime)); - } - - return services; - } - - /// - /// Replaces a typed registration with an in-memory equivalent - /// backed by . Designed for Pattern 2 (SCA.Common style) where - /// multiple typed subclasses are registered in DI and repos resolve - /// the specific typed client. - /// - /// must extend and have a constructor - /// accepting (string connectionString, CosmosClientOptions options). A Castle.Core - /// dynamic proxy is created that intercepts to provide - /// transparent prefix partition key support for hierarchical partition keys. - /// - /// - /// No production code changes are needed. Pass your real production typed client directly: - /// - /// services.UseInMemoryCosmosDB<EmployeeCosmosClient>(o => - /// o.AddContainer("employees", "/id")); - /// - /// - /// - /// Does NOT register in DI — Pattern 2 repos call - /// client.GetContainer() directly. Does NOT register as the base - /// type — each typed client is independent. - /// - /// - public static IServiceCollection UseInMemoryCosmosDB( - this IServiceCollection services, - Action? configure = null) - where TClient : CosmosClient - { - var options = new InMemoryCosmosOptions(); - configure?.Invoke(options); - - var databaseName = options.DatabaseName ?? DefaultDatabaseName; - - // Determine existing lifetime - var existingDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(TClient)); - var lifetime = existingDescriptor?.Lifetime ?? ServiceLifetime.Singleton; - - // Remove existing registration for the typed client - services.RemoveAll(); - - // Determine container configs - var containerConfigs = options.Containers.Count > 0 - ? options.Containers - : [new ContainerConfig("in-memory-container")]; - - // Create InMemoryContainers and FakeCosmosHandlers - var handlers = new Dictionary(); - foreach (var config in containerConfigs) - { - var container = config.ContainerProperties != null - ? new InMemoryContainer(config.ContainerProperties) - : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); - - if (options.StatePersistenceDirectory is not null) - { - var dbName = config.DatabaseName ?? databaseName; - var fileName = $"{dbName}_{config.ContainerName}.json"; - container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); - container.LoadPersistedState(); - } - - var handler = new FakeCosmosHandler(container); - if (config.DatabaseName is not null) - { - handlers[$"{config.DatabaseName}/{config.ContainerName}"] = handler; - } - if (!handlers.ContainsKey(config.ContainerName)) - { - handlers[config.ContainerName] = handler; - } - options.OnHandlerCreated?.Invoke(config.ContainerName, handler); - } - - // Build the HTTP handler (single or router) - HttpMessageHandler httpHandler = handlers.Count == 1 - ? handlers.Values.First() - : FakeCosmosHandler.CreateRouter(handlers); - - // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) - var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; - - // Build CosmosClientOptions with the FakeCosmosHandler - var cosmosClientOptions = new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(finalHandler) - }; - - // Create a Castle.Core dynamic proxy of TClient that intercepts GetContainer() - // to wrap results in PartitionKeyCapturingContainer for hierarchical PK prefix support. - TClient client; - try - { - var generator = new ProxyGenerator(); - var interceptor = new GetContainerInterceptor(handlers); - var proxyOptions = new ProxyGenerationOptions(new GetContainerOnlyHook()); - client = (TClient)generator.CreateClassProxy( - typeof(TClient), - Type.EmptyTypes, - proxyOptions, - new object[] { FakeConnectionString, cosmosClientOptions }, - interceptor); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - throw new InvalidOperationException( - $"UseInMemoryCosmosDB<{typeof(TClient).Name}>() failed to create a dynamic proxy. " + - $"Ensure that '{typeof(TClient).Name}' is not sealed and has a public constructor " + - $"accepting (string connectionString, CosmosClientOptions options). " + - $"See inner exception for details.", ex); - } - - options.OnClientCreated?.Invoke(client); - - // Register as the typed client only — not as base CosmosClient - services.Add(new ServiceDescriptor(typeof(TClient), _ => client, lifetime)); - - return services; - } - - /// - /// Castle.Core interceptor that wraps results - /// in for transparent hierarchical partition - /// key prefix support. - /// - private sealed class GetContainerInterceptor( - IReadOnlyDictionary handlers) : IInterceptor - { - public void Intercept(IInvocation invocation) - { - invocation.Proceed(); - if (invocation.Method.Name == nameof(CosmosClient.GetContainer) - && invocation.ReturnValue is Container container) - { - var databaseId = (string)invocation.Arguments[0]; - var containerId = (string)invocation.Arguments[1]; - if (handlers.TryGetValue(containerId, out var handler) || - handlers.TryGetValue($"{databaseId}/{containerId}", out handler)) - { - invocation.ReturnValue = new PartitionKeyCapturingContainer(container, handler); - } - } - } - } - - /// - /// Restricts Castle.Core to only proxy the GetContainer method, avoiding - /// from internal virtual members on - /// . - /// - private sealed class GetContainerOnlyHook : IProxyGenerationHook - { - public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) - => methodInfo.Name == nameof(CosmosClient.GetContainer); - - public void NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } - public void MethodsInspected() { } - } + private const string DefaultDatabaseName = "in-memory-db"; + private const string FakeConnectionString = "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;"; + + /// + /// Replaces all registered and + /// instances in the service collection with in-memory equivalents backed by + /// . This provides the highest fidelity: a real + /// exercises the full SDK HTTP pipeline, with + /// intercepting requests in-process. + /// LINQ .ToFeedIterator() works without any production code changes. + /// + public static IServiceCollection UseInMemoryCosmosDB( + this IServiceCollection services, + Action? configure = null) + { + var options = new InMemoryCosmosOptions(); + configure?.Invoke(options); + + var databaseName = options.DatabaseName ?? DefaultDatabaseName; + + // Determine how many Container registrations existed and their lifetime + var existingContainerDescriptors = services + .Where(d => d.ServiceType == typeof(Container)) + .ToList(); + var containerLifetime = existingContainerDescriptors.FirstOrDefault()?.Lifetime + ?? ServiceLifetime.Singleton; + + // Always replace CosmosClient + services.RemoveAll(); + + // Determine container configs + List containerConfigs; + if (options.Containers.Count > 0) + { + containerConfigs = options.Containers; + // Explicit mode: remove existing Container registrations + services.RemoveAll(); + } + else if (existingContainerDescriptors.Count == 0) + { + // No existing registrations and no explicit config: create a default container + containerConfigs = [new ContainerConfig("in-memory-container")]; + } + else + { + // Auto-detect mode: no explicit containers, but there are existing registrations. + // We still need to create a handler. Use a default container. + containerConfigs = [new ContainerConfig("in-memory-container")]; + } + + // Create InMemoryContainers and FakeCosmosHandlers + var handlers = new Dictionary(); + foreach (var config in containerConfigs) + { + var container = config.ContainerProperties != null + ? new InMemoryContainer(config.ContainerProperties) + : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); + + if (options.StatePersistenceDirectory is not null) + { + var dbName = config.DatabaseName ?? databaseName; + var fileName = $"{dbName}_{config.ContainerName}.json"; + container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); + container.LoadPersistedState(); + } + + var handler = new FakeCosmosHandler(container); + // Use compound key (db/container) when databaseName is specified + if (config.DatabaseName is not null) + { + handlers[$"{config.DatabaseName}/{config.ContainerName}"] = handler; + } + // Also register container-only key (for backward compat and single-db scenarios), + // but only if no other container with the same name but different database exists. + if (!handlers.ContainsKey(config.ContainerName)) + { + handlers[config.ContainerName] = handler; + } + options.OnHandlerCreated?.Invoke(config.ContainerName, handler); + } + + // Build the HTTP handler (single or router) + HttpMessageHandler httpHandler = handlers.Count == 1 + ? handlers.Values.First() + : FakeCosmosHandler.CreateRouter(handlers); + + // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) + var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; + + // Create a real CosmosClient with the FakeCosmosHandler, wrapped for prefix PK support + var innerClient = new CosmosClient( + FakeConnectionString, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(finalHandler) + }); + var client = FakeCosmosHandler.WrapClient(innerClient, handlers); + + options.OnClientCreated?.Invoke(client); + + // Register Container(s) in DI + if (options.Containers.Count > 0 || existingContainerDescriptors.Count == 0) + { + foreach (var config in containerConfigs) + { + var dbName = config.DatabaseName ?? databaseName; + var containerName = config.ContainerName; + services.Add(new ServiceDescriptor( + typeof(Container), + _ => client.GetContainer(dbName, containerName), + containerLifetime)); + } + } + // else: auto-detect mode — keep existing Container registrations as-is + + // Register the real CosmosClient backed by FakeCosmosHandler + services.AddSingleton(client); + + // No need for InMemoryFeedIteratorSetup — FakeCosmosHandler handles .ToFeedIterator() natively + + return services; + } + + /// + /// Replaces all registered instances in the service collection + /// with in-memory equivalents backed by . + /// Does NOT replace — any existing client registration is preserved. + /// A hidden internal is created for the in-memory containers + /// so that .ToFeedIterator() works without any production code changes. + /// + public static IServiceCollection UseInMemoryCosmosContainers( + this IServiceCollection services, + Action? configure = null) + { + var options = new InMemoryContainerOptions(); + configure?.Invoke(options); + + var databaseName = options.DatabaseName ?? DefaultDatabaseName; + + // Determine existing lifetime + var existingDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(Container)); + var lifetime = existingDescriptor?.Lifetime ?? ServiceLifetime.Singleton; + + // Remove existing Container registrations + services.RemoveAll(); + + // Determine container configs + var containerConfigs = options.Containers.Count > 0 + ? options.Containers + : [new ContainerConfig("in-memory-container")]; + + // Create InMemoryContainers and FakeCosmosHandlers + var handlers = new Dictionary(); + foreach (var config in containerConfigs) + { + var container = config.ContainerProperties != null + ? new InMemoryContainer(config.ContainerProperties) + : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); + + if (options.StatePersistenceDirectory is not null) + { + var fileName = $"{config.ContainerName}.json"; + container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); + container.LoadPersistedState(); + } + + options.OnContainerCreated?.Invoke(container); + + var handler = new FakeCosmosHandler(container); + if (!handlers.ContainsKey(config.ContainerName)) + { + handlers[config.ContainerName] = handler; + } + options.OnHandlerCreated?.Invoke(config.ContainerName, handler); + } + + // Build the HTTP handler (single or router) + HttpMessageHandler httpHandler = handlers.Count == 1 + ? handlers.Values.First() + : FakeCosmosHandler.CreateRouter(handlers); + + // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) + var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; + + // Create a hidden internal CosmosClient with the FakeCosmosHandler + var innerClient = new CosmosClient( + FakeConnectionString, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(finalHandler) + }); + var client = FakeCosmosHandler.WrapClient(innerClient, handlers); + + // Register Container(s) in DI — but NOT the CosmosClient + foreach (var config in containerConfigs) + { + var containerName = config.ContainerName; + services.Add(new ServiceDescriptor( + typeof(Container), + _ => client.GetContainer(databaseName, containerName), + lifetime)); + } + + return services; + } + + /// + /// Replaces a typed registration with an in-memory equivalent + /// backed by . Designed for Pattern 2 (SCA.Common style) where + /// multiple typed subclasses are registered in DI and repos resolve + /// the specific typed client. + /// + /// must extend and have a constructor + /// accepting (string connectionString, CosmosClientOptions options). A Castle.Core + /// dynamic proxy is created that intercepts to provide + /// transparent prefix partition key support for hierarchical partition keys. + /// + /// + /// No production code changes are needed. Pass your real production typed client directly: + /// + /// services.UseInMemoryCosmosDB<EmployeeCosmosClient>(o => + /// o.AddContainer("employees", "/id")); + /// + /// + /// + /// Does NOT register in DI — Pattern 2 repos call + /// client.GetContainer() directly. Does NOT register as the base + /// type — each typed client is independent. + /// + /// + public static IServiceCollection UseInMemoryCosmosDB( + this IServiceCollection services, + Action? configure = null) + where TClient : CosmosClient + { + var options = new InMemoryCosmosOptions(); + configure?.Invoke(options); + + var databaseName = options.DatabaseName ?? DefaultDatabaseName; + + // Determine existing lifetime + var existingDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(TClient)); + var lifetime = existingDescriptor?.Lifetime ?? ServiceLifetime.Singleton; + + // Remove existing registration for the typed client + services.RemoveAll(); + + // Determine container configs + var containerConfigs = options.Containers.Count > 0 + ? options.Containers + : [new ContainerConfig("in-memory-container")]; + + // Create InMemoryContainers and FakeCosmosHandlers + var handlers = new Dictionary(); + foreach (var config in containerConfigs) + { + var container = config.ContainerProperties != null + ? new InMemoryContainer(config.ContainerProperties) + : new InMemoryContainer(config.ContainerName, config.PartitionKeyPath); + + if (options.StatePersistenceDirectory is not null) + { + var dbName = config.DatabaseName ?? databaseName; + var fileName = $"{dbName}_{config.ContainerName}.json"; + container.StateFilePath = Path.Combine(options.StatePersistenceDirectory, fileName); + container.LoadPersistedState(); + } + + var handler = new FakeCosmosHandler(container); + if (config.DatabaseName is not null) + { + handlers[$"{config.DatabaseName}/{config.ContainerName}"] = handler; + } + if (!handlers.ContainsKey(config.ContainerName)) + { + handlers[config.ContainerName] = handler; + } + options.OnHandlerCreated?.Invoke(config.ContainerName, handler); + } + + // Build the HTTP handler (single or router) + HttpMessageHandler httpHandler = handlers.Count == 1 + ? handlers.Values.First() + : FakeCosmosHandler.CreateRouter(handlers); + + // Apply optional wrapper (e.g. DelegatingHandler for logging/tracking) + var finalHandler = options.HttpMessageHandlerWrapper?.Invoke(httpHandler) ?? httpHandler; + + // Build CosmosClientOptions with the FakeCosmosHandler + var cosmosClientOptions = new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(finalHandler) + }; + + // Create a Castle.Core dynamic proxy of TClient that intercepts GetContainer() + // to wrap results in PartitionKeyCapturingContainer for hierarchical PK prefix support. + TClient client; + try + { + var generator = new ProxyGenerator(); + var interceptor = new GetContainerInterceptor(handlers); + var proxyOptions = new ProxyGenerationOptions(new GetContainerOnlyHook()); + client = (TClient)generator.CreateClassProxy( + typeof(TClient), + Type.EmptyTypes, + proxyOptions, + new object[] { FakeConnectionString, cosmosClientOptions }, + interceptor); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + throw new InvalidOperationException( + $"UseInMemoryCosmosDB<{typeof(TClient).Name}>() failed to create a dynamic proxy. " + + $"Ensure that '{typeof(TClient).Name}' is not sealed and has a public constructor " + + $"accepting (string connectionString, CosmosClientOptions options). " + + $"See inner exception for details.", ex); + } + + options.OnClientCreated?.Invoke(client); + + // Register as the typed client only — not as base CosmosClient + services.Add(new ServiceDescriptor(typeof(TClient), _ => client, lifetime)); + + return services; + } + + /// + /// Castle.Core interceptor that wraps results + /// in for transparent hierarchical partition + /// key prefix support. + /// + private sealed class GetContainerInterceptor( + IReadOnlyDictionary handlers) : IInterceptor + { + public void Intercept(IInvocation invocation) + { + invocation.Proceed(); + if (invocation.Method.Name == nameof(CosmosClient.GetContainer) + && invocation.ReturnValue is Container container) + { + var databaseId = (string)invocation.Arguments[0]; + var containerId = (string)invocation.Arguments[1]; + if (handlers.TryGetValue(containerId, out var handler) || + handlers.TryGetValue($"{databaseId}/{containerId}", out handler)) + { + invocation.ReturnValue = new PartitionKeyCapturingContainer(container, handler); + } + } + } + } + + /// + /// Restricts Castle.Core to only proxy the GetContainer method, avoiding + /// from internal virtual members on + /// . + /// + private sealed class GetContainerOnlyHook : IProxyGenerationHook + { + public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) + => methodInfo.Name == nameof(CosmosClient.GetContainer); + + public void NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } + public void MethodsInspected() { } + } } diff --git a/src/CosmosDB.InMemoryEmulator/WireFormatStrategies.cs b/src/CosmosDB.InMemoryEmulator/WireFormatStrategies.cs index cac16cf..37b7766 100644 --- a/src/CosmosDB.InMemoryEmulator/WireFormatStrategies.cs +++ b/src/CosmosDB.InMemoryEmulator/WireFormatStrategies.cs @@ -13,14 +13,14 @@ namespace CosmosDB.InMemoryEmulator; /// public interface IQueryPlanStrategy { - /// - /// Builds a PartitionedQueryExecutionInfo JSON object for the given SQL query. - /// - /// The raw SQL query string. - /// The parsed query (null if parsing failed). - /// The collection resource ID for query ranges. - /// The complete query plan JSON object. - JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid); + /// + /// Builds a PartitionedQueryExecutionInfo JSON object for the given SQL query. + /// + /// The raw SQL query string. + /// The parsed query (null if parsing failed). + /// The collection resource ID for query ranges. + /// The complete query plan JSON object. + JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid); } /// @@ -34,14 +34,14 @@ public interface IQueryPlanStrategy /// public interface IBatchSchemaStrategy { - /// - /// Returns true if batch schema resolution succeeded and batch operations are supported. - /// - bool IsAvailable { get; } + /// + /// Returns true if batch schema resolution succeeded and batch operations are supported. + /// + bool IsAvailable { get; } - /// - /// A human-readable message describing why batch schemas are unavailable, - /// or null if they are available. - /// - string? UnavailableReason { get; } + /// + /// A human-readable message describing why batch schemas are unavailable, + /// or null if they are available. + /// + string? UnavailableReason { get; } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fdf6b25..751adc4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ true - 4.0.17 + 4.0.21 lemonlion Copyright (c) 2026 lemonlion MIT diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBatchTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBatchTests.cs index fdbbbe8..91bb1dc 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBatchTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBatchTests.cs @@ -18,335 +18,335 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerBatchTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-batch", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - [Fact] - public async Task Batch_CreateTwoItems_ReturnsOk() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "item-1", PartitionKey = "pk1", Name = "Alice", Value = 1 }); - batch.CreateItem(new TestDocument { Id = "item-2", PartitionKey = "pk1", Name = "Bob", Value = 2 }); - - var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(2); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - response[1].StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_CreateItems_PersistsToBackingContainer() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Persisted", Value = 42 }); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - // Verify item exists via direct point read through the SDK - var readResponse = await _container.ReadItemAsync("p1", new PartitionKey("pk1")); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - readResponse.Resource.Name.Should().Be("Persisted"); - } - - [Fact] - public async Task Batch_UpsertItem_ReturnsOk() - { - // Create initial item - await _container.CreateItemAsync(new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Original", Value = 1 }, new PartitionKey("pk1")); - - // Upsert via batch - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Updated", Value = 2 }); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var readResponse = await _container.ReadItemAsync("u1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Batch_ReadItem_ReturnsOk() - { - await _container.CreateItemAsync(new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Readable", Value = 1 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("r1"); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_DeleteItem_ReturnsOk() - { - await _container.CreateItemAsync(new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "Deletable", Value = 1 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("d1"); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - // Verify item is gone - try - { - await _container.ReadItemAsync("d1", new PartitionKey("pk1")); - throw new Exception("Should have thrown CosmosException with NotFound"); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected - } - } - - [Fact] - public async Task Batch_ReplaceItem_ReturnsOk() - { - await _container.CreateItemAsync(new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Original", Value = 1 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("rp1", new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Replaced", Value = 99 }); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var readResponse = await _container.ReadItemAsync("rp1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task Batch_MixedOperations_ReturnsOk() - { - // Pre-create items for replace and delete - await _container.CreateItemAsync(new TestDocument { Id = "mx-replace", PartitionKey = "pk1", Name = "ToReplace", Value = 1 }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "mx-delete", PartitionKey = "pk1", Name = "ToDelete", Value = 2 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "mx-new", PartitionKey = "pk1", Name = "New", Value = 10 }); - batch.ReplaceItem("mx-replace", new TestDocument { Id = "mx-replace", PartitionKey = "pk1", Name = "Replaced", Value = 11 }); - batch.DeleteItem("mx-delete"); - batch.ReadItem("mx-replace"); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(4); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); // Create - response[1].StatusCode.Should().Be(HttpStatusCode.OK); // Replace - response[2].StatusCode.Should().Be(HttpStatusCode.NoContent); // Delete - response[3].StatusCode.Should().Be(HttpStatusCode.OK); // Read - } - - [Fact] - public async Task Batch_DuplicateId_RollsBackOnFailure() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "dup-1", PartitionKey = "pk1", Name = "First", Value = 1 }); - batch.CreateItem(new TestDocument { Id = "dup-1", PartitionKey = "pk1", Name = "Duplicate", Value = 2 }); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // First item should have been rolled back - try - { - await _container.ReadItemAsync("dup-1", new PartitionKey("pk1")); - throw new Exception("Should have thrown CosmosException with NotFound"); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected - rollback - } - } - - [Fact] - public async Task Batch_ConcurrentCreateSameId_OnlyOneSucceeds() - { - // Issue #57: When multiple concurrent transactional batches each attempt to CreateItem - // with the same id and partition key, only one should succeed. The others should fail - // with 409 Conflict and roll back (transactional semantics). - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-document - // "If the id already exists in the collection, a 409 Conflict is returned." - var tasks = Enumerable.Range(1, 3).Select(attempt => Task.Run(async () => - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("concurrent-pk")); - batch.CreateItem(new TestDocument - { - Id = "latest-conc", - PartitionKey = "concurrent-pk", - Name = $"Attempt-{attempt}", - Value = attempt - }); - batch.CreateItem(new TestDocument - { - Id = $"tracking-conc-{attempt}", - PartitionKey = "concurrent-pk", - Name = $"Tracking-{attempt}", - Value = attempt - }); - return await batch.ExecuteAsync(); - })).ToArray(); - - var results = await Task.WhenAll(tasks); - - var successCount = results.Count(r => r.IsSuccessStatusCode); - var failCount = results.Count(r => !r.IsSuccessStatusCode); - - // Exactly one batch should succeed - successCount.Should().Be(1, "only one concurrent batch creating the same id should succeed"); - failCount.Should().Be(2, "the other batches should fail with Conflict and roll back"); - - // The failed batches should have 409 Conflict status - foreach (var failed in results.Where(r => !r.IsSuccessStatusCode)) - { - failed.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - // Verify exactly one "latest-conc" document exists - var latestResponse = await _container.ReadItemAsync("latest-conc", new PartitionKey("concurrent-pk")); - latestResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify only the winning batch's tracking document exists - var winningAttempt = latestResponse.Resource.Value; - var trackingResponse = await _container.ReadItemAsync( - $"tracking-conc-{winningAttempt}", new PartitionKey("concurrent-pk")); - trackingResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // The other tracking documents should not exist (were rolled back) - foreach (var attempt in Enumerable.Range(1, 3).Where(a => a != winningAttempt)) - { - var ex = await Assert.ThrowsAsync(() => - _container.ReadItemAsync($"tracking-conc-{attempt}", new PartitionKey("concurrent-pk"))); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public void BatchSchemas_SelfBuiltSchemasMatchSdkInternals() - { - // Canary test: verify our self-built HybridRow schemas produce layouts - // that are compatible with the SDK's internal BatchSchemaProvider. - // If a future Cosmos SDK version changes the batch wire format, this test - // will detect the mismatch immediately. - - var bspType = typeof(CosmosClient).Assembly.GetType("Microsoft.Azure.Cosmos.BatchSchemaProvider"); - if (bspType is null) - { - // SDK reorganised its internals — self-built schemas are the only option. - // This is fine: if batch tests above pass, the schemas are correct. - return; - } - - var flags = BindingFlags.Public | BindingFlags.Static; - var sdkOpLayout = (Layout)bspType.GetProperty("BatchOperationLayout", flags)!.GetValue(null)!; - var sdkResultLayout = (Layout)bspType.GetProperty("BatchResultLayout", flags)!.GetValue(null)!; - var sdkResolver = (LayoutResolverNamespace)bspType.GetProperty("BatchLayoutResolver", flags)!.GetValue(null)!; - - // Verify our schemas resolve to layouts with the same SchemaId - var selfOpLayout = sdkResolver.Resolve(sdkOpLayout.SchemaId); - var selfResultLayout = sdkResolver.Resolve(sdkResultLayout.SchemaId); - - selfOpLayout.SchemaId.Should().Be(sdkOpLayout.SchemaId); - selfResultLayout.SchemaId.Should().Be(sdkResultLayout.SchemaId); - - // Verify the SDK resolver's namespace has the expected schema names - sdkResolver.Namespace.Schemas.Should().Contain(s => s.Name == "BatchOperation"); - sdkResolver.Namespace.Schemas.Should().Contain(s => s.Name == "BatchResult"); - - // Verify field counts match — if the SDK adds new fields, we'll know - var sdkOpSchema = sdkResolver.Namespace.Schemas.First(s => s.Name == "BatchOperation"); - var sdkResultSchema = sdkResolver.Namespace.Schemas.First(s => s.Name == "BatchResult"); - sdkOpSchema.Properties.Should().HaveCount(12, "BatchOperation schema should have 12 fields"); - sdkResultSchema.Properties.Should().HaveCount(6, "BatchResult schema should have 6 fields"); - - // Verify the field names we depend on exist in the SDK schema - var expectedOpFields = new[] { "operationType", "id", "resourceBody", "ifMatch", "ifNoneMatch" }; - foreach (var field in expectedOpFields) - { - sdkOpSchema.Properties.Should().Contain(p => p.Path == field, - $"BatchOperation schema should contain field '{field}'"); - } - - var expectedResultFields = new[] { "statusCode", "eTag", "resourceBody", "requestCharge" }; - foreach (var field in expectedResultFields) - { - sdkResultSchema.Properties.Should().Contain(p => p.Path == field, - $"BatchResult schema should contain field '{field}'"); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public void BatchSchemas_SelfBuiltRoundTrip_WriteThenRead() - { - // Verify that a HybridRow written with our self-built result schema - // can be read back correctly — proves the schema layout is functional. - var schemas = typeof(FakeCosmosHandler) - .GetNestedType("BatchSchemas", BindingFlags.NonPublic)!; - var resultLayoutProp = schemas.GetProperty("ResultLayout", BindingFlags.Public | BindingFlags.Static)!; - var resolverProp = schemas.GetProperty("Resolver", BindingFlags.Public | BindingFlags.Static)!; - var resultLayout = (Layout)resultLayoutProp.GetValue(null)!; - var resolver = (LayoutResolverNamespace)resolverProp.GetValue(null)!; - - // Write - var resizer = new MemorySpanResizer(256); - var row = new RowBuffer(256, resizer); - row.InitLayout(HybridRowVersion.V1, resultLayout, resolver); - var writeResult = RowWriter.WriteBuffer(ref row, 0, (ref RowWriter writer, TypeArgument _, int _2) => - { - var wr = writer.WriteInt32("statusCode", 200); - if (wr != Result.Success) return wr; - wr = writer.WriteString("eTag", "test-etag-123"); - if (wr != Result.Success) return wr; - wr = writer.WriteFloat64("requestCharge", 1.5); - return wr; - }); - writeResult.Should().Be(Result.Success); - - // Read - var readRow = new RowBuffer(row.Length); - readRow.ReadFrom(resizer.Memory.Span[..row.Length], HybridRowVersion.V1, resolver).Should().BeTrue(); - var reader = new RowReader(ref readRow); - int? statusCode = null; - string? eTag = null; - double? requestCharge = null; - while (reader.Read()) - { - switch (reader.Path) - { - case "statusCode": - reader.ReadInt32(out int sc); - statusCode = sc; - break; - case "eTag": - reader.ReadString(out string et); - eTag = et; - break; - case "requestCharge": - reader.ReadFloat64(out double rc); - requestCharge = rc; - break; - } - } - - statusCode.Should().Be(200); - eTag.Should().Be("test-etag-123"); - requestCharge.Should().Be(1.5); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-batch", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task Batch_CreateTwoItems_ReturnsOk() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "item-1", PartitionKey = "pk1", Name = "Alice", Value = 1 }); + batch.CreateItem(new TestDocument { Id = "item-2", PartitionKey = "pk1", Name = "Bob", Value = 2 }); + + var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(2); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + response[1].StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_CreateItems_PersistsToBackingContainer() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Persisted", Value = 42 }); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + // Verify item exists via direct point read through the SDK + var readResponse = await _container.ReadItemAsync("p1", new PartitionKey("pk1")); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + readResponse.Resource.Name.Should().Be("Persisted"); + } + + [Fact] + public async Task Batch_UpsertItem_ReturnsOk() + { + // Create initial item + await _container.CreateItemAsync(new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Original", Value = 1 }, new PartitionKey("pk1")); + + // Upsert via batch + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Updated", Value = 2 }); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var readResponse = await _container.ReadItemAsync("u1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Batch_ReadItem_ReturnsOk() + { + await _container.CreateItemAsync(new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Readable", Value = 1 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("r1"); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_DeleteItem_ReturnsOk() + { + await _container.CreateItemAsync(new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "Deletable", Value = 1 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("d1"); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + // Verify item is gone + try + { + await _container.ReadItemAsync("d1", new PartitionKey("pk1")); + throw new Exception("Should have thrown CosmosException with NotFound"); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected + } + } + + [Fact] + public async Task Batch_ReplaceItem_ReturnsOk() + { + await _container.CreateItemAsync(new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Original", Value = 1 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("rp1", new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Replaced", Value = 99 }); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var readResponse = await _container.ReadItemAsync("rp1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task Batch_MixedOperations_ReturnsOk() + { + // Pre-create items for replace and delete + await _container.CreateItemAsync(new TestDocument { Id = "mx-replace", PartitionKey = "pk1", Name = "ToReplace", Value = 1 }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "mx-delete", PartitionKey = "pk1", Name = "ToDelete", Value = 2 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "mx-new", PartitionKey = "pk1", Name = "New", Value = 10 }); + batch.ReplaceItem("mx-replace", new TestDocument { Id = "mx-replace", PartitionKey = "pk1", Name = "Replaced", Value = 11 }); + batch.DeleteItem("mx-delete"); + batch.ReadItem("mx-replace"); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(4); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); // Create + response[1].StatusCode.Should().Be(HttpStatusCode.OK); // Replace + response[2].StatusCode.Should().Be(HttpStatusCode.NoContent); // Delete + response[3].StatusCode.Should().Be(HttpStatusCode.OK); // Read + } + + [Fact] + public async Task Batch_DuplicateId_RollsBackOnFailure() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "dup-1", PartitionKey = "pk1", Name = "First", Value = 1 }); + batch.CreateItem(new TestDocument { Id = "dup-1", PartitionKey = "pk1", Name = "Duplicate", Value = 2 }); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // First item should have been rolled back + try + { + await _container.ReadItemAsync("dup-1", new PartitionKey("pk1")); + throw new Exception("Should have thrown CosmosException with NotFound"); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected - rollback + } + } + + [Fact] + public async Task Batch_ConcurrentCreateSameId_OnlyOneSucceeds() + { + // Issue #57: When multiple concurrent transactional batches each attempt to CreateItem + // with the same id and partition key, only one should succeed. The others should fail + // with 409 Conflict and roll back (transactional semantics). + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/create-a-document + // "If the id already exists in the collection, a 409 Conflict is returned." + var tasks = Enumerable.Range(1, 3).Select(attempt => Task.Run(async () => + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("concurrent-pk")); + batch.CreateItem(new TestDocument + { + Id = "latest-conc", + PartitionKey = "concurrent-pk", + Name = $"Attempt-{attempt}", + Value = attempt + }); + batch.CreateItem(new TestDocument + { + Id = $"tracking-conc-{attempt}", + PartitionKey = "concurrent-pk", + Name = $"Tracking-{attempt}", + Value = attempt + }); + return await batch.ExecuteAsync(); + })).ToArray(); + + var results = await Task.WhenAll(tasks); + + var successCount = results.Count(r => r.IsSuccessStatusCode); + var failCount = results.Count(r => !r.IsSuccessStatusCode); + + // Exactly one batch should succeed + successCount.Should().Be(1, "only one concurrent batch creating the same id should succeed"); + failCount.Should().Be(2, "the other batches should fail with Conflict and roll back"); + + // The failed batches should have 409 Conflict status + foreach (var failed in results.Where(r => !r.IsSuccessStatusCode)) + { + failed.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // Verify exactly one "latest-conc" document exists + var latestResponse = await _container.ReadItemAsync("latest-conc", new PartitionKey("concurrent-pk")); + latestResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify only the winning batch's tracking document exists + var winningAttempt = latestResponse.Resource.Value; + var trackingResponse = await _container.ReadItemAsync( + $"tracking-conc-{winningAttempt}", new PartitionKey("concurrent-pk")); + trackingResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // The other tracking documents should not exist (were rolled back) + foreach (var attempt in Enumerable.Range(1, 3).Where(a => a != winningAttempt)) + { + var ex = await Assert.ThrowsAsync(() => + _container.ReadItemAsync($"tracking-conc-{attempt}", new PartitionKey("concurrent-pk"))); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public void BatchSchemas_SelfBuiltSchemasMatchSdkInternals() + { + // Canary test: verify our self-built HybridRow schemas produce layouts + // that are compatible with the SDK's internal BatchSchemaProvider. + // If a future Cosmos SDK version changes the batch wire format, this test + // will detect the mismatch immediately. + + var bspType = typeof(CosmosClient).Assembly.GetType("Microsoft.Azure.Cosmos.BatchSchemaProvider"); + if (bspType is null) + { + // SDK reorganised its internals — self-built schemas are the only option. + // This is fine: if batch tests above pass, the schemas are correct. + return; + } + + var flags = BindingFlags.Public | BindingFlags.Static; + var sdkOpLayout = (Layout)bspType.GetProperty("BatchOperationLayout", flags)!.GetValue(null)!; + var sdkResultLayout = (Layout)bspType.GetProperty("BatchResultLayout", flags)!.GetValue(null)!; + var sdkResolver = (LayoutResolverNamespace)bspType.GetProperty("BatchLayoutResolver", flags)!.GetValue(null)!; + + // Verify our schemas resolve to layouts with the same SchemaId + var selfOpLayout = sdkResolver.Resolve(sdkOpLayout.SchemaId); + var selfResultLayout = sdkResolver.Resolve(sdkResultLayout.SchemaId); + + selfOpLayout.SchemaId.Should().Be(sdkOpLayout.SchemaId); + selfResultLayout.SchemaId.Should().Be(sdkResultLayout.SchemaId); + + // Verify the SDK resolver's namespace has the expected schema names + sdkResolver.Namespace.Schemas.Should().Contain(s => s.Name == "BatchOperation"); + sdkResolver.Namespace.Schemas.Should().Contain(s => s.Name == "BatchResult"); + + // Verify field counts match — if the SDK adds new fields, we'll know + var sdkOpSchema = sdkResolver.Namespace.Schemas.First(s => s.Name == "BatchOperation"); + var sdkResultSchema = sdkResolver.Namespace.Schemas.First(s => s.Name == "BatchResult"); + sdkOpSchema.Properties.Should().HaveCount(12, "BatchOperation schema should have 12 fields"); + sdkResultSchema.Properties.Should().HaveCount(6, "BatchResult schema should have 6 fields"); + + // Verify the field names we depend on exist in the SDK schema + var expectedOpFields = new[] { "operationType", "id", "resourceBody", "ifMatch", "ifNoneMatch" }; + foreach (var field in expectedOpFields) + { + sdkOpSchema.Properties.Should().Contain(p => p.Path == field, + $"BatchOperation schema should contain field '{field}'"); + } + + var expectedResultFields = new[] { "statusCode", "eTag", "resourceBody", "requestCharge" }; + foreach (var field in expectedResultFields) + { + sdkResultSchema.Properties.Should().Contain(p => p.Path == field, + $"BatchResult schema should contain field '{field}'"); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public void BatchSchemas_SelfBuiltRoundTrip_WriteThenRead() + { + // Verify that a HybridRow written with our self-built result schema + // can be read back correctly — proves the schema layout is functional. + var schemas = typeof(FakeCosmosHandler) + .GetNestedType("BatchSchemas", BindingFlags.NonPublic)!; + var resultLayoutProp = schemas.GetProperty("ResultLayout", BindingFlags.Public | BindingFlags.Static)!; + var resolverProp = schemas.GetProperty("Resolver", BindingFlags.Public | BindingFlags.Static)!; + var resultLayout = (Layout)resultLayoutProp.GetValue(null)!; + var resolver = (LayoutResolverNamespace)resolverProp.GetValue(null)!; + + // Write + var resizer = new MemorySpanResizer(256); + var row = new RowBuffer(256, resizer); + row.InitLayout(HybridRowVersion.V1, resultLayout, resolver); + var writeResult = RowWriter.WriteBuffer(ref row, 0, (ref RowWriter writer, TypeArgument _, int _2) => + { + var wr = writer.WriteInt32("statusCode", 200); + if (wr != Result.Success) return wr; + wr = writer.WriteString("eTag", "test-etag-123"); + if (wr != Result.Success) return wr; + wr = writer.WriteFloat64("requestCharge", 1.5); + return wr; + }); + writeResult.Should().Be(Result.Success); + + // Read + var readRow = new RowBuffer(row.Length); + readRow.ReadFrom(resizer.Memory.Span[..row.Length], HybridRowVersion.V1, resolver).Should().BeTrue(); + var reader = new RowReader(ref readRow); + int? statusCode = null; + string? eTag = null; + double? requestCharge = null; + while (reader.Read()) + { + switch (reader.Path) + { + case "statusCode": + reader.ReadInt32(out int sc); + statusCode = sc; + break; + case "eTag": + reader.ReadString(out string et); + eTag = et; + break; + case "requestCharge": + reader.ReadFloat64(out double rc); + requestCharge = rc; + break; + } + } + + statusCode.Should().Be(200); + eTag.Should().Be("test-etag-123"); + requestCharge.Should().Be(1.5); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBulkTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBulkTests.cs index 9bb7755..86ec3f3 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBulkTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerBulkTests.cs @@ -13,102 +13,102 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerBulkTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-bulk", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - [Fact] - public async Task Bulk_ConcurrentCreates_AllSucceed() - { - var tasks = Enumerable.Range(0, 50).Select(i => - _container.CreateItemAsync( - new TestDocument { Id = $"bulk-{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - items.AddRange(page); - } - items.Should().HaveCount(50); - } - - [Fact] - public async Task Bulk_ConcurrentUpserts_AllSucceed() - { - // Create initial items - for (int i = 0; i < 10; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"upsert-{i}", PartitionKey = "pk1", Name = $"Original{i}", Value = i }, - new PartitionKey("pk1")); - } - - // Concurrent upserts to update them all - var tasks = Enumerable.Range(0, 10).Select(i => - _container.UpsertItemAsync( - new TestDocument { Id = $"upsert-{i}", PartitionKey = "pk1", Name = $"Updated{i}", Value = i * 10 }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task Bulk_MixedCreateAndUpsert_DifferentPartitions_AllSucceed() - { - // Seed items in different partitions - await _container.CreateItemAsync( - new TestDocument { Id = "u1", PartitionKey = "pk-u", Name = "Original", Value = 1 }, - new PartitionKey("pk-u")); - - // Concurrent creates across different partitions + upsert in separate partition - var tasks = new List>> - { - _container.CreateItemAsync( - new TestDocument { Id = "new1", PartitionKey = "pk-a", Name = "New1", Value = 100 }, - new PartitionKey("pk-a")), - _container.CreateItemAsync( - new TestDocument { Id = "new2", PartitionKey = "pk-b", Name = "New2", Value = 200 }, - new PartitionKey("pk-b")), - _container.UpsertItemAsync( - new TestDocument { Id = "u1", PartitionKey = "pk-u", Name = "Updated", Value = 999 }, - new PartitionKey("pk-u")), - }; - - await Task.WhenAll(tasks); - - var read = await _container.ReadItemAsync("new1", new PartitionKey("pk-a")); - read.Resource.Name.Should().Be("New1"); - - var readUpdated = await _container.ReadItemAsync("u1", new PartitionKey("pk-u")); - readUpdated.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Bulk_ConcurrentCreates_DifferentPartitions_AllSucceed() - { - var tasks = Enumerable.Range(0, 30).Select(i => - _container.CreateItemAsync( - new TestDocument { Id = $"pk-{i}", PartitionKey = $"pk-{i % 5}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i % 5}"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-bulk", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task Bulk_ConcurrentCreates_AllSucceed() + { + var tasks = Enumerable.Range(0, 50).Select(i => + _container.CreateItemAsync( + new TestDocument { Id = $"bulk-{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + items.AddRange(page); + } + items.Should().HaveCount(50); + } + + [Fact] + public async Task Bulk_ConcurrentUpserts_AllSucceed() + { + // Create initial items + for (int i = 0; i < 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"upsert-{i}", PartitionKey = "pk1", Name = $"Original{i}", Value = i }, + new PartitionKey("pk1")); + } + + // Concurrent upserts to update them all + var tasks = Enumerable.Range(0, 10).Select(i => + _container.UpsertItemAsync( + new TestDocument { Id = $"upsert-{i}", PartitionKey = "pk1", Name = $"Updated{i}", Value = i * 10 }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task Bulk_MixedCreateAndUpsert_DifferentPartitions_AllSucceed() + { + // Seed items in different partitions + await _container.CreateItemAsync( + new TestDocument { Id = "u1", PartitionKey = "pk-u", Name = "Original", Value = 1 }, + new PartitionKey("pk-u")); + + // Concurrent creates across different partitions + upsert in separate partition + var tasks = new List>> + { + _container.CreateItemAsync( + new TestDocument { Id = "new1", PartitionKey = "pk-a", Name = "New1", Value = 100 }, + new PartitionKey("pk-a")), + _container.CreateItemAsync( + new TestDocument { Id = "new2", PartitionKey = "pk-b", Name = "New2", Value = 200 }, + new PartitionKey("pk-b")), + _container.UpsertItemAsync( + new TestDocument { Id = "u1", PartitionKey = "pk-u", Name = "Updated", Value = 999 }, + new PartitionKey("pk-u")), + }; + + await Task.WhenAll(tasks); + + var read = await _container.ReadItemAsync("new1", new PartitionKey("pk-a")); + read.Resource.Name.Should().Be("New1"); + + var readUpdated = await _container.ReadItemAsync("u1", new PartitionKey("pk-u")); + readUpdated.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Bulk_ConcurrentCreates_DifferentPartitions_AllSucceed() + { + var tasks = Enumerable.Range(0, 30).Select(i => + _container.CreateItemAsync( + new TestDocument { Id = $"pk-{i}", PartitionKey = $"pk-{i % 5}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i % 5}"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCaseSensitivePropertyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCaseSensitivePropertyTests.cs index 52bc05c..8f9fa13 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCaseSensitivePropertyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCaseSensitivePropertyTests.cs @@ -16,668 +16,715 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerCaseSensitivePropertyTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("issue9-case", "/p"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Test model classes - // ═══════════════════════════════════════════════════════════════════════════ - - private class EquinoxStyleEvent - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - public string d { get; set; } = ""; - public string D { get; set; } = ""; - public string m { get; set; } = ""; - public string M { get; set; } = ""; - } - - public enum Priority { Low, Medium, High, Critical } - - private class EnumDocument - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - public Priority Priority { get; set; } - } - - private class MixedCaseAlphabet - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - public string a { get; set; } = ""; - public string A { get; set; } = ""; - public string aa { get; set; } = ""; - public string AA { get; set; } = ""; - public string aA { get; set; } = ""; - public string Aa { get; set; } = ""; - } - - private class PascalCaseDoc - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; - public int ItemCount { get; set; } - } - - private class JsonPropertyOverrideDoc - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - [JsonProperty("custom_name")] public string PascalCaseProperty { get; set; } = ""; - [JsonProperty("LOUD_NAME")] public string quietProperty { get; set; } = ""; - [JsonProperty("MiXeD")] public string AnotherProperty { get; set; } = ""; - } - - private class NestedCaseSensitive - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("p")] public string P { get; set; } = ""; - public InnerLevel Level1 { get; set; } = new(); - } - - private class InnerLevel - { - public string x { get; set; } = ""; - public string X { get; set; } = ""; - public DeepLevel Deep { get; set; } = new(); - } - - private class DeepLevel - { - public string y { get; set; } = ""; - public string Y { get; set; } = ""; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Core issue: case-sensitive property round-trip via JObject - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CaseSensitiveProperties_RoundTripCorrectly_ViaJObject() - { - var jObj = new JObject - { - ["id"] = "rt-jo-1", - ["p"] = "stream-1", - ["d"] = "lowercase-data", - ["D"] = "uppercase-data", - ["m"] = "lowercase-meta", - ["M"] = "uppercase-meta" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("stream-1")); - - var response = await _container.ReadItemAsync("rt-jo-1", new PartitionKey("stream-1")); - var result = response.Resource; - - result["d"]!.ToString().Should().Be("lowercase-data"); - result["D"]!.ToString().Should().Be("uppercase-data"); - result["m"]!.ToString().Should().Be("lowercase-meta"); - result["M"]!.ToString().Should().Be("uppercase-meta"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Deserialization to strongly-typed class with case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CaseSensitiveProperties_DeserializeCorrectly_ToStrongType() - { - var jObj = new JObject - { - ["id"] = "rt-st-1", - ["p"] = "stream-1", - ["d"] = "lowercase-data", - ["D"] = "uppercase-data", - ["m"] = "lowercase-meta", - ["M"] = "uppercase-meta" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("stream-1")); - - var response = await _container.ReadItemAsync( - "rt-st-1", new PartitionKey("stream-1")); - - var result = response.Resource; - result.d.Should().Be("lowercase-data"); - result.D.Should().Be("uppercase-data"); - result.m.Should().Be("lowercase-meta"); - result.M.Should().Be("uppercase-meta"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Mixed casing combinations: a, A, aa, AA, aA, Aa - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task MixedCaseCombinations_AllSurviveRoundTrip_ViaStrongType() - { - var item = new MixedCaseAlphabet - { - Id = "mc-st-1", P = "p1", - a = "lower-a", A = "upper-A", - aa = "lower-aa", AA = "upper-AA", - aA = "lower-a-upper-A", Aa = "upper-A-lower-a" - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("mc-st-1", new PartitionKey("p1")); - var result = response.Resource; - - result.a.Should().Be("lower-a"); - result.A.Should().Be("upper-A"); - result.aa.Should().Be("lower-aa"); - result.AA.Should().Be("upper-AA"); - result.aA.Should().Be("lower-a-upper-A"); - result.Aa.Should().Be("upper-A-lower-a"); - } - - [Fact] - public async Task MixedCaseCombinations_AllSurviveRoundTrip_ViaJObject() - { - var jObj = new JObject - { - ["id"] = "mc-jo-1", ["p"] = "p1", - ["a"] = "1", ["A"] = "2", - ["aa"] = "3", ["AA"] = "4", - ["aA"] = "5", ["Aa"] = "6" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("mc-jo-1", new PartitionKey("p1")); - var result = response.Resource; - - result["a"]!.ToString().Should().Be("1"); - result["A"]!.ToString().Should().Be("2"); - result["aa"]!.ToString().Should().Be("3"); - result["AA"]!.ToString().Should().Be("4"); - result["aA"]!.ToString().Should().Be("5"); - result["Aa"]!.ToString().Should().Be("6"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // PascalCase properties NOT transformed (regression guard) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task PascalCaseProperties_NotCamelCased_AfterFix() - { - var item = new PascalCaseDoc - { - Id = "pc-1", P = "p1", - FirstName = "Jane", LastName = "Doe", ItemCount = 42 - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("pc-1", new PartitionKey("p1")); - var result = response.Resource; - - result["FirstName"]!.ToString().Should().Be("Jane"); - result["LastName"]!.ToString().Should().Be("Doe"); - result["ItemCount"]!.Value().Should().Be(42); - - result["firstName"].Should().BeNull(); - result["lastName"].Should().BeNull(); - result["itemCount"].Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // System properties still work - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SystemProperties_StillPopulated_WithoutNamingStrategy() - { - var jObj = new JObject - { - ["id"] = "sp-1", ["p"] = "p1", ["Data"] = "hello" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("sp-1", new PartitionKey("p1")); - var result = response.Resource; - - result["_etag"].Should().NotBeNull(); - result["_ts"].Should().NotBeNull(); - result["_rid"].Should().NotBeNull(); - result["Data"]!.ToString().Should().Be("hello"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Enum serialization still works - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task EnumProperties_RoundTripCorrectly() - { - var item = new EnumDocument - { - Id = "en-1", P = "p1", - Priority = Priority.Critical - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - - // Through the SDK pipeline, enums serialize as their integer value - // unless the CosmosClient is explicitly configured with StringEnumConverter - var typed = await _container.ReadItemAsync("en-1", new PartitionKey("p1")); - typed.Resource.Priority.Should().Be(Priority.Critical); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Upsert preserves case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CaseSensitiveProperties_PreservedAfterUpsert() - { - var jObj = new JObject - { - ["id"] = "up-1", ["p"] = "p1", - ["d"] = "original-lowercase", ["D"] = "original-uppercase" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var upsertObj = new JObject - { - ["id"] = "up-1", ["p"] = "p1", - ["d"] = "updated-lowercase", ["D"] = "updated-uppercase" - }; - - await _container.UpsertItemAsync(upsertObj, new PartitionKey("p1")); - - var response = await _container.ReadItemAsync("up-1", new PartitionKey("p1")); - response.Resource["d"]!.ToString().Should().Be("updated-lowercase"); - response.Resource["D"]!.ToString().Should().Be("updated-uppercase"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Replace preserves case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CaseSensitiveProperties_PreservedAfterReplace() - { - var jObj = new JObject - { - ["id"] = "rp-1", ["p"] = "p1", - ["d"] = "original-lowercase", ["D"] = "original-uppercase" - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var replaceObj = new JObject - { - ["id"] = "rp-1", ["p"] = "p1", - ["d"] = "replaced-lowercase", ["D"] = "replaced-uppercase" - }; - - await _container.ReplaceItemAsync(replaceObj, "rp-1", new PartitionKey("p1")); - - var response = await _container.ReadItemAsync("rp-1", new PartitionKey("p1")); - response.Resource["d"]!.ToString().Should().Be("replaced-lowercase"); - response.Resource["D"]!.ToString().Should().Be("replaced-uppercase"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SQL queries referencing case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlQuery_SelectBothCasings_ReturnsDistinctValues() - { - var jObj = new JObject - { - ["id"] = "sq-1", ["p"] = "p1", - ["d"] = "lower-val", ["D"] = "UPPER-val" - }; - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var query = new QueryDefinition("SELECT c.d, c.D FROM c WHERE c.id = 'sq-1'"); - var iter = _container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["d"]!.ToString().Should().Be("lower-val"); - results[0]["D"]!.ToString().Should().Be("UPPER-val"); - } - - [Fact] - public async Task SqlQuery_FilterOnCaseSensitiveProperty() - { - await _container.CreateItemAsync(new JObject - { - ["id"] = "sf-1", ["p"] = "p1", - ["val"] = "match", ["Val"] = "no-match" - }, new PartitionKey("p1")); - - await _container.CreateItemAsync(new JObject - { - ["id"] = "sf-2", ["p"] = "p1", - ["val"] = "no-match", ["Val"] = "match" - }, new PartitionKey("p1")); - - var query = new QueryDefinition("SELECT c.id FROM c WHERE c.val = 'match'"); - var iter = _container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("sf-1"); - } - - [Fact] - public async Task SqlQuery_PascalCasePropertyName_NotCamelCased() - { - var item = new PascalCaseDoc - { - Id = "sq-pc-1", P = "p1", - FirstName = "Ada", LastName = "Lovelace", ItemCount = 1 - }; - await _container.CreateItemAsync(item, new PartitionKey("p1")); - - var query = new QueryDefinition("SELECT c.FirstName, c.LastName FROM c WHERE c.id = 'sq-pc-1'"); - var iter = _container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["FirstName"]!.ToString().Should().Be("Ada"); - results[0]["LastName"]!.ToString().Should().Be("Lovelace"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Patch operations on case-sensitive property paths - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Patch_SetCaseSensitiveProperty_OnlyAffectsTargetCasing() - { - var jObj = new JObject - { - ["id"] = "pa-1", ["p"] = "p1", - ["d"] = "original", ["D"] = "ORIGINAL" - }; - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var patchOps = new[] { PatchOperation.Set("/d", "patched-lower") }; - await _container.PatchItemAsync("pa-1", new PartitionKey("p1"), patchOps); - - var response = await _container.ReadItemAsync("pa-1", new PartitionKey("p1")); - response.Resource["d"]!.ToString().Should().Be("patched-lower"); - response.Resource["D"]!.ToString().Should().Be("ORIGINAL"); - } - - [Fact] - public async Task Patch_NestedCaseSensitivePath_UpdatesCorrectProperty() - { - var jObj = new JObject - { - ["id"] = "pa-n-1", ["p"] = "p1", - ["nested"] = new JObject - { - ["v"] = "lower-original", - ["V"] = "UPPER-original" - } - }; - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var patchOps = new[] { PatchOperation.Set("/nested/v", "lower-patched") }; - await _container.PatchItemAsync("pa-n-1", new PartitionKey("p1"), patchOps); - - var response = await _container.ReadItemAsync("pa-n-1", new PartitionKey("p1")); - var nested = response.Resource["nested"] as JObject; - nested!["v"]!.ToString().Should().Be("lower-patched"); - nested["V"]!.ToString().Should().Be("UPPER-original"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // [JsonProperty] attribute interaction - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task JsonPropertyAttribute_OverridesPropertyName_Correctly() - { - var item = new JsonPropertyOverrideDoc - { - Id = "jp-1", P = "p1", - PascalCaseProperty = "custom-value", - quietProperty = "loud-value", - AnotherProperty = "mixed-value" - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("jp-1", new PartitionKey("p1")); - var result = response.Resource; - - result["custom_name"]!.ToString().Should().Be("custom-value"); - result["LOUD_NAME"]!.ToString().Should().Be("loud-value"); - result["MiXeD"]!.ToString().Should().Be("mixed-value"); - - result["PascalCaseProperty"].Should().BeNull(); - result["quietProperty"].Should().BeNull(); - result["AnotherProperty"].Should().BeNull(); - } - - [Fact] - public async Task JsonPropertyAttribute_RoundTripsCorrectly() - { - var item = new JsonPropertyOverrideDoc - { - Id = "jp-rt-1", P = "p1", - PascalCaseProperty = "val1", - quietProperty = "val2", - AnotherProperty = "val3" - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("jp-rt-1", new PartitionKey("p1")); - var result = response.Resource; - - result.PascalCaseProperty.Should().Be("val1"); - result.quietProperty.Should().Be("val2"); - result.AnotherProperty.Should().Be("val3"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Deeply nested objects with case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DeepNesting_PreservesCaseSensitiveProperties() - { - var item = new NestedCaseSensitive - { - Id = "dn-1", P = "p1", - Level1 = new InnerLevel - { - x = "inner-lower", X = "inner-UPPER", - Deep = new DeepLevel - { - y = "deep-lower", Y = "deep-UPPER" - } - } - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("dn-1", new PartitionKey("p1")); - var result = response.Resource; - - var level1 = result["Level1"] as JObject; - level1.Should().NotBeNull(); - level1!["x"]!.ToString().Should().Be("inner-lower"); - level1["X"]!.ToString().Should().Be("inner-UPPER"); - - var deep = level1["Deep"] as JObject; - deep.Should().NotBeNull(); - deep!["y"]!.ToString().Should().Be("deep-lower"); - deep["Y"]!.ToString().Should().Be("deep-UPPER"); - } - - [Fact] - public async Task DeepNesting_StrongTypeRoundTrip() - { - var item = new NestedCaseSensitive - { - Id = "dn-rt-1", P = "p1", - Level1 = new InnerLevel - { - x = "a", X = "B", - Deep = new DeepLevel { y = "c", Y = "D" } - } - }; - - await _container.CreateItemAsync(item, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("dn-rt-1", new PartitionKey("p1")); - var result = response.Resource; - - result.Level1.x.Should().Be("a"); - result.Level1.X.Should().Be("B"); - result.Level1.Deep.y.Should().Be("c"); - result.Level1.Deep.Y.Should().Be("D"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Arrays containing objects with case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ArraysOfObjects_PreserveCaseSensitiveProperties() - { - var jObj = new JObject - { - ["id"] = "arr-1", ["p"] = "p1", - ["items"] = new JArray( - new JObject { ["x"] = "a1", ["X"] = "A1" }, - new JObject { ["x"] = "a2", ["X"] = "A2" } - ) - }; - - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - var response = await _container.ReadItemAsync("arr-1", new PartitionKey("p1")); - var items = response.Resource["items"] as JArray; - - items.Should().NotBeNull(); - items!.Count.Should().Be(2); - items[0]["x"]!.ToString().Should().Be("a1"); - items[0]["X"]!.ToString().Should().Be("A1"); - items[1]["x"]!.ToString().Should().Be("a2"); - items[1]["X"]!.ToString().Should().Be("A2"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SQL query on nested case-sensitive properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlQuery_OnNestedCaseSensitiveProperties() - { - var jObj = new JObject - { - ["id"] = "sq-n-1", ["p"] = "p1", - ["nested"] = new JObject - { - ["x"] = "find-me", - ["X"] = "not-me" - } - }; - await _container.CreateItemAsync(jObj, new PartitionKey("p1")); - - var query = new QueryDefinition( - "SELECT c.nested.x AS lowerVal, c.nested.X AS upperVal FROM c WHERE c.id = 'sq-n-1'"); - var iter = _container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["lowerVal"]!.ToString().Should().Be("find-me"); - results[0]["upperVal"]!.ToString().Should().Be("not-me"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // All 52 single-letter properties (a-z, A-Z) preserved - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SingleLetterProperties_AllTwentySixPreserved() - { - var jObj = new JObject { ["id"] = "26-1", ["p"] = "lower-p" }; - - for (var c = 'a'; c <= 'z'; c++) - { - jObj[c.ToString()] = $"lower-{c}"; - jObj[char.ToUpper(c).ToString()] = $"upper-{char.ToUpper(c)}"; - } - - await _container.CreateItemAsync(jObj, new PartitionKey("lower-p")); - var response = await _container.ReadItemAsync("26-1", new PartitionKey("lower-p")); - var result = response.Resource; - - for (var c = 'a'; c <= 'z'; c++) - { - result[c.ToString()]!.ToString().Should().Be($"lower-{c}", - $"lowercase property '{c}' should be preserved"); - result[char.ToUpper(c).ToString()]!.ToString().Should().Be($"upper-{char.ToUpper(c)}", - $"uppercase property '{char.ToUpper(c)}' should be preserved"); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Multiple documents with different property casing - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task MultipleDocuments_WithDifferentPropertyCasing_IndependentlyCorrect() - { - await _container.CreateItemAsync(new JObject - { - ["id"] = "md-1", ["p"] = "p1", ["Status"] = "Active" - }, new PartitionKey("p1")); - - await _container.CreateItemAsync(new JObject - { - ["id"] = "md-2", ["p"] = "p1", ["status"] = "inactive" - }, new PartitionKey("p1")); - - var r1 = await _container.ReadItemAsync("md-1", new PartitionKey("p1")); - var r2 = await _container.ReadItemAsync("md-2", new PartitionKey("p1")); - - r1.Resource["Status"]!.ToString().Should().Be("Active"); - r1.Resource["status"].Should().BeNull(); - - r2.Resource["status"]!.ToString().Should().Be("inactive"); - r2.Resource["Status"].Should().BeNull(); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("issue9-case", "/p"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Test model classes + // ═══════════════════════════════════════════════════════════════════════════ + + private class EquinoxStyleEvent + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + public string d { get; set; } = ""; + public string D { get; set; } = ""; + public string m { get; set; } = ""; + public string M { get; set; } = ""; + } + + public enum Priority { Low, Medium, High, Critical } + + private class EnumDocument + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + public Priority Priority { get; set; } + } + + private class MixedCaseAlphabet + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + public string a { get; set; } = ""; + public string A { get; set; } = ""; + public string aa { get; set; } = ""; + public string AA { get; set; } = ""; + public string aA { get; set; } = ""; + public string Aa { get; set; } = ""; + } + + private class PascalCaseDoc + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public int ItemCount { get; set; } + } + + private class JsonPropertyOverrideDoc + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + [JsonProperty("custom_name")] public string PascalCaseProperty { get; set; } = ""; + [JsonProperty("LOUD_NAME")] public string quietProperty { get; set; } = ""; + [JsonProperty("MiXeD")] public string AnotherProperty { get; set; } = ""; + } + + private class NestedCaseSensitive + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("p")] public string P { get; set; } = ""; + public InnerLevel Level1 { get; set; } = new(); + } + + private class InnerLevel + { + public string x { get; set; } = ""; + public string X { get; set; } = ""; + public DeepLevel Deep { get; set; } = new(); + } + + private class DeepLevel + { + public string y { get; set; } = ""; + public string Y { get; set; } = ""; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Core issue: case-sensitive property round-trip via JObject + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CaseSensitiveProperties_RoundTripCorrectly_ViaJObject() + { + var jObj = new JObject + { + ["id"] = "rt-jo-1", + ["p"] = "stream-1", + ["d"] = "lowercase-data", + ["D"] = "uppercase-data", + ["m"] = "lowercase-meta", + ["M"] = "uppercase-meta" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("stream-1")); + + var response = await _container.ReadItemAsync("rt-jo-1", new PartitionKey("stream-1")); + var result = response.Resource; + + result["d"]!.ToString().Should().Be("lowercase-data"); + result["D"]!.ToString().Should().Be("uppercase-data"); + result["m"]!.ToString().Should().Be("lowercase-meta"); + result["M"]!.ToString().Should().Be("uppercase-meta"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Deserialization to strongly-typed class with case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CaseSensitiveProperties_DeserializeCorrectly_ToStrongType() + { + var jObj = new JObject + { + ["id"] = "rt-st-1", + ["p"] = "stream-1", + ["d"] = "lowercase-data", + ["D"] = "uppercase-data", + ["m"] = "lowercase-meta", + ["M"] = "uppercase-meta" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("stream-1")); + + var response = await _container.ReadItemAsync( + "rt-st-1", new PartitionKey("stream-1")); + + var result = response.Resource; + result.d.Should().Be("lowercase-data"); + result.D.Should().Be("uppercase-data"); + result.m.Should().Be("lowercase-meta"); + result.M.Should().Be("uppercase-meta"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Mixed casing combinations: a, A, aa, AA, aA, Aa + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task MixedCaseCombinations_AllSurviveRoundTrip_ViaStrongType() + { + var item = new MixedCaseAlphabet + { + Id = "mc-st-1", + P = "p1", + a = "lower-a", + A = "upper-A", + aa = "lower-aa", + AA = "upper-AA", + aA = "lower-a-upper-A", + Aa = "upper-A-lower-a" + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("mc-st-1", new PartitionKey("p1")); + var result = response.Resource; + + result.a.Should().Be("lower-a"); + result.A.Should().Be("upper-A"); + result.aa.Should().Be("lower-aa"); + result.AA.Should().Be("upper-AA"); + result.aA.Should().Be("lower-a-upper-A"); + result.Aa.Should().Be("upper-A-lower-a"); + } + + [Fact] + public async Task MixedCaseCombinations_AllSurviveRoundTrip_ViaJObject() + { + var jObj = new JObject + { + ["id"] = "mc-jo-1", + ["p"] = "p1", + ["a"] = "1", + ["A"] = "2", + ["aa"] = "3", + ["AA"] = "4", + ["aA"] = "5", + ["Aa"] = "6" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("mc-jo-1", new PartitionKey("p1")); + var result = response.Resource; + + result["a"]!.ToString().Should().Be("1"); + result["A"]!.ToString().Should().Be("2"); + result["aa"]!.ToString().Should().Be("3"); + result["AA"]!.ToString().Should().Be("4"); + result["aA"]!.ToString().Should().Be("5"); + result["Aa"]!.ToString().Should().Be("6"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PascalCase properties NOT transformed (regression guard) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task PascalCaseProperties_NotCamelCased_AfterFix() + { + var item = new PascalCaseDoc + { + Id = "pc-1", + P = "p1", + FirstName = "Jane", + LastName = "Doe", + ItemCount = 42 + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("pc-1", new PartitionKey("p1")); + var result = response.Resource; + + result["FirstName"]!.ToString().Should().Be("Jane"); + result["LastName"]!.ToString().Should().Be("Doe"); + result["ItemCount"]!.Value().Should().Be(42); + + result["firstName"].Should().BeNull(); + result["lastName"].Should().BeNull(); + result["itemCount"].Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // System properties still work + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SystemProperties_StillPopulated_WithoutNamingStrategy() + { + var jObj = new JObject + { + ["id"] = "sp-1", + ["p"] = "p1", + ["Data"] = "hello" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("sp-1", new PartitionKey("p1")); + var result = response.Resource; + + result["_etag"].Should().NotBeNull(); + result["_ts"].Should().NotBeNull(); + result["_rid"].Should().NotBeNull(); + result["Data"]!.ToString().Should().Be("hello"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Enum serialization still works + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task EnumProperties_RoundTripCorrectly() + { + var item = new EnumDocument + { + Id = "en-1", + P = "p1", + Priority = Priority.Critical + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + + // Through the SDK pipeline, enums serialize as their integer value + // unless the CosmosClient is explicitly configured with StringEnumConverter + var typed = await _container.ReadItemAsync("en-1", new PartitionKey("p1")); + typed.Resource.Priority.Should().Be(Priority.Critical); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Upsert preserves case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CaseSensitiveProperties_PreservedAfterUpsert() + { + var jObj = new JObject + { + ["id"] = "up-1", + ["p"] = "p1", + ["d"] = "original-lowercase", + ["D"] = "original-uppercase" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var upsertObj = new JObject + { + ["id"] = "up-1", + ["p"] = "p1", + ["d"] = "updated-lowercase", + ["D"] = "updated-uppercase" + }; + + await _container.UpsertItemAsync(upsertObj, new PartitionKey("p1")); + + var response = await _container.ReadItemAsync("up-1", new PartitionKey("p1")); + response.Resource["d"]!.ToString().Should().Be("updated-lowercase"); + response.Resource["D"]!.ToString().Should().Be("updated-uppercase"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Replace preserves case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CaseSensitiveProperties_PreservedAfterReplace() + { + var jObj = new JObject + { + ["id"] = "rp-1", + ["p"] = "p1", + ["d"] = "original-lowercase", + ["D"] = "original-uppercase" + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var replaceObj = new JObject + { + ["id"] = "rp-1", + ["p"] = "p1", + ["d"] = "replaced-lowercase", + ["D"] = "replaced-uppercase" + }; + + await _container.ReplaceItemAsync(replaceObj, "rp-1", new PartitionKey("p1")); + + var response = await _container.ReadItemAsync("rp-1", new PartitionKey("p1")); + response.Resource["d"]!.ToString().Should().Be("replaced-lowercase"); + response.Resource["D"]!.ToString().Should().Be("replaced-uppercase"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SQL queries referencing case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlQuery_SelectBothCasings_ReturnsDistinctValues() + { + var jObj = new JObject + { + ["id"] = "sq-1", + ["p"] = "p1", + ["d"] = "lower-val", + ["D"] = "UPPER-val" + }; + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var query = new QueryDefinition("SELECT c.d, c.D FROM c WHERE c.id = 'sq-1'"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["d"]!.ToString().Should().Be("lower-val"); + results[0]["D"]!.ToString().Should().Be("UPPER-val"); + } + + [Fact] + public async Task SqlQuery_FilterOnCaseSensitiveProperty() + { + await _container.CreateItemAsync(new JObject + { + ["id"] = "sf-1", + ["p"] = "p1", + ["val"] = "match", + ["Val"] = "no-match" + }, new PartitionKey("p1")); + + await _container.CreateItemAsync(new JObject + { + ["id"] = "sf-2", + ["p"] = "p1", + ["val"] = "no-match", + ["Val"] = "match" + }, new PartitionKey("p1")); + + var query = new QueryDefinition("SELECT c.id FROM c WHERE c.val = 'match'"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("sf-1"); + } + + [Fact] + public async Task SqlQuery_PascalCasePropertyName_NotCamelCased() + { + var item = new PascalCaseDoc + { + Id = "sq-pc-1", + P = "p1", + FirstName = "Ada", + LastName = "Lovelace", + ItemCount = 1 + }; + await _container.CreateItemAsync(item, new PartitionKey("p1")); + + var query = new QueryDefinition("SELECT c.FirstName, c.LastName FROM c WHERE c.id = 'sq-pc-1'"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["FirstName"]!.ToString().Should().Be("Ada"); + results[0]["LastName"]!.ToString().Should().Be("Lovelace"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Patch operations on case-sensitive property paths + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Patch_SetCaseSensitiveProperty_OnlyAffectsTargetCasing() + { + var jObj = new JObject + { + ["id"] = "pa-1", + ["p"] = "p1", + ["d"] = "original", + ["D"] = "ORIGINAL" + }; + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var patchOps = new[] { PatchOperation.Set("/d", "patched-lower") }; + await _container.PatchItemAsync("pa-1", new PartitionKey("p1"), patchOps); + + var response = await _container.ReadItemAsync("pa-1", new PartitionKey("p1")); + response.Resource["d"]!.ToString().Should().Be("patched-lower"); + response.Resource["D"]!.ToString().Should().Be("ORIGINAL"); + } + + [Fact] + public async Task Patch_NestedCaseSensitivePath_UpdatesCorrectProperty() + { + var jObj = new JObject + { + ["id"] = "pa-n-1", + ["p"] = "p1", + ["nested"] = new JObject + { + ["v"] = "lower-original", + ["V"] = "UPPER-original" + } + }; + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var patchOps = new[] { PatchOperation.Set("/nested/v", "lower-patched") }; + await _container.PatchItemAsync("pa-n-1", new PartitionKey("p1"), patchOps); + + var response = await _container.ReadItemAsync("pa-n-1", new PartitionKey("p1")); + var nested = response.Resource["nested"] as JObject; + nested!["v"]!.ToString().Should().Be("lower-patched"); + nested["V"]!.ToString().Should().Be("UPPER-original"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // [JsonProperty] attribute interaction + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JsonPropertyAttribute_OverridesPropertyName_Correctly() + { + var item = new JsonPropertyOverrideDoc + { + Id = "jp-1", + P = "p1", + PascalCaseProperty = "custom-value", + quietProperty = "loud-value", + AnotherProperty = "mixed-value" + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("jp-1", new PartitionKey("p1")); + var result = response.Resource; + + result["custom_name"]!.ToString().Should().Be("custom-value"); + result["LOUD_NAME"]!.ToString().Should().Be("loud-value"); + result["MiXeD"]!.ToString().Should().Be("mixed-value"); + + result["PascalCaseProperty"].Should().BeNull(); + result["quietProperty"].Should().BeNull(); + result["AnotherProperty"].Should().BeNull(); + } + + [Fact] + public async Task JsonPropertyAttribute_RoundTripsCorrectly() + { + var item = new JsonPropertyOverrideDoc + { + Id = "jp-rt-1", + P = "p1", + PascalCaseProperty = "val1", + quietProperty = "val2", + AnotherProperty = "val3" + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("jp-rt-1", new PartitionKey("p1")); + var result = response.Resource; + + result.PascalCaseProperty.Should().Be("val1"); + result.quietProperty.Should().Be("val2"); + result.AnotherProperty.Should().Be("val3"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Deeply nested objects with case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeepNesting_PreservesCaseSensitiveProperties() + { + var item = new NestedCaseSensitive + { + Id = "dn-1", + P = "p1", + Level1 = new InnerLevel + { + x = "inner-lower", + X = "inner-UPPER", + Deep = new DeepLevel + { + y = "deep-lower", + Y = "deep-UPPER" + } + } + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("dn-1", new PartitionKey("p1")); + var result = response.Resource; + + var level1 = result["Level1"] as JObject; + level1.Should().NotBeNull(); + level1!["x"]!.ToString().Should().Be("inner-lower"); + level1["X"]!.ToString().Should().Be("inner-UPPER"); + + var deep = level1["Deep"] as JObject; + deep.Should().NotBeNull(); + deep!["y"]!.ToString().Should().Be("deep-lower"); + deep["Y"]!.ToString().Should().Be("deep-UPPER"); + } + + [Fact] + public async Task DeepNesting_StrongTypeRoundTrip() + { + var item = new NestedCaseSensitive + { + Id = "dn-rt-1", + P = "p1", + Level1 = new InnerLevel + { + x = "a", + X = "B", + Deep = new DeepLevel { y = "c", Y = "D" } + } + }; + + await _container.CreateItemAsync(item, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("dn-rt-1", new PartitionKey("p1")); + var result = response.Resource; + + result.Level1.x.Should().Be("a"); + result.Level1.X.Should().Be("B"); + result.Level1.Deep.y.Should().Be("c"); + result.Level1.Deep.Y.Should().Be("D"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Arrays containing objects with case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ArraysOfObjects_PreserveCaseSensitiveProperties() + { + var jObj = new JObject + { + ["id"] = "arr-1", + ["p"] = "p1", + ["items"] = new JArray( + new JObject { ["x"] = "a1", ["X"] = "A1" }, + new JObject { ["x"] = "a2", ["X"] = "A2" } + ) + }; + + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + var response = await _container.ReadItemAsync("arr-1", new PartitionKey("p1")); + var items = response.Resource["items"] as JArray; + + items.Should().NotBeNull(); + items!.Count.Should().Be(2); + items[0]["x"]!.ToString().Should().Be("a1"); + items[0]["X"]!.ToString().Should().Be("A1"); + items[1]["x"]!.ToString().Should().Be("a2"); + items[1]["X"]!.ToString().Should().Be("A2"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SQL query on nested case-sensitive properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlQuery_OnNestedCaseSensitiveProperties() + { + var jObj = new JObject + { + ["id"] = "sq-n-1", + ["p"] = "p1", + ["nested"] = new JObject + { + ["x"] = "find-me", + ["X"] = "not-me" + } + }; + await _container.CreateItemAsync(jObj, new PartitionKey("p1")); + + var query = new QueryDefinition( + "SELECT c.nested.x AS lowerVal, c.nested.X AS upperVal FROM c WHERE c.id = 'sq-n-1'"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["lowerVal"]!.ToString().Should().Be("find-me"); + results[0]["upperVal"]!.ToString().Should().Be("not-me"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // All 52 single-letter properties (a-z, A-Z) preserved + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SingleLetterProperties_AllTwentySixPreserved() + { + var jObj = new JObject { ["id"] = "26-1", ["p"] = "lower-p" }; + + for (var c = 'a'; c <= 'z'; c++) + { + jObj[c.ToString()] = $"lower-{c}"; + jObj[char.ToUpper(c).ToString()] = $"upper-{char.ToUpper(c)}"; + } + + await _container.CreateItemAsync(jObj, new PartitionKey("lower-p")); + var response = await _container.ReadItemAsync("26-1", new PartitionKey("lower-p")); + var result = response.Resource; + + for (var c = 'a'; c <= 'z'; c++) + { + result[c.ToString()]!.ToString().Should().Be($"lower-{c}", + $"lowercase property '{c}' should be preserved"); + result[char.ToUpper(c).ToString()]!.ToString().Should().Be($"upper-{char.ToUpper(c)}", + $"uppercase property '{char.ToUpper(c)}' should be preserved"); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Multiple documents with different property casing + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task MultipleDocuments_WithDifferentPropertyCasing_IndependentlyCorrect() + { + await _container.CreateItemAsync(new JObject + { + ["id"] = "md-1", + ["p"] = "p1", + ["Status"] = "Active" + }, new PartitionKey("p1")); + + await _container.CreateItemAsync(new JObject + { + ["id"] = "md-2", + ["p"] = "p1", + ["status"] = "inactive" + }, new PartitionKey("p1")); + + var r1 = await _container.ReadItemAsync("md-1", new PartitionKey("p1")); + var r2 = await _container.ReadItemAsync("md-2", new PartitionKey("p1")); + + r1.Resource["Status"]!.ToString().Should().Be("Active"); + r1.Resource["status"].Should().BeNull(); + + r2.Resource["status"]!.ToString().Should().Be("inactive"); + r2.Resource["Status"].Should().BeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerChangeFeedProcessorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerChangeFeedProcessorTests.cs index 4e59815..35bebe6 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerChangeFeedProcessorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerChangeFeedProcessorTests.cs @@ -20,136 +20,136 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] public class FakeCosmosHandlerChangeFeedProcessorTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("changefeed-processor", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - [Fact] - public async Task ChangeFeedProcessor_TypedHandler_ReceivesChanges() - { - // Arrange — seed an item before the processor starts - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Seeded" }, - new PartitionKey("pk1")); - - var received = new List(); - var handlerInvoked = new TaskCompletionSource(); - - var processor = _container - .GetChangeFeedProcessorBuilder("test-processor", - (context, changes, ct) => - { - received.AddRange(changes); - handlerInvoked.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInMemoryLeaseContainer() - .WithStartTime(DateTime.MinValue.ToUniversalTime()) - .Build(); - - // Act - await processor.StartAsync(); - - // Wait for the handler to be invoked (or timeout) - var completed = await Task.WhenAny(handlerInvoked.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - // Assert - completed.Should().Be(handlerInvoked.Task, - "the change feed processor handler should have been invoked within 5 seconds"); - received.Should().ContainSingle(d => d.Id == "1" && d.Name == "Seeded"); - } - - [Fact] - public async Task ChangeFeedProcessor_TypedHandler_ReceivesItemsCreatedAfterStart() - { - var received = new List(); - var handlerInvoked = new TaskCompletionSource(); - - var processor = _container - .GetChangeFeedProcessorBuilder("test-processor", - (context, changes, ct) => - { - received.AddRange(changes); - handlerInvoked.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInMemoryLeaseContainer() - .WithStartTime(DateTime.MinValue.ToUniversalTime()) - .Build(); - - await processor.StartAsync(); - - // Create item after processor has started - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Dynamic" }, - new PartitionKey("pk1")); - - var completed = await Task.WhenAny(handlerInvoked.Task, Task.Delay(TimeSpan.FromSeconds(10))); - await processor.StopAsync(); - - completed.Should().Be(handlerInvoked.Task, - "the change feed processor handler should have been invoked within 10 seconds"); - received.Should().ContainSingle(d => d.Id == "2" && d.Name == "Dynamic"); - } - - [Fact] - public async Task ChangeFeedProcessor_DoesNotThrowEmptyContinuationToken() - { - // This test specifically validates the bug report: the SDK's AutoCheckpointer - // calls CheckpointAsync which requires a non-empty continuation token. - // If FakeCosmosHandler returns an empty/null continuation token for change feed - // responses, the processor throws ArgumentException internally. - - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "TokenTest" }, - new PartitionKey("pk1")); - - Exception? capturedError = null; - var errorReceived = new TaskCompletionSource(); - var handlerInvoked = new TaskCompletionSource(); - - var processor = _container - .GetChangeFeedProcessorBuilder("test-processor", - (context, changes, ct) => - { - handlerInvoked.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInMemoryLeaseContainer() - .WithStartTime(DateTime.MinValue.ToUniversalTime()) - .WithErrorNotification((leaseToken, ex) => - { - capturedError = ex; - errorReceived.TrySetResult(true); - return Task.CompletedTask; - }) - .Build(); - - await processor.StartAsync(); - - // Wait for either the handler to succeed or an error to surface - var completed = await Task.WhenAny( - handlerInvoked.Task, - errorReceived.Task, - Task.Delay(TimeSpan.FromSeconds(5))); - - await processor.StopAsync(); - - // The handler should have been invoked, not the error notification - capturedError.Should().BeNull( - "the change feed processor should not throw an empty continuation token error, " + - $"but got: {capturedError}"); - handlerInvoked.Task.IsCompletedSuccessfully.Should().BeTrue( - "the change feed processor handler should have been invoked"); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("changefeed-processor", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task ChangeFeedProcessor_TypedHandler_ReceivesChanges() + { + // Arrange — seed an item before the processor starts + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Seeded" }, + new PartitionKey("pk1")); + + var received = new List(); + var handlerInvoked = new TaskCompletionSource(); + + var processor = _container + .GetChangeFeedProcessorBuilder("test-processor", + (context, changes, ct) => + { + received.AddRange(changes); + handlerInvoked.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInMemoryLeaseContainer() + .WithStartTime(DateTime.MinValue.ToUniversalTime()) + .Build(); + + // Act + await processor.StartAsync(); + + // Wait for the handler to be invoked (or timeout) + var completed = await Task.WhenAny(handlerInvoked.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + // Assert + completed.Should().Be(handlerInvoked.Task, + "the change feed processor handler should have been invoked within 5 seconds"); + received.Should().ContainSingle(d => d.Id == "1" && d.Name == "Seeded"); + } + + [Fact] + public async Task ChangeFeedProcessor_TypedHandler_ReceivesItemsCreatedAfterStart() + { + var received = new List(); + var handlerInvoked = new TaskCompletionSource(); + + var processor = _container + .GetChangeFeedProcessorBuilder("test-processor", + (context, changes, ct) => + { + received.AddRange(changes); + handlerInvoked.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInMemoryLeaseContainer() + .WithStartTime(DateTime.MinValue.ToUniversalTime()) + .Build(); + + await processor.StartAsync(); + + // Create item after processor has started + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Dynamic" }, + new PartitionKey("pk1")); + + var completed = await Task.WhenAny(handlerInvoked.Task, Task.Delay(TimeSpan.FromSeconds(10))); + await processor.StopAsync(); + + completed.Should().Be(handlerInvoked.Task, + "the change feed processor handler should have been invoked within 10 seconds"); + received.Should().ContainSingle(d => d.Id == "2" && d.Name == "Dynamic"); + } + + [Fact] + public async Task ChangeFeedProcessor_DoesNotThrowEmptyContinuationToken() + { + // This test specifically validates the bug report: the SDK's AutoCheckpointer + // calls CheckpointAsync which requires a non-empty continuation token. + // If FakeCosmosHandler returns an empty/null continuation token for change feed + // responses, the processor throws ArgumentException internally. + + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "TokenTest" }, + new PartitionKey("pk1")); + + Exception? capturedError = null; + var errorReceived = new TaskCompletionSource(); + var handlerInvoked = new TaskCompletionSource(); + + var processor = _container + .GetChangeFeedProcessorBuilder("test-processor", + (context, changes, ct) => + { + handlerInvoked.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInMemoryLeaseContainer() + .WithStartTime(DateTime.MinValue.ToUniversalTime()) + .WithErrorNotification((leaseToken, ex) => + { + capturedError = ex; + errorReceived.TrySetResult(true); + return Task.CompletedTask; + }) + .Build(); + + await processor.StartAsync(); + + // Wait for either the handler to succeed or an error to surface + var completed = await Task.WhenAny( + handlerInvoked.Task, + errorReceived.Task, + Task.Delay(TimeSpan.FromSeconds(5))); + + await processor.StopAsync(); + + // The handler should have been invoked, not the error notification + capturedError.Should().BeNull( + "the change feed processor should not throw an empty continuation token error, " + + $"but got: {capturedError}"); + handlerInvoked.Task.IsCompletedSuccessfully.Should().BeTrue( + "the change feed processor handler should have been invoked"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudHardeningTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudHardeningTests.cs index 36d232e..ac8b331 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudHardeningTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudHardeningTests.cs @@ -19,939 +19,939 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerCrudHardeningTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("harden-crud", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( - string name = "harden-crud", string pkPath = "/partitionKey") - { - var cosmos = InMemoryCosmos.Create(name, pkPath); - return (cosmos.Handler, cosmos.Client, cosmos.Container); - } - - private static MemoryStream ToStream(object obj) - { - var json = JsonConvert.SerializeObject(obj); - return new MemoryStream(Encoding.UTF8.GetBytes(json)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 1: Stream CRUD Operations (S1–S10) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItemStream_ReturnsCreated() - { - using var body = ToStream(new { id = "s1", partitionKey = "pk1", name = "StreamCreate" }); - - using var response = await _container.CreateItemStreamAsync(body, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().NotBeNull(); - using var reader = new StreamReader(response.Content); - var json = JObject.Parse(await reader.ReadToEndAsync()); - json["id"]!.ToString().Should().Be("s1"); - } - - [Fact] - public async Task Handler_ReadItemStream_ReturnsOk() - { - await _container.CreateItemAsync( - new TestDocument { Id = "s2", PartitionKey = "pk1", Name = "StreamRead" }, - new PartitionKey("pk1")); - - using var response = await _container.ReadItemStreamAsync("s2", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var json = JObject.Parse(await reader.ReadToEndAsync()); - json["name"]!.ToString().Should().Be("StreamRead"); - } - - [Fact] - public async Task Handler_ReadItemStream_NotFound_Returns404() - { - using var response = await _container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_UpsertItemStream_NewItem_ReturnsCreated() - { - using var body = ToStream(new { id = "s4", partitionKey = "pk1", name = "StreamUpsertNew" }); - - using var response = await _container.UpsertItemStreamAsync(body, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Handler_UpsertItemStream_ExistingItem_ReturnsOk() - { - await _container.CreateItemAsync( - new TestDocument { Id = "s5", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - using var body = ToStream(new { id = "s5", partitionKey = "pk1", name = "Updated" }); - using var response = await _container.UpsertItemStreamAsync(body, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Handler_ReplaceItemStream_ReturnsOk() - { - await _container.CreateItemAsync( - new TestDocument { Id = "s6", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - using var body = ToStream(new { id = "s6", partitionKey = "pk1", name = "After" }); - using var response = await _container.ReplaceItemStreamAsync(body, "s6", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Handler_ReplaceItemStream_NotFound_Returns404() - { - using var body = ToStream(new { id = "s7", partitionKey = "pk1", name = "Ghost" }); - - using var response = await _container.ReplaceItemStreamAsync(body, "s7", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_DeleteItemStream_ReturnsNoContent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "s8", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - - using var response = await _container.DeleteItemStreamAsync("s8", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Handler_DeleteItemStream_NotFound_Returns404() - { - using var response = await _container.DeleteItemStreamAsync("nonexistent", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_PatchItemStream_ReturnsOk() - { - await _container.CreateItemAsync( - new TestDocument { Id = "s10", PartitionKey = "pk1", Name = "Before", Value = 1 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("s10", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "After")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("After"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 2: ETag & Conditional Write Edge Cases (E1–E9) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_WithIfNoneMatchWildcard_PreventsOverwrite() - { - var doc = new TestDocument { Id = "e1", PartitionKey = "pk1", Name = "First" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Second create with same id should fail with Conflict regardless of IfNoneMatch - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task Handler_UpsertItem_WithIfMatchWildcard_AlwaysSucceeds() - { - var doc = new TestDocument { Id = "e2", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "WildcardUpsert"; - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("WildcardUpsert"); - } - - [Fact] - public async Task Handler_ReplaceItem_WithIfMatchWildcard_AlwaysSucceeds() - { - var doc = new TestDocument { Id = "e3", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "WildcardReplace"; - var response = await _container.ReplaceItemAsync(doc, "e3", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("WildcardReplace"); - } - - [Fact] - public async Task Handler_DeleteItem_WithIfMatchWildcard_AlwaysSucceeds() - { - var doc = new TestDocument { Id = "e4", PartitionKey = "pk1", Name = "ToDelete" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("e4", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Handler_PatchItem_WithIfMatchWildcard_AlwaysSucceeds() - { - var doc = new TestDocument { Id = "e5", PartitionKey = "pk1", Name = "Before" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("e5", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "WildcardPatch")], - new PatchItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("WildcardPatch"); - } - - [Fact] - public async Task Handler_ReadItem_WithIfNoneMatchWildcard_ExistingItem_Returns304() - { - var doc = new TestDocument { Id = "e6", PartitionKey = "pk1", Name = "Exists" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("e6", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task Handler_UpsertItem_NewItem_WithIfMatchETag_CreatesItem() - { - // If-Match is "applicable only on PUT and DELETE" per REST API docs. - // Upsert uses POST, so If-Match is ignored on the insert path. - var doc = new TestDocument { Id = "e7-new", PartitionKey = "pk1", Name = "NeverCreated" }; - - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Handler_CreateItem_ETagChangesOnSubsequentRead() - { - var doc = new TestDocument { Id = "e8", PartitionKey = "pk1", Name = "ETagConsistency" }; - - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var readResponse = await _container.ReadItemAsync("e8", new PartitionKey("pk1")); - - createResponse.ETag.Should().Be(readResponse.ETag); - } - - [Fact] - public async Task Handler_PatchItem_MultiplePatches_ETagIncrements() - { - var doc = new TestDocument { Id = "e9", PartitionKey = "pk1", Name = "Counter", Value = 0 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var patch1 = await _container.PatchItemAsync("e9", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 1)]); - var patch2 = await _container.PatchItemAsync("e9", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 1)]); - - patch1.ETag.Should().NotBe(patch2.ETag); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 3: Replace Validation Edge Cases (R1–R3) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Windows emulator returns 200 (emulator bug); see docs link below - public async Task Handler_ReplaceItem_BodyIdMismatch_Throws400() - { - var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // The SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - var mismatch = new TestDocument { Id = "different-id", PartitionKey = "pk1", Name = "Mismatch" }; - var act = () => _container.ReplaceItemAsync(mismatch, "r1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Handler_ReplaceItem_PreservesPartitionKey() - { - var doc = new TestDocument { Id = "r2", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Replace with same PK - doc.Name = "Updated"; - var response = await _container.ReplaceItemAsync(doc, "r2", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify the PK is preserved - var read = await _container.ReadItemAsync("r2", new PartitionKey("pk1")); - read.Resource.PartitionKey.Should().Be("pk1"); - } - - [Fact] - public async Task Handler_ReplaceItem_WithNullBody_Throws() - { - var doc = new TestDocument { Id = "r3", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.ReplaceItemAsync(null!, "r3", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 4: Patch Validation & Boundaries (P1–P8) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_PatchItem_EmptyOperationsList_Throws() - { - var doc = new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Empty" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("p1", new PartitionKey("pk1"), - []); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_PatchItem_MoreThan10Operations_Throws() - { - var doc = new TestDocument { Id = "p2", PartitionKey = "pk1", Name = "TooMany", Value = 0 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Increment("/value", 1)) - .ToList(); - - var act = () => _container.PatchItemAsync("p2", new PartitionKey("pk1"), ops); - - // Should throw — Cosmos limits patch to max 10 operations - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_PatchItem_SetId_Throws() - { - var doc = new TestDocument { Id = "p3", PartitionKey = "pk1", Name = "HasId" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("p3", new PartitionKey("pk1"), - [PatchOperation.Set("/id", "new-id")]); - - // Patching /id should fail - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_PatchItem_AddToExistingArray_AppendsElement() - { - var doc = new TestDocument { Id = "p5", PartitionKey = "pk1", Name = "Arrays", Tags = ["tag1"] }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p5", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/-", "tag2")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Tags.Should().Contain("tag1"); - response.Resource.Tags.Should().Contain("tag2"); - } - - [Fact] - public async Task Handler_PatchItem_IncrementOnNonNumeric_Throws() - { - var doc = new TestDocument { Id = "p6", PartitionKey = "pk1", Name = "StringField" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("p6", new PartitionKey("pk1"), - [PatchOperation.Increment("/name", 1)]); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_PatchItem_RemoveNonExistentPath_Throws() - { - var doc = new TestDocument { Id = "p7", PartitionKey = "pk1", Name = "Sparse" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Removing a path that doesn't exist throws in real Cosmos and in the emulator - var act = () => _container.PatchItemAsync("p7", new PartitionKey("pk1"), - [PatchOperation.Remove("/nonExistentField")]); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_PatchItem_SetDeepNestedPath_WhenParentExists_Succeeds() - { - // Patch Set on a nested path works when the parent object exists - var doc = new { id = "p8", partitionKey = "pk1", name = "HasNested", nested = new { score = 1.0 } }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p8", new PartitionKey("pk1"), - [PatchOperation.Set("/nested/description", "Deep")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource["nested"]!["description"]!.ToString().Should().Be("Deep"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 5: Document Size & Serialization (D1–D4) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_Over2MB_Throws() - { - var largeContent = new string('x', 2 * 1024 * 1024 + 1); - var doc = new { id = "d1", partitionKey = "pk1", data = largeContent }; - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_UpsertItem_Over2MB_Throws() - { - var largeContent = new string('x', 2 * 1024 * 1024 + 1); - var doc = new { id = "d2", partitionKey = "pk1", data = largeContent }; - - var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_CreateItem_WithEmptyStringId_Throws() - { - var doc = new { id = "", partitionKey = "pk1", name = "EmptyId" }; - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 6: BackingContainer & Handler Properties (H1–H5) — InMemoryOnly - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public void Handler_BackingContainer_ReturnsNonNull() - { - using var cosmos = InMemoryCosmos.Create("backing-test", "/partitionKey"); - cosmos.Handler.BackingContainer.Should().NotBeNull(); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_QueryLog_NotPopulatedOnPureCrud() - { - var (handler, client, container) = CreateInMemoryStack("h2-querylog"); - using (client) - using (handler) - { - var doc = new TestDocument { Id = "h2", PartitionKey = "pk1", Name = "CrudOnly" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - await container.ReadItemAsync("h2", new PartitionKey("pk1")); - await container.DeleteItemAsync("h2", new PartitionKey("pk1")); - - handler.QueryLog.Should().BeEmpty(); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_RequestLog_ContainsAllCrudVerbs() - { - var (handler, client, container) = CreateInMemoryStack("h3-reqlog"); - using (client) - using (handler) - { - var doc = new TestDocument { Id = "h3", PartitionKey = "pk1", Name = "AllVerbs", Value = 1 }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - await container.ReadItemAsync("h3", new PartitionKey("pk1")); - - doc.Name = "Replaced"; - await container.ReplaceItemAsync(doc, "h3", new PartitionKey("pk1")); - - await container.PatchItemAsync("h3", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - await container.DeleteItemAsync("h3", new PartitionKey("pk1")); - - handler.RequestLog.Should().Contain(e => e.StartsWith("POST")); - handler.RequestLog.Should().Contain(e => e.StartsWith("GET")); - handler.RequestLog.Should().Contain(e => e.StartsWith("PUT")); - handler.RequestLog.Should().Contain(e => e.StartsWith("PATCH")); - handler.RequestLog.Should().Contain(e => e.StartsWith("DELETE")); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_Options_CacheTtl_Configurable() - { - using var cosmos = InMemoryCosmos.Create("cache-test", "/partitionKey"); - var c = cosmos.Container; - - await c.CreateItemAsync( - new TestDocument { Id = "cache1", PartitionKey = "pk1", Name = "Cached" }, - new PartitionKey("pk1")); - - var read = await c.ReadItemAsync("cache1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 7: PartitionKey Edge Cases (PK1–PK4) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_PartitionKeyWithUnicode_RoundTrips() - { - var unicodePk = "pk-🎉-日本語"; - var doc = new TestDocument { Id = "pk1", PartitionKey = unicodePk, Name = "Unicode" }; - - await _container.CreateItemAsync(doc, new PartitionKey(unicodePk)); - - var read = await _container.ReadItemAsync("pk1", new PartitionKey(unicodePk)); - read.Resource.Name.Should().Be("Unicode"); - read.Resource.PartitionKey.Should().Be(unicodePk); - } - - [Fact] - public async Task Handler_CrudRoundTrip_WithPartitionKeyContainingQuotes() - { - var quotedPk = "it's a \"quoted\" value"; - var doc = new TestDocument { Id = "pk2", PartitionKey = quotedPk, Name = "Quoted" }; - - await _container.CreateItemAsync(doc, new PartitionKey(quotedPk)); - - var read = await _container.ReadItemAsync("pk2", new PartitionKey(quotedPk)); - read.Resource.Name.Should().Be("Quoted"); - read.Resource.PartitionKey.Should().Be(quotedPk); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_CreateItem_HierarchicalPartitionKey_ThreeLevels() - { - using var cosmos = InMemoryCosmos.Create("hier3-test", new[] { "/tenantId", "/region", "/userId" }); - var c = cosmos.Container; - - var pk = new PartitionKeyBuilder().Add("tenant1").Add("us-east").Add("user1").Build(); - var doc = new { id = "h3pk", tenantId = "tenant1", region = "us-east", userId = "user1", name = "ThreeLevel" }; - var response = await c.CreateItemAsync(doc, pk); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await c.ReadItemAsync("h3pk", pk); - ((string)read.Resource.name).Should().Be("ThreeLevel"); - } - - [Fact] - public async Task Handler_ReadItem_WrongPartitionKey_Throws404() - { - var doc = new TestDocument { Id = "pk4", PartitionKey = "correct-pk", Name = "Isolated" }; - await _container.CreateItemAsync(doc, new PartitionKey("correct-pk")); - - var act = () => _container.ReadItemAsync("pk4", new PartitionKey("wrong-pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 8: Response Contract Verification (RC1–RC5) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_ResponseContainsDiagnostics() - { - var doc = new TestDocument { Id = "rc1", PartitionKey = "pk1", Name = "Diagnostics" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.Diagnostics.Should().NotBeNull(); - } - - [Fact] - public async Task Handler_AllCrudOps_ReturnNonZeroRequestCharge() - { - var doc = new TestDocument { Id = "rc2", PartitionKey = "pk1", Name = "Charge", Value = 1 }; - - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - create.RequestCharge.Should().BeGreaterThan(0); - - var read = await _container.ReadItemAsync("rc2", new PartitionKey("pk1")); - read.RequestCharge.Should().BeGreaterThan(0); - - doc.Name = "Replaced"; - var replace = await _container.ReplaceItemAsync(doc, "rc2", new PartitionKey("pk1")); - replace.RequestCharge.Should().BeGreaterThan(0); - - var patch = await _container.PatchItemAsync("rc2", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - patch.RequestCharge.Should().BeGreaterThan(0); - - var delete = await _container.DeleteItemAsync("rc2", new PartitionKey("pk1")); - delete.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task Handler_AllCrudOps_ReturnNonEmptyActivityId() - { - var doc = new TestDocument { Id = "rc3", PartitionKey = "pk1", Name = "Activity", Value = 1 }; - - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - create.ActivityId.Should().NotBeNullOrEmpty(); - - var read = await _container.ReadItemAsync("rc3", new PartitionKey("pk1")); - read.ActivityId.Should().NotBeNullOrEmpty(); - - doc.Name = "Replaced"; - var replace = await _container.ReplaceItemAsync(doc, "rc3", new PartitionKey("pk1")); - replace.ActivityId.Should().NotBeNullOrEmpty(); - - var patch = await _container.PatchItemAsync("rc3", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - patch.ActivityId.Should().NotBeNullOrEmpty(); - - var delete = await _container.DeleteItemAsync("rc3", new PartitionKey("pk1")); - delete.ActivityId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Handler_CreateItem_ResponseStatusCodeMatchesExpected() - { - var doc = new TestDocument { Id = "rc4", PartitionKey = "pk1", Name = "StatusCode" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - ((int)response.StatusCode).Should().Be(201); - } - - [Fact] - public async Task Handler_DeleteItem_ResponseResource_IsDefault() - { - var doc = new TestDocument { Id = "rc5", PartitionKey = "pk1", Name = "ToDelete" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("rc5", new PartitionKey("pk1")); - - response.Resource.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 9: Concurrency via Handler (CC1–CC3) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ConcurrentCreates_DifferentIds_AllSucceed() - { - var tasks = Enumerable.Range(0, 50).Select(i => - { - var doc = new TestDocument { Id = $"cc1-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }; - return _container.CreateItemAsync(doc, new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - - results.Should().HaveCount(50); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } - - [Fact] - public async Task Handler_ConcurrentUpserts_SameId_LastWriteWins() - { - // Seed the item - var doc = new TestDocument { Id = "cc2", PartitionKey = "pk1", Name = "Seed", Value = 0 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var tasks = Enumerable.Range(1, 10).Select(i => - { - var d = new TestDocument { Id = "cc2", PartitionKey = "pk1", Name = $"V{i}", Value = i }; - return _container.UpsertItemAsync(d, new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - - // All should succeed (no corruption) - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - // Final read should return one consistent version - var read = await _container.ReadItemAsync("cc2", new PartitionKey("pk1")); - read.Resource.Name.Should().StartWith("V"); - } - - [Fact] - public async Task Handler_ConcurrentReadAndWrite_NoCorruption() - { - // Seed some items - for (var i = 0; i < 10; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"cc3-{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - } - - var readTasks = Enumerable.Range(0, 10).Select(i => - _container.ReadItemAsync($"cc3-{i}", new PartitionKey("pk1"))); - - var writeTasks = Enumerable.Range(10, 10).Select(i => - { - var d = new TestDocument { Id = $"cc3-{i}", PartitionKey = "pk1", Name = $"New{i}" }; - return _container.CreateItemAsync(d, new PartitionKey("pk1")); - }); - - var allTasks = readTasks.Cast().Concat(writeTasks.Cast()); - await Task.WhenAll(allTasks); - - // No exceptions means no corruption - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 10: Fault Injection Edge Cases (FI1–FI5) — InMemoryOnly - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_OnUpsert_ThrottlesCorrectly() - { - var (handler, client, container) = CreateInMemoryStack("fi1-upsert"); - using (client) - using (handler) - { - handler.FaultInjector = _ => - new HttpResponseMessage((HttpStatusCode)429) - { - Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } - }; - handler.FaultInjectorIncludesMetadata = false; - - var doc = new TestDocument { Id = "fi1", PartitionKey = "pk1", Name = "Throttled" }; - var act = () => container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be((HttpStatusCode)429); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_OnReplace_Returns500() - { - var (handler, client, container) = CreateInMemoryStack("fi2-replace"); - using (client) - using (handler) - { - var doc = new TestDocument { Id = "fi2", PartitionKey = "pk1", Name = "Original" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - handler.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.InternalServerError); - handler.FaultInjectorIncludesMetadata = false; - - doc.Name = "Broken"; - var act = () => container.ReplaceItemAsync(doc, "fi2", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_OnPatch_Returns503() - { - var (handler, client, container) = CreateInMemoryStack("fi3-patch"); - using (client) - using (handler) - { - var doc = new TestDocument { Id = "fi3", PartitionKey = "pk1", Name = "Before" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - handler.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - handler.FaultInjectorIncludesMetadata = false; - - var act = () => container.PatchItemAsync("fi3", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "After")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_SelectiveByPath_OnlyTargetsSpecificDoc() - { - var (handler, client, container) = CreateInMemoryStack("fi4-selective"); - using (client) - using (handler) - { - await container.CreateItemAsync( - new TestDocument { Id = "fi4-target", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "fi4-safe", PartitionKey = "pk1", Name = "Safe" }, - new PartitionKey("pk1")); - - handler.FaultInjector = req => - { - if (req.RequestUri?.ToString().Contains("fi4-target") == true) - return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - return null; - }; - handler.FaultInjectorIncludesMetadata = false; - - // Target doc is blocked - var act = () => container.ReadItemAsync("fi4-target", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - - // Safe doc is accessible - var read = await container.ReadItemAsync("fi4-safe", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Safe"); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_Clears_WhenSetToNull() - { - var (handler, client, container) = CreateInMemoryStack("fi5-clear"); - using (client) - using (handler) - { - handler.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - handler.FaultInjectorIncludesMetadata = false; - - var doc = new TestDocument { Id = "fi5", PartitionKey = "pk1", Name = "Blocked" }; - var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Clear fault injector - handler.FaultInjector = null; - - // Now should succeed - var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 10: Unique Key Policy – Nested Paths (UK1–UK3) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_UniqueKeyPolicy_NestedPath_EnforcesConstraint() - { - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/unique-keys - // "You can specify unique key paths with up to 16 path values." - // Paths like /address/zipCode are supported and reference nested properties. - var container = await _fixture.CreateContainerAsync("uk-nested", "/partitionKey", props => - { - props.UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } - }; - }); - - var doc1 = new TestDocument { Id = "uk1", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "unique-val", Score = 1.0 } }; - var doc2 = new TestDocument { Id = "uk2", PartitionKey = "pk1", Name = "B", Nested = new NestedObject { Description = "unique-val", Score = 2.0 } }; - - await container.CreateItemAsync(doc1, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(() => - container.CreateItemAsync(doc2, new PartitionKey("pk1"))); - ex.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task Handler_UniqueKeyPolicy_NestedPath_DifferentValues_Succeeds() - { - var container = await _fixture.CreateContainerAsync("uk-nested-ok", "/partitionKey", props => - { - props.UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } - }; - }); - - var doc1 = new TestDocument { Id = "uk3", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "val-one", Score = 1.0 } }; - var doc2 = new TestDocument { Id = "uk4", PartitionKey = "pk1", Name = "B", Nested = new NestedObject { Description = "val-two", Score = 2.0 } }; - - var r1 = await container.CreateItemAsync(doc1, new PartitionKey("pk1")); - var r2 = await container.CreateItemAsync(doc2, new PartitionKey("pk1")); - - r1.StatusCode.Should().Be(HttpStatusCode.Created); - r2.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Handler_UniqueKeyPolicy_NestedPath_DifferentPartitions_Succeeds() - { - // Unique keys are scoped to logical partition — same nested value in different partitions is allowed - var container = await _fixture.CreateContainerAsync("uk-nested-xpk", "/partitionKey", props => - { - props.UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } - }; - }); - - var doc1 = new TestDocument { Id = "uk5", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "same-val", Score = 1.0 } }; - var doc2 = new TestDocument { Id = "uk6", PartitionKey = "pk2", Name = "B", Nested = new NestedObject { Description = "same-val", Score = 2.0 } }; - - var r1 = await container.CreateItemAsync(doc1, new PartitionKey("pk1")); - var r2 = await container.CreateItemAsync(doc2, new PartitionKey("pk2")); - - r1.StatusCode.Should().Be(HttpStatusCode.Created); - r2.StatusCode.Should().Be(HttpStatusCode.Created); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("harden-crud", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( + string name = "harden-crud", string pkPath = "/partitionKey") + { + var cosmos = InMemoryCosmos.Create(name, pkPath); + return (cosmos.Handler, cosmos.Client, cosmos.Container); + } + + private static MemoryStream ToStream(object obj) + { + var json = JsonConvert.SerializeObject(obj); + return new MemoryStream(Encoding.UTF8.GetBytes(json)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 1: Stream CRUD Operations (S1–S10) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItemStream_ReturnsCreated() + { + using var body = ToStream(new { id = "s1", partitionKey = "pk1", name = "StreamCreate" }); + + using var response = await _container.CreateItemStreamAsync(body, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content); + var json = JObject.Parse(await reader.ReadToEndAsync()); + json["id"]!.ToString().Should().Be("s1"); + } + + [Fact] + public async Task Handler_ReadItemStream_ReturnsOk() + { + await _container.CreateItemAsync( + new TestDocument { Id = "s2", PartitionKey = "pk1", Name = "StreamRead" }, + new PartitionKey("pk1")); + + using var response = await _container.ReadItemStreamAsync("s2", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var json = JObject.Parse(await reader.ReadToEndAsync()); + json["name"]!.ToString().Should().Be("StreamRead"); + } + + [Fact] + public async Task Handler_ReadItemStream_NotFound_Returns404() + { + using var response = await _container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_UpsertItemStream_NewItem_ReturnsCreated() + { + using var body = ToStream(new { id = "s4", partitionKey = "pk1", name = "StreamUpsertNew" }); + + using var response = await _container.UpsertItemStreamAsync(body, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Handler_UpsertItemStream_ExistingItem_ReturnsOk() + { + await _container.CreateItemAsync( + new TestDocument { Id = "s5", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + using var body = ToStream(new { id = "s5", partitionKey = "pk1", name = "Updated" }); + using var response = await _container.UpsertItemStreamAsync(body, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Handler_ReplaceItemStream_ReturnsOk() + { + await _container.CreateItemAsync( + new TestDocument { Id = "s6", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + using var body = ToStream(new { id = "s6", partitionKey = "pk1", name = "After" }); + using var response = await _container.ReplaceItemStreamAsync(body, "s6", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Handler_ReplaceItemStream_NotFound_Returns404() + { + using var body = ToStream(new { id = "s7", partitionKey = "pk1", name = "Ghost" }); + + using var response = await _container.ReplaceItemStreamAsync(body, "s7", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_DeleteItemStream_ReturnsNoContent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "s8", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + + using var response = await _container.DeleteItemStreamAsync("s8", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Handler_DeleteItemStream_NotFound_Returns404() + { + using var response = await _container.DeleteItemStreamAsync("nonexistent", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_PatchItemStream_ReturnsOk() + { + await _container.CreateItemAsync( + new TestDocument { Id = "s10", PartitionKey = "pk1", Name = "Before", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("s10", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "After")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("After"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 2: ETag & Conditional Write Edge Cases (E1–E9) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_WithIfNoneMatchWildcard_PreventsOverwrite() + { + var doc = new TestDocument { Id = "e1", PartitionKey = "pk1", Name = "First" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Second create with same id should fail with Conflict regardless of IfNoneMatch + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Handler_UpsertItem_WithIfMatchWildcard_AlwaysSucceeds() + { + var doc = new TestDocument { Id = "e2", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "WildcardUpsert"; + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("WildcardUpsert"); + } + + [Fact] + public async Task Handler_ReplaceItem_WithIfMatchWildcard_AlwaysSucceeds() + { + var doc = new TestDocument { Id = "e3", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "WildcardReplace"; + var response = await _container.ReplaceItemAsync(doc, "e3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("WildcardReplace"); + } + + [Fact] + public async Task Handler_DeleteItem_WithIfMatchWildcard_AlwaysSucceeds() + { + var doc = new TestDocument { Id = "e4", PartitionKey = "pk1", Name = "ToDelete" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("e4", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Handler_PatchItem_WithIfMatchWildcard_AlwaysSucceeds() + { + var doc = new TestDocument { Id = "e5", PartitionKey = "pk1", Name = "Before" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("e5", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "WildcardPatch")], + new PatchItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("WildcardPatch"); + } + + [Fact] + public async Task Handler_ReadItem_WithIfNoneMatchWildcard_ExistingItem_Returns304() + { + var doc = new TestDocument { Id = "e6", PartitionKey = "pk1", Name = "Exists" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("e6", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task Handler_UpsertItem_NewItem_WithIfMatchETag_CreatesItem() + { + // If-Match is "applicable only on PUT and DELETE" per REST API docs. + // Upsert uses POST, so If-Match is ignored on the insert path. + var doc = new TestDocument { Id = "e7-new", PartitionKey = "pk1", Name = "NeverCreated" }; + + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Handler_CreateItem_ETagChangesOnSubsequentRead() + { + var doc = new TestDocument { Id = "e8", PartitionKey = "pk1", Name = "ETagConsistency" }; + + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var readResponse = await _container.ReadItemAsync("e8", new PartitionKey("pk1")); + + createResponse.ETag.Should().Be(readResponse.ETag); + } + + [Fact] + public async Task Handler_PatchItem_MultiplePatches_ETagIncrements() + { + var doc = new TestDocument { Id = "e9", PartitionKey = "pk1", Name = "Counter", Value = 0 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var patch1 = await _container.PatchItemAsync("e9", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 1)]); + var patch2 = await _container.PatchItemAsync("e9", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 1)]); + + patch1.ETag.Should().NotBe(patch2.ETag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 3: Replace Validation Edge Cases (R1–R3) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Windows emulator returns 200 (emulator bug); see docs link below + public async Task Handler_ReplaceItem_BodyIdMismatch_Throws400() + { + var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // The SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + var mismatch = new TestDocument { Id = "different-id", PartitionKey = "pk1", Name = "Mismatch" }; + var act = () => _container.ReplaceItemAsync(mismatch, "r1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Handler_ReplaceItem_PreservesPartitionKey() + { + var doc = new TestDocument { Id = "r2", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Replace with same PK + doc.Name = "Updated"; + var response = await _container.ReplaceItemAsync(doc, "r2", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify the PK is preserved + var read = await _container.ReadItemAsync("r2", new PartitionKey("pk1")); + read.Resource.PartitionKey.Should().Be("pk1"); + } + + [Fact] + public async Task Handler_ReplaceItem_WithNullBody_Throws() + { + var doc = new TestDocument { Id = "r3", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.ReplaceItemAsync(null!, "r3", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 4: Patch Validation & Boundaries (P1–P8) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_PatchItem_EmptyOperationsList_Throws() + { + var doc = new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Empty" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("p1", new PartitionKey("pk1"), + []); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_PatchItem_MoreThan10Operations_Throws() + { + var doc = new TestDocument { Id = "p2", PartitionKey = "pk1", Name = "TooMany", Value = 0 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Increment("/value", 1)) + .ToList(); + + var act = () => _container.PatchItemAsync("p2", new PartitionKey("pk1"), ops); + + // Should throw — Cosmos limits patch to max 10 operations + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_PatchItem_SetId_Throws() + { + var doc = new TestDocument { Id = "p3", PartitionKey = "pk1", Name = "HasId" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("p3", new PartitionKey("pk1"), + [PatchOperation.Set("/id", "new-id")]); + + // Patching /id should fail + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_PatchItem_AddToExistingArray_AppendsElement() + { + var doc = new TestDocument { Id = "p5", PartitionKey = "pk1", Name = "Arrays", Tags = ["tag1"] }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p5", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/-", "tag2")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Tags.Should().Contain("tag1"); + response.Resource.Tags.Should().Contain("tag2"); + } + + [Fact] + public async Task Handler_PatchItem_IncrementOnNonNumeric_Throws() + { + var doc = new TestDocument { Id = "p6", PartitionKey = "pk1", Name = "StringField" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("p6", new PartitionKey("pk1"), + [PatchOperation.Increment("/name", 1)]); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_PatchItem_RemoveNonExistentPath_Throws() + { + var doc = new TestDocument { Id = "p7", PartitionKey = "pk1", Name = "Sparse" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Removing a path that doesn't exist throws in real Cosmos and in the emulator + var act = () => _container.PatchItemAsync("p7", new PartitionKey("pk1"), + [PatchOperation.Remove("/nonExistentField")]); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_PatchItem_SetDeepNestedPath_WhenParentExists_Succeeds() + { + // Patch Set on a nested path works when the parent object exists + var doc = new { id = "p8", partitionKey = "pk1", name = "HasNested", nested = new { score = 1.0 } }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p8", new PartitionKey("pk1"), + [PatchOperation.Set("/nested/description", "Deep")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource["nested"]!["description"]!.ToString().Should().Be("Deep"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 5: Document Size & Serialization (D1–D4) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_Over2MB_Throws() + { + var largeContent = new string('x', 2 * 1024 * 1024 + 1); + var doc = new { id = "d1", partitionKey = "pk1", data = largeContent }; + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_UpsertItem_Over2MB_Throws() + { + var largeContent = new string('x', 2 * 1024 * 1024 + 1); + var doc = new { id = "d2", partitionKey = "pk1", data = largeContent }; + + var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_CreateItem_WithEmptyStringId_Throws() + { + var doc = new { id = "", partitionKey = "pk1", name = "EmptyId" }; + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 6: BackingContainer & Handler Properties (H1–H5) — InMemoryOnly + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public void Handler_BackingContainer_ReturnsNonNull() + { + using var cosmos = InMemoryCosmos.Create("backing-test", "/partitionKey"); + cosmos.Handler.BackingContainer.Should().NotBeNull(); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_QueryLog_NotPopulatedOnPureCrud() + { + var (handler, client, container) = CreateInMemoryStack("h2-querylog"); + using (client) + using (handler) + { + var doc = new TestDocument { Id = "h2", PartitionKey = "pk1", Name = "CrudOnly" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + await container.ReadItemAsync("h2", new PartitionKey("pk1")); + await container.DeleteItemAsync("h2", new PartitionKey("pk1")); + + handler.QueryLog.Should().BeEmpty(); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_RequestLog_ContainsAllCrudVerbs() + { + var (handler, client, container) = CreateInMemoryStack("h3-reqlog"); + using (client) + using (handler) + { + var doc = new TestDocument { Id = "h3", PartitionKey = "pk1", Name = "AllVerbs", Value = 1 }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + await container.ReadItemAsync("h3", new PartitionKey("pk1")); + + doc.Name = "Replaced"; + await container.ReplaceItemAsync(doc, "h3", new PartitionKey("pk1")); + + await container.PatchItemAsync("h3", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + await container.DeleteItemAsync("h3", new PartitionKey("pk1")); + + handler.RequestLog.Should().Contain(e => e.StartsWith("POST")); + handler.RequestLog.Should().Contain(e => e.StartsWith("GET")); + handler.RequestLog.Should().Contain(e => e.StartsWith("PUT")); + handler.RequestLog.Should().Contain(e => e.StartsWith("PATCH")); + handler.RequestLog.Should().Contain(e => e.StartsWith("DELETE")); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_Options_CacheTtl_Configurable() + { + using var cosmos = InMemoryCosmos.Create("cache-test", "/partitionKey"); + var c = cosmos.Container; + + await c.CreateItemAsync( + new TestDocument { Id = "cache1", PartitionKey = "pk1", Name = "Cached" }, + new PartitionKey("pk1")); + + var read = await c.ReadItemAsync("cache1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 7: PartitionKey Edge Cases (PK1–PK4) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_PartitionKeyWithUnicode_RoundTrips() + { + var unicodePk = "pk-🎉-日本語"; + var doc = new TestDocument { Id = "pk1", PartitionKey = unicodePk, Name = "Unicode" }; + + await _container.CreateItemAsync(doc, new PartitionKey(unicodePk)); + + var read = await _container.ReadItemAsync("pk1", new PartitionKey(unicodePk)); + read.Resource.Name.Should().Be("Unicode"); + read.Resource.PartitionKey.Should().Be(unicodePk); + } + + [Fact] + public async Task Handler_CrudRoundTrip_WithPartitionKeyContainingQuotes() + { + var quotedPk = "it's a \"quoted\" value"; + var doc = new TestDocument { Id = "pk2", PartitionKey = quotedPk, Name = "Quoted" }; + + await _container.CreateItemAsync(doc, new PartitionKey(quotedPk)); + + var read = await _container.ReadItemAsync("pk2", new PartitionKey(quotedPk)); + read.Resource.Name.Should().Be("Quoted"); + read.Resource.PartitionKey.Should().Be(quotedPk); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_CreateItem_HierarchicalPartitionKey_ThreeLevels() + { + using var cosmos = InMemoryCosmos.Create("hier3-test", new[] { "/tenantId", "/region", "/userId" }); + var c = cosmos.Container; + + var pk = new PartitionKeyBuilder().Add("tenant1").Add("us-east").Add("user1").Build(); + var doc = new { id = "h3pk", tenantId = "tenant1", region = "us-east", userId = "user1", name = "ThreeLevel" }; + var response = await c.CreateItemAsync(doc, pk); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await c.ReadItemAsync("h3pk", pk); + ((string)read.Resource.name).Should().Be("ThreeLevel"); + } + + [Fact] + public async Task Handler_ReadItem_WrongPartitionKey_Throws404() + { + var doc = new TestDocument { Id = "pk4", PartitionKey = "correct-pk", Name = "Isolated" }; + await _container.CreateItemAsync(doc, new PartitionKey("correct-pk")); + + var act = () => _container.ReadItemAsync("pk4", new PartitionKey("wrong-pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 8: Response Contract Verification (RC1–RC5) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_ResponseContainsDiagnostics() + { + var doc = new TestDocument { Id = "rc1", PartitionKey = "pk1", Name = "Diagnostics" }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.Diagnostics.Should().NotBeNull(); + } + + [Fact] + public async Task Handler_AllCrudOps_ReturnNonZeroRequestCharge() + { + var doc = new TestDocument { Id = "rc2", PartitionKey = "pk1", Name = "Charge", Value = 1 }; + + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + create.RequestCharge.Should().BeGreaterThan(0); + + var read = await _container.ReadItemAsync("rc2", new PartitionKey("pk1")); + read.RequestCharge.Should().BeGreaterThan(0); + + doc.Name = "Replaced"; + var replace = await _container.ReplaceItemAsync(doc, "rc2", new PartitionKey("pk1")); + replace.RequestCharge.Should().BeGreaterThan(0); + + var patch = await _container.PatchItemAsync("rc2", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + patch.RequestCharge.Should().BeGreaterThan(0); + + var delete = await _container.DeleteItemAsync("rc2", new PartitionKey("pk1")); + delete.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Handler_AllCrudOps_ReturnNonEmptyActivityId() + { + var doc = new TestDocument { Id = "rc3", PartitionKey = "pk1", Name = "Activity", Value = 1 }; + + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + create.ActivityId.Should().NotBeNullOrEmpty(); + + var read = await _container.ReadItemAsync("rc3", new PartitionKey("pk1")); + read.ActivityId.Should().NotBeNullOrEmpty(); + + doc.Name = "Replaced"; + var replace = await _container.ReplaceItemAsync(doc, "rc3", new PartitionKey("pk1")); + replace.ActivityId.Should().NotBeNullOrEmpty(); + + var patch = await _container.PatchItemAsync("rc3", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + patch.ActivityId.Should().NotBeNullOrEmpty(); + + var delete = await _container.DeleteItemAsync("rc3", new PartitionKey("pk1")); + delete.ActivityId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Handler_CreateItem_ResponseStatusCodeMatchesExpected() + { + var doc = new TestDocument { Id = "rc4", PartitionKey = "pk1", Name = "StatusCode" }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + ((int)response.StatusCode).Should().Be(201); + } + + [Fact] + public async Task Handler_DeleteItem_ResponseResource_IsDefault() + { + var doc = new TestDocument { Id = "rc5", PartitionKey = "pk1", Name = "ToDelete" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("rc5", new PartitionKey("pk1")); + + response.Resource.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 9: Concurrency via Handler (CC1–CC3) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ConcurrentCreates_DifferentIds_AllSucceed() + { + var tasks = Enumerable.Range(0, 50).Select(i => + { + var doc = new TestDocument { Id = $"cc1-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }; + return _container.CreateItemAsync(doc, new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + + results.Should().HaveCount(50); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } + + [Fact] + public async Task Handler_ConcurrentUpserts_SameId_LastWriteWins() + { + // Seed the item + var doc = new TestDocument { Id = "cc2", PartitionKey = "pk1", Name = "Seed", Value = 0 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var tasks = Enumerable.Range(1, 10).Select(i => + { + var d = new TestDocument { Id = "cc2", PartitionKey = "pk1", Name = $"V{i}", Value = i }; + return _container.UpsertItemAsync(d, new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + + // All should succeed (no corruption) + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + // Final read should return one consistent version + var read = await _container.ReadItemAsync("cc2", new PartitionKey("pk1")); + read.Resource.Name.Should().StartWith("V"); + } + + [Fact] + public async Task Handler_ConcurrentReadAndWrite_NoCorruption() + { + // Seed some items + for (var i = 0; i < 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"cc3-{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + } + + var readTasks = Enumerable.Range(0, 10).Select(i => + _container.ReadItemAsync($"cc3-{i}", new PartitionKey("pk1"))); + + var writeTasks = Enumerable.Range(10, 10).Select(i => + { + var d = new TestDocument { Id = $"cc3-{i}", PartitionKey = "pk1", Name = $"New{i}" }; + return _container.CreateItemAsync(d, new PartitionKey("pk1")); + }); + + var allTasks = readTasks.Cast().Concat(writeTasks.Cast()); + await Task.WhenAll(allTasks); + + // No exceptions means no corruption + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 10: Fault Injection Edge Cases (FI1–FI5) — InMemoryOnly + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_OnUpsert_ThrottlesCorrectly() + { + var (handler, client, container) = CreateInMemoryStack("fi1-upsert"); + using (client) + using (handler) + { + handler.FaultInjector = _ => + new HttpResponseMessage((HttpStatusCode)429) + { + Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } + }; + handler.FaultInjectorIncludesMetadata = false; + + var doc = new TestDocument { Id = "fi1", PartitionKey = "pk1", Name = "Throttled" }; + var act = () => container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be((HttpStatusCode)429); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_OnReplace_Returns500() + { + var (handler, client, container) = CreateInMemoryStack("fi2-replace"); + using (client) + using (handler) + { + var doc = new TestDocument { Id = "fi2", PartitionKey = "pk1", Name = "Original" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + handler.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.InternalServerError); + handler.FaultInjectorIncludesMetadata = false; + + doc.Name = "Broken"; + var act = () => container.ReplaceItemAsync(doc, "fi2", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_OnPatch_Returns503() + { + var (handler, client, container) = CreateInMemoryStack("fi3-patch"); + using (client) + using (handler) + { + var doc = new TestDocument { Id = "fi3", PartitionKey = "pk1", Name = "Before" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + handler.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + handler.FaultInjectorIncludesMetadata = false; + + var act = () => container.PatchItemAsync("fi3", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "After")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_SelectiveByPath_OnlyTargetsSpecificDoc() + { + var (handler, client, container) = CreateInMemoryStack("fi4-selective"); + using (client) + using (handler) + { + await container.CreateItemAsync( + new TestDocument { Id = "fi4-target", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "fi4-safe", PartitionKey = "pk1", Name = "Safe" }, + new PartitionKey("pk1")); + + handler.FaultInjector = req => + { + if (req.RequestUri?.ToString().Contains("fi4-target") == true) + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + return null; + }; + handler.FaultInjectorIncludesMetadata = false; + + // Target doc is blocked + var act = () => container.ReadItemAsync("fi4-target", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + + // Safe doc is accessible + var read = await container.ReadItemAsync("fi4-safe", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Safe"); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_Clears_WhenSetToNull() + { + var (handler, client, container) = CreateInMemoryStack("fi5-clear"); + using (client) + using (handler) + { + handler.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + handler.FaultInjectorIncludesMetadata = false; + + var doc = new TestDocument { Id = "fi5", PartitionKey = "pk1", Name = "Blocked" }; + var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Clear fault injector + handler.FaultInjector = null; + + // Now should succeed + var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 10: Unique Key Policy – Nested Paths (UK1–UK3) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_UniqueKeyPolicy_NestedPath_EnforcesConstraint() + { + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/unique-keys + // "You can specify unique key paths with up to 16 path values." + // Paths like /address/zipCode are supported and reference nested properties. + var container = await _fixture.CreateContainerAsync("uk-nested", "/partitionKey", props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } + }; + }); + + var doc1 = new TestDocument { Id = "uk1", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "unique-val", Score = 1.0 } }; + var doc2 = new TestDocument { Id = "uk2", PartitionKey = "pk1", Name = "B", Nested = new NestedObject { Description = "unique-val", Score = 2.0 } }; + + await container.CreateItemAsync(doc1, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(() => + container.CreateItemAsync(doc2, new PartitionKey("pk1"))); + ex.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Handler_UniqueKeyPolicy_NestedPath_DifferentValues_Succeeds() + { + var container = await _fixture.CreateContainerAsync("uk-nested-ok", "/partitionKey", props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } + }; + }); + + var doc1 = new TestDocument { Id = "uk3", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "val-one", Score = 1.0 } }; + var doc2 = new TestDocument { Id = "uk4", PartitionKey = "pk1", Name = "B", Nested = new NestedObject { Description = "val-two", Score = 2.0 } }; + + var r1 = await container.CreateItemAsync(doc1, new PartitionKey("pk1")); + var r2 = await container.CreateItemAsync(doc2, new PartitionKey("pk1")); + + r1.StatusCode.Should().Be(HttpStatusCode.Created); + r2.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Handler_UniqueKeyPolicy_NestedPath_DifferentPartitions_Succeeds() + { + // Unique keys are scoped to logical partition — same nested value in different partitions is allowed + var container = await _fixture.CreateContainerAsync("uk-nested-xpk", "/partitionKey", props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/nested/description" } } } + }; + }); + + var doc1 = new TestDocument { Id = "uk5", PartitionKey = "pk1", Name = "A", Nested = new NestedObject { Description = "same-val", Score = 1.0 } }; + var doc2 = new TestDocument { Id = "uk6", PartitionKey = "pk2", Name = "B", Nested = new NestedObject { Description = "same-val", Score = 2.0 } }; + + var r1 = await container.CreateItemAsync(doc1, new PartitionKey("pk1")); + var r2 = await container.CreateItemAsync(doc2, new PartitionKey("pk2")); + + r1.StatusCode.Should().Be(HttpStatusCode.Created); + r2.StatusCode.Should().Be(HttpStatusCode.Created); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudTests.cs index d24d883..871f75d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerCrudTests.cs @@ -16,1243 +16,1247 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerCrudTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-crud", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - /// - /// Helper for in-memory-only tests that need direct access to FakeCosmosHandler APIs - /// (RequestLog, FaultInjector, etc.). Creates an isolated handler stack. - /// - private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( - string name = "test", string pkPath = "/partitionKey") - { - var cosmos = InMemoryCosmos.Create(name, pkPath); - return (cosmos.Handler, cosmos.Client, cosmos.Container); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1A. Create Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_ReturnsCreated() - { - var doc = new TestDocument { Id = "c1", PartitionKey = "pk1", Name = "Alice", Value = 10 }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("c1"); - response.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Handler_CreateItem_DuplicateId_ReturnsConflict() - { - var doc = new TestDocument { Id = "c2", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task Handler_CreateItem_ThenQuery_ReturnsItem() - { - var doc = new TestDocument { Id = "c3", PartitionKey = "pk1", Name = "Bob", Value = 20 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var results = new List(); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Id.Should().Be("c3"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1B. Read Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ReadItem_ReturnsDocument() - { - var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Alice", Value = 10 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("r1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Alice"); - response.Resource.Value.Should().Be(10); - } - - [Fact] - public async Task Handler_ReadItem_NotFound_Throws404() - { - var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} - // The exception message should contain "Resource Not Found" to match the real SDK behavior. - [Theory] - [InlineData("ReadItem")] - [InlineData("ReplaceItem")] - [InlineData("DeleteItem")] - [InlineData("PatchItem")] - public async Task Handler_NotFound_Message_ContainsResourceNotFound(string operation) - { - Func act = operation switch - { - "ReadItem" => () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")), - "ReplaceItem" => () => _container.ReplaceItemAsync( - new TestDocument { Id = "nonexistent", PartitionKey = "pk1" }, "nonexistent", new PartitionKey("pk1")), - "DeleteItem" => () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")), - "PatchItem" => () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "x") }), - _ => throw new ArgumentException(operation) - }; - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Which.Message.ToLower().Should().Contain("resource not found"); - } - - [Fact] - public async Task Handler_ReadItem_WithPartitionKey_ReturnsCorrectItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "r3", PartitionKey = "pkA", Name = "FromA" }, - new PartitionKey("pkA")); - await _container.CreateItemAsync( - new TestDocument { Id = "r3", PartitionKey = "pkB", Name = "FromB" }, - new PartitionKey("pkB")); - - var responseA = await _container.ReadItemAsync("r3", new PartitionKey("pkA")); - var responseB = await _container.ReadItemAsync("r3", new PartitionKey("pkB")); - - responseA.Resource.Name.Should().Be("FromA"); - responseB.Resource.Name.Should().Be("FromB"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1C. Upsert Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_UpsertItem_NewItem_ReturnsCreated() - { - var doc = new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Alice" }; - - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("u1"); - } - - [Fact] - public async Task Handler_UpsertItem_ExistingItem_ReturnsOk() - { - var doc = new TestDocument { Id = "u2", PartitionKey = "pk1", Name = "Alice", Value = 10 }; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "Updated"; - doc.Value = 99; - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Updated"); - response.Resource.Value.Should().Be(99); - } - - [Fact] - public async Task Handler_UpsertItem_ThenQuery_ReturnsUpdatedItem() - { - var doc = new TestDocument { Id = "u3", PartitionKey = "pk1", Name = "Original" }; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "Modified"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var results = new List(); - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.id = 'u3'"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Modified"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1D. Replace Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ReplaceItem_ReturnsOk() - { - var doc = new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Before", Value = 1 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "After"; - doc.Value = 2; - var response = await _container.ReplaceItemAsync(doc, "rp1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("After"); - response.Resource.Value.Should().Be(2); - } - - [Fact] - public async Task Handler_ReplaceItem_NotFound_Throws404() - { - var doc = new TestDocument { Id = "rp2", PartitionKey = "pk1", Name = "Ghost" }; - - var act = () => _container.ReplaceItemAsync(doc, "rp2", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_ReplaceItem_WithETag_StaleETag_ThrowsPreconditionFailed() - { - var doc = new TestDocument { Id = "rp3", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Modify to change the ETag - doc.Name = "Modified"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - // Try to replace with a stale ETag - doc.Name = "StaleReplace"; - var act = () => _container.ReplaceItemAsync(doc, "rp3", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1E. Delete Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_DeleteItem_ReturnsNoContent() - { - var doc = new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "ToDelete" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("d1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Handler_DeleteItem_NotFound_Throws404() - { - var act = () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_DeleteItem_ThenRead_Throws404() - { - var doc = new TestDocument { Id = "d3", PartitionKey = "pk1", Name = "Ephemeral" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - await _container.DeleteItemAsync("d3", new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("d3", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1F. Patch Item - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_PatchItem_SetOperation_ReturnsOk() - { - var doc = new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Before", Value = 1 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "After")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("After"); - response.Resource.Value.Should().Be(1); // unchanged - } - - [Fact] - public async Task Handler_PatchItem_MultipleOperations_AllApplied() - { - var doc = new TestDocument { Id = "p2", PartitionKey = "pk1", Name = "Start", Value = 10 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p2", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "Patched"), - PatchOperation.Increment("/value", 5) - ]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Patched"); - response.Resource.Value.Should().Be(15); - } - - [Fact] - public async Task Handler_PatchItem_NotFound_Throws404() - { - var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Ghost")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // Windows emulator (v2.14.0) returns 400 "Syntax error near 'value'" for patch filter predicates. - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task Handler_PatchItem_WithFilterPredicate_MatchingCondition_Succeeds() - { - var doc = new TestDocument { Id = "p4", PartitionKey = "pk1", Name = "Conditional", Value = 100 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p4", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 100" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Updated"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1G. Integration / Multi-Container - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CrudThenLinqQuery_RoundTrip() - { - // Create items via CRUD - await _container.CreateItemAsync( - new TestDocument { Id = "lq1", PartitionKey = "pk1", Name = "Active", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "lq2", PartitionKey = "pk1", Name = "Inactive", Value = 5 }, - new PartitionKey("pk1")); - - // Query via LINQ .ToFeedIterator() — the whole point of this feature - var results = new List(); - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Value > 7) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Active"); - } - - [Fact] - public async Task Handler_MultiContainer_CrudIsolated() - { - var cA = _container; // reuse per-test container to stay within CI's 3-partition limit - var cB = await _fixture.CreateContainerAsync("containerB", "/partitionKey"); - - // Create in A - await cA.CreateItemAsync( - new TestDocument { Id = "iso1", PartitionKey = "pk1", Name = "InA" }, - new PartitionKey("pk1")); - - // Read from A succeeds - var readA = await cA.ReadItemAsync("iso1", new PartitionKey("pk1")); - readA.Resource.Name.Should().Be("InA"); - - // Read from B fails — item doesn't exist there - var act = () => cB.ReadItemAsync("iso1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_RequestLog_RecordsCrudOperations() - { - var (handler, client, container) = CreateInMemoryStack("log-crud"); - using var _h = handler; - using var _c = client; - - var doc = new TestDocument { Id = "log1", PartitionKey = "pk1", Name = "Logged" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - await container.ReadItemAsync("log1", new PartitionKey("pk1")); - await container.DeleteItemAsync("log1", new PartitionKey("pk1")); - - // POST for create, GET for read, DELETE for delete - handler.RequestLog.Should().Contain(e => e.StartsWith("POST") && e.Contains("/docs")); - handler.RequestLog.Should().Contain(e => e.StartsWith("GET") && e.Contains("/docs/")); - handler.RequestLog.Should().Contain(e => e.StartsWith("DELETE") && e.Contains("/docs/")); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1H. SDK Compatibility - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_VerifySdkCompatibility_IncludesCrudCheck() - { - var act = FakeCosmosHandler.VerifySdkCompatibilityAsync; - - await act.Should().NotThrowAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 1I. Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_ThrottlesCreateRequest() - { - var (handler, client, container) = CreateInMemoryStack("fault-throttle"); - using var _h = handler; - using var _c = client; - - handler.FaultInjector = _ => - new HttpResponseMessage((HttpStatusCode)429) - { - Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } - }; - handler.FaultInjectorIncludesMetadata = false; - - var doc = new TestDocument { Id = "fi1", PartitionKey = "pk1", Name = "Throttled" }; - var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be((HttpStatusCode)429); - } - - // Windows emulator (v2.14.0) returns 400 BadRequest instead of 412 PreconditionFailed for non-matching patch filter predicates. - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task Handler_PatchItem_WithFilterPredicate_NonMatchingCondition_ThrowsPreconditionFailed() - { - var doc = new TestDocument { Id = "p5", PartitionKey = "pk1", Name = "Conditional", Value = 50 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("p5", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value > 100" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Handler_ReadItem_WithUrlEncodedId_Succeeds() - { - var id = "doc with spaces"; - var doc = new TestDocument { Id = id, PartitionKey = "pk1", Name = "Encoded" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync(id, new PartitionKey("pk1")); - - response.Resource.Name.Should().Be("Encoded"); - response.Resource.Id.Should().Be(id); - } - - [Fact] - public async Task Handler_Crud_WithCompositePartitionKey_RoundTrip() - { - var container = await _fixture.CreateContainerAsync("composite-test", ["/tenantId", "/userId"]); - - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - var doc = new { id = "cpk1", tenantId = "t1", userId = "u1", name = "Composite" }; - var createResponse = await container.CreateItemAsync(doc, pk); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemAsync("cpk1", pk); - ((string)readResponse.Resource.name).Should().Be("Composite"); - - await container.DeleteItemAsync("cpk1", pk); - var act = () => container.ReadItemAsync("cpk1", pk); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // A1. Special Character IDs - // ═══════════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData("a.b.c")] - [InlineData("hello world")] - [InlineData("日本語")] - [InlineData("a+b")] - public async Task Handler_CreateItem_WithSpecialCharactersInId_Succeeds(string id) - { - var doc = new TestDocument { Id = id, PartitionKey = "pk1", Name = "Special" }; - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await _container.ReadItemAsync(id, new PartitionKey("pk1")); - readResponse.Resource.Id.Should().Be(id); - readResponse.Resource.Name.Should().Be("Special"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // A2. Replace with matching ETag - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ReplaceItem_WithMatchingETag_Succeeds() - { - var doc = new TestDocument { Id = "etag-rp1", PartitionKey = "pk1", Name = "Original" }; - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var etag = createResponse.ETag; - - doc.Name = "Updated"; - var replaceResponse = await _container.ReplaceItemAsync(doc, "etag-rp1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResponse.Resource.Name.Should().Be("Updated"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // A3/A4. Delete with ETag - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_DeleteItem_WithStaleETag_ThrowsPreconditionFailed() - { - var doc = new TestDocument { Id = "etag-d1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Modify to change the ETag - doc.Name = "Modified"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.DeleteItemAsync("etag-d1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Handler_DeleteItem_WithMatchingETag_Succeeds() - { - var doc = new TestDocument { Id = "etag-d2", PartitionKey = "pk1", Name = "ToDelete" }; - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var etag = createResponse.ETag; - - var deleteResponse = await _container.DeleteItemAsync("etag-d2", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // A5/A6. Patch Remove + Add operations - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_PatchItem_RemoveOperation_Succeeds() - { - var doc = new TestDocument { Id = "prm1", PartitionKey = "pk1", Name = "HasName", Value = 42 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("prm1", new PartitionKey("pk1"), - [PatchOperation.Remove("/name")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().BeNull(); - response.Resource.Value.Should().Be(42); // unchanged - } - - [Fact] - public async Task Handler_PatchItem_AddOperation_Succeeds() - { - var doc = new TestDocument { Id = "padd1", PartitionKey = "pk1", Name = "Before" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("padd1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags", new[] { "alpha", "beta" })]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Tags.Should().Contain("alpha"); - response.Resource.Tags.Should().Contain("beta"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // A7-A10. ETag in CRUD responses - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_ReturnsETagInResponse() - { - var doc = new TestDocument { Id = "etag-c1", PartitionKey = "pk1", Name = "Alice" }; + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-crud", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + /// + /// Helper for in-memory-only tests that need direct access to FakeCosmosHandler APIs + /// (RequestLog, FaultInjector, etc.). Creates an isolated handler stack. + /// + private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( + string name = "test", string pkPath = "/partitionKey") + { + var cosmos = InMemoryCosmos.Create(name, pkPath); + return (cosmos.Handler, cosmos.Client, cosmos.Container); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1A. Create Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_ReturnsCreated() + { + var doc = new TestDocument { Id = "c1", PartitionKey = "pk1", Name = "Alice", Value = 10 }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("c1"); + response.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Handler_CreateItem_DuplicateId_ReturnsConflict() + { + var doc = new TestDocument { Id = "c2", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Handler_CreateItem_ThenQuery_ReturnsItem() + { + var doc = new TestDocument { Id = "c3", PartitionKey = "pk1", Name = "Bob", Value = 20 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var results = new List(); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Id.Should().Be("c3"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1B. Read Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ReadItem_ReturnsDocument() + { + var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Alice", Value = 10 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("r1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Alice"); + response.Resource.Value.Should().Be(10); + } + + [Fact] + public async Task Handler_ReadItem_NotFound_Throws404() + { + var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} + // The exception message should contain "Resource Not Found" to match the real SDK behavior. + [Theory] + [InlineData("ReadItem")] + [InlineData("ReplaceItem")] + [InlineData("DeleteItem")] + [InlineData("PatchItem")] + public async Task Handler_NotFound_Message_ContainsResourceNotFound(string operation) + { + Func act = operation switch + { + "ReadItem" => () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")), + "ReplaceItem" => () => _container.ReplaceItemAsync( + new TestDocument { Id = "nonexistent", PartitionKey = "pk1" }, "nonexistent", new PartitionKey("pk1")), + "DeleteItem" => () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")), + "PatchItem" => () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "x") }), + _ => throw new ArgumentException(operation) + }; + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Which.Message.ToLower().Should().Contain("resource not found"); + } + + [Fact] + public async Task Handler_ReadItem_WithPartitionKey_ReturnsCorrectItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "r3", PartitionKey = "pkA", Name = "FromA" }, + new PartitionKey("pkA")); + await _container.CreateItemAsync( + new TestDocument { Id = "r3", PartitionKey = "pkB", Name = "FromB" }, + new PartitionKey("pkB")); + + var responseA = await _container.ReadItemAsync("r3", new PartitionKey("pkA")); + var responseB = await _container.ReadItemAsync("r3", new PartitionKey("pkB")); + + responseA.Resource.Name.Should().Be("FromA"); + responseB.Resource.Name.Should().Be("FromB"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1C. Upsert Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_UpsertItem_NewItem_ReturnsCreated() + { + var doc = new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Alice" }; + + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("u1"); + } + + [Fact] + public async Task Handler_UpsertItem_ExistingItem_ReturnsOk() + { + var doc = new TestDocument { Id = "u2", PartitionKey = "pk1", Name = "Alice", Value = 10 }; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "Updated"; + doc.Value = 99; + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Updated"); + response.Resource.Value.Should().Be(99); + } + + [Fact] + public async Task Handler_UpsertItem_ThenQuery_ReturnsUpdatedItem() + { + var doc = new TestDocument { Id = "u3", PartitionKey = "pk1", Name = "Original" }; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "Modified"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var results = new List(); + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.id = 'u3'"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Modified"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1D. Replace Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ReplaceItem_ReturnsOk() + { + var doc = new TestDocument { Id = "rp1", PartitionKey = "pk1", Name = "Before", Value = 1 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "After"; + doc.Value = 2; + var response = await _container.ReplaceItemAsync(doc, "rp1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("After"); + response.Resource.Value.Should().Be(2); + } + + [Fact] + public async Task Handler_ReplaceItem_NotFound_Throws404() + { + var doc = new TestDocument { Id = "rp2", PartitionKey = "pk1", Name = "Ghost" }; + + var act = () => _container.ReplaceItemAsync(doc, "rp2", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_ReplaceItem_WithETag_StaleETag_ThrowsPreconditionFailed() + { + var doc = new TestDocument { Id = "rp3", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Modify to change the ETag + doc.Name = "Modified"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + // Try to replace with a stale ETag + doc.Name = "StaleReplace"; + var act = () => _container.ReplaceItemAsync(doc, "rp3", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1E. Delete Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_DeleteItem_ReturnsNoContent() + { + var doc = new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "ToDelete" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("d1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Handler_DeleteItem_NotFound_Throws404() + { + var act = () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_DeleteItem_ThenRead_Throws404() + { + var doc = new TestDocument { Id = "d3", PartitionKey = "pk1", Name = "Ephemeral" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + await _container.DeleteItemAsync("d3", new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("d3", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1F. Patch Item + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_PatchItem_SetOperation_ReturnsOk() + { + var doc = new TestDocument { Id = "p1", PartitionKey = "pk1", Name = "Before", Value = 1 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "After")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("After"); + response.Resource.Value.Should().Be(1); // unchanged + } + + [Fact] + public async Task Handler_PatchItem_MultipleOperations_AllApplied() + { + var doc = new TestDocument { Id = "p2", PartitionKey = "pk1", Name = "Start", Value = 10 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p2", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "Patched"), + PatchOperation.Increment("/value", 5) + ]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Patched"); + response.Resource.Value.Should().Be(15); + } + + [Fact] + public async Task Handler_PatchItem_NotFound_Throws404() + { + var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Ghost")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // Windows emulator (v2.14.0) returns 400 "Syntax error near 'value'" for patch filter predicates. + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task Handler_PatchItem_WithFilterPredicate_MatchingCondition_Succeeds() + { + var doc = new TestDocument { Id = "p4", PartitionKey = "pk1", Name = "Conditional", Value = 100 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p4", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 100" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Updated"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1G. Integration / Multi-Container + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CrudThenLinqQuery_RoundTrip() + { + // Create items via CRUD + await _container.CreateItemAsync( + new TestDocument { Id = "lq1", PartitionKey = "pk1", Name = "Active", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "lq2", PartitionKey = "pk1", Name = "Inactive", Value = 5 }, + new PartitionKey("pk1")); + + // Query via LINQ .ToFeedIterator() — the whole point of this feature + var results = new List(); + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Value > 7) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Active"); + } + + [Fact] + public async Task Handler_MultiContainer_CrudIsolated() + { + var cA = _container; // reuse per-test container to stay within CI's 3-partition limit + var cB = await _fixture.CreateContainerAsync("containerB", "/partitionKey"); + + // Create in A + await cA.CreateItemAsync( + new TestDocument { Id = "iso1", PartitionKey = "pk1", Name = "InA" }, + new PartitionKey("pk1")); + + // Read from A succeeds + var readA = await cA.ReadItemAsync("iso1", new PartitionKey("pk1")); + readA.Resource.Name.Should().Be("InA"); + + // Read from B fails — item doesn't exist there + var act = () => cB.ReadItemAsync("iso1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_RequestLog_RecordsCrudOperations() + { + var (handler, client, container) = CreateInMemoryStack("log-crud"); + using var _h = handler; + using var _c = client; + + var doc = new TestDocument { Id = "log1", PartitionKey = "pk1", Name = "Logged" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + await container.ReadItemAsync("log1", new PartitionKey("pk1")); + await container.DeleteItemAsync("log1", new PartitionKey("pk1")); + + // POST for create, GET for read, DELETE for delete + handler.RequestLog.Should().Contain(e => e.StartsWith("POST") && e.Contains("/docs")); + handler.RequestLog.Should().Contain(e => e.StartsWith("GET") && e.Contains("/docs/")); + handler.RequestLog.Should().Contain(e => e.StartsWith("DELETE") && e.Contains("/docs/")); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1H. SDK Compatibility + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_VerifySdkCompatibility_IncludesCrudCheck() + { + var act = FakeCosmosHandler.VerifySdkCompatibilityAsync; + + await act.Should().NotThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1I. Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_ThrottlesCreateRequest() + { + var (handler, client, container) = CreateInMemoryStack("fault-throttle"); + using var _h = handler; + using var _c = client; + + handler.FaultInjector = _ => + new HttpResponseMessage((HttpStatusCode)429) + { + Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } + }; + handler.FaultInjectorIncludesMetadata = false; + + var doc = new TestDocument { Id = "fi1", PartitionKey = "pk1", Name = "Throttled" }; + var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be((HttpStatusCode)429); + } + + // Windows emulator (v2.14.0) returns 400 BadRequest instead of 412 PreconditionFailed for non-matching patch filter predicates. + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task Handler_PatchItem_WithFilterPredicate_NonMatchingCondition_ThrowsPreconditionFailed() + { + var doc = new TestDocument { Id = "p5", PartitionKey = "pk1", Name = "Conditional", Value = 50 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("p5", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value > 100" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Handler_ReadItem_WithUrlEncodedId_Succeeds() + { + var id = "doc with spaces"; + var doc = new TestDocument { Id = id, PartitionKey = "pk1", Name = "Encoded" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync(id, new PartitionKey("pk1")); + + response.Resource.Name.Should().Be("Encoded"); + response.Resource.Id.Should().Be(id); + } + + [Fact] + public async Task Handler_Crud_WithCompositePartitionKey_RoundTrip() + { + var container = await _fixture.CreateContainerAsync("composite-test", ["/tenantId", "/userId"]); + + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + var doc = new { id = "cpk1", tenantId = "t1", userId = "u1", name = "Composite" }; + var createResponse = await container.CreateItemAsync(doc, pk); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemAsync("cpk1", pk); + ((string)readResponse.Resource.name).Should().Be("Composite"); + + await container.DeleteItemAsync("cpk1", pk); + var act = () => container.ReadItemAsync("cpk1", pk); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // A1. Special Character IDs + // ═══════════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("a.b.c")] + [InlineData("hello world")] + [InlineData("日本語")] + [InlineData("a+b")] + public async Task Handler_CreateItem_WithSpecialCharactersInId_Succeeds(string id) + { + var doc = new TestDocument { Id = id, PartitionKey = "pk1", Name = "Special" }; + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await _container.ReadItemAsync(id, new PartitionKey("pk1")); + readResponse.Resource.Id.Should().Be(id); + readResponse.Resource.Name.Should().Be("Special"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // A2. Replace with matching ETag + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ReplaceItem_WithMatchingETag_Succeeds() + { + var doc = new TestDocument { Id = "etag-rp1", PartitionKey = "pk1", Name = "Original" }; + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var etag = createResponse.ETag; + + doc.Name = "Updated"; + var replaceResponse = await _container.ReplaceItemAsync(doc, "etag-rp1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResponse.Resource.Name.Should().Be("Updated"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // A3/A4. Delete with ETag + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_DeleteItem_WithStaleETag_ThrowsPreconditionFailed() + { + var doc = new TestDocument { Id = "etag-d1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Modify to change the ETag + doc.Name = "Modified"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.DeleteItemAsync("etag-d1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Handler_DeleteItem_WithMatchingETag_Succeeds() + { + var doc = new TestDocument { Id = "etag-d2", PartitionKey = "pk1", Name = "ToDelete" }; + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var etag = createResponse.ETag; + + var deleteResponse = await _container.DeleteItemAsync("etag-d2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // A5/A6. Patch Remove + Add operations + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_PatchItem_RemoveOperation_Succeeds() + { + var doc = new TestDocument { Id = "prm1", PartitionKey = "pk1", Name = "HasName", Value = 42 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("prm1", new PartitionKey("pk1"), + [PatchOperation.Remove("/name")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().BeNull(); + response.Resource.Value.Should().Be(42); // unchanged + } + + [Fact] + public async Task Handler_PatchItem_AddOperation_Succeeds() + { + var doc = new TestDocument { Id = "padd1", PartitionKey = "pk1", Name = "Before" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("padd1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags", new[] { "alpha", "beta" })]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Tags.Should().Contain("alpha"); + response.Resource.Tags.Should().Contain("beta"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // A7-A10. ETag in CRUD responses + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_ReturnsETagInResponse() + { + var doc = new TestDocument { Id = "etag-c1", PartitionKey = "pk1", Name = "Alice" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Handler_UpsertItem_ReturnsETagInResponse() - { - var doc = new TestDocument { Id = "etag-u1", PartitionKey = "pk1", Name = "Alice" }; + [Fact] + public async Task Handler_UpsertItem_ReturnsETagInResponse() + { + var doc = new TestDocument { Id = "etag-u1", PartitionKey = "pk1", Name = "Alice" }; - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Handler_ReplaceItem_ReturnsUpdatedETag() - { - var doc = new TestDocument { Id = "etag-rp2", PartitionKey = "pk1", Name = "Before" }; - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var originalETag = createResponse.ETag; + [Fact] + public async Task Handler_ReplaceItem_ReturnsUpdatedETag() + { + var doc = new TestDocument { Id = "etag-rp2", PartitionKey = "pk1", Name = "Before" }; + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var originalETag = createResponse.ETag; - doc.Name = "After"; - var replaceResponse = await _container.ReplaceItemAsync(doc, "etag-rp2", new PartitionKey("pk1")); + doc.Name = "After"; + var replaceResponse = await _container.ReplaceItemAsync(doc, "etag-rp2", new PartitionKey("pk1")); - replaceResponse.ETag.Should().NotBeNullOrEmpty(); - replaceResponse.ETag.Should().NotBe(originalETag); - } + replaceResponse.ETag.Should().NotBeNullOrEmpty(); + replaceResponse.ETag.Should().NotBe(originalETag); + } - [Fact] - public async Task Handler_ReadItem_ReturnsETagInResponse() - { - var doc = new TestDocument { Id = "etag-r1", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + [Fact] + public async Task Handler_ReadItem_ReturnsETagInResponse() + { + var doc = new TestDocument { Id = "etag-r1", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("etag-r1", new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("etag-r1", new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - // ═══════════════════════════════════════════════════════════════════════════ - // B3. PartitionKey.None round-trip - // ═══════════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════════ + // B3. PartitionKey.None round-trip + // ═══════════════════════════════════════════════════════════════════════════ - [Fact] - public async Task Handler_ReadItem_WithPartitionKeyNone_Succeeds() - { - var container = await _fixture.CreateContainerAsync("pknone-test", "/partitionKey"); + [Fact] + public async Task Handler_ReadItem_WithPartitionKeyNone_Succeeds() + { + var container = await _fixture.CreateContainerAsync("pknone-test", "/partitionKey"); - var doc = new { id = "none1", name = "NoPK" }; - await container.CreateItemAsync(doc, PartitionKey.None); + var doc = new { id = "none1", name = "NoPK" }; + await container.CreateItemAsync(doc, PartitionKey.None); - var response = await container.ReadItemAsync("none1", PartitionKey.None); - ((string)response.Resource.name).Should().Be("NoPK"); - } + var response = await container.ReadItemAsync("none1", PartitionKey.None); + ((string)response.Resource.name).Should().Be("NoPK"); + } - // ═══════════════════════════════════════════════════════════════════════════ - // D1-D3. Response headers on CRUD operations - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CrudResponse_ContainsRequestCharge() - { - var doc = new TestDocument { Id = "hdr1", PartitionKey = "pk1", Name = "Alice" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + // ═══════════════════════════════════════════════════════════════════════════ + // D1-D3. Response headers on CRUD operations + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CrudResponse_ContainsRequestCharge() + { + var doc = new TestDocument { Id = "hdr1", PartitionKey = "pk1", Name = "Alice" }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Handler_CrudResponse_ContainsActivityId() + { + var doc = new TestDocument { Id = "hdr2", PartitionKey = "pk1", Name = "Alice" }; - response.RequestCharge.Should().BeGreaterThan(0); - } + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - [Fact] - public async Task Handler_CrudResponse_ContainsActivityId() - { - var doc = new TestDocument { Id = "hdr2", PartitionKey = "pk1", Name = "Alice" }; + response.ActivityId.Should().NotBeNullOrEmpty(); + } - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + [Fact] + public async Task Handler_CrudResponse_ContainsSessionToken() + { + var doc = new TestDocument { Id = "hdr3", PartitionKey = "pk1", Name = "Alice" }; - response.ActivityId.Should().NotBeNullOrEmpty(); - } + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - [Fact] - public async Task Handler_CrudResponse_ContainsSessionToken() - { - var doc = new TestDocument { Id = "hdr3", PartitionKey = "pk1", Name = "Alice" }; + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + // ═══════════════════════════════════════════════════════════════════════════ + // C: Create Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task Handler_CreateItem_WithNullPartitionKey_Succeeds() + { + var doc = new { id = "c-null-pk", partitionKey = (string?)null, name = "NullPK" }; - // ═══════════════════════════════════════════════════════════════════════════ - // C: Create Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ + var response = await _container.CreateItemAsync(doc, PartitionKey.Null); - [Fact] - public async Task Handler_CreateItem_WithNullPartitionKey_Succeeds() - { - var doc = new { id = "c-null-pk", partitionKey = (string?)null, name = "NullPK" }; + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - var response = await _container.CreateItemAsync(doc, PartitionKey.Null); + [Fact] + public async Task Handler_CreateItem_WithEmptyStringPartitionKey_Succeeds() + { + var doc = new TestDocument { Id = "c-empty-pk", PartitionKey = "", Name = "EmptyPK" }; - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + var response = await _container.CreateItemAsync(doc, new PartitionKey("")); - [Fact] - public async Task Handler_CreateItem_WithEmptyStringPartitionKey_Succeeds() - { - var doc = new TestDocument { Id = "c-empty-pk", PartitionKey = "", Name = "EmptyPK" }; + response.StatusCode.Should().Be(HttpStatusCode.Created); + var read = await _container.ReadItemAsync("c-empty-pk", new PartitionKey("")); + read.Resource.Name.Should().Be("EmptyPK"); + } + + [Fact] + public async Task Handler_CreateItem_SameIdDifferentPartitionKey_BothExist() + { + var docA = new TestDocument { Id = "c-dup", PartitionKey = "pkA", Name = "InA" }; + var docB = new TestDocument { Id = "c-dup", PartitionKey = "pkB", Name = "InB" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("")); + await _container.CreateItemAsync(docA, new PartitionKey("pkA")); + await _container.CreateItemAsync(docB, new PartitionKey("pkB")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("c-empty-pk", new PartitionKey("")); - read.Resource.Name.Should().Be("EmptyPK"); - } + var readA = await _container.ReadItemAsync("c-dup", new PartitionKey("pkA")); + var readB = await _container.ReadItemAsync("c-dup", new PartitionKey("pkB")); + readA.Resource.Name.Should().Be("InA"); + readB.Resource.Name.Should().Be("InB"); + } - [Fact] - public async Task Handler_CreateItem_SameIdDifferentPartitionKey_BothExist() - { - var docA = new TestDocument { Id = "c-dup", PartitionKey = "pkA", Name = "InA" }; - var docB = new TestDocument { Id = "c-dup", PartitionKey = "pkB", Name = "InB" }; + [Fact] + public async Task Handler_CreateItem_ReturnsResourceWithSystemProperties() + { + var doc = new TestDocument { Id = "c-sys", PartitionKey = "pk1", Name = "SystemProps" }; - await _container.CreateItemAsync(docA, new PartitionKey("pkA")); - await _container.CreateItemAsync(docB, new PartitionKey("pkB")); + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var readA = await _container.ReadItemAsync("c-dup", new PartitionKey("pkA")); - var readB = await _container.ReadItemAsync("c-dup", new PartitionKey("pkB")); - readA.Resource.Name.Should().Be("InA"); - readB.Resource.Name.Should().Be("InB"); - } + response.ETag.Should().NotBeNullOrEmpty(); + // Read back as JObject to check system properties + var read = await _container.ReadItemAsync("c-sys", new PartitionKey("pk1")); + read.Resource["_ts"].Should().NotBeNull(); + read.Resource["_etag"].Should().NotBeNull(); + } + + [Fact] + public async Task Handler_CreateItem_WithNestedObject_RoundTrips() + { + var doc = new TestDocument + { + Id = "c-nested", + PartitionKey = "pk1", + Name = "Nested", + Nested = new NestedObject { Description = "Deep", Score = 3.14 } + }; - [Fact] - public async Task Handler_CreateItem_ReturnsResourceWithSystemProperties() - { - var doc = new TestDocument { Id = "c-sys", PartitionKey = "pk1", Name = "SystemProps" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.ETag.Should().NotBeNullOrEmpty(); - // Read back as JObject to check system properties - var read = await _container.ReadItemAsync("c-sys", new PartitionKey("pk1")); - read.Resource["_ts"].Should().NotBeNull(); - read.Resource["_etag"].Should().NotBeNull(); - } - - [Fact] - public async Task Handler_CreateItem_WithNestedObject_RoundTrips() - { - var doc = new TestDocument - { - Id = "c-nested", PartitionKey = "pk1", Name = "Nested", - Nested = new NestedObject { Description = "Deep", Score = 3.14 } - }; - - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("c-nested", new PartitionKey("pk1")); - read.Resource.Nested.Should().NotBeNull(); - read.Resource.Nested!.Description.Should().Be("Deep"); - read.Resource.Nested.Score.Should().Be(3.14); - } - - [Fact] - public async Task Handler_CreateItem_WithNumericPartitionKey_Succeeds() - { - var container = await _fixture.CreateContainerAsync("num-pk-test", "/numericPk"); - - var doc = new { id = "num1", numericPk = 42, name = "NumericPK" }; - var response = await container.CreateItemAsync(doc, new PartitionKey(42)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await container.ReadItemAsync("num1", new PartitionKey(42)); - ((string)read.Resource.name).Should().Be("NumericPK"); - } - - [Fact] - public async Task Handler_CreateItem_WithBooleanPartitionKey_Succeeds() - { - var container = await _fixture.CreateContainerAsync("bool-pk-test", "/boolPk"); - - var doc = new { id = "bool1", boolPk = true, name = "BoolPK" }; - var response = await container.CreateItemAsync(doc, new PartitionKey(true)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await container.ReadItemAsync("bool1", new PartitionKey(true)); - ((string)read.Resource.name).Should().Be("BoolPK"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // R: Read Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ReadItem_ReturnsResourceWithSystemProperties() - { - var doc = new TestDocument { Id = "r-sys", PartitionKey = "pk1", Name = "SysRead" }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("r-sys", new PartitionKey("pk1")); - - read.Resource["_ts"].Should().NotBeNull(); - read.Resource["_etag"]!.ToString().Should().Be(create.ETag); - } - - [Fact] - public async Task Handler_ReadItem_WithIfNoneMatch_MatchingETag_ReturnsNotModified() - { - var doc = new TestDocument { Id = "r-inm1", PartitionKey = "pk1", Name = "Conditional" }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("r-inm1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task Handler_ReadItem_WithIfNoneMatch_StaleETag_ReturnsDocument() - { - var doc = new TestDocument { Id = "r-inm2", PartitionKey = "pk1", Name = "Fresh" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - // Modify to change the ETag - doc.Name = "Modified"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("r-inm2", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); - - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Name.Should().Be("Modified"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // U: Upsert Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_UpsertItem_WithIfMatchETag_MatchingETag_Succeeds() - { - var doc = new TestDocument { Id = "u-ifm1", PartitionKey = "pk1", Name = "Original" }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "Updated"; - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = create.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Handler_UpsertItem_WithIfMatchETag_StaleETag_ThrowsPreconditionFailed() - { - var doc = new TestDocument { Id = "u-ifm2", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "StaleUpsert"; - var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Handler_UpsertItem_ChangesETag_BetweenCreateAndUpdate() - { - var doc = new TestDocument { Id = "u-etag", PartitionKey = "pk1", Name = "V1" }; - var create = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - var firstETag = create.ETag; - - doc.Name = "V2"; - var update = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - update.ETag.Should().NotBe(firstETag); - } - - [Fact] - public async Task Handler_UpsertItem_ExistingItem_FullyReplacesDocument() - { - var doc = new TestDocument { Id = "u-full", PartitionKey = "pk1", Name = "HasName", Value = 99, Tags = ["tag1"] }; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - // Upsert with only some fields set — should fully replace - var replacement = new TestDocument { Id = "u-full", PartitionKey = "pk1", Name = "OnlyName" }; - await _container.UpsertItemAsync(replacement, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("u-full", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("OnlyName"); - read.Resource.Value.Should().Be(0); // default int, not 99 - read.Resource.Tags.Should().BeNullOrEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // RP: Replace Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ReplaceItem_FullyReplacesDocument_OldFieldsGone() - { - var original = new TestDocument { Id = "rp-full", PartitionKey = "pk1", Name = "Full", Value = 42, Tags = ["x"] }; - await _container.CreateItemAsync(original, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "rp-full", PartitionKey = "pk1", Name = "Replaced" }; - await _container.ReplaceItemAsync(replacement, "rp-full", new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("rp-full", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Replaced"); - read.Resource.Value.Should().Be(0); // default, not 42 - read.Resource.Tags.Should().BeNullOrEmpty(); - } - - [Fact] - public async Task Handler_ReplaceItem_DoubleReplace_SecondReflectsLatest() - { - var doc = new TestDocument { Id = "rp-dbl", PartitionKey = "pk1", Name = "V1" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "V2"; - await _container.ReplaceItemAsync(doc, "rp-dbl", new PartitionKey("pk1")); - - doc.Name = "V3"; - await _container.ReplaceItemAsync(doc, "rp-dbl", new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("rp-dbl", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("V3"); - } - - [Fact] - public async Task Handler_ReplaceItem_ReturnsResourceWithUpdatedSystemProperties() - { - var doc = new TestDocument { Id = "rp-sys", PartitionKey = "pk1", Name = "Before" }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var originalETag = create.ETag; - - doc.Name = "After"; - var replace = await _container.ReplaceItemAsync(doc, "rp-sys", new PartitionKey("pk1")); - - replace.ETag.Should().NotBe(originalETag); - - var read = await _container.ReadItemAsync("rp-sys", new PartitionKey("pk1")); - read.Resource["_ts"].Should().NotBeNull(); - read.Resource["_etag"]!.ToString().Should().Be(replace.ETag); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D: Delete Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_DeleteItem_ThenCreateSameId_Succeeds() - { - var doc = new TestDocument { Id = "d-recreate", PartitionKey = "pk1", Name = "First" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - await _container.DeleteItemAsync("d-recreate", new PartitionKey("pk1")); - - doc.Name = "Recreated"; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("d-recreate", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Recreated"); - } - - [Fact] - public async Task Handler_DeleteItem_DoubleDelete_SecondThrows404() - { - var doc = new TestDocument { Id = "d-dbl", PartitionKey = "pk1", Name = "DeleteMe" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - await _container.DeleteItemAsync("d-dbl", new PartitionKey("pk1")); - - var act = () => _container.DeleteItemAsync("d-dbl", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // P: Patch Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_PatchItem_WithIfMatchETag_MatchingETag_Succeeds() - { - var doc = new TestDocument { Id = "p-ifm1", PartitionKey = "pk1", Name = "Original", Value = 10 }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p-ifm1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = create.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Handler_PatchItem_WithIfMatchETag_StaleETag_ThrowsPreconditionFailed() - { - var doc = new TestDocument { Id = "p-ifm2", PartitionKey = "pk1", Name = "Original", Value = 10 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("p-ifm2", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Stale")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Handler_PatchItem_IncrementOperation_Standalone() - { - var doc = new TestDocument { Id = "p-inc", PartitionKey = "pk1", Name = "Counter", Value = 100 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p-inc", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 10)]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Value.Should().Be(110); - } - - [Fact] - public async Task Handler_PatchItem_SetOnNestedPath_Succeeds() - { - var doc = new TestDocument - { - Id = "p-nest", PartitionKey = "pk1", Name = "Nested", - Nested = new NestedObject { Description = "Original", Score = 1.0 } - }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p-nest", new PartitionKey("pk1"), - [PatchOperation.Set("/nested/description", "Updated")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Nested!.Description.Should().Be("Updated"); - response.Resource.Nested.Score.Should().Be(1.0); // unchanged - } - - [Fact] - public async Task Handler_PatchItem_ReplaceOperationType_MappedToSet() - { - // In real Cosmos, "replace" fails if path doesn't exist. The handler maps "replace" → Set, - // which creates the path if missing. This test documents the mapping works end-to-end. - var doc = new TestDocument { Id = "p-repl", PartitionKey = "pk1", Name = "Before", Value = 1 }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p-repl", new PartitionKey("pk1"), - [PatchOperation.Replace("/name", "Replaced")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task Handler_PatchItem_ReturnsUpdatedETag() - { - var doc = new TestDocument { Id = "p-etag", PartitionKey = "pk1", Name = "Before", Value = 1 }; - var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var patch = await _container.PatchItemAsync("p-etag", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "After")]); - - patch.ETag.Should().NotBeNullOrEmpty(); - patch.ETag.Should().NotBe(create.ETag); - } - - [Fact] - public async Task Handler_PatchItem_MultipleSetOnSamePath_LastWins() - { - var doc = new TestDocument { Id = "p-last", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("p-last", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "First"), - PatchOperation.Set("/name", "Second") - ]); - - response.Resource.Name.Should().Be("Second"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // PK: Partition Key Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_WithLargePartitionKey_Succeeds() - { - var largePk = new string('x', 1000); - var doc = new TestDocument { Id = "pk-large", PartitionKey = largePk, Name = "LargePK" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey(largePk)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("pk-large", new PartitionKey(largePk)); - read.Resource.Name.Should().Be("LargePK"); - } - - [Fact] - public async Task Handler_CrudRoundTrip_WithSpecialCharsInPartitionKey() - { - var specialPk = "it's a \"quoted\" value / with \\ slashes"; - var doc = new TestDocument { Id = "pk-special", PartitionKey = specialPk, Name = "SpecialPK" }; - - await _container.CreateItemAsync(doc, new PartitionKey(specialPk)); - var read = await _container.ReadItemAsync("pk-special", new PartitionKey(specialPk)); - - read.Resource.Name.Should().Be("SpecialPK"); - read.Resource.PartitionKey.Should().Be(specialPk); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // FI: Fault Injection on Other CRUD Ops - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_ThrottlesReadRequest() - { - using var cosmos = InMemoryCosmos.Create("fault-read", "/partitionKey", - configureOptions: o => o.MaxRetryAttemptsOnRateLimitedRequests = 0); - var handler = cosmos.Handler; - var container = cosmos.Container; - - var doc = new TestDocument { Id = "fi-read", PartitionKey = "pk1", Name = "Throttled" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - handler.FaultInjector = _ => - new HttpResponseMessage((HttpStatusCode)429) - { - Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } - }; - handler.FaultInjectorIncludesMetadata = false; - - var act = () => container.ReadItemAsync("fi-read", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be((HttpStatusCode)429); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_ReturnsServiceUnavailable_OnDelete() - { - var (handler, client, container) = CreateInMemoryStack("fault-del"); - using var _h = handler; - using var _c = client; - - var doc = new TestDocument { Id = "fi-del", PartitionKey = "pk1", Name = "Unavailable" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - handler.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - handler.FaultInjectorIncludesMetadata = false; - - var act = () => container.DeleteItemAsync("fi-del", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_FaultInjection_NullReturn_ProceedsNormally() - { - var (handler, client, container) = CreateInMemoryStack("fault-null"); - using var _h = handler; - using var _c = client; - - handler.FaultInjector = _ => null!; - - var doc = new TestDocument { Id = "fi-null", PartitionKey = "pk1", Name = "Normal" }; - var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // RL: Request Log Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_RequestLog_RecordsPatchOperation() - { - var (handler, client, container) = CreateInMemoryStack("log-patch"); - using var _h = handler; - using var _c = client; - - var doc = new TestDocument { Id = "rl-patch", PartitionKey = "pk1", Name = "ToPatch" }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - await container.PatchItemAsync("rl-patch", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - handler.RequestLog.Should().Contain(e => e.Contains("PATCH") || e.Contains("patch")); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Handler_RequestLog_RecordsUpsertOperation() - { - var (handler, client, container) = CreateInMemoryStack("log-upsert"); - using var _h = handler; - using var _c = client; - - var doc = new TestDocument { Id = "rl-upsert", PartitionKey = "pk1", Name = "ToUpsert" }; - - await container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - handler.RequestLog.Should().Contain(e => e.StartsWith("POST") && e.Contains("/docs")); - } - - // ── IX: Error Path Edge Cases ─────────────────────────────────────────── - - [Fact] - public async Task Handler_EmptyBodyCreate_Returns400() - { - var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); - - // Null body should result in an error - await act.Should().ThrowAsync(); - } + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("c-nested", new PartitionKey("pk1")); + read.Resource.Nested.Should().NotBeNull(); + read.Resource.Nested!.Description.Should().Be("Deep"); + read.Resource.Nested.Score.Should().Be(3.14); + } + + [Fact] + public async Task Handler_CreateItem_WithNumericPartitionKey_Succeeds() + { + var container = await _fixture.CreateContainerAsync("num-pk-test", "/numericPk"); + + var doc = new { id = "num1", numericPk = 42, name = "NumericPK" }; + var response = await container.CreateItemAsync(doc, new PartitionKey(42)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await container.ReadItemAsync("num1", new PartitionKey(42)); + ((string)read.Resource.name).Should().Be("NumericPK"); + } + + [Fact] + public async Task Handler_CreateItem_WithBooleanPartitionKey_Succeeds() + { + var container = await _fixture.CreateContainerAsync("bool-pk-test", "/boolPk"); + + var doc = new { id = "bool1", boolPk = true, name = "BoolPK" }; + var response = await container.CreateItemAsync(doc, new PartitionKey(true)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await container.ReadItemAsync("bool1", new PartitionKey(true)); + ((string)read.Resource.name).Should().Be("BoolPK"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // R: Read Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ReadItem_ReturnsResourceWithSystemProperties() + { + var doc = new TestDocument { Id = "r-sys", PartitionKey = "pk1", Name = "SysRead" }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("r-sys", new PartitionKey("pk1")); + + read.Resource["_ts"].Should().NotBeNull(); + read.Resource["_etag"]!.ToString().Should().Be(create.ETag); + } + + [Fact] + public async Task Handler_ReadItem_WithIfNoneMatch_MatchingETag_ReturnsNotModified() + { + var doc = new TestDocument { Id = "r-inm1", PartitionKey = "pk1", Name = "Conditional" }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("r-inm1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task Handler_ReadItem_WithIfNoneMatch_StaleETag_ReturnsDocument() + { + var doc = new TestDocument { Id = "r-inm2", PartitionKey = "pk1", Name = "Fresh" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + // Modify to change the ETag + doc.Name = "Modified"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("r-inm2", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); + + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Name.Should().Be("Modified"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // U: Upsert Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_UpsertItem_WithIfMatchETag_MatchingETag_Succeeds() + { + var doc = new TestDocument { Id = "u-ifm1", PartitionKey = "pk1", Name = "Original" }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "Updated"; + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Handler_UpsertItem_WithIfMatchETag_StaleETag_ThrowsPreconditionFailed() + { + var doc = new TestDocument { Id = "u-ifm2", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "StaleUpsert"; + var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Handler_UpsertItem_ChangesETag_BetweenCreateAndUpdate() + { + var doc = new TestDocument { Id = "u-etag", PartitionKey = "pk1", Name = "V1" }; + var create = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + var firstETag = create.ETag; + + doc.Name = "V2"; + var update = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + update.ETag.Should().NotBe(firstETag); + } + + [Fact] + public async Task Handler_UpsertItem_ExistingItem_FullyReplacesDocument() + { + var doc = new TestDocument { Id = "u-full", PartitionKey = "pk1", Name = "HasName", Value = 99, Tags = ["tag1"] }; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + // Upsert with only some fields set — should fully replace + var replacement = new TestDocument { Id = "u-full", PartitionKey = "pk1", Name = "OnlyName" }; + await _container.UpsertItemAsync(replacement, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("u-full", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("OnlyName"); + read.Resource.Value.Should().Be(0); // default int, not 99 + read.Resource.Tags.Should().BeNullOrEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RP: Replace Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ReplaceItem_FullyReplacesDocument_OldFieldsGone() + { + var original = new TestDocument { Id = "rp-full", PartitionKey = "pk1", Name = "Full", Value = 42, Tags = ["x"] }; + await _container.CreateItemAsync(original, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "rp-full", PartitionKey = "pk1", Name = "Replaced" }; + await _container.ReplaceItemAsync(replacement, "rp-full", new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("rp-full", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Replaced"); + read.Resource.Value.Should().Be(0); // default, not 42 + read.Resource.Tags.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task Handler_ReplaceItem_DoubleReplace_SecondReflectsLatest() + { + var doc = new TestDocument { Id = "rp-dbl", PartitionKey = "pk1", Name = "V1" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "V2"; + await _container.ReplaceItemAsync(doc, "rp-dbl", new PartitionKey("pk1")); + + doc.Name = "V3"; + await _container.ReplaceItemAsync(doc, "rp-dbl", new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("rp-dbl", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("V3"); + } + + [Fact] + public async Task Handler_ReplaceItem_ReturnsResourceWithUpdatedSystemProperties() + { + var doc = new TestDocument { Id = "rp-sys", PartitionKey = "pk1", Name = "Before" }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var originalETag = create.ETag; + + doc.Name = "After"; + var replace = await _container.ReplaceItemAsync(doc, "rp-sys", new PartitionKey("pk1")); + + replace.ETag.Should().NotBe(originalETag); + + var read = await _container.ReadItemAsync("rp-sys", new PartitionKey("pk1")); + read.Resource["_ts"].Should().NotBeNull(); + read.Resource["_etag"]!.ToString().Should().Be(replace.ETag); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D: Delete Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_DeleteItem_ThenCreateSameId_Succeeds() + { + var doc = new TestDocument { Id = "d-recreate", PartitionKey = "pk1", Name = "First" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + await _container.DeleteItemAsync("d-recreate", new PartitionKey("pk1")); + + doc.Name = "Recreated"; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var read = await _container.ReadItemAsync("d-recreate", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Recreated"); + } + + [Fact] + public async Task Handler_DeleteItem_DoubleDelete_SecondThrows404() + { + var doc = new TestDocument { Id = "d-dbl", PartitionKey = "pk1", Name = "DeleteMe" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + await _container.DeleteItemAsync("d-dbl", new PartitionKey("pk1")); + + var act = () => _container.DeleteItemAsync("d-dbl", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // P: Patch Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_PatchItem_WithIfMatchETag_MatchingETag_Succeeds() + { + var doc = new TestDocument { Id = "p-ifm1", PartitionKey = "pk1", Name = "Original", Value = 10 }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p-ifm1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Handler_PatchItem_WithIfMatchETag_StaleETag_ThrowsPreconditionFailed() + { + var doc = new TestDocument { Id = "p-ifm2", PartitionKey = "pk1", Name = "Original", Value = 10 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("p-ifm2", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Stale")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Handler_PatchItem_IncrementOperation_Standalone() + { + var doc = new TestDocument { Id = "p-inc", PartitionKey = "pk1", Name = "Counter", Value = 100 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p-inc", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 10)]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Value.Should().Be(110); + } + + [Fact] + public async Task Handler_PatchItem_SetOnNestedPath_Succeeds() + { + var doc = new TestDocument + { + Id = "p-nest", + PartitionKey = "pk1", + Name = "Nested", + Nested = new NestedObject { Description = "Original", Score = 1.0 } + }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p-nest", new PartitionKey("pk1"), + [PatchOperation.Set("/nested/description", "Updated")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Nested!.Description.Should().Be("Updated"); + response.Resource.Nested.Score.Should().Be(1.0); // unchanged + } + + [Fact] + public async Task Handler_PatchItem_ReplaceOperationType_MappedToSet() + { + // In real Cosmos, "replace" fails if path doesn't exist. The handler maps "replace" → Set, + // which creates the path if missing. This test documents the mapping works end-to-end. + var doc = new TestDocument { Id = "p-repl", PartitionKey = "pk1", Name = "Before", Value = 1 }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p-repl", new PartitionKey("pk1"), + [PatchOperation.Replace("/name", "Replaced")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task Handler_PatchItem_ReturnsUpdatedETag() + { + var doc = new TestDocument { Id = "p-etag", PartitionKey = "pk1", Name = "Before", Value = 1 }; + var create = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var patch = await _container.PatchItemAsync("p-etag", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "After")]); + + patch.ETag.Should().NotBeNullOrEmpty(); + patch.ETag.Should().NotBe(create.ETag); + } + + [Fact] + public async Task Handler_PatchItem_MultipleSetOnSamePath_LastWins() + { + var doc = new TestDocument { Id = "p-last", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("p-last", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "First"), + PatchOperation.Set("/name", "Second") + ]); + + response.Resource.Name.Should().Be("Second"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PK: Partition Key Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_WithLargePartitionKey_Succeeds() + { + var largePk = new string('x', 1000); + var doc = new TestDocument { Id = "pk-large", PartitionKey = largePk, Name = "LargePK" }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey(largePk)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var read = await _container.ReadItemAsync("pk-large", new PartitionKey(largePk)); + read.Resource.Name.Should().Be("LargePK"); + } + + [Fact] + public async Task Handler_CrudRoundTrip_WithSpecialCharsInPartitionKey() + { + var specialPk = "it's a \"quoted\" value / with \\ slashes"; + var doc = new TestDocument { Id = "pk-special", PartitionKey = specialPk, Name = "SpecialPK" }; + + await _container.CreateItemAsync(doc, new PartitionKey(specialPk)); + var read = await _container.ReadItemAsync("pk-special", new PartitionKey(specialPk)); + + read.Resource.Name.Should().Be("SpecialPK"); + read.Resource.PartitionKey.Should().Be(specialPk); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FI: Fault Injection on Other CRUD Ops + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_ThrottlesReadRequest() + { + using var cosmos = InMemoryCosmos.Create("fault-read", "/partitionKey", + configureOptions: o => o.MaxRetryAttemptsOnRateLimitedRequests = 0); + var handler = cosmos.Handler; + var container = cosmos.Container; + + var doc = new TestDocument { Id = "fi-read", PartitionKey = "pk1", Name = "Throttled" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + handler.FaultInjector = _ => + new HttpResponseMessage((HttpStatusCode)429) + { + Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } + }; + handler.FaultInjectorIncludesMetadata = false; + + var act = () => container.ReadItemAsync("fi-read", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be((HttpStatusCode)429); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_ReturnsServiceUnavailable_OnDelete() + { + var (handler, client, container) = CreateInMemoryStack("fault-del"); + using var _h = handler; + using var _c = client; + + var doc = new TestDocument { Id = "fi-del", PartitionKey = "pk1", Name = "Unavailable" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + handler.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + handler.FaultInjectorIncludesMetadata = false; + + var act = () => container.DeleteItemAsync("fi-del", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_FaultInjection_NullReturn_ProceedsNormally() + { + var (handler, client, container) = CreateInMemoryStack("fault-null"); + using var _h = handler; + using var _c = client; + + handler.FaultInjector = _ => null!; + + var doc = new TestDocument { Id = "fi-null", PartitionKey = "pk1", Name = "Normal" }; + var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RL: Request Log Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_RequestLog_RecordsPatchOperation() + { + var (handler, client, container) = CreateInMemoryStack("log-patch"); + using var _h = handler; + using var _c = client; + + var doc = new TestDocument { Id = "rl-patch", PartitionKey = "pk1", Name = "ToPatch" }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + await container.PatchItemAsync("rl-patch", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + handler.RequestLog.Should().Contain(e => e.Contains("PATCH") || e.Contains("patch")); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Handler_RequestLog_RecordsUpsertOperation() + { + var (handler, client, container) = CreateInMemoryStack("log-upsert"); + using var _h = handler; + using var _c = client; + + var doc = new TestDocument { Id = "rl-upsert", PartitionKey = "pk1", Name = "ToUpsert" }; + + await container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + handler.RequestLog.Should().Contain(e => e.StartsWith("POST") && e.Contains("/docs")); + } + + // ── IX: Error Path Edge Cases ─────────────────────────────────────────── + + [Fact] + public async Task Handler_EmptyBodyCreate_Returns400() + { + var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); + + // Null body should result in an error + await act.Should().ThrowAsync(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs new file mode 100644 index 0000000..70ea689 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs @@ -0,0 +1,718 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Hardening tests for insertion order behavior in queries without ORDER BY. +/// Covers edge cases: large batches, multi-partition interleaving, repeated replaces, +/// mixed operation sequences, projections, filters, pagination, and more. +/// Ref: Observed behavior on Windows Cosmos DB Emulator — documents return +/// in the order they were created when no ORDER BY is applied. +/// +[Collection(IntegrationCollection.Name)] +public class FakeCosmosHandlerInsertionOrderHardeningTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-insertion-order-hardening", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(queryDef, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1. Large batch insertion order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Inserting 50+ documents and querying without ORDER BY must return them + /// in exact insertion order. + /// + [Fact] + public async Task Query_LargeBatch_ReturnsDocsInInsertionOrder() + { + const string pk = "pk-large-batch"; + var insertedIds = new List(); + + for (var i = 1; i <= 55; i++) + { + var id = $"large-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 2. Multiple partitions interleaved + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Documents inserted across 3+ partitions in an interleaved pattern must + /// appear in global insertion order when queried cross-partition. + /// + [Fact] + public async Task Query_MultiplePKsInterleaved_ReturnsCrossPartitionInsertionOrder() + { + var partitions = new[] { "pk-interleave-a", "pk-interleave-b", "pk-interleave-c" }; + var insertedIds = new List(); + + for (var i = 1; i <= 15; i++) + { + var pk = partitions[(i - 1) % 3]; + var id = $"interleave-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery("SELECT * FROM c WHERE STARTSWITH(c.partitionKey, 'pk-interleave-')"); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 3. Replace multiple times + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Replacing the same document 5+ times must never change its position in + /// the insertion order. + /// + [Fact] + public async Task Query_AfterMultipleReplacesOnSameDoc_PositionNeverChanges() + { + const string pk = "pk-multi-replace"; + var insertedIds = new List(); + + for (var i = 1; i <= 5; i++) + { + var id = $"mr-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Replace the 3rd document 6 times with different content each time + for (var r = 1; r <= 6; r++) + { + await _container.ReplaceItemAsync( + new TestDocument { Id = "mr-0003", PartitionKey = pk, Name = $"Replaced-{r}", Value = 100 + r }, + "mr-0003", + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + // Verify the content was actually updated + results.Single(r => r.Id == "mr-0003").Name.Should().Be("Replaced-6"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 4. Mixed operations sequence + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Complex mixed operation sequence: + /// create A, B, C, D, E → delete C → replace A → create F → upsert B (existing) → upsert G (new) + /// Expected order: A, B, D, E, F, G + /// + [Fact] + public async Task Query_MixedOperationSequence_ReturnsExpectedInsertionOrder() + { + const string pk = "pk-mixed-ops"; + + // Create A, B, C, D, E + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-A", PartitionKey = pk, Name = "A", Value = 1 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-B", PartitionKey = pk, Name = "B", Value = 2 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-C", PartitionKey = pk, Name = "C", Value = 3 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-D", PartitionKey = pk, Name = "D", Value = 4 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-E", PartitionKey = pk, Name = "E", Value = 5 }, + new PartitionKey(pk)); + + // Delete C + await _container.DeleteItemAsync("mixed-C", new PartitionKey(pk)); + + // Replace A (should not change position) + await _container.ReplaceItemAsync( + new TestDocument { Id = "mixed-A", PartitionKey = pk, Name = "A-replaced", Value = 100 }, + "mixed-A", + new PartitionKey(pk)); + + // Create F (should appear at end) + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-F", PartitionKey = pk, Name = "F", Value = 6 }, + new PartitionKey(pk)); + + // Upsert B (existing — should not change position) + await _container.UpsertItemAsync( + new TestDocument { Id = "mixed-B", PartitionKey = pk, Name = "B-upserted", Value = 200 }, + new PartitionKey(pk)); + + // Upsert G (new — should appear at end) + await _container.UpsertItemAsync( + new TestDocument { Id = "mixed-G", PartitionKey = pk, Name = "G", Value = 7 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal( + ["mixed-A", "mixed-B", "mixed-D", "mixed-E", "mixed-F", "mixed-G"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 5. Query with SELECT specific fields (projection) + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Projecting specific fields (SELECT c.id, c.name) must still maintain + /// insertion order. + /// + [Fact] + public async Task Query_WithProjection_MaintainsInsertionOrder() + { + const string pk = "pk-projection"; + var insertedIds = new List(); + + for (var i = 1; i <= 8; i++) + { + var id = $"proj-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Name-{i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT c.id, c.name FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 6. Query with WHERE filter + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Filtering with a WHERE clause must preserve relative insertion order of + /// the matched documents. + /// + [Fact] + public async Task Query_WithWhereFilter_MaintainsRelativeInsertionOrder() + { + const string pk = "pk-where-filter"; + + // Insert with alternating isActive values: true, false, true, false, true... + for (var i = 1; i <= 10; i++) + { + await _container.CreateItemAsync( + new TestDocument + { + Id = $"filter-{i:D4}", + PartitionKey = pk, + Name = $"Doc {i}", + Value = i, + IsActive = i % 2 != 0 + }, + new PartitionKey(pk)); + } + + // Query only active documents + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' AND c.isActive = true", pk); + + // Expect odd-numbered docs only, in original order + results.Select(r => r.Id).Should().Equal( + ["filter-0001", "filter-0003", "filter-0005", "filter-0007", "filter-0009"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 7. Query with TOP/OFFSET pagination + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Using TOP N returns the first N documents in insertion order. + /// + [Fact] + public async Task Query_WithTop_ReturnsFirstNInInsertionOrder() + { + const string pk = "pk-top"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"top-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT TOP 5 * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds.Take(5)); + } + + /// + /// Using OFFSET/LIMIT returns the correct page of documents in insertion order. + /// + [Fact] + public async Task Query_WithOffsetLimit_ReturnsCorrectPageInInsertionOrder() + { + const string pk = "pk-offset"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"offset-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Get page 2 (items 6-10) + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' OFFSET 5 LIMIT 5", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds.Skip(5).Take(5)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 8. Empty container query + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Querying an empty container must return an empty result without errors. + /// + [Fact] + public async Task Query_EmptyContainer_ReturnsEmptyResults() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-nonexistent'", "pk-nonexistent"); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 9. Single document + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A container with a single document must return just that document. + /// + [Fact] + public async Task Query_SingleDocument_ReturnsThatDocument() + { + const string pk = "pk-single-doc"; + + await _container.CreateItemAsync( + new TestDocument { Id = "only-one", PartitionKey = pk, Name = "Solo", Value = 42 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Id.Should().Be("only-one"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 10. Delete all then recreate + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// After deleting all documents and inserting new ones, the new documents + /// should appear in their own insertion order with no ghost entries. + /// + [Fact] + public async Task Query_DeleteAllThenRecreate_ShowsOnlyNewDocsInOrder() + { + const string pk = "pk-del-all-recreate"; + + // Insert A, B, C + await _container.CreateItemAsync( + new TestDocument { Id = "orig-A", PartitionKey = pk, Name = "A", Value = 1 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "orig-B", PartitionKey = pk, Name = "B", Value = 2 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "orig-C", PartitionKey = pk, Name = "C", Value = 3 }, + new PartitionKey(pk)); + + // Delete all + await _container.DeleteItemAsync("orig-A", new PartitionKey(pk)); + await _container.DeleteItemAsync("orig-B", new PartitionKey(pk)); + await _container.DeleteItemAsync("orig-C", new PartitionKey(pk)); + + // Insert D, E + await _container.CreateItemAsync( + new TestDocument { Id = "new-D", PartitionKey = pk, Name = "D", Value = 4 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "new-E", PartitionKey = pk, Name = "E", Value = 5 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(["new-D", "new-E"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 11. Upsert-only to create multiple documents + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Using only UpsertItemAsync to create new documents must produce results + /// in the order the upserts were called. + /// + [Fact] + public async Task Query_UpsertOnlyCreates_ReturnsInUpsertCallOrder() + { + const string pk = "pk-upsert-create"; + var insertedIds = new List(); + + for (var i = 1; i <= 10; i++) + { + var id = $"upsert-new-{i:D4}"; + insertedIds.Add(id); + await _container.UpsertItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 12. Query with DISTINCT + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// DISTINCT on a field with duplicates must preserve the insertion order of + /// the first occurrence of each distinct value. + /// + [Fact] + public async Task Query_WithDistinctValue_MaintainsFirstOccurrenceInsertionOrder() + { + const string pk = "pk-distinct"; + + // Insert documents with duplicate Value fields + // Values: 10, 20, 10, 30, 20, 40 + var docs = new[] + { + ("dist-01", 10), ("dist-02", 20), ("dist-03", 10), + ("dist-04", 30), ("dist-05", 20), ("dist-06", 40) + }; + foreach (var (id, value) in docs) + { + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc-{value}", Value = value }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT DISTINCT VALUE c[\"value\"] FROM c WHERE c.partitionKey = '{pk}'", pk); + + // First occurrences in insertion order: 10, 20, 30, 40 + results.Should().Equal([10, 20, 30, 40]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 13. Aggregate functions don't crash + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// COUNT aggregate should return the correct count without crashing. + /// + [Fact] + public async Task Query_CountAggregate_ReturnsCorrectCount() + { + const string pk = "pk-agg-count"; + + for (var i = 1; i <= 7; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"agg-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT VALUE COUNT(1) FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Should().Be(7); + } + + /// + /// SUM aggregate should return the correct sum without crashing. + /// + [Fact] + public async Task Query_SumAggregate_ReturnsCorrectSum() + { + const string pk = "pk-agg-sum"; + + for (var i = 1; i <= 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"sum-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i * 10 }, + new PartitionKey(pk)); + } + + // SUM of 10+20+30+40+50 = 150 + var results = await DrainQuery( + $"SELECT VALUE SUM(c[\"value\"]) FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Should().Be(150); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 14. Cross-partition: with vs without partition key filter + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// When querying within a single partition, insertion order within that + /// partition is preserved regardless of documents in other partitions. + /// + [Fact] + public async Task Query_SinglePartitionFilter_PreservesOrderWithinPartition() + { + var pkTarget = "pk-xpart-target"; + var pkOther = "pk-xpart-other"; + + // Interleave: target-1, other-1, target-2, other-2, target-3 + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t1", PartitionKey = pkTarget, Name = "T1", Value = 1 }, + new PartitionKey(pkTarget)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-o1", PartitionKey = pkOther, Name = "O1", Value = 2 }, + new PartitionKey(pkOther)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t2", PartitionKey = pkTarget, Name = "T2", Value = 3 }, + new PartitionKey(pkTarget)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-o2", PartitionKey = pkOther, Name = "O2", Value = 4 }, + new PartitionKey(pkOther)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t3", PartitionKey = pkTarget, Name = "T3", Value = 5 }, + new PartitionKey(pkTarget)); + + // Query with partition key filter — should give target docs in their insertion order + var filteredResults = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pkTarget}'", pkTarget); + filteredResults.Select(r => r.Id).Should().Equal(["xpart-t1", "xpart-t2", "xpart-t3"]); + + // Cross-partition query — should give all docs in global insertion order + var allResults = await DrainQuery( + "SELECT * FROM c WHERE STARTSWITH(c.partitionKey, 'pk-xpart-')"); + allResults.Select(r => r.Id).Should().Equal( + ["xpart-t1", "xpart-o1", "xpart-t2", "xpart-o2", "xpart-t3"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 15. Rapid sequential creates + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Rapidly creating 20 documents sequentially (each awaited) must produce + /// results in the exact call order. + /// + [Fact] + public async Task Query_RapidSequentialCreates_MaintainsCallOrder() + { + const string pk = "pk-rapid"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"rapid-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Rapid {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: ORDER BY overrides insertion order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A query with ORDER BY must return documents sorted by the specified field, + /// overriding the natural insertion order. + /// + [Fact] + public async Task Query_WithOrderBy_OverridesInsertionOrder() + { + const string pk = "pk-orderby"; + + // Insert in non-sorted order: values 50, 10, 40, 20, 30 + var values = new[] { 50, 10, 40, 20, 30 }; + for (var i = 0; i < values.Length; i++) + { + await _container.CreateItemAsync( + new TestDocument + { + Id = $"orderby-{i + 1:D4}", + PartitionKey = pk, + Name = $"Doc {values[i]}", + Value = values[i] + }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' ORDER BY c[\"value\"] ASC", pk); + + results.Select(r => r.Value).Should().Equal([10, 20, 30, 40, 50]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: WHERE + VALUE filter preserves relative order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A range filter (WHERE c.value > X) must preserve relative insertion order + /// of the matching documents. + /// + [Fact] + public async Task Query_WithRangeFilter_MaintainsRelativeInsertionOrder() + { + const string pk = "pk-range-filter"; + + // Insert values: 5, 15, 3, 25, 8, 30, 2 + var values = new[] { 5, 15, 3, 25, 8, 30, 2 }; + var expectedIdsAbove10 = new List(); + + for (var i = 0; i < values.Length; i++) + { + var id = $"range-{i + 1:D4}"; + if (values[i] > 10) expectedIdsAbove10.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc-{values[i]}", Value = values[i] }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' AND c[\"value\"] > 10", pk); + + // Expected: range-0002 (15), range-0004 (25), range-0006 (30) — in insertion order + results.Select(r => r.Id).Should().Equal(expectedIdsAbove10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: Delete from middle, verify remaining order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Deleting multiple non-consecutive documents must not disturb the relative + /// order of the remaining documents. + /// + [Fact] + public async Task Query_AfterMultipleNonConsecutiveDeletes_PreservesRemainingOrder() + { + const string pk = "pk-multi-del"; + + for (var i = 1; i <= 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"mdel-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Delete positions 2, 5, 8 (non-consecutive) + await _container.DeleteItemAsync("mdel-0002", new PartitionKey(pk)); + await _container.DeleteItemAsync("mdel-0005", new PartitionKey(pk)); + await _container.DeleteItemAsync("mdel-0008", new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal( + ["mdel-0001", "mdel-0003", "mdel-0004", "mdel-0006", "mdel-0007", "mdel-0009", "mdel-0010"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Projection helper models + // ═══════════════════════════════════════════════════════════════════════════ + + private class ProjectedDocument + { + [JsonProperty("id")] + public string Id { get; set; } = default!; + + [JsonProperty("name")] + public string Name { get; set; } = default!; + } + +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs new file mode 100644 index 0000000..8e96160 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs @@ -0,0 +1,245 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Tests that queries without an ORDER BY clause return documents in insertion order, +/// matching the observed behavior of real Cosmos DB and the Windows Cosmos Emulator. +/// +[Collection(IntegrationCollection.Name)] +public class FakeCosmosHandlerInsertionOrderTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-insertion-order", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(queryDef, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Insertion order — queries without ORDER BY + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Verifies that documents queried within a single partition are returned in + /// insertion order when no ORDER BY clause is specified. + /// Ref: Observed behavior on Windows Cosmos DB Emulator — documents return + /// in the order they were created when no ORDER BY is applied. + /// + [Fact] + public async Task Query_WithoutOrderBy_ReturnsSinglePartitionDocsInInsertionOrder() + { + // Arrange — insert 10 documents sequentially + var insertedIds = new List(); + for (var i = 1; i <= 10; i++) + { + var id = $"order-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk-order", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-order")); + } + + // Act — query without ORDER BY + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-order'", + "pk-order"); + + // Assert — returned IDs match insertion order + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that a cross-partition query without ORDER BY returns documents + /// in insertion order across all partitions. + /// + [Fact] + public async Task Query_WithoutOrderBy_ReturnsCrossPartitionDocsInInsertionOrder() + { + // Arrange — insert documents across two partitions, interleaved + var insertedIds = new List(); + for (var i = 1; i <= 8; i++) + { + var pk = i % 2 == 0 ? "pk-even" : "pk-odd"; + var id = $"cross-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Act — cross-partition query without ORDER BY + var results = await DrainQuery("SELECT * FROM c"); + + // Assert — returned IDs match insertion order + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that replacing a document does not change its position in the + /// insertion order (replace preserves original creation position). + /// + [Fact] + public async Task Query_AfterReplace_PreservesInsertionOrder() + { + // Arrange — insert 5 documents + var insertedIds = new List(); + for (var i = 1; i <= 5; i++) + { + var id = $"replace-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk-replace", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-replace")); + } + + // Replace the 2nd document + await _container.ReplaceItemAsync( + new TestDocument { Id = "replace-0002", PartitionKey = "pk-replace", Name = "Updated", Value = 99 }, + "replace-0002", + new PartitionKey("pk-replace")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-replace'", + "pk-replace"); + + // Assert — order unchanged + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that deleting a document and re-creating it places the new + /// document at the end (new insertion position). + /// + [Fact] + public async Task Query_AfterDeleteAndRecreate_NewDocAppearsAtEnd() + { + // Arrange — insert 5 documents + for (var i = 1; i <= 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"delrec-{i:D4}", PartitionKey = "pk-delrec", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-delrec")); + } + + // Delete the 2nd document + await _container.DeleteItemAsync("delrec-0002", new PartitionKey("pk-delrec")); + + // Re-create with same ID + await _container.CreateItemAsync( + new TestDocument { Id = "delrec-0002", PartitionKey = "pk-delrec", Name = "Recreated", Value = 200 }, + new PartitionKey("pk-delrec")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-delrec'", + "pk-delrec"); + + // Assert — recreated doc is now at the end + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["delrec-0001", "delrec-0003", "delrec-0004", "delrec-0005", "delrec-0002"]); + } + + /// + /// Verifies that upsert of a new document places it at the end of + /// the insertion order (same as create). + /// + [Fact] + public async Task Query_UpsertNewDoc_AppearsAtEnd() + { + // Arrange — insert 3 documents + for (var i = 1; i <= 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"upsert-{i:D4}", PartitionKey = "pk-upsert", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-upsert")); + } + + // Upsert a new document + await _container.UpsertItemAsync( + new TestDocument { Id = "upsert-0004", PartitionKey = "pk-upsert", Name = "New", Value = 4 }, + new PartitionKey("pk-upsert")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-upsert'", + "pk-upsert"); + + // Assert — upserted new doc at end + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["upsert-0001", "upsert-0002", "upsert-0003", "upsert-0004"]); + } + + /// + /// Verifies that upsert of an existing document preserves its original + /// insertion position (same as replace). + /// + [Fact] + public async Task Query_UpsertExistingDoc_PreservesInsertionOrder() + { + // Arrange — insert 3 documents + for (var i = 1; i <= 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"upsert-ex-{i:D4}", PartitionKey = "pk-upsert-ex", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-upsert-ex")); + } + + // Upsert an existing document (update) + await _container.UpsertItemAsync( + new TestDocument { Id = "upsert-ex-0002", PartitionKey = "pk-upsert-ex", Name = "Updated", Value = 99 }, + new PartitionKey("pk-upsert-ex")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-upsert-ex'", + "pk-upsert-ex"); + + // Assert — order preserved + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["upsert-ex-0001", "upsert-ex-0002", "upsert-ex-0003"]); + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerLinqTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerLinqTests.cs index f691382..d446e46 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerLinqTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerLinqTests.cs @@ -15,295 +15,295 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerLinqTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-linq", "/partitionKey"); - await SeedData(); - } - - private async Task SeedData() - { - var docs = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["admin", "user"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["user"] }, - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 50, IsActive = true, Tags = ["admin"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 10, IsActive = true, Tags = ["user", "moderator"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 40, IsActive = false, Tags = [] }, - }; - foreach (var doc in docs) - await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - private async Task> DrainIterator(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ Where - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_Where_FiltersByProperty() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_Where_BooleanFilter() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.IsActive) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Diana"); - } - - [Fact] - public async Task Linq_Where_ComparisonOperator() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Value > 25) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); - } - - [Fact] - public async Task Linq_Where_CombinedConditions() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.IsActive && d.Value > 25) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ Select Projections - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_Select_AnonymousProjection() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .Select(d => new { d.Name, d.Value }) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(30); - } - - [Fact] - public async Task Linq_Select_SingleProperty() - { - var iterator = _container.GetItemLinqQueryable() - .Select(d => d.Name) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(5); - results.Should().Contain("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ OrderBy - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_OrderBy_Ascending() - { - var iterator = _container.GetItemLinqQueryable() - .OrderBy(d => d.Value) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Select(r => r.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task Linq_OrderByDescending() - { - var iterator = _container.GetItemLinqQueryable() - .OrderByDescending(d => d.Value) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Select(r => r.Value).Should().BeInDescendingOrder(); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Real Cosmos requires composite index for multi-field ORDER BY - public async Task Linq_OrderBy_ThenBy() - { - var iterator = _container.GetItemLinqQueryable() - .OrderBy(d => d.PartitionKey) - .ThenBy(d => d.Name) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Eve", "Charlie", "Diana"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ Take / Skip - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_Take_LimitsResults() - { - var iterator = _container.GetItemLinqQueryable() - .OrderBy(d => d.Name) - .Take(2) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results[0].Name.Should().Be("Alice"); - results[1].Name.Should().Be("Bob"); - } - - [Fact] - public async Task Linq_Skip_Take_Pagination() - { - var iterator = _container.GetItemLinqQueryable() - .OrderBy(d => d.Name) - .Skip(1) - .Take(2) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results[0].Name.Should().Be("Bob"); - results[1].Name.Should().Be("Charlie"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ Count / Aggregates - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_CountAsync_ReturnsCorrectCount() - { - var count = await _container.GetItemLinqQueryable() - .CountAsync(); - - count.Resource.Should().Be(5); - } - - [Fact] - public async Task Linq_CountAsync_WithFilter() - { - var count = await _container.GetItemLinqQueryable() - .Where(d => d.IsActive) - .CountAsync(); - - count.Resource.Should().Be(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ with PartitionKey scoping - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_WithPartitionKey_ScopesQuery() - { - var iterator = _container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results.Should().OnlyContain(d => d.PartitionKey == "pk1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LINQ complex expressions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Linq_Where_StringContains() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Name.Contains("li")) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task Linq_Where_NestedPropertyAccess() - { - // Add a doc with nested object - await _container.CreateItemAsync( - new TestDocument { Id = "n1", PartitionKey = "pk1", Name = "Nested", Nested = new NestedObject { Description = "test", Score = 99.5 } }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Nested != null && d.Nested.Score > 50) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Nested"); - } - - [Fact] - public async Task Linq_Where_OrCondition() - { - var iterator = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice" || d.Name == "Bob") - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Linq_Select_WithOrderByAndTake() - { - var iterator = _container.GetItemLinqQueryable() - .OrderByDescending(d => d.Value) - .Take(3) - .Select(d => new { d.Name, d.Value }) - .ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results[0].Value.Should().Be(50); - results[1].Value.Should().Be(40); - results[2].Value.Should().Be(30); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-linq", "/partitionKey"); + await SeedData(); + } + + private async Task SeedData() + { + var docs = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["admin", "user"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["user"] }, + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 50, IsActive = true, Tags = ["admin"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 10, IsActive = true, Tags = ["user", "moderator"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 40, IsActive = false, Tags = [] }, + }; + foreach (var doc in docs) + await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainIterator(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ Where + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_Where_FiltersByProperty() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_Where_BooleanFilter() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.IsActive) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Diana"); + } + + [Fact] + public async Task Linq_Where_ComparisonOperator() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Value > 25) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); + } + + [Fact] + public async Task Linq_Where_CombinedConditions() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.IsActive && d.Value > 25) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ Select Projections + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_Select_AnonymousProjection() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .Select(d => new { d.Name, d.Value }) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(30); + } + + [Fact] + public async Task Linq_Select_SingleProperty() + { + var iterator = _container.GetItemLinqQueryable() + .Select(d => d.Name) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(5); + results.Should().Contain("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ OrderBy + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_OrderBy_Ascending() + { + var iterator = _container.GetItemLinqQueryable() + .OrderBy(d => d.Value) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Select(r => r.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task Linq_OrderByDescending() + { + var iterator = _container.GetItemLinqQueryable() + .OrderByDescending(d => d.Value) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Select(r => r.Value).Should().BeInDescendingOrder(); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Real Cosmos requires composite index for multi-field ORDER BY + public async Task Linq_OrderBy_ThenBy() + { + var iterator = _container.GetItemLinqQueryable() + .OrderBy(d => d.PartitionKey) + .ThenBy(d => d.Name) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Eve", "Charlie", "Diana"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ Take / Skip + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_Take_LimitsResults() + { + var iterator = _container.GetItemLinqQueryable() + .OrderBy(d => d.Name) + .Take(2) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results[0].Name.Should().Be("Alice"); + results[1].Name.Should().Be("Bob"); + } + + [Fact] + public async Task Linq_Skip_Take_Pagination() + { + var iterator = _container.GetItemLinqQueryable() + .OrderBy(d => d.Name) + .Skip(1) + .Take(2) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results[0].Name.Should().Be("Bob"); + results[1].Name.Should().Be("Charlie"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ Count / Aggregates + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_CountAsync_ReturnsCorrectCount() + { + var count = await _container.GetItemLinqQueryable() + .CountAsync(); + + count.Resource.Should().Be(5); + } + + [Fact] + public async Task Linq_CountAsync_WithFilter() + { + var count = await _container.GetItemLinqQueryable() + .Where(d => d.IsActive) + .CountAsync(); + + count.Resource.Should().Be(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ with PartitionKey scoping + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_WithPartitionKey_ScopesQuery() + { + var iterator = _container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(3); + results.Should().OnlyContain(d => d.PartitionKey == "pk1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LINQ complex expressions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Linq_Where_StringContains() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Name.Contains("li")) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task Linq_Where_NestedPropertyAccess() + { + // Add a doc with nested object + await _container.CreateItemAsync( + new TestDocument { Id = "n1", PartitionKey = "pk1", Name = "Nested", Nested = new NestedObject { Description = "test", Score = 99.5 } }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Nested != null && d.Nested.Score > 50) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Nested"); + } + + [Fact] + public async Task Linq_Where_OrCondition() + { + var iterator = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice" || d.Name == "Bob") + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Linq_Select_WithOrderByAndTake() + { + var iterator = _container.GetItemLinqQueryable() + .OrderByDescending(d => d.Value) + .Take(3) + .Select(d => new { d.Name, d.Value }) + .ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(3); + results[0].Value.Should().Be(50); + results[1].Value.Should().Be(40); + results[2].Value.Should().Be(30); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPartitionKeyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPartitionKeyTests.cs index ce10f6f..5e56dbf 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPartitionKeyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPartitionKeyTests.cs @@ -14,140 +14,140 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerPartitionKeyTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-pk", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - private async Task> DrainQuery(string sql) - { - return await DrainQuery(_container, sql); - } - - private static async Task> DrainQuery(Container container, string sql) - { - var iterator = container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // DeleteAllItemsByPartitionKeyStreamAsync (InMemoryOnly — uses BackingContainer API) - // ═══════════════════════════════════════════════════════════════════════════ - - private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( - string name = "test-pk", string pkPath = "/partitionKey") - { - var cosmos = InMemoryCosmos.Create(name, pkPath); - return (cosmos.Handler, cosmos.Client, cosmos.Container); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task DeleteAllByPK_RemovesAllItemsInPartition() - { - var (handler, client, container) = CreateInMemoryStack("test-pk-del1"); - using (client) - using (handler) - { - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "C" }, new PartitionKey("pk2")); - - var response = await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var remaining = await DrainQuery(container, "SELECT * FROM c"); - remaining.Should().HaveCount(1); - remaining[0].Name.Should().Be("C"); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task DeleteAllByPK_EmptyPartition_Succeeds() - { - var (handler, client, container) = CreateInMemoryStack("test-pk-del2"); - using (client) - using (handler) - { - var response = await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("nonexistent")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task DeleteAllByPK_AdvancesChangeFeed() - { - var (handler, client, container) = CreateInMemoryStack("test-pk-del3"); - using (client) - using (handler) - { - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - - // DeleteAllByPK is not handled via HTTP routing, so call on backing container - await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("pk1")); - - // Verify all items in the partition were deleted - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var remaining = new List(); - while (iterator.HasMoreResults) - remaining.AddRange(await iterator.ReadNextAsync()); - - remaining.Should().BeEmpty(); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Cross-partition queries through handler - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CrossPartitionQuery_ReturnsItemsFromAllPartitions() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C" }, new PartitionKey("pk3")); - - var results = await DrainQuery("SELECT * FROM c ORDER BY c.name"); - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().ContainInOrder("A", "B", "C"); - } - - [Fact] - public async Task PartitionKeyNone_CrudRoundTrip() - { - await _container.CreateItemAsync( - new TestDocument { Id = "none-1", PartitionKey = null!, Name = "NoPartition" }, - PartitionKey.None); - - var read = await _container.ReadItemAsync("none-1", PartitionKey.None); - read.Resource.Name.Should().Be("NoPartition"); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-pk", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql) + { + return await DrainQuery(_container, sql); + } + + private static async Task> DrainQuery(Container container, string sql) + { + var iterator = container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DeleteAllItemsByPartitionKeyStreamAsync (InMemoryOnly — uses BackingContainer API) + // ═══════════════════════════════════════════════════════════════════════════ + + private static (FakeCosmosHandler Handler, CosmosClient Client, Container Container) CreateInMemoryStack( + string name = "test-pk", string pkPath = "/partitionKey") + { + var cosmos = InMemoryCosmos.Create(name, pkPath); + return (cosmos.Handler, cosmos.Client, cosmos.Container); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task DeleteAllByPK_RemovesAllItemsInPartition() + { + var (handler, client, container) = CreateInMemoryStack("test-pk-del1"); + using (client) + using (handler) + { + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "C" }, new PartitionKey("pk2")); + + var response = await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var remaining = await DrainQuery(container, "SELECT * FROM c"); + remaining.Should().HaveCount(1); + remaining[0].Name.Should().Be("C"); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task DeleteAllByPK_EmptyPartition_Succeeds() + { + var (handler, client, container) = CreateInMemoryStack("test-pk-del2"); + using (client) + using (handler) + { + var response = await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("nonexistent")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task DeleteAllByPK_AdvancesChangeFeed() + { + var (handler, client, container) = CreateInMemoryStack("test-pk-del3"); + using (client) + using (handler) + { + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + + // DeleteAllByPK is not handled via HTTP routing, so call on backing container + await handler.BackingContainer.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("pk1")); + + // Verify all items in the partition were deleted + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var remaining = new List(); + while (iterator.HasMoreResults) + remaining.AddRange(await iterator.ReadNextAsync()); + + remaining.Should().BeEmpty(); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Cross-partition queries through handler + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CrossPartitionQuery_ReturnsItemsFromAllPartitions() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C" }, new PartitionKey("pk3")); + + var results = await DrainQuery("SELECT * FROM c ORDER BY c.name"); + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().ContainInOrder("A", "B", "C"); + } + + [Fact] + public async Task PartitionKeyNone_CrudRoundTrip() + { + await _container.CreateItemAsync( + new TestDocument { Id = "none-1", PartitionKey = null!, Name = "NoPartition" }, + PartitionKey.None); + + var read = await _container.ReadItemAsync("none-1", PartitionKey.None); + read.Resource.Name.Should().Be("NoPartition"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPkMismatchTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPkMismatchTests.cs index 2c9bff9..cbdc18c 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPkMismatchTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerPkMismatchTests.cs @@ -14,85 +14,85 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerPkMismatchTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-pk-mismatch", "/partitionKey"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - [Fact] - public async Task Create_PkMismatchWithBody_Returns400SubStatus1001() - { - var act = () => _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "body-pk", Name = "A" }, - new PartitionKey("header-pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Upsert_PkMismatchWithBody_Returns400SubStatus1001() - { - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "body-pk", Name = "A" }, - new PartitionKey("header-pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Replace_PkMismatchWithBody_Returns400SubStatus1001() - { - // Create a valid item first - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "Original" }, - new PartitionKey("a")); - - // Replace: header says "a", body says "different" - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "different", Name = "Replaced" }, - "1", new PartitionKey("a")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Create_PkMatchesBody_Succeeds() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "match1", PartitionKey = "same", Name = "OK" }, - new PartitionKey("same")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Upsert_PkMatchesBody_Succeeds() - { - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "match2", PartitionKey = "same", Name = "OK" }, - new PartitionKey("same")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_PkFieldMissingFromBody_Succeeds() - { - // When the PK field is absent from the body, Cosmos accepts the document - var doc = new JObject { ["id"] = "no-pk-field", ["name"] = "no pk" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("any-value")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-pk-mismatch", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task Create_PkMismatchWithBody_Returns400SubStatus1001() + { + var act = () => _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "body-pk", Name = "A" }, + new PartitionKey("header-pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Upsert_PkMismatchWithBody_Returns400SubStatus1001() + { + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "body-pk", Name = "A" }, + new PartitionKey("header-pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Replace_PkMismatchWithBody_Returns400SubStatus1001() + { + // Create a valid item first + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "Original" }, + new PartitionKey("a")); + + // Replace: header says "a", body says "different" + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "different", Name = "Replaced" }, + "1", new PartitionKey("a")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Create_PkMatchesBody_Succeeds() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "match1", PartitionKey = "same", Name = "OK" }, + new PartitionKey("same")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_PkMatchesBody_Succeeds() + { + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "match2", PartitionKey = "same", Name = "OK" }, + new PartitionKey("same")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_PkFieldMissingFromBody_Succeeds() + { + // When the PK field is absent from the body, Cosmos accepts the document + var doc = new JObject { ["id"] = "no-pk-field", ["name"] = "no pk" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("any-value")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerQueryAdvancedTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerQueryAdvancedTests.cs index 8857285..82061c1 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerQueryAdvancedTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerQueryAdvancedTests.cs @@ -18,1020 +18,1020 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerQueryAdvancedTests(EmulatorSession session) : IAsyncLifetime { - private class QueryDoc - { - [JsonProperty("id")] public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = default!; - [JsonProperty("name")] public string Name { get; set; } = default!; - [JsonProperty("score")] public int Score { get; set; } - [JsonProperty("isActive")] public bool IsActive { get; set; } = true; - [JsonProperty("tags")] public string[] Tags { get; set; } = []; - [JsonProperty("nested")] public NestedObject? Nested { get; set; } - } - - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-query-adv", "/partitionKey"); - await SeedData(); - } - - private async Task SeedData() - { - var docs = new[] - { - new QueryDoc { Id = "1", PartitionKey = "pk1", Name = "Alice", Score = 30, Tags = ["admin", "user"] }, - new QueryDoc { Id = "2", PartitionKey = "pk1", Name = "Bob", Score = 20, Tags = ["user"] }, - new QueryDoc { Id = "3", PartitionKey = "pk2", Name = "Charlie", Score = 50, Tags = ["admin"] }, - new QueryDoc { Id = "4", PartitionKey = "pk2", Name = "Diana", Score = 10, Tags = ["user", "moderator"] }, - new QueryDoc { Id = "5", PartitionKey = "pk1", Name = "Eve", Score = 40, Tags = [] }, - }; - foreach (var doc in docs) - await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - private async Task> DrainQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - private async Task> DrainQuery(QueryDefinition queryDef) - { - var iterator = _container.GetItemQueryIterator(queryDef); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // String Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_StringUpper_ReturnsUppercased() - { - var results = await DrainQuery( - "SELECT UPPER(c.name) AS upper_name FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["upper_name"]!.Value().Should().Be("ALICE"); - } - - [Fact] - public async Task Query_StringLower_ReturnsLowercased() - { - var results = await DrainQuery( - "SELECT LOWER(c.name) AS lower_name FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["lower_name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_StringConcat_Works() - { - var results = await DrainQuery( - "SELECT CONCAT(c.name, '-', c.partitionKey) AS full FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["full"]!.Value().Should().Be("Alice-pk1"); - } - - [Fact] - public async Task Query_StringContains_Filters() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE CONTAINS(c.name, 'li')"); - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task Query_Length_ReturnsCorrectLength() - { - var results = await DrainQuery( - "SELECT LENGTH(c.name) AS len FROM c WHERE c.id = '3'"); - - results.Should().HaveCount(1); - results[0]["len"]!.Value().Should().Be(7); // "Charlie" - } - - [Fact] - public async Task Query_Substring_ExtractsCorrectly() - { - var results = await DrainQuery( - "SELECT SUBSTRING(c.name, 0, 3) AS sub FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["sub"]!.Value().Should().Be("Ali"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Math Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_MathAbs_ReturnsAbsoluteValue() - { - var results = await DrainQuery( - "SELECT ABS(c.score - 35) AS diff FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["diff"]!.Value().Should().Be(5); - } - - [Fact] - public async Task Query_MathFloor_Works() - { - var results = await DrainQuery( - "SELECT FLOOR(c.score / 3.0) AS floored FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["floored"]!.Value().Should().Be(10); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Array Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_ArrayContains_Filters() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'admin')"); - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task Query_ArrayLength_ReturnsCount() - { - var results = await DrainQuery( - "SELECT ARRAY_LENGTH(c.tags) AS tagCount FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["tagCount"]!.Value().Should().Be(2); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Type-Checking Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_IsDefined_ChecksPropertyExistence() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE IS_DEFINED(c.nonExistentProperty)"); - - // No docs have this property - results.Should().BeEmpty(); - } - - [Fact] - public async Task Query_IsString_ChecksType() - { - var results = await DrainQuery( - "SELECT IS_STRING(c.name) AS isStr FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["isStr"]!.Value().Should().BeTrue(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Subqueries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_ExistsSubquery_FiltersCorrectly() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE EXISTS (SELECT VALUE t FROM t IN c.tags WHERE t = 'admin')"); - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task Query_ArraySubquery_ReturnsArray() - { - var results = await DrainQuery( - "SELECT c.name, ARRAY(SELECT VALUE UPPER(t) FROM t IN c.tags) AS upperTags FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - var tags = results[0]["upperTags"]!.ToObject(); - tags.Should().BeEquivalentTo("ADMIN", "USER"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Parameterized Queries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Parameterized_WithStringParam() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Charlie"); - - var results = await DrainQuery(query); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Charlie"); - } - - [Fact] - public async Task Query_Parameterized_WithNumericParam() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.score > @minVal") - .WithParameter("@minVal", 25); - - var results = await DrainQuery(query); - results.Should().HaveCount(3); - } - - [Fact] - public async Task Query_Parameterized_WithMultipleParams() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.partitionKey = @pk") - .WithParameter("@name", "Alice") - .WithParameter("@pk", "pk1"); - - var results = await DrainQuery(query); - results.Should().HaveCount(1); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Conditional Expressions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_TernaryExpression_ReturnsCorrectValue() - { - var results = await DrainQuery( - "SELECT c.name, (c.score >= 30 ? 'high' : 'low') AS level FROM c"); - - results.Should().HaveCount(5); - results.First(r => r["name"]!.Value() == "Alice")["level"]!.Value().Should().Be("high"); - results.First(r => r["name"]!.Value() == "Bob")["level"]!.Value().Should().Be("low"); - } - - // Windows emulator (v2.14.0) returns null instead of coalesced value for ?? operator. - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task Query_NullCoalesce_Works() - { - await _container.CreateItemAsync( - new QueryDoc { Id = "null1", PartitionKey = "pk1", Name = null!, Score = 0 }, - new PartitionKey("pk1")); - - var results = await DrainQuery( - "SELECT (c.name ?? 'Unknown') AS displayName FROM c WHERE c.id = 'null1'"); - - results.Should().HaveCount(1); - results[0]["displayName"]!.Value().Should().Be("Unknown"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // IN / BETWEEN operators - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_InOperator_FiltersCorrectly() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE c.name IN ('Alice', 'Charlie', 'Eve')"); - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); - } - - [Fact] - public async Task Query_BetweenOperator_FiltersRange() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE c.score BETWEEN 20 AND 40"); - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Bob", "Eve"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LIKE operator - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_LikeOperator_Wildcard() - { - var results = await DrainQuery( - "SELECT * FROM c WHERE c.name LIKE 'A%'"); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // JOIN - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Join_FlattensTags() - { - var results = await DrainQuery( - "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.id = '1'"); - - results.Should().HaveCount(2); - results.Select(r => r["tag"]!.Value()).Should().BeEquivalentTo("admin", "user"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Conversion Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_ToString_ConvertsNumber() - { - var results = await DrainQuery( - "SELECT ToString(c.score) AS strVal FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - results[0]["strVal"]!.Value().Should().Be("30"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Aggregate with GROUP BY - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_GroupBy_WithCount_ThroughHandler() - { - var results = await DrainQuery( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); - - results.Should().HaveCount(2); - var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); - pk1["cnt"]!.Value().Should().Be(3); - var pk2 = results.First(r => r["partitionKey"]!.Value() == "pk2"); - pk2["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Query_GroupBy_WithSum_ThroughHandler() - { - var results = await DrainQuery( - "SELECT c.partitionKey, SUM(c.score) AS total FROM c GROUP BY c.partitionKey"); - - results.Should().HaveCount(2); - var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); - pk1["total"]!.Value().Should().Be(90); // 30+20+40 - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Multiple aggregates - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_MultipleAggregates_ThroughHandler() - { - var results = await DrainQuery( - "SELECT COUNT(1) AS cnt, SUM(c.score) AS total, AVG(c.score) AS average FROM c"); - - results.Should().HaveCount(1); - results[0]["cnt"]!.Value().Should().Be(5); - results[0]["total"]!.Value().Should().Be(150); - results[0]["average"]!.Value().Should().Be(30); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Continuation token pagination through queries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_ContinuationToken_PaginatesCorrectly() - { - var results = new List(); - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - int pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - pageCount++; - } - - results.Should().HaveCount(5); - pageCount.Should().BeGreaterThan(1); - results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Charlie", "Diana", "Eve"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Date/Time Functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_GetCurrentTimestamp_ReturnsNumber() - { - var results = await DrainQuery( - "SELECT GetCurrentTimestamp() AS ts FROM c WHERE c.id = '1'"); - - results.Should().HaveCount(1); - var ts = results[0]["ts"]!.Value(); - ts.Should().BeGreaterThan(0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // DISTINCT - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Distinct_ReturnsUniqueValues() - { - var results = await DrainQuery( - "SELECT DISTINCT c.partitionKey FROM c"); - - results.Should().HaveCount(2); - results.Select(r => r["partitionKey"]!.Value()).Should().BeEquivalentTo("pk1", "pk2"); - } - - [Fact] - public async Task Query_DistinctValue_ReturnsScalars() - { - var results = await DrainQuery( - "SELECT DISTINCT VALUE c.partitionKey FROM c"); - - results.Should().HaveCount(2); - results.Select(r => r.Value()).Should().BeEquivalentTo("pk1", "pk2"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // TOP - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Top_LimitsResults() - { - var results = await DrainQuery( - "SELECT TOP 3 * FROM c ORDER BY c.name"); - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task Query_Top1_ReturnsSingleResult() - { - var results = await DrainQuery( - "SELECT TOP 1 * FROM c ORDER BY c.score DESC"); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Charlie"); // Score = 50 - } - - // ═══════════════════════════════════════════════════════════════════════════ - // OFFSET / LIMIT - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_OffsetLimit_PaginatesCorrectly() - { - var results = await DrainQuery( - "SELECT * FROM c ORDER BY c.name OFFSET 1 LIMIT 2"); - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().ContainInOrder("Bob", "Charlie"); - } - - [Fact] - public async Task Query_OffsetLimit_LastPage() - { - var results = await DrainQuery( - "SELECT * FROM c ORDER BY c.name OFFSET 4 LIMIT 10"); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Eve"); - } - - [Fact] - public async Task Query_OffsetLimit_BeyondResults_ReturnsEmpty() - { - var results = await DrainQuery( - "SELECT * FROM c ORDER BY c.name OFFSET 100 LIMIT 10"); - - results.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // GROUP BY with HAVING - // ═══════════════════════════════════════════════════════════════════════════ - - // Windows emulator (v2.14.0) returns 400 "Syntax error near 'HAVING'" for GROUP BY HAVING queries. - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task Query_GroupByHaving_FiltersGroups() - { - // HAVING is rejected by SDK ServiceInterop (SC1001), so enable distributed query gateway mode - // to bypass ServiceInterop and send raw SQL directly through the handler pipeline - var results = await DrainDistributedQuery( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) >= 3"); - - results.Should().HaveCount(1); - results[0]["partitionKey"]!.Value().Should().Be("pk1"); // pk1 has 3 items - results[0]["cnt"]!.Value().Should().Be(3); - } - - private async Task> DrainDistributedQuery(string sql) - { - return await DrainDistributedQuery(_container, sql); - } - - private static async Task> DrainDistributedQuery(Container container, string sql) - { - var options = new QueryRequestOptions(); - typeof(QueryRequestOptions) - .GetProperty("EnableDistributedQueryGatewayMode", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(options, true); - var iterator = container.GetItemQueryIterator(sql, requestOptions: options); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Vector Search (VECTORDISTANCE) — InMemoryOnly (uses inline containers) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_VectorDistance_Cosine_OrdersByDistance() - { - // VectorDistance ORDER BY goes through the full SDK → HTTP → handler pipeline - using var cosmos = InMemoryCosmos.Create("test-vector", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "v1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "v2", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "v3", pk = "a", embedding = new[] { 0.9, 0.1, 0.0 } }), - new PartitionKey("a")); - - // Query through SDK pipeline — VectorDistance in ORDER BY is now handled by FakeCosmosHandler - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0])"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - // Cosine similarity ORDER BY ascending: v2 (orthogonal, ~0) is first, v1 (exact, 1.0) is last - var lastResult = results[2]; - lastResult["id"]!.Value().Should().Be("v1"); - lastResult["score"]!.Value().Should().BeApproximately(1.0, 0.001); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_VectorDistance_Euclidean_Works() - { - // VectorDistance without ORDER BY goes through the full SDK pipeline - using var cosmos = InMemoryCosmos.Create("test-vec-euc", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "e1", pk = "a", emb = new[] { 0.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "e2", pk = "a", emb = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - - // Query through SDK pipeline - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.emb, [0.0, 0.0], false, {'distanceFunction': 'euclidean'}) AS dist FROM c"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - var e1 = results.First(r => r["id"]!.Value() == "e1"); - e1["dist"]!.Value().Should().BeApproximately(0.0, 0.001); - var e2 = results.First(r => r["id"]!.Value() == "e2"); - e2["dist"]!.Value().Should().BeApproximately(5.0, 0.001); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Full-Text Search — InMemoryOnly (uses inline containers) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_FullTextContains_Filters() - { - // FullTextContains is rejected by SDK ServiceInterop (SC2005), - // so enable distributed query gateway mode to bypass ServiceInterop - using var cosmos = InMemoryCosmos.Create("test-fts", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "f2", pk = "a", text = "SQL Server is a relational database" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "f3", pk = "a", text = "Redis is an in-memory cache" }), - new PartitionKey("a")); - - // Query through SDK pipeline with distributed query gateway mode - var results = await DrainDistributedQuery(container, - "SELECT c.id FROM c WHERE FullTextContains(c.text, 'database')"); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("f1", "f2"); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_FullTextContainsAll_RequiresAllTerms() - { - using var cosmos = InMemoryCosmos.Create("test-fts-all", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "f2", pk = "a", text = "SQL Server is a relational database" }), - new PartitionKey("a")); - - // Query through SDK pipeline with distributed query gateway mode - var results = await DrainDistributedQuery(container, - "SELECT c.id FROM c WHERE FullTextContainsAll(c.text, 'database', 'NoSQL')"); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("f1"); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_FullTextContainsAny_MatchesAnyTerm() - { - using var cosmos = InMemoryCosmos.Create("test-fts-any", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "f2", pk = "a", text = "Redis cache" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "f3", pk = "a", text = "PostgreSQL database" }), - new PartitionKey("a")); - - // Query through SDK pipeline with distributed query gateway mode - var results = await DrainDistributedQuery(container, - "SELECT c.id FROM c WHERE FullTextContainsAny(c.text, 'Cosmos', 'Redis')"); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("f1", "f2"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Geospatial (ST_* functions) — InMemoryOnly (uses inline containers) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_StDistance_CalculatesDistanceBetweenPoints() - { - using var cosmos = InMemoryCosmos.Create("test-geo", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.Parse("""{"id":"g1","pk":"a","location":{"type":"Point","coordinates":[-122.12,47.67]}}"""), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.Parse("""{"id":"g2","pk":"a","location":{"type":"Point","coordinates":[-73.97,40.77]}}"""), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - """SELECT c.id, ST_DISTANCE(c.location, {"type":"Point","coordinates":[-122.12,47.67]}) AS dist FROM c"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - var g1 = results.First(r => r["id"]!.Value() == "g1"); - g1["dist"]!.Value().Should().BeLessThan(1); // Same point, ~0m - var g2 = results.First(r => r["id"]!.Value() == "g2"); - g2["dist"]!.Value().Should().BeGreaterThan(3_000_000); // ~3900km - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_StWithin_ChecksPointInPolygon() - { - using var cosmos = InMemoryCosmos.Create("test-geo-within", "/pk"); - var container = cosmos.Container; - - // Point inside polygon (Seattle area) - await container.CreateItemAsync( - JObject.Parse("""{"id":"in","pk":"a","loc":{"type":"Point","coordinates":[-122.3,47.6]}}"""), - new PartitionKey("a")); - // Point outside polygon (New York) - await container.CreateItemAsync( - JObject.Parse("""{"id":"out","pk":"a","loc":{"type":"Point","coordinates":[-73.97,40.77]}}"""), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - """SELECT c.id FROM c WHERE ST_WITHIN(c.loc, {"type":"Polygon","coordinates":[[[-123,47],[-123,48],[-121,48],[-121,47],[-123,47]]]})"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("in"); - } - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task Query_StIsValid_ValidatesGeoJson() - { - using var cosmos = InMemoryCosmos.Create("test-geo-valid", "/pk"); - var container = cosmos.Container; - - await container.CreateItemAsync( - JObject.Parse("""{"id":"valid","pk":"a","loc":{"type":"Point","coordinates":[-122.12,47.67]}}"""), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, ST_ISVALID(c.loc) AS isValid FROM c"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["isValid"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task Query_DollarPrefixedProperty_Works() - { - // Issue #35: EF Core Cosmos generates queries using c.$type as a discriminator column. - // The parser must accept $-prefixed property names. - var doc = JObject.FromObject(new - { - id = "dollar-1", - partitionKey = "pk1", - // $type is the discriminator column used by EF Core Cosmos - }); - doc["$type"] = "IdentityUser"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c FROM c WHERE c[\"$type\"] = @type") - .WithParameter("@type", "IdentityUser"); - - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["$type"]!.Value().Should().Be("IdentityUser"); - } - - [Fact] - public async Task Query_DollarPrefixedProperty_DotNotation_Works() - { - // Issue #35: Verify c.$type dot notation works (not just bracket notation) - var doc = JObject.FromObject(new - { - id = "dollar-2", - partitionKey = "pk1", - }); - doc["$type"] = "IdentityRole"; - await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT c.id, c[\"$type\"] AS dtype FROM c WHERE c[\"$type\"] = 'IdentityRole'"); - - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["dtype"]!.Value().Should().Be("IdentityRole"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Nested Function Calls (Issue #11) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_ContainsLower_NestedFunctionCall_Works() - { - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/contains - // Nested function calls like CONTAINS(LOWER(c.field), @value) should be evaluated correctly. - var doc = new TestDocument { Id = "nf1", PartitionKey = "pk1", Name = "T1000" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") - .WithParameter("@val", "t1000"); - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1, "CONTAINS(LOWER(c.name), 't1000') should match document with Name='T1000'"); - results[0].Name.Should().Be("T1000"); - } - - [Fact] - public async Task Query_StartsWithUpper_NestedFunctionCall_Works() - { - var doc = new TestDocument { Id = "nf2", PartitionKey = "pk1", Name = "hello-world" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(UPPER(c.name), @val)") - .WithParameter("@val", "HELLO"); - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Name.Should().Be("hello-world"); - } - - // Issue #16: SELECT VALUE projecting scalar strings should be deserialized correctly - [Fact] - public async Task Query_SelectValueScalarString_Works() - { - var doc = new TestDocument { Id = "sv1", PartitionKey = "pk1", Name = "Widget" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.id = 'sv1'"); - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Should().Be("Widget"); - } - - // Issue #16: SELECT VALUE projecting scalar int should be deserialized correctly - [Fact] - public async Task Query_SelectValueScalarInt_Works() - { - // Use a JObject to have a custom integer property (avoiding 'value' which is a Cosmos SQL reserved word) - var doc = JObject.FromObject(new { id = "sv2", partitionKey = "pk1", amount = 42 }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c.amount FROM c WHERE c.id = 'sv2'"); - var results = new List(); - using var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Should().Be(42); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // COUNTIF aggregate - // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 - // "Adds an aggregate operator for CountIf" — undocumented server-side - // aggregate function. COUNTIF() counts items where the - // expression evaluates to true. - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_CountIf_SimpleCondition() - { - // 5 seeded docs: scores 30, 20, 50, 10, 40. Three have score >= 30. - var results = await DrainQuery( - "SELECT VALUE COUNTIF(c.score >= 30) FROM c"); - - results.Should().HaveCount(1); - results[0].Should().Be(3); - } - - [Fact] - public async Task Query_CountIf_WithAlias() - { - var results = await DrainQuery( - "SELECT COUNTIF(c.isActive = true) AS activeCount FROM c"); - - results.Should().HaveCount(1); - results[0]["activeCount"]!.Value().Should().Be(5); - } - - [Fact] - public async Task Query_CountIf_NoMatchesReturnsZero() - { - var results = await DrainQuery( - "SELECT VALUE COUNTIF(c.score > 100) FROM c"); - - results.Should().HaveCount(1); - results[0].Should().Be(0); - } - - [Fact] - public async Task Query_CountIf_WithGroupBy() - { - var results = await DrainQuery( - "SELECT c.partitionKey, COUNTIF(c.score >= 30) AS highScorers FROM c GROUP BY c.partitionKey"); - - results.Should().HaveCount(2); - var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); - pk1["highScorers"]!.Value().Should().Be(2); // Alice=30, Eve=40 - var pk2 = results.First(r => r["partitionKey"]!.Value() == "pk2"); - pk2["highScorers"]!.Value().Should().Be(1); // Charlie=50 - } - - [Fact] - public async Task Query_CountIf_MixedWithOtherAggregates() - { - var results = await DrainQuery( - "SELECT COUNT(1) AS total, COUNTIF(c.score >= 30) AS highScorers, SUM(c.score) AS totalScore FROM c"); - - results.Should().HaveCount(1); - results[0]["total"]!.Value().Should().Be(5); - results[0]["highScorers"]!.Value().Should().Be(3); - results[0]["totalScore"]!.Value().Should().Be(150); - } + private class QueryDoc + { + [JsonProperty("id")] public string Id { get; set; } = default!; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = default!; + [JsonProperty("name")] public string Name { get; set; } = default!; + [JsonProperty("score")] public int Score { get; set; } + [JsonProperty("isActive")] public bool IsActive { get; set; } = true; + [JsonProperty("tags")] public string[] Tags { get; set; } = []; + [JsonProperty("nested")] public NestedObject? Nested { get; set; } + } + + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-query-adv", "/partitionKey"); + await SeedData(); + } + + private async Task SeedData() + { + var docs = new[] + { + new QueryDoc { Id = "1", PartitionKey = "pk1", Name = "Alice", Score = 30, Tags = ["admin", "user"] }, + new QueryDoc { Id = "2", PartitionKey = "pk1", Name = "Bob", Score = 20, Tags = ["user"] }, + new QueryDoc { Id = "3", PartitionKey = "pk2", Name = "Charlie", Score = 50, Tags = ["admin"] }, + new QueryDoc { Id = "4", PartitionKey = "pk2", Name = "Diana", Score = 10, Tags = ["user", "moderator"] }, + new QueryDoc { Id = "5", PartitionKey = "pk1", Name = "Eve", Score = 40, Tags = [] }, + }; + foreach (var doc in docs) + await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef) + { + var iterator = _container.GetItemQueryIterator(queryDef); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // String Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_StringUpper_ReturnsUppercased() + { + var results = await DrainQuery( + "SELECT UPPER(c.name) AS upper_name FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["upper_name"]!.Value().Should().Be("ALICE"); + } + + [Fact] + public async Task Query_StringLower_ReturnsLowercased() + { + var results = await DrainQuery( + "SELECT LOWER(c.name) AS lower_name FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["lower_name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_StringConcat_Works() + { + var results = await DrainQuery( + "SELECT CONCAT(c.name, '-', c.partitionKey) AS full FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["full"]!.Value().Should().Be("Alice-pk1"); + } + + [Fact] + public async Task Query_StringContains_Filters() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE CONTAINS(c.name, 'li')"); + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task Query_Length_ReturnsCorrectLength() + { + var results = await DrainQuery( + "SELECT LENGTH(c.name) AS len FROM c WHERE c.id = '3'"); + + results.Should().HaveCount(1); + results[0]["len"]!.Value().Should().Be(7); // "Charlie" + } + + [Fact] + public async Task Query_Substring_ExtractsCorrectly() + { + var results = await DrainQuery( + "SELECT SUBSTRING(c.name, 0, 3) AS sub FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["sub"]!.Value().Should().Be("Ali"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Math Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_MathAbs_ReturnsAbsoluteValue() + { + var results = await DrainQuery( + "SELECT ABS(c.score - 35) AS diff FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["diff"]!.Value().Should().Be(5); + } + + [Fact] + public async Task Query_MathFloor_Works() + { + var results = await DrainQuery( + "SELECT FLOOR(c.score / 3.0) AS floored FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["floored"]!.Value().Should().Be(10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Array Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_ArrayContains_Filters() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'admin')"); + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task Query_ArrayLength_ReturnsCount() + { + var results = await DrainQuery( + "SELECT ARRAY_LENGTH(c.tags) AS tagCount FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["tagCount"]!.Value().Should().Be(2); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Type-Checking Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_IsDefined_ChecksPropertyExistence() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE IS_DEFINED(c.nonExistentProperty)"); + + // No docs have this property + results.Should().BeEmpty(); + } + + [Fact] + public async Task Query_IsString_ChecksType() + { + var results = await DrainQuery( + "SELECT IS_STRING(c.name) AS isStr FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["isStr"]!.Value().Should().BeTrue(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Subqueries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_ExistsSubquery_FiltersCorrectly() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE EXISTS (SELECT VALUE t FROM t IN c.tags WHERE t = 'admin')"); + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task Query_ArraySubquery_ReturnsArray() + { + var results = await DrainQuery( + "SELECT c.name, ARRAY(SELECT VALUE UPPER(t) FROM t IN c.tags) AS upperTags FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + var tags = results[0]["upperTags"]!.ToObject(); + tags.Should().BeEquivalentTo("ADMIN", "USER"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Parameterized Queries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Parameterized_WithStringParam() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Charlie"); + + var results = await DrainQuery(query); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Charlie"); + } + + [Fact] + public async Task Query_Parameterized_WithNumericParam() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.score > @minVal") + .WithParameter("@minVal", 25); + + var results = await DrainQuery(query); + results.Should().HaveCount(3); + } + + [Fact] + public async Task Query_Parameterized_WithMultipleParams() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.partitionKey = @pk") + .WithParameter("@name", "Alice") + .WithParameter("@pk", "pk1"); + + var results = await DrainQuery(query); + results.Should().HaveCount(1); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Conditional Expressions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_TernaryExpression_ReturnsCorrectValue() + { + var results = await DrainQuery( + "SELECT c.name, (c.score >= 30 ? 'high' : 'low') AS level FROM c"); + + results.Should().HaveCount(5); + results.First(r => r["name"]!.Value() == "Alice")["level"]!.Value().Should().Be("high"); + results.First(r => r["name"]!.Value() == "Bob")["level"]!.Value().Should().Be("low"); + } + + // Windows emulator (v2.14.0) returns null instead of coalesced value for ?? operator. + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task Query_NullCoalesce_Works() + { + await _container.CreateItemAsync( + new QueryDoc { Id = "null1", PartitionKey = "pk1", Name = null!, Score = 0 }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT (c.name ?? 'Unknown') AS displayName FROM c WHERE c.id = 'null1'"); + + results.Should().HaveCount(1); + results[0]["displayName"]!.Value().Should().Be("Unknown"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // IN / BETWEEN operators + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_InOperator_FiltersCorrectly() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE c.name IN ('Alice', 'Charlie', 'Eve')"); + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); + } + + [Fact] + public async Task Query_BetweenOperator_FiltersRange() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE c.score BETWEEN 20 AND 40"); + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Bob", "Eve"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LIKE operator + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_LikeOperator_Wildcard() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE c.name LIKE 'A%'"); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // JOIN + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Join_FlattensTags() + { + var results = await DrainQuery( + "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.id = '1'"); + + results.Should().HaveCount(2); + results.Select(r => r["tag"]!.Value()).Should().BeEquivalentTo("admin", "user"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Conversion Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_ToString_ConvertsNumber() + { + var results = await DrainQuery( + "SELECT ToString(c.score) AS strVal FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["strVal"]!.Value().Should().Be("30"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Aggregate with GROUP BY + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_GroupBy_WithCount_ThroughHandler() + { + var results = await DrainQuery( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); + + results.Should().HaveCount(2); + var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); + pk1["cnt"]!.Value().Should().Be(3); + var pk2 = results.First(r => r["partitionKey"]!.Value() == "pk2"); + pk2["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Query_GroupBy_WithSum_ThroughHandler() + { + var results = await DrainQuery( + "SELECT c.partitionKey, SUM(c.score) AS total FROM c GROUP BY c.partitionKey"); + + results.Should().HaveCount(2); + var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); + pk1["total"]!.Value().Should().Be(90); // 30+20+40 + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Multiple aggregates + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_MultipleAggregates_ThroughHandler() + { + var results = await DrainQuery( + "SELECT COUNT(1) AS cnt, SUM(c.score) AS total, AVG(c.score) AS average FROM c"); + + results.Should().HaveCount(1); + results[0]["cnt"]!.Value().Should().Be(5); + results[0]["total"]!.Value().Should().Be(150); + results[0]["average"]!.Value().Should().Be(30); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Continuation token pagination through queries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_ContinuationToken_PaginatesCorrectly() + { + var results = new List(); + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + + int pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + pageCount++; + } + + results.Should().HaveCount(5); + pageCount.Should().BeGreaterThan(1); + results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Charlie", "Diana", "Eve"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Date/Time Functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_GetCurrentTimestamp_ReturnsNumber() + { + var results = await DrainQuery( + "SELECT GetCurrentTimestamp() AS ts FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + var ts = results[0]["ts"]!.Value(); + ts.Should().BeGreaterThan(0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DISTINCT + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Distinct_ReturnsUniqueValues() + { + var results = await DrainQuery( + "SELECT DISTINCT c.partitionKey FROM c"); + + results.Should().HaveCount(2); + results.Select(r => r["partitionKey"]!.Value()).Should().BeEquivalentTo("pk1", "pk2"); + } + + [Fact] + public async Task Query_DistinctValue_ReturnsScalars() + { + var results = await DrainQuery( + "SELECT DISTINCT VALUE c.partitionKey FROM c"); + + results.Should().HaveCount(2); + results.Select(r => r.Value()).Should().BeEquivalentTo("pk1", "pk2"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TOP + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Top_LimitsResults() + { + var results = await DrainQuery( + "SELECT TOP 3 * FROM c ORDER BY c.name"); + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().ContainInOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task Query_Top1_ReturnsSingleResult() + { + var results = await DrainQuery( + "SELECT TOP 1 * FROM c ORDER BY c.score DESC"); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Charlie"); // Score = 50 + } + + // ═══════════════════════════════════════════════════════════════════════════ + // OFFSET / LIMIT + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_OffsetLimit_PaginatesCorrectly() + { + var results = await DrainQuery( + "SELECT * FROM c ORDER BY c.name OFFSET 1 LIMIT 2"); + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().ContainInOrder("Bob", "Charlie"); + } + + [Fact] + public async Task Query_OffsetLimit_LastPage() + { + var results = await DrainQuery( + "SELECT * FROM c ORDER BY c.name OFFSET 4 LIMIT 10"); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Eve"); + } + + [Fact] + public async Task Query_OffsetLimit_BeyondResults_ReturnsEmpty() + { + var results = await DrainQuery( + "SELECT * FROM c ORDER BY c.name OFFSET 100 LIMIT 10"); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // GROUP BY with HAVING + // ═══════════════════════════════════════════════════════════════════════════ + + // Windows emulator (v2.14.0) returns 400 "Syntax error near 'HAVING'" for GROUP BY HAVING queries. + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task Query_GroupByHaving_FiltersGroups() + { + // HAVING is rejected by SDK ServiceInterop (SC1001), so enable distributed query gateway mode + // to bypass ServiceInterop and send raw SQL directly through the handler pipeline + var results = await DrainDistributedQuery( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) >= 3"); + + results.Should().HaveCount(1); + results[0]["partitionKey"]!.Value().Should().Be("pk1"); // pk1 has 3 items + results[0]["cnt"]!.Value().Should().Be(3); + } + + private async Task> DrainDistributedQuery(string sql) + { + return await DrainDistributedQuery(_container, sql); + } + + private static async Task> DrainDistributedQuery(Container container, string sql) + { + var options = new QueryRequestOptions(); + typeof(QueryRequestOptions) + .GetProperty("EnableDistributedQueryGatewayMode", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(options, true); + var iterator = container.GetItemQueryIterator(sql, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Vector Search (VECTORDISTANCE) — InMemoryOnly (uses inline containers) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_VectorDistance_Cosine_OrdersByDistance() + { + // VectorDistance ORDER BY goes through the full SDK → HTTP → handler pipeline + using var cosmos = InMemoryCosmos.Create("test-vector", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "v1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "v2", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "v3", pk = "a", embedding = new[] { 0.9, 0.1, 0.0 } }), + new PartitionKey("a")); + + // Query through SDK pipeline — VectorDistance in ORDER BY is now handled by FakeCosmosHandler + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0])"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + // Cosine similarity ORDER BY ascending: v2 (orthogonal, ~0) is first, v1 (exact, 1.0) is last + var lastResult = results[2]; + lastResult["id"]!.Value().Should().Be("v1"); + lastResult["score"]!.Value().Should().BeApproximately(1.0, 0.001); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_VectorDistance_Euclidean_Works() + { + // VectorDistance without ORDER BY goes through the full SDK pipeline + using var cosmos = InMemoryCosmos.Create("test-vec-euc", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "e1", pk = "a", emb = new[] { 0.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "e2", pk = "a", emb = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + + // Query through SDK pipeline + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.emb, [0.0, 0.0], false, {'distanceFunction': 'euclidean'}) AS dist FROM c"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + var e1 = results.First(r => r["id"]!.Value() == "e1"); + e1["dist"]!.Value().Should().BeApproximately(0.0, 0.001); + var e2 = results.First(r => r["id"]!.Value() == "e2"); + e2["dist"]!.Value().Should().BeApproximately(5.0, 0.001); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Full-Text Search — InMemoryOnly (uses inline containers) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_FullTextContains_Filters() + { + // FullTextContains is rejected by SDK ServiceInterop (SC2005), + // so enable distributed query gateway mode to bypass ServiceInterop + using var cosmos = InMemoryCosmos.Create("test-fts", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "f2", pk = "a", text = "SQL Server is a relational database" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "f3", pk = "a", text = "Redis is an in-memory cache" }), + new PartitionKey("a")); + + // Query through SDK pipeline with distributed query gateway mode + var results = await DrainDistributedQuery(container, + "SELECT c.id FROM c WHERE FullTextContains(c.text, 'database')"); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("f1", "f2"); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_FullTextContainsAll_RequiresAllTerms() + { + using var cosmos = InMemoryCosmos.Create("test-fts-all", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "f2", pk = "a", text = "SQL Server is a relational database" }), + new PartitionKey("a")); + + // Query through SDK pipeline with distributed query gateway mode + var results = await DrainDistributedQuery(container, + "SELECT c.id FROM c WHERE FullTextContainsAll(c.text, 'database', 'NoSQL')"); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("f1"); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_FullTextContainsAny_MatchesAnyTerm() + { + using var cosmos = InMemoryCosmos.Create("test-fts-any", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "f1", pk = "a", text = "Azure Cosmos DB" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "f2", pk = "a", text = "Redis cache" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "f3", pk = "a", text = "PostgreSQL database" }), + new PartitionKey("a")); + + // Query through SDK pipeline with distributed query gateway mode + var results = await DrainDistributedQuery(container, + "SELECT c.id FROM c WHERE FullTextContainsAny(c.text, 'Cosmos', 'Redis')"); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("f1", "f2"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Geospatial (ST_* functions) — InMemoryOnly (uses inline containers) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_StDistance_CalculatesDistanceBetweenPoints() + { + using var cosmos = InMemoryCosmos.Create("test-geo", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.Parse("""{"id":"g1","pk":"a","location":{"type":"Point","coordinates":[-122.12,47.67]}}"""), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.Parse("""{"id":"g2","pk":"a","location":{"type":"Point","coordinates":[-73.97,40.77]}}"""), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + """SELECT c.id, ST_DISTANCE(c.location, {"type":"Point","coordinates":[-122.12,47.67]}) AS dist FROM c"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + var g1 = results.First(r => r["id"]!.Value() == "g1"); + g1["dist"]!.Value().Should().BeLessThan(1); // Same point, ~0m + var g2 = results.First(r => r["id"]!.Value() == "g2"); + g2["dist"]!.Value().Should().BeGreaterThan(3_000_000); // ~3900km + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_StWithin_ChecksPointInPolygon() + { + using var cosmos = InMemoryCosmos.Create("test-geo-within", "/pk"); + var container = cosmos.Container; + + // Point inside polygon (Seattle area) + await container.CreateItemAsync( + JObject.Parse("""{"id":"in","pk":"a","loc":{"type":"Point","coordinates":[-122.3,47.6]}}"""), + new PartitionKey("a")); + // Point outside polygon (New York) + await container.CreateItemAsync( + JObject.Parse("""{"id":"out","pk":"a","loc":{"type":"Point","coordinates":[-73.97,40.77]}}"""), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + """SELECT c.id FROM c WHERE ST_WITHIN(c.loc, {"type":"Polygon","coordinates":[[[-123,47],[-123,48],[-121,48],[-121,47],[-123,47]]]})"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("in"); + } + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task Query_StIsValid_ValidatesGeoJson() + { + using var cosmos = InMemoryCosmos.Create("test-geo-valid", "/pk"); + var container = cosmos.Container; + + await container.CreateItemAsync( + JObject.Parse("""{"id":"valid","pk":"a","loc":{"type":"Point","coordinates":[-122.12,47.67]}}"""), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, ST_ISVALID(c.loc) AS isValid FROM c"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["isValid"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task Query_DollarPrefixedProperty_Works() + { + // Issue #35: EF Core Cosmos generates queries using c.$type as a discriminator column. + // The parser must accept $-prefixed property names. + var doc = JObject.FromObject(new + { + id = "dollar-1", + partitionKey = "pk1", + // $type is the discriminator column used by EF Core Cosmos + }); + doc["$type"] = "IdentityUser"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c FROM c WHERE c[\"$type\"] = @type") + .WithParameter("@type", "IdentityUser"); + + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["$type"]!.Value().Should().Be("IdentityUser"); + } + + [Fact] + public async Task Query_DollarPrefixedProperty_DotNotation_Works() + { + // Issue #35: Verify c.$type dot notation works (not just bracket notation) + var doc = JObject.FromObject(new + { + id = "dollar-2", + partitionKey = "pk1", + }); + doc["$type"] = "IdentityRole"; + await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT c.id, c[\"$type\"] AS dtype FROM c WHERE c[\"$type\"] = 'IdentityRole'"); + + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["dtype"]!.Value().Should().Be("IdentityRole"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Nested Function Calls (Issue #11) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_ContainsLower_NestedFunctionCall_Works() + { + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/contains + // Nested function calls like CONTAINS(LOWER(c.field), @value) should be evaluated correctly. + var doc = new TestDocument { Id = "nf1", PartitionKey = "pk1", Name = "T1000" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "t1000"); + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1, "CONTAINS(LOWER(c.name), 't1000') should match document with Name='T1000'"); + results[0].Name.Should().Be("T1000"); + } + + [Fact] + public async Task Query_StartsWithUpper_NestedFunctionCall_Works() + { + var doc = new TestDocument { Id = "nf2", PartitionKey = "pk1", Name = "hello-world" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(UPPER(c.name), @val)") + .WithParameter("@val", "HELLO"); + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Name.Should().Be("hello-world"); + } + + // Issue #16: SELECT VALUE projecting scalar strings should be deserialized correctly + [Fact] + public async Task Query_SelectValueScalarString_Works() + { + var doc = new TestDocument { Id = "sv1", PartitionKey = "pk1", Name = "Widget" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.id = 'sv1'"); + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Should().Be("Widget"); + } + + // Issue #16: SELECT VALUE projecting scalar int should be deserialized correctly + [Fact] + public async Task Query_SelectValueScalarInt_Works() + { + // Use a JObject to have a custom integer property (avoiding 'value' which is a Cosmos SQL reserved word) + var doc = JObject.FromObject(new { id = "sv2", partitionKey = "pk1", amount = 42 }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c.amount FROM c WHERE c.id = 'sv2'"); + var results = new List(); + using var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Should().Be(42); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // COUNTIF aggregate + // Ref: https://github.com/Azure/azure-cosmos-dotnet-v3/pull/4738 + // "Adds an aggregate operator for CountIf" — undocumented server-side + // aggregate function. COUNTIF() counts items where the + // expression evaluates to true. + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_CountIf_SimpleCondition() + { + // 5 seeded docs: scores 30, 20, 50, 10, 40. Three have score >= 30. + var results = await DrainQuery( + "SELECT VALUE COUNTIF(c.score >= 30) FROM c"); + + results.Should().HaveCount(1); + results[0].Should().Be(3); + } + + [Fact] + public async Task Query_CountIf_WithAlias() + { + var results = await DrainQuery( + "SELECT COUNTIF(c.isActive = true) AS activeCount FROM c"); + + results.Should().HaveCount(1); + results[0]["activeCount"]!.Value().Should().Be(5); + } + + [Fact] + public async Task Query_CountIf_NoMatchesReturnsZero() + { + var results = await DrainQuery( + "SELECT VALUE COUNTIF(c.score > 100) FROM c"); + + results.Should().HaveCount(1); + results[0].Should().Be(0); + } + + [Fact] + public async Task Query_CountIf_WithGroupBy() + { + var results = await DrainQuery( + "SELECT c.partitionKey, COUNTIF(c.score >= 30) AS highScorers FROM c GROUP BY c.partitionKey"); + + results.Should().HaveCount(2); + var pk1 = results.First(r => r["partitionKey"]!.Value() == "pk1"); + pk1["highScorers"]!.Value().Should().Be(2); // Alice=30, Eve=40 + var pk2 = results.First(r => r["partitionKey"]!.Value() == "pk2"); + pk2["highScorers"]!.Value().Should().Be(1); // Charlie=50 + } + + [Fact] + public async Task Query_CountIf_MixedWithOtherAggregates() + { + var results = await DrainQuery( + "SELECT COUNT(1) AS total, COUNTIF(c.score >= 30) AS highScorers, SUM(c.score) AS totalScore FROM c"); + + results.Should().HaveCount(1); + results[0]["total"]!.Value().Should().Be(5); + results[0]["highScorers"]!.Value().Should().Be(3); + results[0]["totalScore"]!.Value().Should().Be(150); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerTtlTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerTtlTests.cs index 23a9523..4200d16 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerTtlTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerTtlTests.cs @@ -17,161 +17,161 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class FakeCosmosHandlerTtlTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("test-ttl", "/partitionKey", - configure: props => props.DefaultTimeToLive = 2); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - private async Task> DrainQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - [Fact] - public async Task TTL_ItemBeforeExpiry_VisibleInQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fresh" }, - new PartitionKey("pk1")); - - var results = await DrainQuery("SELECT * FROM c"); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Fresh"); - } - - [Fact] - public async Task TTL_ItemAfterExpiry_FilteredFromQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, - new PartitionKey("pk1")); - - // Wait for TTL to expire (container TTL = 2 seconds) - await Task.Delay(3000); - - var results = await DrainQuery("SELECT * FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task TTL_ItemAfterExpiry_PointReadThrows404() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, - new PartitionKey("pk1")); - - await Task.Delay(3000); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task TTL_NonExpiredItems_StillVisibleAfterOthersExpire() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, - new PartitionKey("pk1")); - - await Task.Delay(3000); - - // Create a new item after old one expired - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "StillFresh" }, - new PartitionKey("pk1")); - - var results = await DrainQuery("SELECT * FROM c"); - results.Should().HaveCount(1); - results[0].Name.Should().Be("StillFresh"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Per-Item TTL Override - // ═══════════════════════════════════════════════════════════════════════════ - - // Windows emulator (v2.14.0) does not correctly enforce per-item TTL overrides (ttl: -1 still expires). - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task TTL_PerItemOverride_ShortTtlExpiresBeforeContainerDefault() - { - // Container default = 2s. Item TTL = 1s → expires faster - await _container.CreateItemAsync( - JObject.FromObject(new { id = "short", partitionKey = "pk1", name = "ShortLived", _ttl = 1 }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "default", partitionKey = "pk1", name = "DefaultTtl" }), - new PartitionKey("pk1")); - - // After 1.5s: short-TTL expired, default-TTL still alive - await Task.Delay(1500); - - var results = await DrainQuery("SELECT c.id FROM c"); - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("default"); - } - - // Windows emulator (v2.14.0) does not correctly enforce per-item TTL overrides (ttl: -1 still expires). - // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - [Fact] - public async Task TTL_PerItemOverride_MinusOne_NeverExpires() - { - // Container default = 2s. Item TTL = -1 → never expires - await _container.CreateItemAsync( - JObject.FromObject(new { id = "forever", partitionKey = "pk1", name = "Immortal", _ttl = -1 }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "mortal", partitionKey = "pk1", name = "WillDie" }), - new PartitionKey("pk1")); - - await Task.Delay(3000); - - var results = await DrainQuery("SELECT c.id FROM c"); - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("forever"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // TTL items produce tombstones in change feed - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] - public async Task TTL_ExpiredItem_ProducesTombstoneInChangeFeed() - { - using var cosmos = InMemoryCosmos.Create("test-ttl-cf", "/partitionKey", - configureContainer: setup => setup.DefaultTimeToLive = 2); - var container = cosmos.Container; - - await container.CreateItemAsync( - new TestDocument { Id = "ttl1", PartitionKey = "pk1", Name = "WillExpire" }, - new PartitionKey("pk1")); - - await Task.Delay(3000); // wait for TTL expiry - - // Verify item is no longer returned by query (TTL eviction) - var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'ttl1'"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("TTL-expired items should not be returned by queries"); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-ttl", "/partitionKey", + configure: props => props.DefaultTimeToLive = 2); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + [Fact] + public async Task TTL_ItemBeforeExpiry_VisibleInQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fresh" }, + new PartitionKey("pk1")); + + var results = await DrainQuery("SELECT * FROM c"); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Fresh"); + } + + [Fact] + public async Task TTL_ItemAfterExpiry_FilteredFromQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, + new PartitionKey("pk1")); + + // Wait for TTL to expire (container TTL = 2 seconds) + await Task.Delay(3000); + + var results = await DrainQuery("SELECT * FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task TTL_ItemAfterExpiry_PointReadThrows404() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, + new PartitionKey("pk1")); + + await Task.Delay(3000); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task TTL_NonExpiredItems_StillVisibleAfterOthersExpire() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WillExpire" }, + new PartitionKey("pk1")); + + await Task.Delay(3000); + + // Create a new item after old one expired + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "StillFresh" }, + new PartitionKey("pk1")); + + var results = await DrainQuery("SELECT * FROM c"); + results.Should().HaveCount(1); + results[0].Name.Should().Be("StillFresh"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Per-Item TTL Override + // ═══════════════════════════════════════════════════════════════════════════ + + // Windows emulator (v2.14.0) does not correctly enforce per-item TTL overrides (ttl: -1 still expires). + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task TTL_PerItemOverride_ShortTtlExpiresBeforeContainerDefault() + { + // Container default = 2s. Item TTL = 1s → expires faster + await _container.CreateItemAsync( + JObject.FromObject(new { id = "short", partitionKey = "pk1", name = "ShortLived", _ttl = 1 }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "default", partitionKey = "pk1", name = "DefaultTtl" }), + new PartitionKey("pk1")); + + // After 1.5s: short-TTL expired, default-TTL still alive + await Task.Delay(1500); + + var results = await DrainQuery("SELECT c.id FROM c"); + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("default"); + } + + // Windows emulator (v2.14.0) does not correctly enforce per-item TTL overrides (ttl: -1 still expires). + // Tracked: https://github.com/lemonlion/CosmosDB.InMemoryEmulator/issues/53 + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task TTL_PerItemOverride_MinusOne_NeverExpires() + { + // Container default = 2s. Item TTL = -1 → never expires + await _container.CreateItemAsync( + JObject.FromObject(new { id = "forever", partitionKey = "pk1", name = "Immortal", _ttl = -1 }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "mortal", partitionKey = "pk1", name = "WillDie" }), + new PartitionKey("pk1")); + + await Task.Delay(3000); + + var results = await DrainQuery("SELECT c.id FROM c"); + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("forever"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TTL items produce tombstones in change feed + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + public async Task TTL_ExpiredItem_ProducesTombstoneInChangeFeed() + { + using var cosmos = InMemoryCosmos.Create("test-ttl-cf", "/partitionKey", + configureContainer: setup => setup.DefaultTimeToLive = 2); + var container = cosmos.Container; + + await container.CreateItemAsync( + new TestDocument { Id = "ttl1", PartitionKey = "pk1", Name = "WillExpire" }, + new PartitionKey("pk1")); + + await Task.Delay(3000); // wait for TTL expiry + + // Verify item is no longer returned by query (TTL eviction) + var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'ttl1'"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("TTL-expired items should not be returned by queries"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18EdgeCaseIntegrationTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18EdgeCaseIntegrationTests.cs index 44864d7..deace2c 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18EdgeCaseIntegrationTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18EdgeCaseIntegrationTests.cs @@ -11,293 +11,292 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// verifying that exceptions are exactly and carry the /// expected status codes. /// -/// Tagged EmulatorFlaky: the entire class has been reproducibly failing on -/// emulator-linux / emulator-windows targets in CI — the emulators return 503 -/// ("high demand in this region") under the concurrent error-path load this class -/// exercises, where the in-memory backend returns the expected 4xx/5xx. The -/// behaviour under test is still validated on the in-memory target; emulator -/// parity would be nice-to-have but isn't load-bearing. +/// The concurrent test uses adaptive concurrency: lower volume when targeting a real +/// emulator to avoid overwhelming its partition service with 503 "high demand" errors, +/// while still validating the same thread-safety behaviour. /// [Collection(IntegrationCollection.Name)] -[Trait(TestTraits.Target, TestTraits.EmulatorFlaky)] public class Issue18EdgeCaseIntegrationTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("issue18-edge", "/pk"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ETAG / PRECONDITION FAILED - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ETagMismatch_Replace_ThrowsCosmosException_412() - { - await _container.CreateItemAsync( - new { id = "etag1", pk = "pk1", value = "a" }, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(async () => - await _container.ReplaceItemAsync( - new { id = "etag1", pk = "pk1", value = "b" }, "etag1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); - - ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task IfNoneMatch_Star_OnExistingItem_ThrowsCosmosException_304() - { - await _container.CreateItemAsync( - new { id = "nm1", pk = "pk1" }, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(async () => - await _container.ReadItemAsync("nm1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" })); - - ex.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task IfNoneMatch_MatchingEtag_ThrowsCosmosException_304() - { - var created = await _container.CreateItemAsync( - new { id = "nm2", pk = "pk1" }, new PartitionKey("pk1")); - var etag = created.ETag; - - var ex = await Assert.ThrowsAsync(async () => - await _container.ReadItemAsync("nm2", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etag })); - - ex.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task ETagMismatch_Patch_ThrowsCosmosException_412() - { - await _container.CreateItemAsync( - new { id = "etag2", pk = "pk1", name = "test" }, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(async () => - await _container.PatchItemAsync("etag2", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "new") }, - new PatchItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); - - ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // MULTIPLE SEQUENTIAL ERRORS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task MultipleSequentialErrors_AllCatchableAsCosmosException() - { - await _container.CreateItemAsync(new { id = "seq1", pk = "pk1" }, new PartitionKey("pk1")); - - var errors = new List(); - - // Error 1: Read non-existent → 404 - try { await _container.ReadItemAsync("nope", new PartitionKey("pk1")); } - catch (CosmosException ex) { errors.Add(ex.StatusCode); } - - // Error 2: Duplicate create → 409 - try { await _container.CreateItemAsync(new { id = "seq1", pk = "pk1" }, new PartitionKey("pk1")); } - catch (CosmosException ex) { errors.Add(ex.StatusCode); } - - // Error 3: Delete non-existent → 404 - try { await _container.DeleteItemAsync("nope", new PartitionKey("pk1")); } - catch (CosmosException ex) { errors.Add(ex.StatusCode); } - - errors.Should().HaveCount(3); - errors[0].Should().Be(HttpStatusCode.NotFound); - errors[1].Should().Be(HttpStatusCode.Conflict); - errors[2].Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // VALIDATION ERRORS (400 BadRequest) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task EmptyId_ThrowsCosmosException_400() - { - var ex = await Assert.ThrowsAsync(async () => - await _container.CreateItemAsync(new { id = "", pk = "pk1" }, new PartitionKey("pk1"))); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchTooManyOps_ThrowsCosmosException_400() - { - await _container.CreateItemAsync(new { id = "patch1", pk = "pk1", a = 0 }, new PartitionKey("pk1")); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", i)) - .ToArray(); - - var ex = await Assert.ThrowsAsync(async () => - await _container.PatchItemAsync("patch1", new PartitionKey("pk1"), ops)); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchEmptyOps_ThrowsException() - { - await _container.CreateItemAsync(new { id = "patch2", pk = "pk1" }, new PartitionKey("pk1")); - - // SDK validates empty patch operations before reaching the handler, - // throwing ArgumentException rather than CosmosException - var act = () => _container.PatchItemAsync("patch2", new PartitionKey("pk1"), - Array.Empty()); - - await act.Should().ThrowAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // TRANSACTIONAL BATCH ERRORS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BatchConflict_ReturnsFailedResponse() - { - await _container.CreateItemAsync(new { id = "bdup", pk = "pk1" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")) - .CreateItem(new { id = "bdup", pk = "pk1" }); - - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // CONCURRENT CONTAINER OPERATIONS RAISING EXCEPTIONS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ConcurrentReadsOfNonExistent_AllThrowCosmosException() - { - const int concurrency = 50; - var tasks = Enumerable.Range(0, concurrency).Select(async i => - { - var ex = await Assert.ThrowsAsync(async () => - await _container.ReadItemAsync($"nope-{i}", new PartitionKey("pk1"))); - - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - return ex; - }); - - var results = await Task.WhenAll(tasks); - results.Should().HaveCount(concurrency); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // AGGREGATE EXCEPTION SCENARIOS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task AggregateException_ContainingCosmosException_CanBeUnwrapped() - { - var task1 = Task.Run(() => - _container.ReadItemAsync("agg1", new PartitionKey("pk1"))); - var task2 = Task.Run(() => - _container.ReadItemAsync("agg2", new PartitionKey("pk1"))); - - var allTask = Task.WhenAll(task1, task2); - try - { - await allTask; - Assert.Fail("Should have thrown"); - } - catch (CosmosException firstEx) - { - firstEx.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - task1.IsFaulted.Should().BeTrue(); - task2.IsFaulted.Should().BeTrue(); - - allTask.Exception.Should().NotBeNull(); - allTask.Exception!.InnerExceptions.Should().HaveCount(2); - foreach (var inner in allTask.Exception.InnerExceptions) - { - inner.Should().BeOfType(); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // REPLACE WITH WRONG BODY ID - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Windows emulator returns 200 (emulator bug); see docs link below - public async Task ReplaceItem_BodyIdMismatch_ThrowsBadRequest() - { - // SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - await _container.CreateItemAsync(new { id = "rep1", pk = "pk1" }, new PartitionKey("pk1")); - - var act = () => _container.ReplaceItemAsync( - new { id = "DIFFERENT", pk = "pk1" }, "rep1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // UPSERT WITH IF-MATCH ON NON-EXISTENT ITEM - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Upsert_IfMatch_NonExistent_CreatesItem() - { - // Real Cosmos DB creates the item. If-Match is "applicable only on PUT and DELETE" - // per the REST API common headers docs. Upsert uses POST, so If-Match is ignored - // on the insert path. - var response = await _container.UpsertItemAsync( - new { id = "noexist", pk = "pk1" }, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // EXCEPTION PRESERVES PROPERTIES AFTER ASYNC STATE MACHINE TRAVERSAL - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ExceptionProperties_Preserved_AfterAsyncAwait() - { - CosmosException? caught = null; - try - { - await NestedAsyncCall(_container); - } - catch (CosmosException ex) - { - caught = ex; - } - - caught.Should().NotBeNull(); - caught!.StatusCode.Should().Be(HttpStatusCode.NotFound); - // Through the SDK pipeline, Diagnostics is populated by DiagnosticsHandler - caught.Diagnostics.Should().NotBeNull(); - } - - private static async Task NestedAsyncCall(Container container) - { - await Task.Yield(); - await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("issue18-edge", "/pk"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ETAG / PRECONDITION FAILED + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETagMismatch_Replace_ThrowsCosmosException_412() + { + await _container.CreateItemAsync( + new { id = "etag1", pk = "pk1", value = "a" }, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(async () => + await _container.ReplaceItemAsync( + new { id = "etag1", pk = "pk1", value = "b" }, "etag1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task IfNoneMatch_Star_OnExistingItem_ThrowsCosmosException_304() + { + await _container.CreateItemAsync( + new { id = "nm1", pk = "pk1" }, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(async () => + await _container.ReadItemAsync("nm1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" })); + + ex.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task IfNoneMatch_MatchingEtag_ThrowsCosmosException_304() + { + var created = await _container.CreateItemAsync( + new { id = "nm2", pk = "pk1" }, new PartitionKey("pk1")); + var etag = created.ETag; + + var ex = await Assert.ThrowsAsync(async () => + await _container.ReadItemAsync("nm2", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etag })); + + ex.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task ETagMismatch_Patch_ThrowsCosmosException_412() + { + await _container.CreateItemAsync( + new { id = "etag2", pk = "pk1", name = "test" }, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(async () => + await _container.PatchItemAsync("etag2", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "new") }, + new PatchItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MULTIPLE SEQUENTIAL ERRORS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task MultipleSequentialErrors_AllCatchableAsCosmosException() + { + await _container.CreateItemAsync(new { id = "seq1", pk = "pk1" }, new PartitionKey("pk1")); + + var errors = new List(); + + // Error 1: Read non-existent → 404 + try { await _container.ReadItemAsync("nope", new PartitionKey("pk1")); } + catch (CosmosException ex) { errors.Add(ex.StatusCode); } + + // Error 2: Duplicate create → 409 + try { await _container.CreateItemAsync(new { id = "seq1", pk = "pk1" }, new PartitionKey("pk1")); } + catch (CosmosException ex) { errors.Add(ex.StatusCode); } + + // Error 3: Delete non-existent → 404 + try { await _container.DeleteItemAsync("nope", new PartitionKey("pk1")); } + catch (CosmosException ex) { errors.Add(ex.StatusCode); } + + errors.Should().HaveCount(3); + errors[0].Should().Be(HttpStatusCode.NotFound); + errors[1].Should().Be(HttpStatusCode.Conflict); + errors[2].Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VALIDATION ERRORS (400 BadRequest) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task EmptyId_ThrowsCosmosException_400() + { + var ex = await Assert.ThrowsAsync(async () => + await _container.CreateItemAsync(new { id = "", pk = "pk1" }, new PartitionKey("pk1"))); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchTooManyOps_ThrowsCosmosException_400() + { + await _container.CreateItemAsync(new { id = "patch1", pk = "pk1", a = 0 }, new PartitionKey("pk1")); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", i)) + .ToArray(); + + var ex = await Assert.ThrowsAsync(async () => + await _container.PatchItemAsync("patch1", new PartitionKey("pk1"), ops)); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchEmptyOps_ThrowsException() + { + await _container.CreateItemAsync(new { id = "patch2", pk = "pk1" }, new PartitionKey("pk1")); + + // SDK validates empty patch operations before reaching the handler, + // throwing ArgumentException rather than CosmosException + var act = () => _container.PatchItemAsync("patch2", new PartitionKey("pk1"), + Array.Empty()); + + await act.Should().ThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TRANSACTIONAL BATCH ERRORS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BatchConflict_ReturnsFailedResponse() + { + await _container.CreateItemAsync(new { id = "bdup", pk = "pk1" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")) + .CreateItem(new { id = "bdup", pk = "pk1" }); + + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CONCURRENT CONTAINER OPERATIONS RAISING EXCEPTIONS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConcurrentReadsOfNonExistent_AllThrowCosmosException() + { + // Use lower concurrency on emulators to avoid overwhelming the partition + // service (503 "high demand in this region"). The behavioural assertion is + // the same — all reads of non-existent items throw CosmosException(404). + var concurrency = session.IsEmulator ? 10 : 50; + var tasks = Enumerable.Range(0, concurrency).Select(async i => + { + var ex = await Assert.ThrowsAsync(async () => + await _container.ReadItemAsync($"nope-{i}", new PartitionKey("pk1"))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + return ex; + }); + + var results = await Task.WhenAll(tasks); + results.Should().HaveCount(concurrency); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // AGGREGATE EXCEPTION SCENARIOS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task AggregateException_ContainingCosmosException_CanBeUnwrapped() + { + var task1 = Task.Run(() => + _container.ReadItemAsync("agg1", new PartitionKey("pk1"))); + var task2 = Task.Run(() => + _container.ReadItemAsync("agg2", new PartitionKey("pk1"))); + + var allTask = Task.WhenAll(task1, task2); + try + { + await allTask; + Assert.Fail("Should have thrown"); + } + catch (CosmosException firstEx) + { + firstEx.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + task1.IsFaulted.Should().BeTrue(); + task2.IsFaulted.Should().BeTrue(); + + allTask.Exception.Should().NotBeNull(); + allTask.Exception!.InnerExceptions.Should().HaveCount(2); + foreach (var inner in allTask.Exception.InnerExceptions) + { + inner.Should().BeOfType(); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // REPLACE WITH WRONG BODY ID + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] // Windows emulator returns 200 (emulator bug); see docs link below + public async Task ReplaceItem_BodyIdMismatch_ThrowsBadRequest() + { + // SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + await _container.CreateItemAsync(new { id = "rep1", pk = "pk1" }, new PartitionKey("pk1")); + + var act = () => _container.ReplaceItemAsync( + new { id = "DIFFERENT", pk = "pk1" }, "rep1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UPSERT WITH IF-MATCH ON NON-EXISTENT ITEM + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Upsert_IfMatch_NonExistent_CreatesItem() + { + // Real Cosmos DB creates the item. If-Match is "applicable only on PUT and DELETE" + // per the REST API common headers docs. Upsert uses POST, so If-Match is ignored + // on the insert path. + var response = await _container.UpsertItemAsync( + new { id = "noexist", pk = "pk1" }, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EXCEPTION PRESERVES PROPERTIES AFTER ASYNC STATE MACHINE TRAVERSAL + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ExceptionProperties_Preserved_AfterAsyncAwait() + { + CosmosException? caught = null; + try + { + await NestedAsyncCall(_container); + } + catch (CosmosException ex) + { + caught = ex; + } + + caught.Should().NotBeNull(); + caught!.StatusCode.Should().Be(HttpStatusCode.NotFound); + // Through the SDK pipeline, Diagnostics is populated by DiagnosticsHandler + caught.Diagnostics.Should().NotBeNull(); + } + + private static async Task NestedAsyncCall(Container container) + { + await Task.Yield(); + await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18ExceptionTypeTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18ExceptionTypeTests.cs index 1d820f3..ea24ce5 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18ExceptionTypeTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue18ExceptionTypeTests.cs @@ -16,137 +16,137 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection(IntegrationCollection.Name)] public class Issue18ExceptionTypeTests(EmulatorSession session) : IAsyncLifetime { - private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _container = await _fixture.CreateContainerAsync("issue18", "/pk"); - } - - public async ValueTask DisposeAsync() - { - await _fixture.DisposeAsync(); - } - - [Fact] - public async Task ReadItem_NotFound_CatchCosmosException_Works() - { - bool caughtAsCosmosException = false; - try - { - await _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtAsCosmosException = true; - } - - caughtAsCosmosException.Should().BeTrue( - "CosmosException should be catchable with status code filter"); - } - - [Fact] - public async Task ReadItem_NotFound_AssertThrowsExactType() - { - var exception = await Assert.ThrowsAsync(async () => - await _container.ReadItemAsync("nonexistent", new PartitionKey("pk1"))); - - exception.StatusCode.Should().Be(HttpStatusCode.NotFound); - exception.GetType().Should().Be(typeof(CosmosException), - "thrown exception must be exactly CosmosException, not a subclass"); - } - - [Fact] - public async Task ReadItem_NotFound_FluentThrowAsync() - { - Func act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task CreateItem_Duplicate_CatchCosmosException_Works() - { - await _container.CreateItemAsync(new { id = "dup1", pk = "pk1" }, new PartitionKey("pk1")); - - bool caughtConflict = false; - try - { - await _container.CreateItemAsync(new { id = "dup1", pk = "pk1" }, new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - caughtConflict = true; - } - - caughtConflict.Should().BeTrue( - "Duplicate create should throw CosmosException with 409 Conflict"); - } - - [Fact] - public async Task CreateItem_Duplicate_AssertThrowsExactType() - { - await _container.CreateItemAsync(new { id = "dup2", pk = "pk1" }, new PartitionKey("pk1")); - - var exception = await Assert.ThrowsAsync(async () => - await _container.CreateItemAsync(new { id = "dup2", pk = "pk1" }, new PartitionKey("pk1"))); - - exception.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task DeleteItem_NotFound_CatchCosmosException_Works() - { - bool caughtNotFound = false; - try - { - await _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue( - "Delete of nonexistent item should throw CosmosException with 404"); - } - - [Fact] - public async Task ReplaceItem_NotFound_CatchCosmosException_Works() - { - bool caughtNotFound = false; - try - { - await _container.ReplaceItemAsync( - new { id = "nonexistent", pk = "pk1" }, "nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue( - "Replace of nonexistent item should throw CosmosException with 404"); - } - - [Fact] - public async Task PatchItem_NotFound_CatchCosmosException_Works() - { - bool caughtNotFound = false; - try - { - await _container.PatchItemAsync( - "nonexistent", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "test") }); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue( - "Patch of nonexistent item should throw CosmosException with 404"); - } + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("issue18", "/pk"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task ReadItem_NotFound_CatchCosmosException_Works() + { + bool caughtAsCosmosException = false; + try + { + await _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtAsCosmosException = true; + } + + caughtAsCosmosException.Should().BeTrue( + "CosmosException should be catchable with status code filter"); + } + + [Fact] + public async Task ReadItem_NotFound_AssertThrowsExactType() + { + var exception = await Assert.ThrowsAsync(async () => + await _container.ReadItemAsync("nonexistent", new PartitionKey("pk1"))); + + exception.StatusCode.Should().Be(HttpStatusCode.NotFound); + exception.GetType().Should().Be(typeof(CosmosException), + "thrown exception must be exactly CosmosException, not a subclass"); + } + + [Fact] + public async Task ReadItem_NotFound_FluentThrowAsync() + { + Func act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateItem_Duplicate_CatchCosmosException_Works() + { + await _container.CreateItemAsync(new { id = "dup1", pk = "pk1" }, new PartitionKey("pk1")); + + bool caughtConflict = false; + try + { + await _container.CreateItemAsync(new { id = "dup1", pk = "pk1" }, new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + caughtConflict = true; + } + + caughtConflict.Should().BeTrue( + "Duplicate create should throw CosmosException with 409 Conflict"); + } + + [Fact] + public async Task CreateItem_Duplicate_AssertThrowsExactType() + { + await _container.CreateItemAsync(new { id = "dup2", pk = "pk1" }, new PartitionKey("pk1")); + + var exception = await Assert.ThrowsAsync(async () => + await _container.CreateItemAsync(new { id = "dup2", pk = "pk1" }, new PartitionKey("pk1"))); + + exception.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DeleteItem_NotFound_CatchCosmosException_Works() + { + bool caughtNotFound = false; + try + { + await _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue( + "Delete of nonexistent item should throw CosmosException with 404"); + } + + [Fact] + public async Task ReplaceItem_NotFound_CatchCosmosException_Works() + { + bool caughtNotFound = false; + try + { + await _container.ReplaceItemAsync( + new { id = "nonexistent", pk = "pk1" }, "nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue( + "Replace of nonexistent item should throw CosmosException with 404"); + } + + [Fact] + public async Task PatchItem_NotFound_CatchCosmosException_Works() + { + bool caughtNotFound = false; + try + { + await _container.PatchItemAsync( + "nonexistent", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "test") }); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue( + "Patch of nonexistent item should throw CosmosException with 404"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue64CountTernaryUndefinedTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue64CountTernaryUndefinedTests.cs new file mode 100644 index 0000000..204b5b1 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue64CountTernaryUndefinedTests.cs @@ -0,0 +1,219 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Regression tests for GitHub Issue #64 — COUNT(expr > 0 ? 1 : undefined) +/// incorrectly counts documents where the expression evaluates to undefined. +/// +/// Root cause: ExprToString in CosmosSqlParser did not parenthesise +/// ternary/coalesce sub-expressions within higher-precedence binary operators, so the +/// round-tripped SQL was re-parsed into a different AST. +/// +/// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/ternary-coalesce-operators +/// Ternary (?) and Coalesce (??) have the lowest operator precedence in Cosmos DB SQL. +/// +[Collection(IntegrationCollection.Name)] +public class Issue64CountTernaryUndefinedTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("issue64", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SELECT VALUE with ternary returning undefined + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SelectValue_SimpleTernary_WhenConditionFalse_ReturnsEmpty() + { + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", amount = 0 }, new PartitionKey("pk1")); + + // amount = 0, so (0 > 0) is false → ternary returns undefined → excluded from results + var results = await DrainQuery( + "SELECT VALUE c.amount > 0 ? 1 : undefined FROM c"); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task SelectValue_SimpleTernary_WhenConditionTrue_ReturnsValue() + { + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", amount = 5 }, new PartitionKey("pk1")); + + // amount = 5, so (5 > 0) is true → ternary returns 1 + var results = await DrainQuery( + "SELECT VALUE c.amount > 0 ? 1 : undefined FROM c"); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task SelectValue_NestedTernary_WhenInnerResolvesAndComparisonFails_ReturnsEmpty() + { + // creditValue.amount = 0, so inner ternary resolves to 0; 0 > 0 is false → undefined + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", creditValue = new { amount = 0 }, grossValue = new { amount = 100 } }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT VALUE (IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossValue.amount) > 0 ? 1 : undefined FROM c"); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task SelectValue_NestedTernary_WhenInnerResolvesAndComparisonSucceeds_ReturnsValue() + { + // creditValue.amount = 50, so inner ternary resolves to 50; 50 > 0 is true → 1 + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", creditValue = new { amount = 50 }, grossValue = new { amount = 100 } }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT VALUE (IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossValue.amount) > 0 ? 1 : undefined FROM c"); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // COUNT with ternary returning undefined — the core bug scenario + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Count_SimpleTernary_WhenConditionFalse_ShouldNotCount() + { + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", amount = 0 }, new PartitionKey("pk1")); + + // amount = 0, so (0 > 0) is false → undefined → COUNT should skip + var results = await DrainQuery( + "SELECT VALUE COUNT(c.amount > 0 ? 1 : undefined) FROM c"); + + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task Count_SimpleTernary_MixedDocuments_CountsOnlyMatching() + { + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", amount = 0 }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new { id = "2", partitionKey = "pk1", amount = 5 }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new { id = "3", partitionKey = "pk1", amount = -1 }, new PartitionKey("pk1")); + + // Only id=2 (amount=5) satisfies amount > 0 + var results = await DrainQuery( + "SELECT VALUE COUNT(c.amount > 0 ? 1 : undefined) FROM c"); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task Count_NestedTernary_WhenInnerResolvesToZero_ShouldNotCount() + { + // This is the exact bug scenario from the issue: + // creditValue.amount = 0, IS_DEFINED(c.creditValue) is true, so inner ternary → 0 + // 0 > 0 is false → outer ternary returns undefined → COUNT should NOT count this doc + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", creditValue = new { amount = 0 }, grossValue = new { amount = 100 } }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT VALUE COUNT((IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossValue.amount) > 0 ? 1 : undefined) FROM c"); + + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task Count_NestedTernary_WhenInnerResolvesToPositive_ShouldCount() + { + // creditValue.amount = 50, IS_DEFINED(c.creditValue) is true, so inner ternary → 50 + // 50 > 0 is true → outer ternary returns 1 → COUNT SHOULD count this doc + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", creditValue = new { amount = 50 }, grossValue = new { amount = 100 } }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT VALUE COUNT((IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossValue.amount) > 0 ? 1 : undefined) FROM c"); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task Count_NestedTernary_FallsBackToGrossValue_WhenCreditUndefined() + { + // creditValue is NOT defined, so IS_DEFINED is false → inner ternary → grossValue.amount = 200 + // 200 > 0 is true → outer ternary returns 1 → COUNT SHOULD count + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", grossValue = new { amount = 200 } }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT VALUE COUNT((IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossValue.amount) > 0 ? 1 : undefined) FROM c"); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Multi-aggregate query (mirrors the original issue's query pattern) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Count_MultipleAggregatesWithTernary_IssueScenario() + { + // Mirrors the exact pattern from the bug report + await _container.CreateItemAsync(new + { + id = "doc1", + partitionKey = "pk1", + merchantId = "m1", + transactionType = "Settlement", + settlementDate = "2023-09-07", + creditValue = new { amount = 0 }, + grossSettlementValue = new { amount = 1000.25 }, + upfrontPaymentValue = new { amount = 1000.25 } + }, new PartitionKey("pk1")); + + var query = + "SELECT " + + "COUNT((IS_DEFINED(c.creditValue) ? c.creditValue.amount : c.grossSettlementValue.amount) > 0 ? 1 : undefined) AS NumberTransactions, " + + "COUNT(c.upfrontPaymentValue.amount > 0 ? 1 : undefined) AS UpfrontPaymentCount " + + "FROM c WHERE c.transactionType = 'Settlement' AND c.settlementDate = '2023-09-07' AND c.merchantId = 'm1'"; + + var results = await DrainQuery(query); + + var row = results.Should().ContainSingle().Which; + // creditValue.amount = 0 → inner ternary resolves to 0 → 0 > 0 is false → undefined → NOT counted + row["NumberTransactions"]!.Value().Should().Be(0); + // upfrontPaymentValue.amount = 1000.25 → 1000.25 > 0 is true → 1 → counted + row["UpfrontPaymentCount"]!.Value().Should().Be(1); + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue67StringLiteralAliasTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue67StringLiteralAliasTests.cs new file mode 100644 index 0000000..311b3f5 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue67StringLiteralAliasTests.cs @@ -0,0 +1,144 @@ +using AwesomeAssertions; +using CosmosDB.InMemoryEmulator.Tests.Infrastructure; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Regression tests for GitHub Issue #67 — String literal aliases in aggregate queries +/// return null on Linux (where ServiceInterop is unavailable). +/// +/// Root cause: ProjectAggregateFields did not evaluate non-aggregate literal +/// expressions (like 'Settlement' AS Label). Instead it fell through to a +/// path-lookup branch that called SelectToken("'Settlement'") → null. +/// +/// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/select +/// "The SELECT clause supports arbitrary expressions including literal values." +/// +[Collection(IntegrationCollection.Name)] +public class Issue67StringLiteralAliasTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("issue67", "/partitionKey"); + + await _container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", type = "Settlement", amount = 100.0m }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new { id = "2", partitionKey = "pk1", type = "Settlement", amount = 200.0m }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new { id = "3", partitionKey = "pk1", type = "Refund", amount = 50.0m }, + new PartitionKey("pk1")); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql, PartitionKey? pk = null) + { + var opts = pk is not null ? new QueryRequestOptions { PartitionKey = pk } : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: opts); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // String literal in aggregate SELECT projection + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StringLiteralAlias_InAggregateQuery_ShouldDeserializeCorrectly() + { + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/select + // "The SELECT clause supports arbitrary expressions including literal values." + var results = await DrainQuery( + "SELECT 'Settlement' AS Label, COUNT(1) AS ItemCount, SUM(c.amount) AS Total FROM c WHERE c.type = 'Settlement'", + new PartitionKey("pk1")); + + results.Should().ContainSingle(); + var result = results[0]; + result["Label"]!.Value().Should().Be("Settlement"); + result["ItemCount"]!.Value().Should().Be(2); + result["Total"]!.Value().Should().Be(300.0m); + } + + [Fact] + public async Task StringLiteralAlias_WithMultipleQueries_ShouldReturnDistinctLiterals() + { + var settlements = await DrainQuery( + "SELECT 'Settlement' AS Label, COUNT(1) AS ItemCount, SUM(c.amount) AS Total FROM c WHERE c.type = 'Settlement'", + new PartitionKey("pk1")); + + var refunds = await DrainQuery( + "SELECT 'Refund' AS Label, COUNT(1) AS ItemCount, SUM(c.amount) AS Total FROM c WHERE c.type = 'Refund'", + new PartitionKey("pk1")); + + settlements.Should().ContainSingle(); + settlements[0]["Label"]!.Value().Should().Be("Settlement"); + settlements[0]["ItemCount"]!.Value().Should().Be(2); + settlements[0]["Total"]!.Value().Should().Be(300.0m); + + refunds.Should().ContainSingle(); + refunds[0]["Label"]!.Value().Should().Be("Refund"); + refunds[0]["ItemCount"]!.Value().Should().Be(1); + refunds[0]["Total"]!.Value().Should().Be(50.0m); + } + + [Fact] + public async Task NumericLiteralAlias_InAggregateQuery_ShouldDeserializeCorrectly() + { + // Also test numeric and boolean literal aliases for completeness + var results = await DrainQuery( + "SELECT 42 AS MagicNumber, COUNT(1) AS ItemCount FROM c WHERE c.type = 'Settlement'", + new PartitionKey("pk1")); + + results.Should().ContainSingle(); + results[0]["MagicNumber"]!.Value().Should().Be(42); + results[0]["ItemCount"]!.Value().Should().Be(2); + } + + [Fact] + public async Task BooleanLiteralAlias_InAggregateQuery_ShouldDeserializeCorrectly() + { + var results = await DrainQuery( + "SELECT true AS IsActive, COUNT(1) AS ItemCount FROM c WHERE c.type = 'Settlement'", + new PartitionKey("pk1")); + + results.Should().ContainSingle(); + results[0]["IsActive"]!.Value().Should().BeTrue(); + results[0]["ItemCount"]!.Value().Should().Be(2); + } + + [Fact] + public async Task NullLiteralAlias_InAggregateQuery_ShouldDeserializeCorrectly() + { + var results = await DrainQuery( + "SELECT null AS Nothing, COUNT(1) AS ItemCount FROM c WHERE c.type = 'Settlement'", + new PartitionKey("pk1")); + + results.Should().ContainSingle(); + results[0]["ItemCount"]!.Value().Should().Be(2); + + // On Linux (no ServiceInterop), our ProjectAggregateFields correctly returns null. + // On Windows, ServiceInterop's pipeline may omit null literal fields from aggregates. + var nothingToken = results[0]["Nothing"]; + if (nothingToken is not null) + { + nothingToken.Type.Should().Be(JTokenType.Null); + } + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue70FilterPredicateMissingPropertyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue70FilterPredicateMissingPropertyTests.cs new file mode 100644 index 0000000..ba1ad5b --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue70FilterPredicateMissingPropertyTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Regression tests for GitHub Issue #70 — FilterPredicate does not treat missing +/// properties as null when NullValueHandling.Ignore is used. +/// +/// When a document is serialized with NullValueHandling.Ignore, null properties are +/// omitted entirely. Real Cosmos DB treats these missing properties as null during +/// FilterPredicate evaluation, so "FROM c WHERE c.prop = null" should match. +/// +/// Tagged InMemoryOnly because the Windows Cosmos DB Emulator (v2.14.0) does not +/// support FilterPredicate syntax — it returns 400 BadRequest (tracked: #53). +/// +/// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update#filter-predicate +/// "The filter predicate is evaluated against the existing state of the document." +/// Missing properties are semantically equivalent to null in filter predicate evaluation. +/// +public class Issue70FilterPredicateMissingPropertyTests : IAsyncLifetime +{ + private InMemoryCosmosResult _cosmos = null!; + private Container _container = null!; + + public ValueTask InitializeAsync() + { + _cosmos = InMemoryCosmos.Create("issue70", "/partitionKey", + configureOptions: opts => opts.Serializer = new CosmosJsonDotNetSerializer(new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + NullValueHandling = NullValueHandling.Ignore + })); + _container = _cosmos.Container; + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + _cosmos.Dispose(); + return ValueTask.CompletedTask; + } + + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task PatchWithFilterPredicate_MissingPropertyEqualsNull_Succeeds() + { + // linkedId is null → will NOT be serialized due to NullValueHandling.Ignore + var document = new + { + id = Guid.NewGuid().ToString(), + partitionKey = "pk-1", + linkedId = (string?)null, + name = "test" + }; + await _container.CreateItemAsync(document, new PartitionKey("pk-1")); + + // This FilterPredicate should match because the missing property should be treated as null + var patchOperations = new[] { PatchOperation.Set("/linkedId", Guid.NewGuid().ToString()) }; + var options = new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.linkedId = null" + }; + + var response = await _container.PatchItemAsync( + document.id, new PartitionKey("pk-1"), patchOperations, options); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task PatchWithFilterPredicate_MissingPropertyNotEqualNull_FailsPrecondition() + { + // linkedId is null → will NOT be serialized due to NullValueHandling.Ignore + var document = new + { + id = Guid.NewGuid().ToString(), + partitionKey = "pk-1", + linkedId = (string?)null, + name = "test" + }; + await _container.CreateItemAsync(document, new PartitionKey("pk-1")); + + // WHERE c.linkedId != null should NOT match when property is missing (treated as null) + var patchOperations = new[] { PatchOperation.Set("/name", "updated") }; + var options = new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.linkedId != null" + }; + + var act = () => _container.PatchItemAsync( + document.id, new PartitionKey("pk-1"), patchOperations, options); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task PatchWithFilterPredicate_ExplicitNullPropertyEqualsNull_Succeeds() + { + // Use stream to explicitly write null (bypassing NullValueHandling.Ignore) + var id = Guid.NewGuid().ToString(); + var json = $$"""{"id":"{{id}}","partitionKey":"pk-1","linkedId":null,"name":"test"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.CreateItemStreamAsync(stream, new PartitionKey("pk-1")); + + var patchOperations = new[] { PatchOperation.Set("/linkedId", "new-value") }; + var options = new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.linkedId = null" + }; + + var response = await _container.PatchItemAsync( + id, new PartitionKey("pk-1"), patchOperations, options); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task PatchWithFilterPredicate_MissingPropertyEqualsValue_FailsPrecondition() + { + // linkedId is null → will NOT be serialized due to NullValueHandling.Ignore + var document = new + { + id = Guid.NewGuid().ToString(), + partitionKey = "pk-1", + linkedId = (string?)null, + name = "test" + }; + await _container.CreateItemAsync(document, new PartitionKey("pk-1")); + + // WHERE c.linkedId = 'some-value' should NOT match — missing property treated as null ≠ 'some-value' + var patchOperations = new[] { PatchOperation.Set("/name", "updated") }; + var options = new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.linkedId = 'some-value'" + }; + + var act = () => _container.PatchItemAsync( + document.id, new PartitionKey("pk-1"), patchOperations, options); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } +} + +/// +/// A simple Newtonsoft.Json-based CosmosSerializer for test use. +/// The SDK's built-in one is internal, so we provide a minimal implementation. +/// +internal sealed class CosmosJsonDotNetSerializer : CosmosSerializer +{ + private readonly JsonSerializer _serializer; + + public CosmosJsonDotNetSerializer(JsonSerializerSettings settings) + { + _serializer = JsonSerializer.Create(settings); + } + + public override T FromStream(Stream stream) + { + using var sr = new StreamReader(stream); + using var jr = new JsonTextReader(sr); + return _serializer.Deserialize(jr)!; + } + + public override Stream ToStream(T input) + { + var ms = new MemoryStream(); + using (var sw = new StreamWriter(ms, leaveOpen: true)) + using (var jw = new JsonTextWriter(sw)) + { + _serializer.Serialize(jw, input); + jw.Flush(); + } + ms.Position = 0; + return ms; + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue75DecimalScaleTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue75DecimalScaleTests.cs new file mode 100644 index 0000000..9bf0332 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/Issue75DecimalScaleTests.cs @@ -0,0 +1,290 @@ +using AwesomeAssertions; +using CosmosDB.InMemoryEmulator.Tests.Infrastructure; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Regression tests for GitHub Issue #75 — Decimal scale not preserved on round-trip +/// and SUM aggregate returns platform-inconsistent decimal scales. +/// +/// Bug 1: When a document contains a whole-number decimal (e.g. 1500m), Newtonsoft.Json +/// serialises it as "1500.0" (EnsureDecimalPlace adds a decimal point). Real Cosmos DB +/// normalises this to the integer 1500 (JavaScript engine behaviour). The in-memory emulator +/// was leaving it as 1500.0, so round-tripped values had an unexpected .0 suffix. +/// +/// Bug 2: SUM aggregate over decimal amounts returned "750.0" on Linux and "750" on Windows +/// because the aggregate pipeline used double arithmetic and stored the result as JValue(double), +/// whose Newtonsoft.Json serialisation of a whole-number double is platform-dependent. +/// +/// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/aggregate-sum +/// "The SUM system function returns the sum of all the values in an expression." +/// Real Cosmos DB (JavaScript engine) normalises 1500.0 → 1500 and SUM(250, 500) → 750 +/// (integer representation, no .0 suffix). +/// +public class Issue75DecimalScaleTests : IAsyncLifetime +{ + private InMemoryCosmosResult _cosmos = null!; + private Container _container = null!; + + private static readonly JsonSerializerSettings NewtonsoftSettings = new() + { + FloatParseHandling = FloatParseHandling.Decimal + }; + + public ValueTask InitializeAsync() + { + // Use a Newtonsoft.Json-based serialiser so that decimal values are round-tripped + // through the same JSON pipeline as user code that relies on Newtonsoft.Json behaviour + // (EnsureDecimalPlace for whole-number decimals, trailing-zero preservation for + // fractional decimals). + _cosmos = InMemoryCosmos.Create("issue75", "/partitionKey", + configureOptions: opts => opts.Serializer = new Issue75NewtonsoftSerializer(NewtonsoftSettings)); + _container = _cosmos.Container; + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + _cosmos.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task> DrainQuery(string sql, PartitionKey? pk = null) + { + var opts = pk is not null ? new QueryRequestOptions { PartitionKey = pk } : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: opts); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Bug 1 — Whole-number decimal normalised to integer on round-trip + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Newtonsoft.Json serialises decimal 1500m as "1500.0" (EnsureDecimalPlace). + /// Real Cosmos DB (JavaScript) normalises "1500.0" → integer 1500 during storage. + /// The in-memory emulator must do the same so that read-back produces an integer + /// token, not a float-with-unnecessary-decimal-point. + /// + /// Ref: JavaScript: JSON.stringify(JSON.parse("1500.0")) === "1500" + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task WholeNumberDecimal_RoundTrip_StoredAsIntegerNotFloat() + { + var doc = new { id = "1", partitionKey = "pk1", amount = 1500m }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var readBack = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + + // Ref: Real Cosmos DB stores whole-number JSON floats as integers (JavaScript normalisation). + // "1500.0" must round-trip as integer 1500, not float 1500.0. + readBack.Resource["amount"]!.Type.Should().Be(JTokenType.Integer); + readBack.Resource["amount"]!.Value().Should().Be(1500L); + } + + /// + /// Zero stored as a decimal literal (0m) should also round-trip as the integer 0, + /// not as 0.0 (float with unnecessary decimal point). + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task ZeroDecimal_RoundTrip_StoredAsIntegerNotFloat() + { + var doc = new { id = "2", partitionKey = "pk1", amount = 0m }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var readBack = await _container.ReadItemAsync("2", new PartitionKey("pk1")); + + readBack.Resource["amount"]!.Type.Should().Be(JTokenType.Integer); + readBack.Resource["amount"]!.Value().Should().Be(0L); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Bug 2 — Fractional decimal trailing zeros preserved through round-trip + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Newtonsoft.Json serialises decimal 100.50m as "100.50". Real Cosmos DB's JavaScript + /// engine normalises this to "100.5" on storage (JavaScript strips trailing zeros from + /// fractional values). The in-memory emulator must match this behaviour. + /// + /// Ref: JavaScript: JSON.stringify(JSON.parse("100.50")) === "100.5" + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task FractionalDecimal_RoundTrip_TrailingZeroIsStripped() + { + var doc = new { id = "3", partitionKey = "pk1", amount = 100.50m }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var readBack = await _container.ReadItemAsync("3", new PartitionKey("pk1")); + + // Ref: Real Cosmos DB (JavaScript engine) strips trailing zeros from fractional values. + // "100.50" must round-trip as "100.5", not "100.50". + readBack.Resource["amount"]!.ToString().Should().Be("100.5"); + } + + /// + /// A fractional decimal with non-trivially significant trailing zeros (25.00m) should + /// round-trip with both decimal places preserved. + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task FractionalDecimalWithTwoTrailingZeros_RoundTrip_PreservesScale() + { + var doc = new { id = "4", partitionKey = "pk1", amount = 25.00m }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var readBack = await _container.ReadItemAsync("4", new PartitionKey("pk1")); + + // 25.00m serialised as "25.0" by Newtonsoft.Json (EnsureDecimalPlace: "25" → "25.0"). + // After fix: stored as integer 25 (whole-number normalisation), not "25.0". + readBack.Resource["amount"]!.Type.Should().Be(JTokenType.Integer); + readBack.Resource["amount"]!.Value().Should().Be(25L); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Bug 3 — SUM aggregate returns consistent integer for whole-number results + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// SUM(250, 500) = 750. When the aggregate result is a whole number, it must be returned + /// as the integer 750, not as float 750.0. The previous implementation stored the result as + /// JValue(double 750.0), whose Newtonsoft.Json serialisation is platform-dependent + /// ("750.0" on Linux, "750" on Windows), causing test flakiness across CI environments. + /// + /// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/aggregate-sum + /// Real Cosmos DB returns SUM(250, 500) as the integer 750 (JavaScript normalisation). + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task SumAggregate_WhenResultIsWholeNumber_ReturnsIntegerType() + { + await _container.CreateItemAsync( + new { id = "s1", partitionKey = "pk2", amount = 250m }, new PartitionKey("pk2")); + await _container.CreateItemAsync( + new { id = "s2", partitionKey = "pk2", amount = 500m }, new PartitionKey("pk2")); + + var results = await DrainQuery( + "SELECT VALUE SUM(c.amount) FROM c", new PartitionKey("pk2")); + + results.Should().ContainSingle(); + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/aggregate-sum + // Whole-number SUM results must be integers, not floats. + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(750L); + } + + /// + /// SUM in a projection (SELECT SUM(c.amount) AS Total) must also return an integer + /// when the result is a whole number. + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task SumAggregate_InProjection_WhenResultIsWholeNumber_ReturnsIntegerType() + { + await _container.CreateItemAsync( + new { id = "s3", partitionKey = "pk3", amount = 250m }, new PartitionKey("pk3")); + await _container.CreateItemAsync( + new { id = "s4", partitionKey = "pk3", amount = 500m }, new PartitionKey("pk3")); + + var results = await DrainQuery( + "SELECT SUM(c.amount) AS Total FROM c", new PartitionKey("pk3")); + + results.Should().ContainSingle(); + results[0]["Total"]!.Type.Should().Be(JTokenType.Integer); + results[0]["Total"]!.Value().Should().Be(750L); + } + + /// + /// AVG aggregate where the result is a whole number should also return an integer type. + /// AVG(500, 1000) = 750. + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task AvgAggregate_WhenResultIsWholeNumber_ReturnsIntegerType() + { + await _container.CreateItemAsync( + new { id = "a1", partitionKey = "pk4", amount = 500m }, new PartitionKey("pk4")); + await _container.CreateItemAsync( + new { id = "a2", partitionKey = "pk4", amount = 1000m }, new PartitionKey("pk4")); + + var results = await DrainQuery( + "SELECT VALUE AVG(c.amount) FROM c", new PartitionKey("pk4")); + + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(750L); + } + + /// + /// SUM aggregate where the result is a fractional number must remain a float (not be + /// erroneously truncated to an integer). SUM(250.5, 499.5) = 750.0 — but this is a + /// whole number so it should still be integer 750. + /// SUM(250.3, 499.7) = 750.0 — same. + /// SUM(250.3, 499.8) = 750.1 — must remain a float. + /// + [Trait(TestTraits.Target, TestTraits.InMemoryOnly)] + [Fact] + public async Task SumAggregate_WhenResultIsFractional_RemainsFloat() + { + await _container.CreateItemAsync( + new { id = "f1", partitionKey = "pk5", amount = 250.3m }, new PartitionKey("pk5")); + await _container.CreateItemAsync( + new { id = "f2", partitionKey = "pk5", amount = 499.8m }, new PartitionKey("pk5")); + + var results = await DrainQuery( + "SELECT VALUE SUM(c.amount) FROM c", new PartitionKey("pk5")); + + results.Should().ContainSingle(); + // 250.3 + 499.8 = 750.1 — fractional, must NOT be truncated to integer + results[0].Type.Should().Be(JTokenType.Float); + results[0].Value().Should().Be(750.1m); + } +} + +/// +/// Newtonsoft.Json-based CosmosSerializer for Issue 75 repro tests. +/// Uses FloatParseHandling.Decimal on deserialisation so that fractional +/// decimal values round-trip with their trailing zeros preserved. +/// +internal sealed class Issue75NewtonsoftSerializer : CosmosSerializer +{ + private readonly JsonSerializer _serializer; + + public Issue75NewtonsoftSerializer(JsonSerializerSettings settings) + { + _serializer = JsonSerializer.Create(settings); + } + + public override T FromStream(Stream stream) + { + using var sr = new StreamReader(stream); + using var jr = new JsonTextReader(sr) { FloatParseHandling = FloatParseHandling.Decimal }; + return _serializer.Deserialize(jr)!; + } + + public override Stream ToStream(T input) + { + var ms = new MemoryStream(); + using (var sw = new StreamWriter(ms, leaveOpen: true)) + using (var jw = new JsonTextWriter(sw)) + { + _serializer.Serialize(jw, input); + jw.Flush(); + } + ms.Position = 0; + return ms; + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/EmulatorLoadTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/EmulatorLoadTests.cs index 85a16fb..062f469 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/EmulatorLoadTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/EmulatorLoadTests.cs @@ -11,557 +11,557 @@ namespace CosmosDB.InMemoryEmulator.Tests.Performance; public class EmulatorLoadTests(ITestOutputHelper output) : IAsyncLifetime { - private const string EmulatorEndpoint = "https://localhost:8081"; - - private const string EmulatorKey = - "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - - private const string DatabaseName = "perf-test-db"; - private const string ContainerName = "perf-test-container"; - - private static readonly int _targetCallsPerSecond = - int.TryParse(Environment.GetEnvironmentVariable("EMULATOR_LOAD_TEST_RPS"), out var rps) ? rps : 50; - - private static readonly int _durationSeconds = - int.TryParse(Environment.GetEnvironmentVariable("EMULATOR_LOAD_TEST_DURATION_SECONDS"), out var dur) - ? dur - : 30; - - private static readonly int _totalOperations = _targetCallsPerSecond * _durationSeconds; - - private CosmosClient _client = null!; - private Container _container = null!; - - public async ValueTask InitializeAsync() - { - _client = new CosmosClient(EmulatorEndpoint, EmulatorKey, new CosmosClientOptions - { - HttpClientFactory = () => - { - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }; - return new HttpClient(handler); - }, - ConnectionMode = ConnectionMode.Gateway - }); - - var databaseResponse = await _client.CreateDatabaseIfNotExistsAsync(DatabaseName); - var containerResponse = await databaseResponse.Database.CreateContainerIfNotExistsAsync( - ContainerName, "/partitionKey"); - _container = containerResponse.Container; - } - - public async ValueTask DisposeAsync() - { - try - { - await _client.GetDatabase(DatabaseName).DeleteAsync(); - } - catch - { - // best-effort cleanup - } - - _client.Dispose(); - } - - [Fact] - public async Task ReadHeavyLoad_80PercentReads_20PercentWrites() - { - var stats = new LoadStats(); - - await SeedDocuments(count: 1000); - - await RunLoad(stats, readWeight: 80, writeWeight: 20, seedCount: 1000); - - ReportAndAssert(stats, "Emulator Read-Heavy (80/20)"); - } - - [Fact] - public async Task WriteHeavyLoad_20PercentReads_80PercentWrites() - { - var stats = new LoadStats(); - - await SeedDocuments(count: 200); - - await RunLoad(stats, readWeight: 20, writeWeight: 80, seedCount: 200); - - ReportAndAssert(stats, "Emulator Write-Heavy (20/80)"); - } - - [Fact] - public async Task EvenMixLoad_50PercentReads_50PercentWrites() - { - var stats = new LoadStats(); - - await SeedDocuments(count: 500); - - await RunLoad(stats, readWeight: 50, writeWeight: 50, seedCount: 500); - - ReportAndAssert(stats, "Emulator Even Mix (50/50)"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Load runner - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task RunLoad( - LoadStats stats, - int readWeight, - int writeWeight, - int seedCount) - { - var knownIds = new ConcurrentDictionary(); - - foreach (var id in Enumerable.Range(1, seedCount)) - { - knownIds.TryAdd(id.ToString(), $"pk-{id % 20}"); - } - - var nextId = new AtomicCounter(10_000); - var maxConcurrency = Math.Max(_targetCallsPerSecond * 2, 100); - var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - var tasks = new List(_totalOperations); - var overallStopwatch = Stopwatch.StartNew(); - var intervalMs = 1000.0 / _targetCallsPerSecond; - - for (var operationIndex = 0; operationIndex < _totalOperations; operationIndex++) - { - var targetTime = TimeSpan.FromMilliseconds(operationIndex * intervalMs); - var elapsed = overallStopwatch.Elapsed; - if (targetTime > elapsed) - { - await Task.Delay(targetTime - elapsed); - } - - await semaphore.WaitAsync(); - - var isRead = Random.Shared.Next(100) < readWeight; - var capturedIndex = operationIndex; - - tasks.Add(Task.Run(async () => - { - var operationStopwatch = Stopwatch.StartNew(); - try - { - if (isRead) - { - await ExecuteReadOperation(knownIds, stats); - } - else - { - await ExecuteWriteOperation(knownIds, stats, nextId); - } - - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - } - // Note: 404s from replace/patch racing with concurrent delete are expected and counted - // here rather than under the specific operation stat. totalOps still includes NotFound, - // so the assertion in ReportAndAssert remains correct. - catch (CosmosException cosmosException) - when (cosmosException.StatusCode == HttpStatusCode.NotFound) - { - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - Interlocked.Increment(ref stats.NotFound); - } - catch (Exception exception) - { - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - Interlocked.Increment(ref stats.Errors); - stats.ErrorMessages.Enqueue( - $"Op {capturedIndex}: {exception.GetType().Name}: {exception.Message}"); - } - finally - { - semaphore.Release(); - } - })); - } - - stats.SchedulingTime = overallStopwatch.Elapsed; - - await Task.WhenAll(tasks); - - overallStopwatch.Stop(); - stats.WallClockTime = overallStopwatch.Elapsed; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Read operations (with verification) - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task ExecuteReadOperation( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var readType = Random.Shared.Next(4); - - switch (readType) - { - case 0: - await ReadAndVerifySingle(knownIds, stats); - break; - case 1: - await ReadAndVerifyQuery(stats); - break; - case 2: - await ReadAndVerifyQueryWithFilter(knownIds, stats); - break; - case 3: - await ReadAndVerifyCrossPartitionQuery(stats); - break; - } - } - - private async Task ReadAndVerifySingle( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Reads); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Reads); - return; - } - - var response = await _container.ReadItemAsync(targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be(targetId); - response.Resource.PartitionKey.Should().Be(partitionKey); - Interlocked.Increment(ref stats.Reads); - } - - private async Task ReadAndVerifyCrossPartitionQuery(LoadStats stats) - { - var threshold = Random.Shared.Next(500); - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") - .WithParameter("@threshold", threshold)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().OnlyContain(d => d.Counter > threshold); - Interlocked.Increment(ref stats.Reads); - } - - private async Task ReadAndVerifyQuery(LoadStats stats) - { - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.counter DESC")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCountLessThanOrEqualTo(10); - - for (var resultIndex = 1; resultIndex < results.Count; resultIndex++) - { - results[resultIndex].Counter.Should().BeLessThanOrEqualTo(results[resultIndex - 1].Counter); - } - - Interlocked.Increment(ref stats.Reads); - } - - private async Task ReadAndVerifyQueryWithFilter( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Reads); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", targetId)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - if (results.Count == 1) - { - results[0].Id.Should().Be(targetId); - } - - Interlocked.Increment(ref stats.Reads); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Write operations (mixed) - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task ExecuteWriteOperation( - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var writeType = Random.Shared.Next(5); - - switch (writeType) - { - case 0: - await WriteCreate(knownIds, stats, nextId); - break; - case 1: - await WriteUpsert(knownIds, stats, nextId); - break; - case 2: - await WriteReplace(knownIds, stats); - break; - case 3: - await WritePatch(knownIds, stats); - break; - case 4: - await WriteDelete(knownIds, stats); - break; - } - } - - private async Task WriteCreate( - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var id = nextId.Increment().ToString(); - var partitionKey = $"pk-{id}"; - var document = CreateDocument(id, partitionKey); - - var response = await _container.CreateItemAsync(document, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - knownIds.TryAdd(id, partitionKey); - Interlocked.Increment(ref stats.Creates); - } - - private async Task WriteUpsert( - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var updateExisting = Random.Shared.Next(2) == 0; - - if (updateExisting) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length > 0) - { - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Upserts); - return; - } - - var document = CreateDocument(targetId, partitionKey); - - var response = await _container.UpsertItemAsync(document, new PartitionKey(partitionKey)); - - // Concurrent delete can remove the item between TryGetValue and UpsertItemAsync, - // causing Cosmos to create fresh (201) instead of update (200) - response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); - knownIds.TryAdd(targetId, partitionKey); - Interlocked.Increment(ref stats.Upserts); - return; - } - } - - var id = nextId.Increment().ToString(); - var newPartitionKey = $"pk-{id}"; - var newDocument = CreateDocument(id, newPartitionKey); - - var createResponse = await _container.UpsertItemAsync(newDocument, new PartitionKey(newPartitionKey)); - - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - knownIds.TryAdd(id, newPartitionKey); - Interlocked.Increment(ref stats.Upserts); - } - - private async Task WriteReplace( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Replaces); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Replaces); - return; - } - - var document = CreateDocument(targetId, partitionKey); - - var response = await _container.ReplaceItemAsync(document, targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - Interlocked.Increment(ref stats.Replaces); - } - - private async Task WritePatch( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Patches); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var patchPartitionKey)) - { - Interlocked.Increment(ref stats.Patches); - return; - } - - var patchOperations = new List - { - PatchOperation.Increment("/counter", 1), - PatchOperation.Set("/data", $"patched-{Guid.NewGuid():N}") - }; - - var response = await _container.PatchItemAsync( - targetId, new PartitionKey(patchPartitionKey), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - Interlocked.Increment(ref stats.Patches); - } - - private async Task WriteDelete( - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Deletes); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryRemove(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Deletes); - return; - } - - var response = await _container.DeleteItemAsync( - targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - Interlocked.Increment(ref stats.Deletes); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Seeding - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task SeedDocuments(int count) - { - for (var seedIndex = 1; seedIndex <= count; seedIndex++) - { - var id = seedIndex.ToString(); - var partitionKey = $"pk-{seedIndex % 20}"; - var document = CreateDocument(id, partitionKey); - await _container.CreateItemAsync(document, new PartitionKey(partitionKey)); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static LoadDocument CreateDocument(string id, string partitionKey) => new() - { - Id = id, - PartitionKey = partitionKey, - Counter = Random.Shared.Next(1000), - Data = $"data-{Guid.NewGuid():N}", - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }; - - private void ReportAndAssert(LoadStats stats, string scenarioName) - { - var totalOps = stats.Reads + stats.Creates + stats.Upserts - + stats.Replaces + stats.Patches + stats.Deletes - + stats.NotFound + stats.Errors; - - var opsPerSecond = totalOps / stats.SchedulingTime.TotalSeconds; - - output.WriteLine($"║ Config: {_targetCallsPerSecond} rps × {_durationSeconds}s = {_totalOperations:N0} ops"); - output.WriteLine($"╔══════════════════════════════════════════════════╗"); - output.WriteLine($"║ {scenarioName,-46} ║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Scheduling time : {stats.SchedulingTime.TotalSeconds,8:F1}s{"",-17}║"); - output.WriteLine($"║ Wall clock : {stats.WallClockTime.TotalSeconds,8:F1}s{"",-17}║"); - output.WriteLine($"║ Total operations : {totalOps,8:N0}{"",-18}║"); - output.WriteLine($"║ Throughput : {opsPerSecond,8:F1} ops/s{"",-12}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Reads : {stats.Reads,8:N0}{"",-18}║"); - output.WriteLine($"║ Creates : {stats.Creates,8:N0}{"",-18}║"); - output.WriteLine($"║ Upserts : {stats.Upserts,8:N0}{"",-18}║"); - output.WriteLine($"║ Replaces : {stats.Replaces,8:N0}{"",-18}║"); - output.WriteLine($"║ Patches : {stats.Patches,8:N0}{"",-18}║"); - output.WriteLine($"║ Deletes : {stats.Deletes,8:N0}{"",-18}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Expected 404s : {stats.NotFound,8:N0}{"",-18}║"); - output.WriteLine($"║ Unexpected errors : {stats.Errors,8:N0}{"",-18}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Mean latency : {stats.MeanLatency,8:F3}ms{"",-15}║"); - output.WriteLine($"║ P50 latency : {stats.GetPercentile(50),8:F3}ms{"",-15}║"); - output.WriteLine($"║ P95 latency : {stats.GetPercentile(95),8:F3}ms{"",-15}║"); - output.WriteLine($"║ P99 latency : {stats.GetPercentile(99),8:F3}ms{"",-15}║"); - output.WriteLine($"║ Max latency : {stats.GetPercentile(100),8:F3}ms{"",-15}║"); - output.WriteLine($"╚══════════════════════════════════════════════════╝"); - - foreach (var error in stats.ErrorMessages.Take(10)) - { - output.WriteLine($" ERROR: {error}"); - } - - totalOps.Should().Be(_totalOperations, - $"all {_totalOperations:N0} operations should complete"); - - stats.Errors.Should().Be(0, - "no unexpected errors should occur under sustained load"); - - stats.GetPercentile(99).Should().BeLessThan(5000, - "P99 latency should stay under 5000ms even for the real emulator to catch hangs"); - } + private const string EmulatorEndpoint = "https://localhost:8081"; + + private const string EmulatorKey = + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + private const string DatabaseName = "perf-test-db"; + private const string ContainerName = "perf-test-container"; + + private static readonly int _targetCallsPerSecond = + int.TryParse(Environment.GetEnvironmentVariable("EMULATOR_LOAD_TEST_RPS"), out var rps) ? rps : 50; + + private static readonly int _durationSeconds = + int.TryParse(Environment.GetEnvironmentVariable("EMULATOR_LOAD_TEST_DURATION_SECONDS"), out var dur) + ? dur + : 30; + + private static readonly int _totalOperations = _targetCallsPerSecond * _durationSeconds; + + private CosmosClient _client = null!; + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _client = new CosmosClient(EmulatorEndpoint, EmulatorKey, new CosmosClientOptions + { + HttpClientFactory = () => + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + return new HttpClient(handler); + }, + ConnectionMode = ConnectionMode.Gateway + }); + + var databaseResponse = await _client.CreateDatabaseIfNotExistsAsync(DatabaseName); + var containerResponse = await databaseResponse.Database.CreateContainerIfNotExistsAsync( + ContainerName, "/partitionKey"); + _container = containerResponse.Container; + } + + public async ValueTask DisposeAsync() + { + try + { + await _client.GetDatabase(DatabaseName).DeleteAsync(); + } + catch + { + // best-effort cleanup + } + + _client.Dispose(); + } + + [Fact] + public async Task ReadHeavyLoad_80PercentReads_20PercentWrites() + { + var stats = new LoadStats(); + + await SeedDocuments(count: 1000); + + await RunLoad(stats, readWeight: 80, writeWeight: 20, seedCount: 1000); + + ReportAndAssert(stats, "Emulator Read-Heavy (80/20)"); + } + + [Fact] + public async Task WriteHeavyLoad_20PercentReads_80PercentWrites() + { + var stats = new LoadStats(); + + await SeedDocuments(count: 200); + + await RunLoad(stats, readWeight: 20, writeWeight: 80, seedCount: 200); + + ReportAndAssert(stats, "Emulator Write-Heavy (20/80)"); + } + + [Fact] + public async Task EvenMixLoad_50PercentReads_50PercentWrites() + { + var stats = new LoadStats(); + + await SeedDocuments(count: 500); + + await RunLoad(stats, readWeight: 50, writeWeight: 50, seedCount: 500); + + ReportAndAssert(stats, "Emulator Even Mix (50/50)"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Load runner + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task RunLoad( + LoadStats stats, + int readWeight, + int writeWeight, + int seedCount) + { + var knownIds = new ConcurrentDictionary(); + + foreach (var id in Enumerable.Range(1, seedCount)) + { + knownIds.TryAdd(id.ToString(), $"pk-{id % 20}"); + } + + var nextId = new AtomicCounter(10_000); + var maxConcurrency = Math.Max(_targetCallsPerSecond * 2, 100); + var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); + var tasks = new List(_totalOperations); + var overallStopwatch = Stopwatch.StartNew(); + var intervalMs = 1000.0 / _targetCallsPerSecond; + + for (var operationIndex = 0; operationIndex < _totalOperations; operationIndex++) + { + var targetTime = TimeSpan.FromMilliseconds(operationIndex * intervalMs); + var elapsed = overallStopwatch.Elapsed; + if (targetTime > elapsed) + { + await Task.Delay(targetTime - elapsed); + } + + await semaphore.WaitAsync(); + + var isRead = Random.Shared.Next(100) < readWeight; + var capturedIndex = operationIndex; + + tasks.Add(Task.Run(async () => + { + var operationStopwatch = Stopwatch.StartNew(); + try + { + if (isRead) + { + await ExecuteReadOperation(knownIds, stats); + } + else + { + await ExecuteWriteOperation(knownIds, stats, nextId); + } + + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + } + // Note: 404s from replace/patch racing with concurrent delete are expected and counted + // here rather than under the specific operation stat. totalOps still includes NotFound, + // so the assertion in ReportAndAssert remains correct. + catch (CosmosException cosmosException) + when (cosmosException.StatusCode == HttpStatusCode.NotFound) + { + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + Interlocked.Increment(ref stats.NotFound); + } + catch (Exception exception) + { + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + Interlocked.Increment(ref stats.Errors); + stats.ErrorMessages.Enqueue( + $"Op {capturedIndex}: {exception.GetType().Name}: {exception.Message}"); + } + finally + { + semaphore.Release(); + } + })); + } + + stats.SchedulingTime = overallStopwatch.Elapsed; + + await Task.WhenAll(tasks); + + overallStopwatch.Stop(); + stats.WallClockTime = overallStopwatch.Elapsed; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Read operations (with verification) + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task ExecuteReadOperation( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var readType = Random.Shared.Next(4); + + switch (readType) + { + case 0: + await ReadAndVerifySingle(knownIds, stats); + break; + case 1: + await ReadAndVerifyQuery(stats); + break; + case 2: + await ReadAndVerifyQueryWithFilter(knownIds, stats); + break; + case 3: + await ReadAndVerifyCrossPartitionQuery(stats); + break; + } + } + + private async Task ReadAndVerifySingle( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Reads); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Reads); + return; + } + + var response = await _container.ReadItemAsync(targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be(targetId); + response.Resource.PartitionKey.Should().Be(partitionKey); + Interlocked.Increment(ref stats.Reads); + } + + private async Task ReadAndVerifyCrossPartitionQuery(LoadStats stats) + { + var threshold = Random.Shared.Next(500); + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") + .WithParameter("@threshold", threshold)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().OnlyContain(d => d.Counter > threshold); + Interlocked.Increment(ref stats.Reads); + } + + private async Task ReadAndVerifyQuery(LoadStats stats) + { + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.counter DESC")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCountLessThanOrEqualTo(10); + + for (var resultIndex = 1; resultIndex < results.Count; resultIndex++) + { + results[resultIndex].Counter.Should().BeLessThanOrEqualTo(results[resultIndex - 1].Counter); + } + + Interlocked.Increment(ref stats.Reads); + } + + private async Task ReadAndVerifyQueryWithFilter( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Reads); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", targetId)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + if (results.Count == 1) + { + results[0].Id.Should().Be(targetId); + } + + Interlocked.Increment(ref stats.Reads); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Write operations (mixed) + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task ExecuteWriteOperation( + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var writeType = Random.Shared.Next(5); + + switch (writeType) + { + case 0: + await WriteCreate(knownIds, stats, nextId); + break; + case 1: + await WriteUpsert(knownIds, stats, nextId); + break; + case 2: + await WriteReplace(knownIds, stats); + break; + case 3: + await WritePatch(knownIds, stats); + break; + case 4: + await WriteDelete(knownIds, stats); + break; + } + } + + private async Task WriteCreate( + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var id = nextId.Increment().ToString(); + var partitionKey = $"pk-{id}"; + var document = CreateDocument(id, partitionKey); + + var response = await _container.CreateItemAsync(document, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + knownIds.TryAdd(id, partitionKey); + Interlocked.Increment(ref stats.Creates); + } + + private async Task WriteUpsert( + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var updateExisting = Random.Shared.Next(2) == 0; + + if (updateExisting) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length > 0) + { + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Upserts); + return; + } + + var document = CreateDocument(targetId, partitionKey); + + var response = await _container.UpsertItemAsync(document, new PartitionKey(partitionKey)); + + // Concurrent delete can remove the item between TryGetValue and UpsertItemAsync, + // causing Cosmos to create fresh (201) instead of update (200) + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); + knownIds.TryAdd(targetId, partitionKey); + Interlocked.Increment(ref stats.Upserts); + return; + } + } + + var id = nextId.Increment().ToString(); + var newPartitionKey = $"pk-{id}"; + var newDocument = CreateDocument(id, newPartitionKey); + + var createResponse = await _container.UpsertItemAsync(newDocument, new PartitionKey(newPartitionKey)); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + knownIds.TryAdd(id, newPartitionKey); + Interlocked.Increment(ref stats.Upserts); + } + + private async Task WriteReplace( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Replaces); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Replaces); + return; + } + + var document = CreateDocument(targetId, partitionKey); + + var response = await _container.ReplaceItemAsync(document, targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + Interlocked.Increment(ref stats.Replaces); + } + + private async Task WritePatch( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Patches); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var patchPartitionKey)) + { + Interlocked.Increment(ref stats.Patches); + return; + } + + var patchOperations = new List + { + PatchOperation.Increment("/counter", 1), + PatchOperation.Set("/data", $"patched-{Guid.NewGuid():N}") + }; + + var response = await _container.PatchItemAsync( + targetId, new PartitionKey(patchPartitionKey), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + Interlocked.Increment(ref stats.Patches); + } + + private async Task WriteDelete( + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Deletes); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryRemove(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Deletes); + return; + } + + var response = await _container.DeleteItemAsync( + targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Interlocked.Increment(ref stats.Deletes); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Seeding + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task SeedDocuments(int count) + { + for (var seedIndex = 1; seedIndex <= count; seedIndex++) + { + var id = seedIndex.ToString(); + var partitionKey = $"pk-{seedIndex % 20}"; + var document = CreateDocument(id, partitionKey); + await _container.CreateItemAsync(document, new PartitionKey(partitionKey)); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static LoadDocument CreateDocument(string id, string partitionKey) => new() + { + Id = id, + PartitionKey = partitionKey, + Counter = Random.Shared.Next(1000), + Data = $"data-{Guid.NewGuid():N}", + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }; + + private void ReportAndAssert(LoadStats stats, string scenarioName) + { + var totalOps = stats.Reads + stats.Creates + stats.Upserts + + stats.Replaces + stats.Patches + stats.Deletes + + stats.NotFound + stats.Errors; + + var opsPerSecond = totalOps / stats.SchedulingTime.TotalSeconds; + + output.WriteLine($"║ Config: {_targetCallsPerSecond} rps × {_durationSeconds}s = {_totalOperations:N0} ops"); + output.WriteLine($"╔══════════════════════════════════════════════════╗"); + output.WriteLine($"║ {scenarioName,-46} ║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Scheduling time : {stats.SchedulingTime.TotalSeconds,8:F1}s{"",-17}║"); + output.WriteLine($"║ Wall clock : {stats.WallClockTime.TotalSeconds,8:F1}s{"",-17}║"); + output.WriteLine($"║ Total operations : {totalOps,8:N0}{"",-18}║"); + output.WriteLine($"║ Throughput : {opsPerSecond,8:F1} ops/s{"",-12}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Reads : {stats.Reads,8:N0}{"",-18}║"); + output.WriteLine($"║ Creates : {stats.Creates,8:N0}{"",-18}║"); + output.WriteLine($"║ Upserts : {stats.Upserts,8:N0}{"",-18}║"); + output.WriteLine($"║ Replaces : {stats.Replaces,8:N0}{"",-18}║"); + output.WriteLine($"║ Patches : {stats.Patches,8:N0}{"",-18}║"); + output.WriteLine($"║ Deletes : {stats.Deletes,8:N0}{"",-18}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Expected 404s : {stats.NotFound,8:N0}{"",-18}║"); + output.WriteLine($"║ Unexpected errors : {stats.Errors,8:N0}{"",-18}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Mean latency : {stats.MeanLatency,8:F3}ms{"",-15}║"); + output.WriteLine($"║ P50 latency : {stats.GetPercentile(50),8:F3}ms{"",-15}║"); + output.WriteLine($"║ P95 latency : {stats.GetPercentile(95),8:F3}ms{"",-15}║"); + output.WriteLine($"║ P99 latency : {stats.GetPercentile(99),8:F3}ms{"",-15}║"); + output.WriteLine($"║ Max latency : {stats.GetPercentile(100),8:F3}ms{"",-15}║"); + output.WriteLine($"╚══════════════════════════════════════════════════╝"); + + foreach (var error in stats.ErrorMessages.Take(10)) + { + output.WriteLine($" ERROR: {error}"); + } + + totalOps.Should().Be(_totalOperations, + $"all {_totalOperations:N0} operations should complete"); + + stats.Errors.Should().Be(0, + "no unexpected errors should occur under sustained load"); + + stats.GetPercentile(99).Should().BeLessThan(5000, + "P99 latency should stay under 5000ms even for the real emulator to catch hangs"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTests.cs index 812803f..2493722 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTests.cs @@ -10,602 +10,602 @@ namespace CosmosDB.InMemoryEmulator.Tests.Performance; public class LoadTests(ITestOutputHelper output) { - private static readonly int _targetCallsPerSecond = - int.TryParse(Environment.GetEnvironmentVariable("LOAD_TEST_RPS"), out var rps) ? rps : 500; - - private static readonly int _durationSeconds = - int.TryParse(Environment.GetEnvironmentVariable("LOAD_TEST_DURATION_SECONDS"), out var dur) ? dur : 60; - - private static readonly int _totalOperations = _targetCallsPerSecond * _durationSeconds; - - [Fact] - public async Task ReadHeavyLoad_80PercentReads_20PercentWrites() - { - var container = new InMemoryContainer("read-heavy", "/partitionKey"); - var stats = new LoadStats(); - - await SeedDocuments(container, count: 1000); - - await RunLoad(container, stats, readWeight: 80, writeWeight: 20, seedCount: 1000); - - ReportAndAssert(stats, "Read-Heavy (80/20)"); - } - - [Fact] - public async Task WriteHeavyLoad_20PercentReads_80PercentWrites() - { - var container = new InMemoryContainer("write-heavy", "/partitionKey"); - var stats = new LoadStats(); - - await SeedDocuments(container, count: 200); - - await RunLoad(container, stats, readWeight: 20, writeWeight: 80, seedCount: 200); - - ReportAndAssert(stats, "Write-Heavy (20/80)"); - } - - [Fact] - public async Task EvenMixLoad_50PercentReads_50PercentWrites() - { - var container = new InMemoryContainer("even-mix", "/partitionKey"); - var stats = new LoadStats(); - - await SeedDocuments(container, count: 500); - - await RunLoad(container, stats, readWeight: 50, writeWeight: 50, seedCount: 500); - - ReportAndAssert(stats, "Even Mix (50/50)"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Load runner - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task RunLoad( - InMemoryContainer container, - LoadStats stats, - int readWeight, - int writeWeight, - int seedCount) - { - var knownIds = new ConcurrentDictionary(); - - foreach (var id in Enumerable.Range(1, seedCount)) - { - knownIds.TryAdd(id.ToString(), $"pk-{id % 20}"); - } - - var nextId = new AtomicCounter(10_000); - var maxConcurrency = Math.Max(_targetCallsPerSecond * 2, 200); - var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - var tasks = new List(_totalOperations); - var overallStopwatch = Stopwatch.StartNew(); - var intervalMs = 1000.0 / _targetCallsPerSecond; - - for (var operationIndex = 0; operationIndex < _totalOperations; operationIndex++) - { - var targetTime = TimeSpan.FromMilliseconds(operationIndex * intervalMs); - var elapsed = overallStopwatch.Elapsed; - if (targetTime > elapsed) - { - await Task.Delay(targetTime - elapsed); - } - - await semaphore.WaitAsync(); - - var isRead = Random.Shared.Next(100) < readWeight; - var capturedIndex = operationIndex; - - tasks.Add(Task.Run(async () => - { - var operationStopwatch = Stopwatch.StartNew(); - try - { - if (isRead) - { - await ExecuteReadOperation(container, knownIds, stats); - } - else - { - await ExecuteWriteOperation(container, knownIds, stats, nextId); - } - - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - } - // Note: 404s from replace/patch racing with concurrent delete are expected and counted - // here rather than under the specific operation stat. totalOps still includes NotFound, - // so the assertion in ReportAndAssert remains correct. - catch (CosmosException cosmosException) when (cosmosException.StatusCode == HttpStatusCode.NotFound) - { - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - Interlocked.Increment(ref stats.NotFound); - } - catch (Exception exception) - { - operationStopwatch.Stop(); - stats.RecordLatency(operationStopwatch.Elapsed); - Interlocked.Increment(ref stats.Errors); - stats.ErrorMessages.Enqueue($"Op {capturedIndex}: {exception.GetType().Name}: {exception.Message}"); - } - finally - { - semaphore.Release(); - } - })); - } - - stats.SchedulingTime = overallStopwatch.Elapsed; - - await Task.WhenAll(tasks); - - overallStopwatch.Stop(); - stats.WallClockTime = overallStopwatch.Elapsed; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Read operations (with verification) - // ═══════════════════════════════════════════════════════════════════════════ - - private static async Task ExecuteReadOperation( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var readType = Random.Shared.Next(4); - - switch (readType) - { - case 0: - await ReadAndVerifySingle(container, knownIds, stats); - break; - case 1: - await ReadAndVerifyQuery(container, stats); - break; - case 2: - await ReadAndVerifyQueryWithFilter(container, knownIds, stats); - break; - case 3: - await ReadAndVerifyCrossPartitionQuery(container, stats); - break; - } - } - - private static async Task ReadAndVerifySingle( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var response = await container.ReadItemAsync(targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be(targetId); - response.Resource.PartitionKey.Should().Be(partitionKey); - Interlocked.Increment(ref stats.Reads); - } - - private static async Task ReadAndVerifyCrossPartitionQuery( - InMemoryContainer container, - LoadStats stats) - { - var threshold = Random.Shared.Next(500); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") - .WithParameter("@threshold", threshold)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().OnlyContain(d => d.Counter > threshold); - Interlocked.Increment(ref stats.Reads); - } - - private static async Task ReadAndVerifyQuery( - InMemoryContainer container, - LoadStats stats) - { - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.counter DESC")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCountLessThanOrEqualTo(10); - - for (var resultIndex = 1; resultIndex < results.Count; resultIndex++) - { - results[resultIndex].Counter.Should().BeLessThanOrEqualTo(results[resultIndex - 1].Counter); - } - - Interlocked.Increment(ref stats.Reads); - } - - private static async Task ReadAndVerifyQueryWithFilter( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", targetId)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - if (results.Count == 1) - { - results[0].Id.Should().Be(targetId); - } - - Interlocked.Increment(ref stats.Reads); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Write operations (mixed) - // ═══════════════════════════════════════════════════════════════════════════ - - private static async Task ExecuteWriteOperation( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var writeType = Random.Shared.Next(5); - - switch (writeType) - { - case 0: - await WriteCreate(container, knownIds, stats, nextId); - break; - case 1: - await WriteUpsert(container, knownIds, stats, nextId); - break; - case 2: - await WriteReplace(container, knownIds, stats); - break; - case 3: - await WritePatch(container, knownIds, stats); - break; - case 4: - await WriteDelete(container, knownIds, stats); - break; - } - } - - private static async Task WriteCreate( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var id = nextId.Increment().ToString(); - var partitionKey = $"pk-{id}"; - var document = CreateDocument(id, partitionKey); - - var response = await container.CreateItemAsync(document, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - knownIds.TryAdd(id, partitionKey); - Interlocked.Increment(ref stats.Creates); - } - - private static async Task WriteUpsert( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats, - AtomicCounter nextId) - { - var updateExisting = Random.Shared.Next(2) == 0; - - if (updateExisting) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length > 0) - { - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var document = CreateDocument(targetId, partitionKey); - - var response = await container.UpsertItemAsync(document, new PartitionKey(partitionKey)); - - // Concurrent delete can remove the item between TryGetValue and UpsertItemAsync, - // causing Cosmos to create fresh (201) instead of update (200) - response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); - knownIds.TryAdd(targetId, partitionKey); - Interlocked.Increment(ref stats.Upserts); - return; - } - } - - var id = nextId.Increment().ToString(); - var newPartitionKey = $"pk-{id}"; - var newDocument = CreateDocument(id, newPartitionKey); - - var createResponse = await container.UpsertItemAsync(newDocument, new PartitionKey(newPartitionKey)); - - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - knownIds.TryAdd(id, newPartitionKey); - Interlocked.Increment(ref stats.Upserts); - } - - private static async Task WriteReplace( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var document = CreateDocument(targetId, partitionKey); - - var response = await container.ReplaceItemAsync(document, targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - Interlocked.Increment(ref stats.Replaces); - } - - private static async Task WritePatch( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryGetValue(targetId, out var patchPartitionKey)) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var patchOperations = new List - { - PatchOperation.Increment("/counter", 1), - PatchOperation.Set("/data", $"patched-{Guid.NewGuid():N}") - }; - - var response = await container.PatchItemAsync( - targetId, new PartitionKey(patchPartitionKey), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - Interlocked.Increment(ref stats.Patches); - } - - private static async Task WriteDelete( - InMemoryContainer container, - ConcurrentDictionary knownIds, - LoadStats stats) - { - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryRemove(targetId, out var partitionKey)) - { - Interlocked.Increment(ref stats.Skipped); - return; - } - - try - { - var response = await container.DeleteItemAsync( - targetId, new PartitionKey(partitionKey)); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - Interlocked.Increment(ref stats.Deletes); - } - catch - { - // Re-add to knownIds so the item isn't orphaned if the delete failed - knownIds.TryAdd(targetId, partitionKey); - throw; - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Seeding - // ═══════════════════════════════════════════════════════════════════════════ - - private static async Task SeedDocuments(InMemoryContainer container, int count) - { - for (var seedIndex = 1; seedIndex <= count; seedIndex++) - { - var id = seedIndex.ToString(); - var partitionKey = $"pk-{seedIndex % 20}"; - var document = CreateDocument(id, partitionKey); - await container.CreateItemAsync(document, new PartitionKey(partitionKey)); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static LoadDocument CreateDocument(string id, string partitionKey) => new() - { - Id = id, - PartitionKey = partitionKey, - Counter = Random.Shared.Next(1000), - Data = $"data-{Guid.NewGuid():N}", - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }; - - private void ReportAndAssert(LoadStats stats, string scenarioName) - { - var totalOps = stats.Reads + stats.Creates + stats.Upserts - + stats.Replaces + stats.Patches + stats.Deletes - + stats.NotFound + stats.Errors + stats.Skipped; - - var opsPerSecond = totalOps / stats.SchedulingTime.TotalSeconds; - - output.WriteLine($"║ Config: {_targetCallsPerSecond} rps × {_durationSeconds}s = {_totalOperations:N0} ops"); - output.WriteLine($"╔══════════════════════════════════════════════════╗"); - output.WriteLine($"║ {scenarioName,-46} ║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Scheduling time : {stats.SchedulingTime.TotalSeconds,8:F1}s{"",-17}║"); - output.WriteLine($"║ Wall clock : {stats.WallClockTime.TotalSeconds,8:F1}s{"",-17}║"); - output.WriteLine($"║ Total operations : {totalOps,8:N0}{"",-18}║"); - output.WriteLine($"║ Throughput : {opsPerSecond,8:F1} ops/s{"",-12}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Reads : {stats.Reads,8:N0}{"",-18}║"); - output.WriteLine($"║ Creates : {stats.Creates,8:N0}{"",-18}║"); - output.WriteLine($"║ Upserts : {stats.Upserts,8:N0}{"",-18}║"); - output.WriteLine($"║ Replaces : {stats.Replaces,8:N0}{"",-18}║"); - output.WriteLine($"║ Patches : {stats.Patches,8:N0}{"",-18}║"); - output.WriteLine($"║ Deletes : {stats.Deletes,8:N0}{"",-18}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Expected 404s : {stats.NotFound,8:N0}{"",-18}║"); - output.WriteLine($"║ Skipped (no-ops) : {stats.Skipped,8:N0}{"",-18}║"); - output.WriteLine($"║ Unexpected errors : {stats.Errors,8:N0}{"",-18}║"); - output.WriteLine($"╠══════════════════════════════════════════════════╣"); - output.WriteLine($"║ Mean latency : {stats.MeanLatency,8:F3}ms{"",-15}║"); - output.WriteLine($"║ P50 latency : {stats.GetPercentile(50),8:F3}ms{"",-15}║"); - output.WriteLine($"║ P95 latency : {stats.GetPercentile(95),8:F3}ms{"",-15}║"); - output.WriteLine($"║ P99 latency : {stats.GetPercentile(99),8:F3}ms{"",-15}║"); - output.WriteLine($"║ Max latency : {stats.GetPercentile(100),8:F3}ms{"",-15}║"); - output.WriteLine($"╚══════════════════════════════════════════════════╝"); - - foreach (var error in stats.ErrorMessages.Take(10)) - { - output.WriteLine($" ERROR: {error}"); - } - - totalOps.Should().Be(_totalOperations, - $"all {_totalOperations:N0} operations should complete"); - - stats.Errors.Should().Be(0, - "no unexpected errors should occur under sustained load"); - - opsPerSecond.Should().BeGreaterThanOrEqualTo(_targetCallsPerSecond * 0.50, - $"throughput should sustain at least 50% of target ({_targetCallsPerSecond} ops/s)"); - - stats.GetPercentile(99).Should().BeLessThan(2000, - "P99 latency should stay under 2000ms for in-memory operations (includes cross-partition queries)"); - } + private static readonly int _targetCallsPerSecond = + int.TryParse(Environment.GetEnvironmentVariable("LOAD_TEST_RPS"), out var rps) ? rps : 500; + + private static readonly int _durationSeconds = + int.TryParse(Environment.GetEnvironmentVariable("LOAD_TEST_DURATION_SECONDS"), out var dur) ? dur : 60; + + private static readonly int _totalOperations = _targetCallsPerSecond * _durationSeconds; + + [Fact] + public async Task ReadHeavyLoad_80PercentReads_20PercentWrites() + { + var container = new InMemoryContainer("read-heavy", "/partitionKey"); + var stats = new LoadStats(); + + await SeedDocuments(container, count: 1000); + + await RunLoad(container, stats, readWeight: 80, writeWeight: 20, seedCount: 1000); + + ReportAndAssert(stats, "Read-Heavy (80/20)"); + } + + [Fact] + public async Task WriteHeavyLoad_20PercentReads_80PercentWrites() + { + var container = new InMemoryContainer("write-heavy", "/partitionKey"); + var stats = new LoadStats(); + + await SeedDocuments(container, count: 200); + + await RunLoad(container, stats, readWeight: 20, writeWeight: 80, seedCount: 200); + + ReportAndAssert(stats, "Write-Heavy (20/80)"); + } + + [Fact] + public async Task EvenMixLoad_50PercentReads_50PercentWrites() + { + var container = new InMemoryContainer("even-mix", "/partitionKey"); + var stats = new LoadStats(); + + await SeedDocuments(container, count: 500); + + await RunLoad(container, stats, readWeight: 50, writeWeight: 50, seedCount: 500); + + ReportAndAssert(stats, "Even Mix (50/50)"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Load runner + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task RunLoad( + InMemoryContainer container, + LoadStats stats, + int readWeight, + int writeWeight, + int seedCount) + { + var knownIds = new ConcurrentDictionary(); + + foreach (var id in Enumerable.Range(1, seedCount)) + { + knownIds.TryAdd(id.ToString(), $"pk-{id % 20}"); + } + + var nextId = new AtomicCounter(10_000); + var maxConcurrency = Math.Max(_targetCallsPerSecond * 2, 200); + var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); + var tasks = new List(_totalOperations); + var overallStopwatch = Stopwatch.StartNew(); + var intervalMs = 1000.0 / _targetCallsPerSecond; + + for (var operationIndex = 0; operationIndex < _totalOperations; operationIndex++) + { + var targetTime = TimeSpan.FromMilliseconds(operationIndex * intervalMs); + var elapsed = overallStopwatch.Elapsed; + if (targetTime > elapsed) + { + await Task.Delay(targetTime - elapsed); + } + + await semaphore.WaitAsync(); + + var isRead = Random.Shared.Next(100) < readWeight; + var capturedIndex = operationIndex; + + tasks.Add(Task.Run(async () => + { + var operationStopwatch = Stopwatch.StartNew(); + try + { + if (isRead) + { + await ExecuteReadOperation(container, knownIds, stats); + } + else + { + await ExecuteWriteOperation(container, knownIds, stats, nextId); + } + + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + } + // Note: 404s from replace/patch racing with concurrent delete are expected and counted + // here rather than under the specific operation stat. totalOps still includes NotFound, + // so the assertion in ReportAndAssert remains correct. + catch (CosmosException cosmosException) when (cosmosException.StatusCode == HttpStatusCode.NotFound) + { + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + Interlocked.Increment(ref stats.NotFound); + } + catch (Exception exception) + { + operationStopwatch.Stop(); + stats.RecordLatency(operationStopwatch.Elapsed); + Interlocked.Increment(ref stats.Errors); + stats.ErrorMessages.Enqueue($"Op {capturedIndex}: {exception.GetType().Name}: {exception.Message}"); + } + finally + { + semaphore.Release(); + } + })); + } + + stats.SchedulingTime = overallStopwatch.Elapsed; + + await Task.WhenAll(tasks); + + overallStopwatch.Stop(); + stats.WallClockTime = overallStopwatch.Elapsed; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Read operations (with verification) + // ═══════════════════════════════════════════════════════════════════════════ + + private static async Task ExecuteReadOperation( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var readType = Random.Shared.Next(4); + + switch (readType) + { + case 0: + await ReadAndVerifySingle(container, knownIds, stats); + break; + case 1: + await ReadAndVerifyQuery(container, stats); + break; + case 2: + await ReadAndVerifyQueryWithFilter(container, knownIds, stats); + break; + case 3: + await ReadAndVerifyCrossPartitionQuery(container, stats); + break; + } + } + + private static async Task ReadAndVerifySingle( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var response = await container.ReadItemAsync(targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be(targetId); + response.Resource.PartitionKey.Should().Be(partitionKey); + Interlocked.Increment(ref stats.Reads); + } + + private static async Task ReadAndVerifyCrossPartitionQuery( + InMemoryContainer container, + LoadStats stats) + { + var threshold = Random.Shared.Next(500); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") + .WithParameter("@threshold", threshold)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().OnlyContain(d => d.Counter > threshold); + Interlocked.Increment(ref stats.Reads); + } + + private static async Task ReadAndVerifyQuery( + InMemoryContainer container, + LoadStats stats) + { + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.counter DESC")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCountLessThanOrEqualTo(10); + + for (var resultIndex = 1; resultIndex < results.Count; resultIndex++) + { + results[resultIndex].Counter.Should().BeLessThanOrEqualTo(results[resultIndex - 1].Counter); + } + + Interlocked.Increment(ref stats.Reads); + } + + private static async Task ReadAndVerifyQueryWithFilter( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", targetId)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + if (results.Count == 1) + { + results[0].Id.Should().Be(targetId); + } + + Interlocked.Increment(ref stats.Reads); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Write operations (mixed) + // ═══════════════════════════════════════════════════════════════════════════ + + private static async Task ExecuteWriteOperation( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var writeType = Random.Shared.Next(5); + + switch (writeType) + { + case 0: + await WriteCreate(container, knownIds, stats, nextId); + break; + case 1: + await WriteUpsert(container, knownIds, stats, nextId); + break; + case 2: + await WriteReplace(container, knownIds, stats); + break; + case 3: + await WritePatch(container, knownIds, stats); + break; + case 4: + await WriteDelete(container, knownIds, stats); + break; + } + } + + private static async Task WriteCreate( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var id = nextId.Increment().ToString(); + var partitionKey = $"pk-{id}"; + var document = CreateDocument(id, partitionKey); + + var response = await container.CreateItemAsync(document, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + knownIds.TryAdd(id, partitionKey); + Interlocked.Increment(ref stats.Creates); + } + + private static async Task WriteUpsert( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats, + AtomicCounter nextId) + { + var updateExisting = Random.Shared.Next(2) == 0; + + if (updateExisting) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length > 0) + { + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var document = CreateDocument(targetId, partitionKey); + + var response = await container.UpsertItemAsync(document, new PartitionKey(partitionKey)); + + // Concurrent delete can remove the item between TryGetValue and UpsertItemAsync, + // causing Cosmos to create fresh (201) instead of update (200) + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); + knownIds.TryAdd(targetId, partitionKey); + Interlocked.Increment(ref stats.Upserts); + return; + } + } + + var id = nextId.Increment().ToString(); + var newPartitionKey = $"pk-{id}"; + var newDocument = CreateDocument(id, newPartitionKey); + + var createResponse = await container.UpsertItemAsync(newDocument, new PartitionKey(newPartitionKey)); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + knownIds.TryAdd(id, newPartitionKey); + Interlocked.Increment(ref stats.Upserts); + } + + private static async Task WriteReplace( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var document = CreateDocument(targetId, partitionKey); + + var response = await container.ReplaceItemAsync(document, targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + Interlocked.Increment(ref stats.Replaces); + } + + private static async Task WritePatch( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryGetValue(targetId, out var patchPartitionKey)) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var patchOperations = new List + { + PatchOperation.Increment("/counter", 1), + PatchOperation.Set("/data", $"patched-{Guid.NewGuid():N}") + }; + + var response = await container.PatchItemAsync( + targetId, new PartitionKey(patchPartitionKey), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + Interlocked.Increment(ref stats.Patches); + } + + private static async Task WriteDelete( + InMemoryContainer container, + ConcurrentDictionary knownIds, + LoadStats stats) + { + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryRemove(targetId, out var partitionKey)) + { + Interlocked.Increment(ref stats.Skipped); + return; + } + + try + { + var response = await container.DeleteItemAsync( + targetId, new PartitionKey(partitionKey)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Interlocked.Increment(ref stats.Deletes); + } + catch + { + // Re-add to knownIds so the item isn't orphaned if the delete failed + knownIds.TryAdd(targetId, partitionKey); + throw; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Seeding + // ═══════════════════════════════════════════════════════════════════════════ + + private static async Task SeedDocuments(InMemoryContainer container, int count) + { + for (var seedIndex = 1; seedIndex <= count; seedIndex++) + { + var id = seedIndex.ToString(); + var partitionKey = $"pk-{seedIndex % 20}"; + var document = CreateDocument(id, partitionKey); + await container.CreateItemAsync(document, new PartitionKey(partitionKey)); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static LoadDocument CreateDocument(string id, string partitionKey) => new() + { + Id = id, + PartitionKey = partitionKey, + Counter = Random.Shared.Next(1000), + Data = $"data-{Guid.NewGuid():N}", + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }; + + private void ReportAndAssert(LoadStats stats, string scenarioName) + { + var totalOps = stats.Reads + stats.Creates + stats.Upserts + + stats.Replaces + stats.Patches + stats.Deletes + + stats.NotFound + stats.Errors + stats.Skipped; + + var opsPerSecond = totalOps / stats.SchedulingTime.TotalSeconds; + + output.WriteLine($"║ Config: {_targetCallsPerSecond} rps × {_durationSeconds}s = {_totalOperations:N0} ops"); + output.WriteLine($"╔══════════════════════════════════════════════════╗"); + output.WriteLine($"║ {scenarioName,-46} ║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Scheduling time : {stats.SchedulingTime.TotalSeconds,8:F1}s{"",-17}║"); + output.WriteLine($"║ Wall clock : {stats.WallClockTime.TotalSeconds,8:F1}s{"",-17}║"); + output.WriteLine($"║ Total operations : {totalOps,8:N0}{"",-18}║"); + output.WriteLine($"║ Throughput : {opsPerSecond,8:F1} ops/s{"",-12}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Reads : {stats.Reads,8:N0}{"",-18}║"); + output.WriteLine($"║ Creates : {stats.Creates,8:N0}{"",-18}║"); + output.WriteLine($"║ Upserts : {stats.Upserts,8:N0}{"",-18}║"); + output.WriteLine($"║ Replaces : {stats.Replaces,8:N0}{"",-18}║"); + output.WriteLine($"║ Patches : {stats.Patches,8:N0}{"",-18}║"); + output.WriteLine($"║ Deletes : {stats.Deletes,8:N0}{"",-18}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Expected 404s : {stats.NotFound,8:N0}{"",-18}║"); + output.WriteLine($"║ Skipped (no-ops) : {stats.Skipped,8:N0}{"",-18}║"); + output.WriteLine($"║ Unexpected errors : {stats.Errors,8:N0}{"",-18}║"); + output.WriteLine($"╠══════════════════════════════════════════════════╣"); + output.WriteLine($"║ Mean latency : {stats.MeanLatency,8:F3}ms{"",-15}║"); + output.WriteLine($"║ P50 latency : {stats.GetPercentile(50),8:F3}ms{"",-15}║"); + output.WriteLine($"║ P95 latency : {stats.GetPercentile(95),8:F3}ms{"",-15}║"); + output.WriteLine($"║ P99 latency : {stats.GetPercentile(99),8:F3}ms{"",-15}║"); + output.WriteLine($"║ Max latency : {stats.GetPercentile(100),8:F3}ms{"",-15}║"); + output.WriteLine($"╚══════════════════════════════════════════════════╝"); + + foreach (var error in stats.ErrorMessages.Take(10)) + { + output.WriteLine($" ERROR: {error}"); + } + + totalOps.Should().Be(_totalOperations, + $"all {_totalOperations:N0} operations should complete"); + + stats.Errors.Should().Be(0, + "no unexpected errors should occur under sustained load"); + + opsPerSecond.Should().BeGreaterThanOrEqualTo(_targetCallsPerSecond * 0.50, + $"throughput should sustain at least 50% of target ({_targetCallsPerSecond} ops/s)"); + + stats.GetPercentile(99).Should().BeLessThan(2000, + "P99 latency should stay under 2000ms for in-memory operations (includes cross-partition queries)"); + } } public class LoadDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("counter")] - public int Counter { get; set; } + [JsonProperty("counter")] + public int Counter { get; set; } - [JsonProperty("data")] - public string Data { get; set; } = default!; + [JsonProperty("data")] + public string Data { get; set; } = default!; - [JsonProperty("timestamp")] - public string Timestamp { get; set; } = default!; + [JsonProperty("timestamp")] + public string Timestamp { get; set; } = default!; } public class LoadStats { - public long Reads; - public long Creates; - public long Upserts; - public long Replaces; - public long Patches; - public long Deletes; - public long NotFound; - public long Errors; - public long Skipped; - public TimeSpan SchedulingTime; - public TimeSpan WallClockTime; - - public ConcurrentQueue ErrorMessages { get; } = new(); - - private readonly ConcurrentBag _latenciesMs = []; - - public void RecordLatency(TimeSpan latency) => _latenciesMs.Add(latency.TotalMilliseconds); - - public double MeanLatency => _latenciesMs.Count > 0 ? _latenciesMs.Average() : 0; - - public double GetPercentile(int percentile) - { - var sorted = _latenciesMs.OrderBy(latency => latency).ToArray(); - if (sorted.Length == 0) - { - return 0; - } - - if (percentile >= 100) - { - return sorted[^1]; - } - - var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Length) - 1; - return sorted[Math.Max(0, index)]; - } + public long Reads; + public long Creates; + public long Upserts; + public long Replaces; + public long Patches; + public long Deletes; + public long NotFound; + public long Errors; + public long Skipped; + public TimeSpan SchedulingTime; + public TimeSpan WallClockTime; + + public ConcurrentQueue ErrorMessages { get; } = new(); + + private readonly ConcurrentBag _latenciesMs = []; + + public void RecordLatency(TimeSpan latency) => _latenciesMs.Add(latency.TotalMilliseconds); + + public double MeanLatency => _latenciesMs.Count > 0 ? _latenciesMs.Average() : 0; + + public double GetPercentile(int percentile) + { + var sorted = _latenciesMs.OrderBy(latency => latency).ToArray(); + if (sorted.Length == 0) + { + return 0; + } + + if (percentile >= 100) + { + return sorted[^1]; + } + + var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Length) - 1; + return sorted[Math.Max(0, index)]; + } } public class AtomicCounter(int initial) { - private int _value = initial; - public int Increment() => Interlocked.Increment(ref _value); + private int _value = initial; + public int Increment() => Interlocked.Increment(ref _value); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -614,176 +614,176 @@ public class AtomicCounter(int initial) public class LoadTestOperationTypes(ITestOutputHelper output) { - [Fact] - public async Task ReadManyUnderLoad_VerifiesAllItemsReturned() - { - var container = new InMemoryContainer("readmany-load", "/partitionKey"); - var seedCount = 200; - var knownIds = new ConcurrentDictionary(); - - for (var i = 1; i <= seedCount; i++) - { - var id = i.ToString(); - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "seed", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - } - - var errors = 0; - var totalReads = 0; - var tasks = Enumerable.Range(0, 100).Select(async _ => - { - var ids = knownIds.Keys.ToArray(); - var batch = ids.OrderBy(_ => Random.Shared.Next()).Take(Random.Shared.Next(3, 8)).ToList(); - var feedItems = batch - .Where(id => knownIds.ContainsKey(id)) - .Select(id => (id, new PartitionKey(knownIds[id]))) - .ToList(); - - if (feedItems.Count == 0) return; - - var response = await container.ReadManyItemsAsync(feedItems); - if (response.StatusCode != HttpStatusCode.OK) - Interlocked.Increment(ref errors); - else if (response.Count != feedItems.Count) - Interlocked.Increment(ref errors); - - Interlocked.Increment(ref totalReads); - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"ReadMany batches: {totalReads}, errors: {errors}"); - errors.Should().Be(0); - totalReads.Should().Be(100); - } - - [Fact] - public async Task TransactionalBatchUnderLoad_AtomicCreateAndRead() - { - var container = new InMemoryContainer("batch-load", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - var successes = 0; - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - var batchPk = $"pk-batch-{nextId.Increment()}"; - var batch = container.CreateTransactionalBatch(new PartitionKey(batchPk)); - var items = Enumerable.Range(0, 3).Select(j => new LoadDocument - { - Id = $"{batchPk}-{j}", - PartitionKey = batchPk, - Counter = j, - Data = "batch", - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }).ToList(); - - foreach (var item in items) - batch.CreateItem(item); - - using var response = await batch.ExecuteAsync(); - if (response.IsSuccessStatusCode) - Interlocked.Increment(ref successes); - else - Interlocked.Increment(ref errors); - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"Batches: {successes} succeeded, {errors} errors"); - errors.Should().Be(0); - successes.Should().Be(50); - } - - [Fact] - public async Task StreamApiOperationsUnderLoad() - { - var container = new InMemoryContainer("stream-load", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - var ops = 0; - - // Seed some items - for (var i = 0; i < 50; i++) - { - var id = $"seed-{i}"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = "stream-pk", Counter = i, Data = "seed", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("stream-pk")); - } - - var tasks = Enumerable.Range(0, 200).Select(async idx => - { - try - { - if (idx % 2 == 0) - { - // Stream create - var id = $"stream-{nextId.Increment()}"; - var doc = new LoadDocument { Id = id, PartitionKey = "stream-pk", Counter = idx, Data = "stream", Timestamp = DateTimeOffset.UtcNow.ToString("O") }; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(doc); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - using var response = await container.CreateItemStreamAsync(stream, new PartitionKey("stream-pk")); - if (!response.IsSuccessStatusCode) - Interlocked.Increment(ref errors); - } - else - { - // Stream read - var readId = $"seed-{idx % 50}"; - using var response = await container.ReadItemStreamAsync(readId, new PartitionKey("stream-pk")); - if (!response.IsSuccessStatusCode) - Interlocked.Increment(ref errors); - } - - Interlocked.Increment(ref ops); - } - catch - { - Interlocked.Increment(ref errors); - } - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"Stream ops: {ops}, errors: {errors}"); - errors.Should().Be(0); - } - - [Fact] - public async Task ChangeFeedReadDuringWrites() - { - var container = new InMemoryContainer("changefeed-load", "/partitionKey"); - - // Write items - var writeCount = 50; - for (var i = 0; i < writeCount; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"cf-{i}", PartitionKey = $"pk-{i % 5}", Counter = i, Data = "cf", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 5}")); - } - - // Read change feed - var changeFeedItems = new List(); - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - break; - changeFeedItems.AddRange(response); - } - - output.WriteLine($"Change feed items: {changeFeedItems.Count} (expected {writeCount})"); - changeFeedItems.Should().HaveCount(writeCount); - } + [Fact] + public async Task ReadManyUnderLoad_VerifiesAllItemsReturned() + { + var container = new InMemoryContainer("readmany-load", "/partitionKey"); + var seedCount = 200; + var knownIds = new ConcurrentDictionary(); + + for (var i = 1; i <= seedCount; i++) + { + var id = i.ToString(); + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "seed", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + } + + var errors = 0; + var totalReads = 0; + var tasks = Enumerable.Range(0, 100).Select(async _ => + { + var ids = knownIds.Keys.ToArray(); + var batch = ids.OrderBy(_ => Random.Shared.Next()).Take(Random.Shared.Next(3, 8)).ToList(); + var feedItems = batch + .Where(id => knownIds.ContainsKey(id)) + .Select(id => (id, new PartitionKey(knownIds[id]))) + .ToList(); + + if (feedItems.Count == 0) return; + + var response = await container.ReadManyItemsAsync(feedItems); + if (response.StatusCode != HttpStatusCode.OK) + Interlocked.Increment(ref errors); + else if (response.Count != feedItems.Count) + Interlocked.Increment(ref errors); + + Interlocked.Increment(ref totalReads); + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"ReadMany batches: {totalReads}, errors: {errors}"); + errors.Should().Be(0); + totalReads.Should().Be(100); + } + + [Fact] + public async Task TransactionalBatchUnderLoad_AtomicCreateAndRead() + { + var container = new InMemoryContainer("batch-load", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + var successes = 0; + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + var batchPk = $"pk-batch-{nextId.Increment()}"; + var batch = container.CreateTransactionalBatch(new PartitionKey(batchPk)); + var items = Enumerable.Range(0, 3).Select(j => new LoadDocument + { + Id = $"{batchPk}-{j}", + PartitionKey = batchPk, + Counter = j, + Data = "batch", + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }).ToList(); + + foreach (var item in items) + batch.CreateItem(item); + + using var response = await batch.ExecuteAsync(); + if (response.IsSuccessStatusCode) + Interlocked.Increment(ref successes); + else + Interlocked.Increment(ref errors); + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"Batches: {successes} succeeded, {errors} errors"); + errors.Should().Be(0); + successes.Should().Be(50); + } + + [Fact] + public async Task StreamApiOperationsUnderLoad() + { + var container = new InMemoryContainer("stream-load", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + var ops = 0; + + // Seed some items + for (var i = 0; i < 50; i++) + { + var id = $"seed-{i}"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = "stream-pk", Counter = i, Data = "seed", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("stream-pk")); + } + + var tasks = Enumerable.Range(0, 200).Select(async idx => + { + try + { + if (idx % 2 == 0) + { + // Stream create + var id = $"stream-{nextId.Increment()}"; + var doc = new LoadDocument { Id = id, PartitionKey = "stream-pk", Counter = idx, Data = "stream", Timestamp = DateTimeOffset.UtcNow.ToString("O") }; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(doc); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + using var response = await container.CreateItemStreamAsync(stream, new PartitionKey("stream-pk")); + if (!response.IsSuccessStatusCode) + Interlocked.Increment(ref errors); + } + else + { + // Stream read + var readId = $"seed-{idx % 50}"; + using var response = await container.ReadItemStreamAsync(readId, new PartitionKey("stream-pk")); + if (!response.IsSuccessStatusCode) + Interlocked.Increment(ref errors); + } + + Interlocked.Increment(ref ops); + } + catch + { + Interlocked.Increment(ref errors); + } + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"Stream ops: {ops}, errors: {errors}"); + errors.Should().Be(0); + } + + [Fact] + public async Task ChangeFeedReadDuringWrites() + { + var container = new InMemoryContainer("changefeed-load", "/partitionKey"); + + // Write items + var writeCount = 50; + for (var i = 0; i < writeCount; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"cf-{i}", PartitionKey = $"pk-{i % 5}", Counter = i, Data = "cf", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 5}")); + } + + // Read change feed + var changeFeedItems = new List(); + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + break; + changeFeedItems.AddRange(response); + } + + output.WriteLine($"Change feed items: {changeFeedItems.Count} (expected {writeCount})"); + changeFeedItems.Should().HaveCount(writeCount); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -792,146 +792,146 @@ await container.CreateItemAsync( public class LoadTestQueryDiversity(ITestOutputHelper output) { - [Fact] - public async Task CrossPartitionQueryUnderLoad() - { - var container = new InMemoryContainer("xpart-query", "/partitionKey"); - for (var i = 0; i < 100; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"q-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "query", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 10}")); - } - - var errors = 0; - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - var threshold = Random.Shared.Next(100); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.counter > @t") - .WithParameter("@t", threshold)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Any(r => r.Counter <= threshold)) - Interlocked.Increment(ref errors); - - // Verify results span multiple partitions - var distinctPks = results.Select(r => r.PartitionKey).Distinct().Count(); - if (results.Count > 10 && distinctPks < 2) - Interlocked.Increment(ref errors); - }); - - await Task.WhenAll(tasks); - output.WriteLine($"Cross-partition query errors: {errors}"); - errors.Should().Be(0); - } - - [Fact] - public async Task AggregateQueryUnderLoad() - { - var container = new InMemoryContainer("agg-query", "/partitionKey"); - var totalItems = 100; - var expectedSum = 0; - for (var i = 0; i < totalItems; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"a-{i}", PartitionKey = $"pk-{i % 5}", Counter = i, Data = "agg", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 5}")); - expectedSum += i; - } - - // COUNT - var countIterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - var count = 0; - while (countIterator.HasMoreResults) - { - var page = await countIterator.ReadNextAsync(); - count += page.Sum(); - } - - output.WriteLine($"COUNT: {count}, expected: {totalItems}"); - count.Should().Be(totalItems); - - // SUM - var sumIterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE SUM(c.counter) FROM c")); - var sum = 0.0; - while (sumIterator.HasMoreResults) - { - var page = await sumIterator.ReadNextAsync(); - sum += page.Sum(); - } - - output.WriteLine($"SUM: {sum}, expected: {expectedSum}"); - sum.Should().Be(expectedSum); - } - - [Fact] - public async Task DistinctQueryUnderLoad() - { - var container = new InMemoryContainer("distinct-query", "/partitionKey"); - var partitionCount = 10; - for (var i = 0; i < 100; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"d-{i}", PartitionKey = $"pk-{i % partitionCount}", Counter = i, Data = "distinct", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % partitionCount}")); - } - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - output.WriteLine($"Distinct PKs: {results.Count}, expected: {partitionCount}"); - results.Should().HaveCount(partitionCount); - results.Should().OnlyHaveUniqueItems(); - } - - [Fact] - public async Task OffsetLimitPaginationUnderLoad() - { - var container = new InMemoryContainer("offset-query", "/partitionKey"); - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"ol-{i:D3}", PartitionKey = "pk-0", Counter = i, Data = "offset", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("pk-0")); - } - - var allPages = new List>(); - for (var offset = 0; offset < 50; offset += 10) - { - var iterator = container.GetItemQueryIterator( - new QueryDefinition($"SELECT * FROM c ORDER BY c.id OFFSET {offset} LIMIT 10")); - - var pageItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - pageItems.AddRange(page); - } - - allPages.Add(pageItems); - } - - var totalFromPages = allPages.Sum(p => p.Count); - output.WriteLine($"Offset/Limit pages: {allPages.Count}, total items: {totalFromPages}"); - allPages.Should().OnlyContain(page => page.Count <= 10); - totalFromPages.Should().Be(50); - } + [Fact] + public async Task CrossPartitionQueryUnderLoad() + { + var container = new InMemoryContainer("xpart-query", "/partitionKey"); + for (var i = 0; i < 100; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"q-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "query", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 10}")); + } + + var errors = 0; + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + var threshold = Random.Shared.Next(100); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.counter > @t") + .WithParameter("@t", threshold)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Any(r => r.Counter <= threshold)) + Interlocked.Increment(ref errors); + + // Verify results span multiple partitions + var distinctPks = results.Select(r => r.PartitionKey).Distinct().Count(); + if (results.Count > 10 && distinctPks < 2) + Interlocked.Increment(ref errors); + }); + + await Task.WhenAll(tasks); + output.WriteLine($"Cross-partition query errors: {errors}"); + errors.Should().Be(0); + } + + [Fact] + public async Task AggregateQueryUnderLoad() + { + var container = new InMemoryContainer("agg-query", "/partitionKey"); + var totalItems = 100; + var expectedSum = 0; + for (var i = 0; i < totalItems; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"a-{i}", PartitionKey = $"pk-{i % 5}", Counter = i, Data = "agg", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 5}")); + expectedSum += i; + } + + // COUNT + var countIterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + var count = 0; + while (countIterator.HasMoreResults) + { + var page = await countIterator.ReadNextAsync(); + count += page.Sum(); + } + + output.WriteLine($"COUNT: {count}, expected: {totalItems}"); + count.Should().Be(totalItems); + + // SUM + var sumIterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE SUM(c.counter) FROM c")); + var sum = 0.0; + while (sumIterator.HasMoreResults) + { + var page = await sumIterator.ReadNextAsync(); + sum += page.Sum(); + } + + output.WriteLine($"SUM: {sum}, expected: {expectedSum}"); + sum.Should().Be(expectedSum); + } + + [Fact] + public async Task DistinctQueryUnderLoad() + { + var container = new InMemoryContainer("distinct-query", "/partitionKey"); + var partitionCount = 10; + for (var i = 0; i < 100; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"d-{i}", PartitionKey = $"pk-{i % partitionCount}", Counter = i, Data = "distinct", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % partitionCount}")); + } + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + output.WriteLine($"Distinct PKs: {results.Count}, expected: {partitionCount}"); + results.Should().HaveCount(partitionCount); + results.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task OffsetLimitPaginationUnderLoad() + { + var container = new InMemoryContainer("offset-query", "/partitionKey"); + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"ol-{i:D3}", PartitionKey = "pk-0", Counter = i, Data = "offset", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("pk-0")); + } + + var allPages = new List>(); + for (var offset = 0; offset < 50; offset += 10) + { + var iterator = container.GetItemQueryIterator( + new QueryDefinition($"SELECT * FROM c ORDER BY c.id OFFSET {offset} LIMIT 10")); + + var pageItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + pageItems.AddRange(page); + } + + allPages.Add(pageItems); + } + + var totalFromPages = allPages.Sum(p => p.Count); + output.WriteLine($"Offset/Limit pages: {allPages.Count}, total items: {totalFromPages}"); + allPages.Should().OnlyContain(page => page.Count <= 10); + totalFromPages.Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -940,214 +940,214 @@ await container.CreateItemAsync( public class LoadTestConcurrencyEdgeCases(ITestOutputHelper output) { - [Fact] - public async Task ETagConflictsUnderLoad() - { - var container = new InMemoryContainer("etag-load", "/partitionKey"); - await container.CreateItemAsync( - new LoadDocument { Id = "e1", PartitionKey = "pk-e", Counter = 0, Data = "etag", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("pk-e")); - - var read = await container.ReadItemAsync("e1", new PartitionKey("pk-e")); - var etag = read.ETag; - - var successes = 0; - var conflicts = 0; - - var tasks = Enumerable.Range(0, 10).Select(async i => - { - try - { - var doc = new LoadDocument { Id = "e1", PartitionKey = "pk-e", Counter = i, Data = $"attempt-{i}", Timestamp = DateTimeOffset.UtcNow.ToString("O") }; - await container.ReplaceItemAsync(doc, "e1", new PartitionKey("pk-e"), - new ItemRequestOptions { IfMatchEtag = etag }); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - Interlocked.Increment(ref conflicts); - } - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"ETag: {successes} succeeded, {conflicts} conflicts (total {successes + conflicts})"); - successes.Should().Be(1, "exactly one writer should succeed with the original ETag"); - conflicts.Should().Be(9); - } - - [Fact] - public async Task CreateAfterDelete_SameId_Succeeds() - { - var container = new InMemoryContainer("create-after-delete", "/partitionKey"); - var errors = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - var id = $"cad-{i}"; - var pk = $"pk-{i}"; - try - { - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = 0, Data = "v1", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - await container.DeleteItemAsync(id, new PartitionKey(pk)); - var response = await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = 1, Data = "v2", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - - if (response.StatusCode != HttpStatusCode.Created) - Interlocked.Increment(ref errors); - } - catch - { - Interlocked.Increment(ref errors); - } - }); - - await Task.WhenAll(tasks); - output.WriteLine($"Create-after-delete errors: {errors}"); - errors.Should().Be(0); - } - - [Fact] - public async Task DoubleDelete_Returns404() - { - var container = new InMemoryContainer("double-delete", "/partitionKey"); - var successes = 0; - var notFounds = 0; - var otherErrors = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - var id = $"dd-{i}"; - var pk = "pk-dd"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = 0, Data = "del", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - - // Two concurrent deletes - var deleteTasks = Enumerable.Range(0, 2).Select(async _ => - { - try - { - await container.DeleteItemAsync(id, new PartitionKey(pk)); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Interlocked.Increment(ref notFounds); - } - catch - { - Interlocked.Increment(ref otherErrors); - } - }); - - await Task.WhenAll(deleteTasks); - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"Double delete: {successes} succeeded, {notFounds} 404s, {otherErrors} other errors"); - otherErrors.Should().Be(0); - (successes + notFounds).Should().Be(100); // 50 items × 2 delete attempts each - } - - [Fact] - public async Task HotPartitionLoadTest() - { - var container = new InMemoryContainer("hot-partition", "/partitionKey"); - var hotPk = "hot-pk"; - var coldPk = "cold-pk"; - - // Seed both partitions - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"hot-{i}", PartitionKey = hotPk, Counter = i, Data = "hot", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(hotPk)); - await container.CreateItemAsync( - new LoadDocument { Id = $"cold-{i}", PartitionKey = coldPk, Counter = i, Data = "cold", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(coldPk)); - } - - var hotErrors = 0; - var coldErrors = 0; - - // 80% hot, 20% cold - var tasks = Enumerable.Range(0, 200).Select(async i => - { - try - { - if (i % 5 != 0) // 80% hot - { - var readId = $"hot-{i % 50}"; - var response = await container.ReadItemAsync(readId, new PartitionKey(hotPk)); - if (response.StatusCode != HttpStatusCode.OK) - Interlocked.Increment(ref hotErrors); - } - else // 20% cold - { - var readId = $"cold-{i % 50}"; - var response = await container.ReadItemAsync(readId, new PartitionKey(coldPk)); - if (response.StatusCode != HttpStatusCode.OK) - Interlocked.Increment(ref coldErrors); - } - } - catch - { - if (i % 5 != 0) Interlocked.Increment(ref hotErrors); - else Interlocked.Increment(ref coldErrors); - } - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"Hot partition errors: {hotErrors}, Cold partition errors: {coldErrors}"); - hotErrors.Should().Be(0); - coldErrors.Should().Be(0); - } - - [Fact] - public async Task HighCardinalityPartitionKeys() - { - var container = new InMemoryContainer("high-card", "/partitionKey"); - var errors = 0; - var uniquePks = 500; - - var tasks = Enumerable.Range(0, uniquePks).Select(async i => - { - try - { - var pk = $"unique-pk-{i}"; - await container.CreateItemAsync( - new LoadDocument { Id = $"hc-{i}", PartitionKey = pk, Counter = i, Data = "hc", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - } - catch - { - Interlocked.Increment(ref errors); - } - }); - - await Task.WhenAll(tasks); - - // Verify all items exist - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - } - - output.WriteLine($"High cardinality: {allItems.Count} items across {uniquePks} PKs, errors: {errors}"); - errors.Should().Be(0); - allItems.Should().HaveCount(uniquePks); - allItems.Select(d => d.PartitionKey).Distinct().Should().HaveCount(uniquePks); - } + [Fact] + public async Task ETagConflictsUnderLoad() + { + var container = new InMemoryContainer("etag-load", "/partitionKey"); + await container.CreateItemAsync( + new LoadDocument { Id = "e1", PartitionKey = "pk-e", Counter = 0, Data = "etag", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("pk-e")); + + var read = await container.ReadItemAsync("e1", new PartitionKey("pk-e")); + var etag = read.ETag; + + var successes = 0; + var conflicts = 0; + + var tasks = Enumerable.Range(0, 10).Select(async i => + { + try + { + var doc = new LoadDocument { Id = "e1", PartitionKey = "pk-e", Counter = i, Data = $"attempt-{i}", Timestamp = DateTimeOffset.UtcNow.ToString("O") }; + await container.ReplaceItemAsync(doc, "e1", new PartitionKey("pk-e"), + new ItemRequestOptions { IfMatchEtag = etag }); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + Interlocked.Increment(ref conflicts); + } + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"ETag: {successes} succeeded, {conflicts} conflicts (total {successes + conflicts})"); + successes.Should().Be(1, "exactly one writer should succeed with the original ETag"); + conflicts.Should().Be(9); + } + + [Fact] + public async Task CreateAfterDelete_SameId_Succeeds() + { + var container = new InMemoryContainer("create-after-delete", "/partitionKey"); + var errors = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + var id = $"cad-{i}"; + var pk = $"pk-{i}"; + try + { + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = 0, Data = "v1", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + await container.DeleteItemAsync(id, new PartitionKey(pk)); + var response = await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = 1, Data = "v2", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + + if (response.StatusCode != HttpStatusCode.Created) + Interlocked.Increment(ref errors); + } + catch + { + Interlocked.Increment(ref errors); + } + }); + + await Task.WhenAll(tasks); + output.WriteLine($"Create-after-delete errors: {errors}"); + errors.Should().Be(0); + } + + [Fact] + public async Task DoubleDelete_Returns404() + { + var container = new InMemoryContainer("double-delete", "/partitionKey"); + var successes = 0; + var notFounds = 0; + var otherErrors = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + var id = $"dd-{i}"; + var pk = "pk-dd"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = 0, Data = "del", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + + // Two concurrent deletes + var deleteTasks = Enumerable.Range(0, 2).Select(async _ => + { + try + { + await container.DeleteItemAsync(id, new PartitionKey(pk)); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + Interlocked.Increment(ref notFounds); + } + catch + { + Interlocked.Increment(ref otherErrors); + } + }); + + await Task.WhenAll(deleteTasks); + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"Double delete: {successes} succeeded, {notFounds} 404s, {otherErrors} other errors"); + otherErrors.Should().Be(0); + (successes + notFounds).Should().Be(100); // 50 items × 2 delete attempts each + } + + [Fact] + public async Task HotPartitionLoadTest() + { + var container = new InMemoryContainer("hot-partition", "/partitionKey"); + var hotPk = "hot-pk"; + var coldPk = "cold-pk"; + + // Seed both partitions + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"hot-{i}", PartitionKey = hotPk, Counter = i, Data = "hot", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(hotPk)); + await container.CreateItemAsync( + new LoadDocument { Id = $"cold-{i}", PartitionKey = coldPk, Counter = i, Data = "cold", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(coldPk)); + } + + var hotErrors = 0; + var coldErrors = 0; + + // 80% hot, 20% cold + var tasks = Enumerable.Range(0, 200).Select(async i => + { + try + { + if (i % 5 != 0) // 80% hot + { + var readId = $"hot-{i % 50}"; + var response = await container.ReadItemAsync(readId, new PartitionKey(hotPk)); + if (response.StatusCode != HttpStatusCode.OK) + Interlocked.Increment(ref hotErrors); + } + else // 20% cold + { + var readId = $"cold-{i % 50}"; + var response = await container.ReadItemAsync(readId, new PartitionKey(coldPk)); + if (response.StatusCode != HttpStatusCode.OK) + Interlocked.Increment(ref coldErrors); + } + } + catch + { + if (i % 5 != 0) Interlocked.Increment(ref hotErrors); + else Interlocked.Increment(ref coldErrors); + } + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"Hot partition errors: {hotErrors}, Cold partition errors: {coldErrors}"); + hotErrors.Should().Be(0); + coldErrors.Should().Be(0); + } + + [Fact] + public async Task HighCardinalityPartitionKeys() + { + var container = new InMemoryContainer("high-card", "/partitionKey"); + var errors = 0; + var uniquePks = 500; + + var tasks = Enumerable.Range(0, uniquePks).Select(async i => + { + try + { + var pk = $"unique-pk-{i}"; + await container.CreateItemAsync( + new LoadDocument { Id = $"hc-{i}", PartitionKey = pk, Counter = i, Data = "hc", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + } + catch + { + Interlocked.Increment(ref errors); + } + }); + + await Task.WhenAll(tasks); + + // Verify all items exist + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + } + + output.WriteLine($"High cardinality: {allItems.Count} items across {uniquePks} PKs, errors: {errors}"); + errors.Should().Be(0); + allItems.Should().HaveCount(uniquePks); + allItems.Select(d => d.PartitionKey).Distinct().Should().HaveCount(uniquePks); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1156,155 +1156,155 @@ await container.CreateItemAsync( public class LoadTestStressPatterns(ITestOutputHelper output) { - [Fact] - public async Task BurstTrafficPattern_SpikeThenCalm() - { - var container = new InMemoryContainer("burst-load", "/partitionKey"); - for (var i = 0; i < 100; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"b-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "burst", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 10}")); - } - - var errors = 0; - var ops = 0; - - // Spike: 100 concurrent reads - var spikeTasks = Enumerable.Range(0, 100).Select(async i => - { - try - { - var response = await container.ReadItemAsync($"b-{i % 100}", new PartitionKey($"pk-{i % 10}")); - if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref ops); - } - catch { Interlocked.Increment(ref errors); } - }); - await Task.WhenAll(spikeTasks); - - // Calm: 10 sequential reads - for (var i = 0; i < 10; i++) - { - var response = await container.ReadItemAsync($"b-{i}", new PartitionKey($"pk-{i % 10}")); - if (response.StatusCode == HttpStatusCode.OK) ops++; - } - - output.WriteLine($"Burst pattern: {ops} ops, {errors} errors"); - errors.Should().Be(0); - ops.Should().Be(110); - } - - [Fact] - public async Task WriteOnlyStress() - { - var container = new InMemoryContainer("write-only", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - var totalWrites = 500; - - var tasks = Enumerable.Range(0, totalWrites).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = $"pk-{id}", Counter = 0, Data = "write-only", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{id}")); - } - catch - { - Interlocked.Increment(ref errors); - } - }); - - await Task.WhenAll(tasks); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - var count = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - count += page.Sum(); - } - - output.WriteLine($"Write-only: {count} items, {errors} errors"); - errors.Should().Be(0); - count.Should().Be(totalWrites); - } - - [Fact] - public async Task ReadOnlyStress_LargeDataset() - { - var container = new InMemoryContainer("read-only-large", "/partitionKey"); - var seedCount = 1000; - - for (var i = 0; i < seedCount; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"ro-{i}", PartitionKey = $"pk-{i % 50}", Counter = i, Data = "read-only", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 50}")); - } - - var errors = 0; - var reads = 0; - - var tasks = Enumerable.Range(0, 500).Select(async i => - { - try - { - var id = $"ro-{i % seedCount}"; - var pk = $"pk-{i % seedCount % 50}"; - var response = await container.ReadItemAsync(id, new PartitionKey(pk)); - if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref reads); - else Interlocked.Increment(ref errors); - } - catch - { - Interlocked.Increment(ref errors); - } - }); - - await Task.WhenAll(tasks); - - output.WriteLine($"Read-only large: {reads} reads, {errors} errors"); - errors.Should().Be(0); - reads.Should().Be(500); - } - - [Fact] - public async Task GradualRampUp_LinearIncrease() - { - var container = new InMemoryContainer("ramp-up", "/partitionKey"); - for (var i = 0; i < 100; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"r-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "ramp", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey($"pk-{i % 10}")); - } - - var errors = 0; - var totalOps = 0; - - // Ramp from 10 to 100 concurrent ops - for (var level = 10; level <= 100; level += 10) - { - var tasks = Enumerable.Range(0, level).Select(async i => - { - try - { - var response = await container.ReadItemAsync($"r-{i % 100}", new PartitionKey($"pk-{i % 10}")); - if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref totalOps); - } - catch { Interlocked.Increment(ref errors); } - }); - await Task.WhenAll(tasks); - } - - output.WriteLine($"Ramp-up: {totalOps} ops, {errors} errors"); - errors.Should().Be(0); - totalOps.Should().Be(550); // 10+20+30+...+100 - } + [Fact] + public async Task BurstTrafficPattern_SpikeThenCalm() + { + var container = new InMemoryContainer("burst-load", "/partitionKey"); + for (var i = 0; i < 100; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"b-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "burst", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 10}")); + } + + var errors = 0; + var ops = 0; + + // Spike: 100 concurrent reads + var spikeTasks = Enumerable.Range(0, 100).Select(async i => + { + try + { + var response = await container.ReadItemAsync($"b-{i % 100}", new PartitionKey($"pk-{i % 10}")); + if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref ops); + } + catch { Interlocked.Increment(ref errors); } + }); + await Task.WhenAll(spikeTasks); + + // Calm: 10 sequential reads + for (var i = 0; i < 10; i++) + { + var response = await container.ReadItemAsync($"b-{i}", new PartitionKey($"pk-{i % 10}")); + if (response.StatusCode == HttpStatusCode.OK) ops++; + } + + output.WriteLine($"Burst pattern: {ops} ops, {errors} errors"); + errors.Should().Be(0); + ops.Should().Be(110); + } + + [Fact] + public async Task WriteOnlyStress() + { + var container = new InMemoryContainer("write-only", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + var totalWrites = 500; + + var tasks = Enumerable.Range(0, totalWrites).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = $"pk-{id}", Counter = 0, Data = "write-only", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{id}")); + } + catch + { + Interlocked.Increment(ref errors); + } + }); + + await Task.WhenAll(tasks); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + var count = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + count += page.Sum(); + } + + output.WriteLine($"Write-only: {count} items, {errors} errors"); + errors.Should().Be(0); + count.Should().Be(totalWrites); + } + + [Fact] + public async Task ReadOnlyStress_LargeDataset() + { + var container = new InMemoryContainer("read-only-large", "/partitionKey"); + var seedCount = 1000; + + for (var i = 0; i < seedCount; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"ro-{i}", PartitionKey = $"pk-{i % 50}", Counter = i, Data = "read-only", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 50}")); + } + + var errors = 0; + var reads = 0; + + var tasks = Enumerable.Range(0, 500).Select(async i => + { + try + { + var id = $"ro-{i % seedCount}"; + var pk = $"pk-{i % seedCount % 50}"; + var response = await container.ReadItemAsync(id, new PartitionKey(pk)); + if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref reads); + else Interlocked.Increment(ref errors); + } + catch + { + Interlocked.Increment(ref errors); + } + }); + + await Task.WhenAll(tasks); + + output.WriteLine($"Read-only large: {reads} reads, {errors} errors"); + errors.Should().Be(0); + reads.Should().Be(500); + } + + [Fact] + public async Task GradualRampUp_LinearIncrease() + { + var container = new InMemoryContainer("ramp-up", "/partitionKey"); + for (var i = 0; i < 100; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"r-{i}", PartitionKey = $"pk-{i % 10}", Counter = i, Data = "ramp", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey($"pk-{i % 10}")); + } + + var errors = 0; + var totalOps = 0; + + // Ramp from 10 to 100 concurrent ops + for (var level = 10; level <= 100; level += 10) + { + var tasks = Enumerable.Range(0, level).Select(async i => + { + try + { + var response = await container.ReadItemAsync($"r-{i % 100}", new PartitionKey($"pk-{i % 10}")); + if (response.StatusCode == HttpStatusCode.OK) Interlocked.Increment(ref totalOps); + } + catch { Interlocked.Increment(ref errors); } + }); + await Task.WhenAll(tasks); + } + + output.WriteLine($"Ramp-up: {totalOps} ops, {errors} errors"); + errors.Should().Be(0); + totalOps.Should().Be(550); // 10+20+30+...+100 + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1313,139 +1313,139 @@ await container.CreateItemAsync( public class LoadTestDataIntegrity(ITestOutputHelper output) { - [Fact] - public async Task PostLoadStateVerification() - { - var container = new InMemoryContainer("state-verify", "/partitionKey"); - var knownIds = new ConcurrentDictionary(); - - // Seed - for (var i = 0; i < 100; i++) - { - var id = $"sv-{i}"; - var pk = $"pk-{i % 10}"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "verify", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - } - - // Perform mixed operations - for (var i = 100; i < 150; i++) - { - var id = $"sv-{i}"; - var pk = $"pk-{i % 10}"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "verify", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - } - - // Delete some - for (var i = 0; i < 20; i++) - { - var id = $"sv-{i}"; - if (knownIds.TryRemove(id, out var pk)) - await container.DeleteItemAsync(id, new PartitionKey(pk)); - } - - // Verify all known IDs exist in container - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - } - - var containerIds = allItems.Select(d => d.Id).ToHashSet(); - var knownIdSet = knownIds.Keys.ToHashSet(); - - output.WriteLine($"Container: {containerIds.Count} items, Known: {knownIdSet.Count}"); - - containerIds.Should().BeEquivalentTo(knownIdSet, - "every known ID should exist in container, and no orphans should remain"); - } - - [Fact] - public async Task PatchCounterMonotonicity() - { - var container = new InMemoryContainer("patch-mono", "/partitionKey"); - var itemCount = 20; - var patchesPerItem = 10; - - // Seed with counter=0 - for (var i = 0; i < itemCount; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"pm-{i}", PartitionKey = "pk-pm", Counter = 0, Data = "patch", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("pk-pm")); - } - - // Patch increment each item N times (sequentially per item, parallel across items) - var tasks = Enumerable.Range(0, itemCount).Select(async i => - { - for (var j = 0; j < patchesPerItem; j++) - { - await container.PatchItemAsync( - $"pm-{i}", new PartitionKey("pk-pm"), - new[] { PatchOperation.Increment("/counter", 1) }); - } - }); - - await Task.WhenAll(tasks); - - // Verify all counters equal patchesPerItem - for (var i = 0; i < itemCount; i++) - { - var response = await container.ReadItemAsync($"pm-{i}", new PartitionKey("pk-pm")); - output.WriteLine($"Item pm-{i}: counter={response.Resource.Counter}"); - response.Resource.Counter.Should().Be(patchesPerItem, - $"item pm-{i} should have counter={patchesPerItem} after {patchesPerItem} increments"); - } - } - - [Fact(Skip = "Document size enforcement varies between InMemoryContainer (2MB limit checked on serialized JSON) " - + "and real Cosmos DB (which measures payload differently). Under load, 100KB+ documents cause memory pressure " - + "and GC flakiness. See sister test: LargeDocumentPayload_DivergentBehavior")] - public async Task LargeDocumentPayload_UnderLoad() - { - var container = new InMemoryContainer("large-doc", "/partitionKey"); - var largeData = new string('x', 100_000); - - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new LoadDocument { Id = $"lg-{i}", PartitionKey = "pk-lg", Counter = i, Data = largeData, Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("pk-lg")); - } - - for (var i = 0; i < 50; i++) - { - var response = await container.ReadItemAsync($"lg-{i}", new PartitionKey("pk-lg")); - response.Resource.Data.Should().HaveLength(100_000); - } - } - - [Fact] - public async Task LargeDocumentPayload_DivergentBehavior() - { - // DIVERGENT BEHAVIOR: InMemoryContainer measures document size using Newtonsoft.Json serialized - // byte count, while real Cosmos DB uses binary encoding overhead. Creating documents near the - // limit succeeds in both, but the exact boundary differs. This test verifies that a moderately - // large document (10KB) round-trips correctly without size-related errors. - var container = new InMemoryContainer("large-doc-sister", "/partitionKey"); - var data = new string('A', 10_000); - - var response = await container.CreateItemAsync( - new LoadDocument { Id = "lg-sister", PartitionKey = "pk-lg", Counter = 0, Data = data, Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey("pk-lg")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await container.ReadItemAsync("lg-sister", new PartitionKey("pk-lg")); - read.Resource.Data.Should().Be(data); - } + [Fact] + public async Task PostLoadStateVerification() + { + var container = new InMemoryContainer("state-verify", "/partitionKey"); + var knownIds = new ConcurrentDictionary(); + + // Seed + for (var i = 0; i < 100; i++) + { + var id = $"sv-{i}"; + var pk = $"pk-{i % 10}"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "verify", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + } + + // Perform mixed operations + for (var i = 100; i < 150; i++) + { + var id = $"sv-{i}"; + var pk = $"pk-{i % 10}"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = "verify", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + } + + // Delete some + for (var i = 0; i < 20; i++) + { + var id = $"sv-{i}"; + if (knownIds.TryRemove(id, out var pk)) + await container.DeleteItemAsync(id, new PartitionKey(pk)); + } + + // Verify all known IDs exist in container + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + } + + var containerIds = allItems.Select(d => d.Id).ToHashSet(); + var knownIdSet = knownIds.Keys.ToHashSet(); + + output.WriteLine($"Container: {containerIds.Count} items, Known: {knownIdSet.Count}"); + + containerIds.Should().BeEquivalentTo(knownIdSet, + "every known ID should exist in container, and no orphans should remain"); + } + + [Fact] + public async Task PatchCounterMonotonicity() + { + var container = new InMemoryContainer("patch-mono", "/partitionKey"); + var itemCount = 20; + var patchesPerItem = 10; + + // Seed with counter=0 + for (var i = 0; i < itemCount; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"pm-{i}", PartitionKey = "pk-pm", Counter = 0, Data = "patch", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("pk-pm")); + } + + // Patch increment each item N times (sequentially per item, parallel across items) + var tasks = Enumerable.Range(0, itemCount).Select(async i => + { + for (var j = 0; j < patchesPerItem; j++) + { + await container.PatchItemAsync( + $"pm-{i}", new PartitionKey("pk-pm"), + new[] { PatchOperation.Increment("/counter", 1) }); + } + }); + + await Task.WhenAll(tasks); + + // Verify all counters equal patchesPerItem + for (var i = 0; i < itemCount; i++) + { + var response = await container.ReadItemAsync($"pm-{i}", new PartitionKey("pk-pm")); + output.WriteLine($"Item pm-{i}: counter={response.Resource.Counter}"); + response.Resource.Counter.Should().Be(patchesPerItem, + $"item pm-{i} should have counter={patchesPerItem} after {patchesPerItem} increments"); + } + } + + [Fact(Skip = "Document size enforcement varies between InMemoryContainer (2MB limit checked on serialized JSON) " + + "and real Cosmos DB (which measures payload differently). Under load, 100KB+ documents cause memory pressure " + + "and GC flakiness. See sister test: LargeDocumentPayload_DivergentBehavior")] + public async Task LargeDocumentPayload_UnderLoad() + { + var container = new InMemoryContainer("large-doc", "/partitionKey"); + var largeData = new string('x', 100_000); + + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new LoadDocument { Id = $"lg-{i}", PartitionKey = "pk-lg", Counter = i, Data = largeData, Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("pk-lg")); + } + + for (var i = 0; i < 50; i++) + { + var response = await container.ReadItemAsync($"lg-{i}", new PartitionKey("pk-lg")); + response.Resource.Data.Should().HaveLength(100_000); + } + } + + [Fact] + public async Task LargeDocumentPayload_DivergentBehavior() + { + // DIVERGENT BEHAVIOR: InMemoryContainer measures document size using Newtonsoft.Json serialized + // byte count, while real Cosmos DB uses binary encoding overhead. Creating documents near the + // limit succeeds in both, but the exact boundary differs. This test verifies that a moderately + // large document (10KB) round-trips correctly without size-related errors. + var container = new InMemoryContainer("large-doc-sister", "/partitionKey"); + var data = new string('A', 10_000); + + var response = await container.CreateItemAsync( + new LoadDocument { Id = "lg-sister", PartitionKey = "pk-lg", Counter = 0, Data = data, Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey("pk-lg")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var read = await container.ReadItemAsync("lg-sister", new PartitionKey("pk-lg")); + read.Resource.Data.Should().Be(data); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1454,52 +1454,52 @@ public async Task LargeDocumentPayload_DivergentBehavior() public class LoadTestContainerLifecycle(ITestOutputHelper output) { - [Fact] - public async Task MultipleContainersConcurrentLoad() - { - var containers = Enumerable.Range(0, 3) - .Select(i => new InMemoryContainer($"multi-{i}", "/partitionKey")) - .ToArray(); - - var errors = 0; - - var tasks = containers.SelectMany((container, containerIndex) => - Enumerable.Range(0, 100).Select(async i => - { - try - { - var id = $"mc-{containerIndex}-{i}"; - var pk = $"pk-{i % 5}"; - await container.CreateItemAsync( - new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = $"container-{containerIndex}", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, - new PartitionKey(pk)); - } - catch - { - Interlocked.Increment(ref errors); - } - })); - - await Task.WhenAll(tasks); - - // Verify complete isolation - for (var containerIndex = 0; containerIndex < 3; containerIndex++) - { - var iterator = containers[containerIndex].GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - } - - output.WriteLine($"Container {containerIndex}: {allItems.Count} items"); - allItems.Should().HaveCount(100); - allItems.Should().OnlyContain(d => d.Data == $"container-{containerIndex}", - $"container {containerIndex} should only contain its own data"); - } - - errors.Should().Be(0); - } + [Fact] + public async Task MultipleContainersConcurrentLoad() + { + var containers = Enumerable.Range(0, 3) + .Select(i => new InMemoryContainer($"multi-{i}", "/partitionKey")) + .ToArray(); + + var errors = 0; + + var tasks = containers.SelectMany((container, containerIndex) => + Enumerable.Range(0, 100).Select(async i => + { + try + { + var id = $"mc-{containerIndex}-{i}"; + var pk = $"pk-{i % 5}"; + await container.CreateItemAsync( + new LoadDocument { Id = id, PartitionKey = pk, Counter = i, Data = $"container-{containerIndex}", Timestamp = DateTimeOffset.UtcNow.ToString("O") }, + new PartitionKey(pk)); + } + catch + { + Interlocked.Increment(ref errors); + } + })); + + await Task.WhenAll(tasks); + + // Verify complete isolation + for (var containerIndex = 0; containerIndex < 3; containerIndex++) + { + var iterator = containers[containerIndex].GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + } + + output.WriteLine($"Container {containerIndex}: {allItems.Count} items"); + allItems.Should().HaveCount(100); + allItems.Should().OnlyContain(d => d.Data == $"container-{containerIndex}", + $"container {containerIndex} should only contain its own data"); + } + + errors.Should().Be(0); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTestsExtended.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTestsExtended.cs index 0c1175e..b716d60 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTestsExtended.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Performance/LoadTestsExtended.cs @@ -12,1543 +12,1549 @@ namespace CosmosDB.InMemoryEmulator.Tests.Performance; public class LoadTestsExtended(ITestOutputHelper output) { - // ═══════════════════════════════════════════════════════════════════════════ - // Category E: Data Integrity Verification - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task E1_PostLoadStateVerification_NoOrphanedDocuments() - { - var (container, knownIds) = await SeedContainer("e1", 200); - var nextId = new AtomicCounter(1000); - - // Mixed load: creates + upserts + patches (no deletes keeps tracking deterministic) - await Task.WhenAll(Enumerable.Range(0, 500).Select(async _ => - { - switch (Random.Shared.Next(3)) - { - case 0: - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - break; - case 1: - var (uId, uPk) = PickRandom(knownIds); - if (uId != null) - await container.UpsertItemAsync(MakeDoc(uId, uPk!), new PartitionKey(uPk)); - break; - case 2: - var (pId, pPk) = PickRandom(knownIds); - if (pId != null) - await container.PatchItemAsync(pId, new PartitionKey(pPk), - [PatchOperation.Increment("/counter", 1)]); - break; - } - })); - - var allItems = await QueryAll(container); - var containerIds = allItems.Select(d => d.Id).ToHashSet(); - - knownIds.Keys.Should().BeSubsetOf(containerIds, "every tracked item should exist in the container"); - containerIds.Should().BeSubsetOf(knownIds.Keys.ToHashSet(), "every container item should be tracked"); - output.WriteLine($"Verified {allItems.Count} items, {knownIds.Count} tracked — no orphans"); - } - - [Fact] - public async Task E2_PatchCounterMonotonicity_CountersMatchPatchCount() - { - var container = new InMemoryContainer("e2", "/partitionKey"); - var itemIds = new List(); - - // Seed 20 items with counter=0 in the same partition for simplicity - for (var i = 1; i <= 20; i++) - { - var id = i.ToString(); - await container.CreateItemAsync(MakeDoc(id, "pk-e2", counter: 0), new PartitionKey("pk-e2")); - itemIds.Add(id); - } - - var patchCounts = new ConcurrentDictionary(); - - // Run 500 concurrent patch-increment operations on random items - await Task.WhenAll(Enumerable.Range(0, 500).Select(async _ => - { - var targetId = itemIds[Random.Shared.Next(itemIds.Count)]; - await container.PatchItemAsync(targetId, new PartitionKey("pk-e2"), - [PatchOperation.Increment("/counter", 1)]); - patchCounts.AddOrUpdate(targetId, 1, (_, count) => count + 1); - })); - - // Verify each item's counter matches the tracked patch count - foreach (var id in itemIds) - { - var response = await container.ReadItemAsync(id, new PartitionKey("pk-e2")); - var expectedPatches = patchCounts.GetValueOrDefault(id, 0); - response.Resource.Counter.Should().Be(expectedPatches, - $"item {id} should have counter={expectedPatches} after {expectedPatches} patch increments"); - } - - output.WriteLine($"Verified patch monotonicity across {itemIds.Count} items, {patchCounts.Values.Sum()} total patches"); - } - - [Fact(Skip = "Document size enforcement varies between InMemoryContainer (Newtonsoft.Json serialized byte count) " + - "and real Cosmos DB (binary transport encoding with different overhead). Under load, the overhead of " + - "generating/verifying 100KB+ documents makes the test flaky due to memory pressure and GC, not due " + - "to Cosmos behavior differences.")] - public async Task E3_LargeDocumentPayload_UnderLoad() - { - var container = new InMemoryContainer("e3", "/partitionKey"); - var nextId = new AtomicCounter(0); - - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var largeData = new string('x', 100_000); - var doc = new LoadDocument - { - Id = id, PartitionKey = $"pk-{id}", Counter = 0, Data = largeData, - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }; - await container.CreateItemAsync(doc, new PartitionKey($"pk-{id}")); - })); - - container.ItemCount.Should().Be(100); - await Task.CompletedTask; - } - - [Fact] - public async Task E3_LargeDocumentPayload_DivergentBehavior() - { - // InMemoryContainer measures document size using Newtonsoft.Json serialized byte count. - // Real Cosmos DB uses a binary transport encoding with different overhead. - // Both enforce approximately 2MB limits, but the exact boundary point differs. - // - // InMemoryContainer: rejects documents where JsonConvert.SerializeObject(doc).Length > ~2MB - // Real Cosmos DB: measures the wire-format payload size which includes property name encoding, - // type markers, and partition key routing headers. A ~1.95MB JSON document may fail on real - // Cosmos but succeed in InMemoryContainer, or vice versa depending on property structure. - // - // This test demonstrates a document well under the limit (500KB) succeeds in the emulator, - // confirming no truncation or corruption of large payloads. - - var container = new InMemoryContainer("e3-divergent", "/partitionKey"); - - var largeData = new string('x', 500_000); - var doc = new LoadDocument - { - Id = "1", PartitionKey = "pk-1", Counter = 42, Data = largeData, - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }; - - var response = await container.CreateItemAsync(doc, new PartitionKey("pk-1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk-1")); - readResponse.Resource.Data.Should().HaveLength(500_000, "document data should not be truncated"); - readResponse.Resource.Counter.Should().Be(42, "document fields should not be corrupted"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category A: Missing Operation Types Under Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task A1_ReadManyUnderLoad_VerifiesAllItemsReturned() - { - var (container, knownIds) = await SeedContainer("a1", 200); - var errors = 0; - - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - var snapshot = knownIds.ToArray(); - var selected = snapshot.OrderBy(_ => Random.Shared.Next()).Take(5).ToList(); - var items = selected.Select(kv => (kv.Key, new PartitionKey(kv.Value))).ToList(); - - var response = await container.ReadManyItemsAsync(items); - - if (response.Count != items.Count) - Interlocked.Increment(ref errors); - })); - - errors.Should().Be(0, "all ReadMany calls should return the requested number of items"); - output.WriteLine("200 ReadMany calls completed successfully"); - } - - [Fact] - public async Task A2_TransactionalBatchUnderLoad_AtomicCreateAndRead() - { - var container = new InMemoryContainer("a2", "/partitionKey"); - - // Pre-create one item per partition for batch read operations - for (var i = 0; i < 10; i++) - await container.CreateItemAsync(MakeDoc($"pre-{i}", $"pk-{i}"), new PartitionKey($"pk-{i}")); - - var nextId = new AtomicCounter(100); - var errors = 0; - - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var batchPk = $"pk-{Random.Shared.Next(10)}"; - var batch = container.CreateTransactionalBatch(new PartitionKey(batchPk)); - var id1 = nextId.Increment().ToString(); - var id2 = nextId.Increment().ToString(); - var id3 = nextId.Increment().ToString(); - batch.CreateItem(MakeDoc(id1, batchPk)); - batch.CreateItem(MakeDoc(id2, batchPk)); - batch.CreateItem(MakeDoc(id3, batchPk)); - using var response = await batch.ExecuteAsync(); - if (!response.IsSuccessStatusCode) - Interlocked.Increment(ref errors); - })); - - errors.Should().Be(0, "all batches should succeed"); - container.ItemCount.Should().BeGreaterThanOrEqualTo(310, "10 pre-created + 300 from batches"); - output.WriteLine($"100 batches completed, {container.ItemCount} total items"); - } - - [Fact] - public async Task A3_DeleteAllByPartitionKeyUnderLoad() - { - var container = new InMemoryContainer("a3", "/partitionKey"); - - // Seed items into pk-target (to be bulk-deleted) and pk-safe-0..pk-safe-4 (to survive) - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(MakeDoc($"target-{i}", "pk-target"), new PartitionKey("pk-target")); - for (var i = 0; i < 50; i++) - { - var safePk = $"pk-safe-{i % 5}"; - await container.CreateItemAsync(MakeDoc($"safe-{i}", safePk), new PartitionKey(safePk)); - } - - container.ItemCount.Should().Be(100); - - // Concurrently: delete all in pk-target + write new items to pk-safe partitions - var nextId = new AtomicCounter(1000); - var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk-target")); - var writeTasks = Enumerable.Range(0, 50).Select(async _ => - { - var id = nextId.Increment().ToString(); - var safePk = $"pk-safe-{Random.Shared.Next(5)}"; - await container.CreateItemAsync(MakeDoc(id, safePk), new PartitionKey(safePk)); - }); - - await Task.WhenAll(writeTasks.Append(deleteTask)); - - // Verify pk-target items are gone - var targetIterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk-target'")); - var targetResults = new List(); - while (targetIterator.HasMoreResults) - { - var page = await targetIterator.ReadNextAsync(); - targetResults.AddRange(page); - } - - targetResults.Should().BeEmpty("all pk-target items should be deleted"); - - // Verify safe items are intact (original 50 + new writes) - container.ItemCount.Should().BeGreaterThanOrEqualTo(100, "50 original safe + 50 new writes"); - output.WriteLine($"DeleteAllByPartitionKey verified: {container.ItemCount} items remain, pk-target cleared"); - } - - [Fact] - public async Task A4_StreamApiOperationsUnderLoad() - { - var container = new InMemoryContainer("a4", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - - // Concurrent stream create operations - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - var doc = MakeDoc(id, pk); - var json = JsonConvert.SerializeObject(doc); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - using var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pk)); - if (response.StatusCode != HttpStatusCode.Created) - Interlocked.Increment(ref errors); - })); - - errors.Should().Be(0, "all stream creates should succeed"); - container.ItemCount.Should().Be(100); - - // Concurrent stream read operations — IDs 1..100 match the creates above - await Task.WhenAll(Enumerable.Range(1, 100).Select(async i => - { - var id = i.ToString(); - var pk = $"pk-{id}"; - using var response = await container.ReadItemStreamAsync(id, new PartitionKey(pk)); - if (response.StatusCode != HttpStatusCode.OK) - { - Interlocked.Increment(ref errors); - return; - } - - using var reader = new StreamReader(response.Content); - var content = await reader.ReadToEndAsync(); - var readDoc = JsonConvert.DeserializeObject(content)!; - if (readDoc.Id != id) - Interlocked.Increment(ref errors); - })); - - errors.Should().Be(0, "all stream reads should succeed with correct data"); - output.WriteLine($"200 stream operations completed, {container.ItemCount} items"); - } - - [Fact] - public async Task A5_ChangeFeedReadDuringWrites() - { - var container = new InMemoryContainer("a5", "/partitionKey"); - - // Seed initial items - for (var i = 1; i <= 50; i++) - { - var pk = $"pk-{i % 10}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - // Write more items concurrently - var nextId = new AtomicCounter(1000); - var writtenIds = new ConcurrentBag(); - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - writtenIds.Add(id); - })); - - // Read entire change feed from beginning - var feedItems = new List(); - var feedIterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - while (feedIterator.HasMoreResults) - { - try - { - var response = await feedIterator.ReadNextAsync(); - feedItems.AddRange(response); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotModified) - { - break; - } - } - - var feedIds = feedItems.Select(d => d.Id).ToHashSet(); - var allExpectedIds = Enumerable.Range(1, 50).Select(i => i.ToString()).Concat(writtenIds).ToHashSet(); - allExpectedIds.Should().BeSubsetOf(feedIds, "all seeded and written items should appear in the change feed"); - output.WriteLine($"Change feed returned {feedItems.Count} items, expected {allExpectedIds.Count}"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category B: Query Diversity Under Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task B1_CrossPartitionQueryUnderLoad() - { - var (container, _) = await SeedContainer("b1", 200); - var errors = 0; - - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var threshold = Random.Shared.Next(500); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") - .WithParameter("@threshold", threshold)); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Verify all returned documents actually satisfy the filter - if (results.Any(d => d.Counter <= threshold)) - Interlocked.Increment(ref errors); - - // Verify cross-partition: results should come from multiple PKs (with high probability) - // We don't assert this strictly since small result sets might come from one PK - })); - - errors.Should().Be(0, "all cross-partition query results should satisfy the WHERE clause"); - output.WriteLine("100 cross-partition queries completed successfully"); - } - - [Fact] - public async Task B2_AggregateQueryUnderLoad() - { - var (container, _) = await SeedContainer("b2", 200); - var nextId = new AtomicCounter(1000); - var errors = 0; - - // Concurrent: writes happening alongside aggregate queries - var writeTasks = Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - }); - - var queryTasks = Enumerable.Range(0, 50).Select(async _ => - { - // COUNT query - var countIterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - var counts = new List(); - while (countIterator.HasMoreResults) - { - var page = await countIterator.ReadNextAsync(); - counts.AddRange(page); - } - - var count = counts.Sum(); - // Count should be at least the seed count (200) since we never delete - if (count < 200) - Interlocked.Increment(ref errors); - }); - - await Task.WhenAll(writeTasks.Concat(queryTasks)); - - errors.Should().Be(0, "aggregate COUNT should always be >= seed count during write-only load"); - output.WriteLine($"50 aggregate queries during 100 writes — container has {container.ItemCount} items"); - } - - [Fact] - public async Task B3_DistinctQueryUnderLoad() - { - var (container, _) = await SeedContainer("b3", 200); - var nextId = new AtomicCounter(1000); - var errors = 0; - - var writeTasks = Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{Random.Shared.Next(20)}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - }); - - var queryTasks = Enumerable.Range(0, 50).Select(async _ => - { - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); - - var partitionKeys = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - partitionKeys.AddRange(page); - } - - // DISTINCT should have no duplicates - if (partitionKeys.Count != partitionKeys.Distinct().Count()) - Interlocked.Increment(ref errors); - }); - - await Task.WhenAll(writeTasks.Concat(queryTasks)); - - errors.Should().Be(0, "DISTINCT queries should never return duplicate values"); - output.WriteLine("50 DISTINCT queries during 100 writes completed without duplicates"); - } - - [Fact] - public async Task B4_OffsetLimitPaginationUnderLoad() - { - var (container, _) = await SeedContainer("b4", 200); - var errors = 0; - - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var offset = Random.Shared.Next(50); - var iterator = container.GetItemQueryIterator( - new QueryDefinition($"SELECT * FROM c ORDER BY c.id OFFSET {offset} LIMIT 10")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count > 10) - Interlocked.Increment(ref errors); - })); - - errors.Should().Be(0, "OFFSET/LIMIT queries should never return more than the LIMIT"); - output.WriteLine("100 OFFSET/LIMIT queries completed with correct page sizes"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category C: Concurrency Edge Cases Under Sustained Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task C1_ETagConflictsUnderLoad_ExactlyOneWinsPerRound() - { - var container = new InMemoryContainer("c1", "/partitionKey"); - var doc = MakeDoc("1", "pk-1"); - var created = await container.CreateItemAsync(doc, new PartitionKey("pk-1")); - var originalEtag = created.ETag; - - var successes = 0; - var preconditionFailures = 0; - - // 50 threads all try to replace using the SAME original ETag — exactly one should win - await Task.WhenAll(Enumerable.Range(0, 50).Select(async _ => - { - try - { - var replacement = MakeDoc("1", "pk-1"); - await container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk-1"), - new ItemRequestOptions { IfMatchEtag = originalEtag }); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - Interlocked.Increment(ref preconditionFailures); - } - })); - - successes.Should().Be(1, "exactly one thread should win the ETag race"); - preconditionFailures.Should().Be(49, "all other threads should get 412 PreconditionFailed"); - output.WriteLine($"ETag race: {successes} winner, {preconditionFailures} losers"); - } - - [Fact] - public async Task C2_CreateAfterDelete_SameId_Succeeds() - { - var container = new InMemoryContainer("c2", "/partitionKey"); - - // Pre-create 50 items - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c2"), new PartitionKey("pk-c2")); - - var errors = 0; - - // For each item: delete then immediately re-create with same ID - await Task.WhenAll(Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.DeleteItemAsync(i.ToString(), new PartitionKey("pk-c2")); - await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c2"), new PartitionKey("pk-c2")); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "delete-then-create on same ID should always succeed"); - container.ItemCount.Should().Be(50, "all 50 items should exist after delete+recreate"); - output.WriteLine("50 delete+recreate cycles completed successfully"); - } - - [Fact] - public async Task C3_DoubleDelete_Returns404OnSecond() - { - var container = new InMemoryContainer("c3", "/partitionKey"); - - // Pre-create 50 items - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c3"), new PartitionKey("pk-c3")); - - var successes = 0; - var notFounds = 0; - - // Two threads each try to delete every item - await Task.WhenAll(Enumerable.Range(0, 50).SelectMany(i => Enumerable.Range(0, 2).Select(async _ => - { - try - { - await container.DeleteItemAsync(i.ToString(), new PartitionKey("pk-c3")); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Interlocked.Increment(ref notFounds); - } - }))); - - successes.Should().Be(50, "exactly one delete per item should succeed"); - notFounds.Should().Be(50, "exactly one delete per item should get 404"); - container.ItemCount.Should().Be(0, "all items should be deleted"); - output.WriteLine($"Double delete: {successes} successes, {notFounds} not-founds"); - } - - [Fact] - public async Task C4_HotPartitionLoadTest_NoStarvation() - { - var container = new InMemoryContainer("c4", "/partitionKey"); - - // Seed items in hot partition and cold partitions - for (var i = 0; i < 100; i++) - await container.CreateItemAsync(MakeDoc($"hot-{i}", "pk-hot"), new PartitionKey("pk-hot")); - for (var i = 0; i < 50; i++) - { - var coldPk = $"pk-cold-{i % 5}"; - await container.CreateItemAsync(MakeDoc($"cold-{i}", coldPk), new PartitionKey(coldPk)); - } - - var hotErrors = 0; - var coldErrors = 0; - - // 80% operations target pk-hot, 20% target cold partitions - await Task.WhenAll(Enumerable.Range(0, 500).Select(async opIndex => - { - try - { - if (Random.Shared.Next(100) < 80) - { - var targetId = $"hot-{Random.Shared.Next(100)}"; - await container.ReadItemAsync(targetId, new PartitionKey("pk-hot")); - } - else - { - var coldPk = $"pk-cold-{Random.Shared.Next(5)}"; - var targetId = $"cold-{Random.Shared.Next(50)}"; - await container.ReadItemAsync(targetId, new PartitionKey(coldPk)); - } - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Item may not exist in the specific cold PK — that's OK - } - catch (Exception) - { - if (Random.Shared.Next(100) < 80) Interlocked.Increment(ref hotErrors); - else Interlocked.Increment(ref coldErrors); - } - })); - - hotErrors.Should().Be(0, "hot partition operations should not fail"); - coldErrors.Should().Be(0, "cold partition operations should not be starved"); - output.WriteLine("500 hot-partition-biased operations completed without errors"); - } - - [Fact] - public async Task C5_HighCardinalityPartitionKeys_ThousandsOfDistinctPKs() - { - var container = new InMemoryContainer("c5", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - - // Create 2000 items, each with a unique partition key - await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-unique-{id}"; - try - { - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "all creates with unique PKs should succeed"); - container.ItemCount.Should().Be(2000, "all 2000 unique-PK items should exist"); - - // Read back a random sample to verify - var readErrors = 0; - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - var id = Random.Shared.Next(2000).ToString(); - try - { - await container.ReadItemAsync(id, new PartitionKey($"pk-unique-{id}")); - } - catch (Exception) - { - Interlocked.Increment(ref readErrors); - } - })); - - readErrors.Should().Be(0, "reads across high-cardinality PKs should succeed"); - output.WriteLine("2000 unique PKs + 200 random reads completed successfully"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category D: Stress Patterns - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task D1_BurstTrafficPattern_SpikeThenCalm() - { - var container = new InMemoryContainer("d1", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - - for (var cycle = 0; cycle < 3; cycle++) - { - // Burst: 200 concurrent operations - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - // Calm: 20 sequential operations - for (var calm = 0; calm < 20; calm++) - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - } - - errors.Should().Be(0, "no errors should occur during burst/calm cycles"); - container.ItemCount.Should().Be(660, "3 cycles × (200 burst + 20 calm) = 660 items"); - output.WriteLine($"3 burst/calm cycles completed: {container.ItemCount} items, {errors} errors"); - } - - [Fact] - public async Task D2_GradualRampUp_LinearIncrease() - { - var container = new InMemoryContainer("d2", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - var totalOps = 0; - - // Ramp from 50 ops to 500 ops per "second" (10 levels) - for (var level = 1; level <= 10; level++) - { - var opsThisLevel = 50 * level; - await Task.WhenAll(Enumerable.Range(0, opsThisLevel).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - totalOps += opsThisLevel; - } - - errors.Should().Be(0, "no errors during gradual ramp-up"); - container.ItemCount.Should().Be(totalOps, $"all {totalOps} ramped operations should create items"); - output.WriteLine($"Ramp complete: {totalOps} ops across 10 levels, {errors} errors"); - } - - [Fact] - public async Task D3_WriteOnlyStress_MonotonicallyIncreasingItemCount() - { - var container = new InMemoryContainer("d3", "/partitionKey"); - var nextId = new AtomicCounter(0); - var errors = 0; - - await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "no errors during write-only stress"); - container.ItemCount.Should().Be(2000, "all 2000 write-only items should exist"); - output.WriteLine($"Write-only stress: {container.ItemCount} items, {errors} errors"); - } - - [Fact] - public async Task D4_ReadOnlyStress_LargeDataset() - { - var container = new InMemoryContainer("d4", "/partitionKey"); - - // Seed 5000 items - for (var i = 0; i < 5000; i++) - { - var pk = $"pk-{i % 50}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - var errors = 0; - - // 2000 concurrent read operations (point reads + queries) - await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => - { - try - { - if (Random.Shared.Next(2) == 0) - { - // Point read - var id = Random.Shared.Next(5000).ToString(); - var pk = $"pk-{int.Parse(id) % 50}"; - var response = await container.ReadItemAsync(id, new PartitionKey(pk)); - if (response.StatusCode != HttpStatusCode.OK) - Interlocked.Increment(ref errors); - } - else - { - // Query - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 5 * FROM c WHERE c.counter > @t") - .WithParameter("@t", Random.Shared.Next(500))); - while (iterator.HasMoreResults) - { - await iterator.ReadNextAsync(); - } - } - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "no errors during read-only stress on large dataset"); - output.WriteLine($"Read-only stress: 2000 ops on {container.ItemCount} items, {errors} errors"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category F: Container/Database Lifecycle Under Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task F1_MultipleContainersConcurrentLoad_CompleteIsolation() - { - var container1 = new InMemoryContainer("f1-alpha", "/partitionKey"); - var container2 = new InMemoryContainer("f1-beta", "/partitionKey"); - var container3 = new InMemoryContainer("f1-gamma", "/partitionKey"); - - var errors = 0; - - // Run concurrent operations on all three containers simultaneously - async Task WriteToContainer(InMemoryContainer container, string prefix, int count) - { - var nextId = new AtomicCounter(0); - await Task.WhenAll(Enumerable.Range(0, count).Select(async _ => - { - try - { - var id = $"{prefix}-{nextId.Increment()}"; - var pk = $"pk-{prefix}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - } - - await Task.WhenAll( - WriteToContainer(container1, "alpha", 200), - WriteToContainer(container2, "beta", 200), - WriteToContainer(container3, "gamma", 200)); - - errors.Should().Be(0, "no errors across any container"); - - // Verify complete isolation: each container has exactly its own items - container1.ItemCount.Should().Be(200, "container alpha should have exactly 200 items"); - container2.ItemCount.Should().Be(200, "container beta should have exactly 200 items"); - container3.ItemCount.Should().Be(200, "container gamma should have exactly 200 items"); - - // Verify no cross-contamination by checking item prefixes - var items1 = await QueryAll(container1); - var items2 = await QueryAll(container2); - var items3 = await QueryAll(container3); - - items1.Should().OnlyContain(d => d.Id.StartsWith("alpha-"), "container1 should only have alpha items"); - items2.Should().OnlyContain(d => d.Id.StartsWith("beta-"), "container2 should only have beta items"); - items3.Should().OnlyContain(d => d.Id.StartsWith("gamma-"), "container3 should only have gamma items"); - output.WriteLine("3 containers × 200 items each — complete isolation verified"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category G: Delete-Heavy Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task G1_DeleteHeavyLoad_90PercentDeletes_ContainerDrainsWithoutCorruption() - { - var container = new InMemoryContainer("g1", "/partitionKey"); - var knownIds = new ConcurrentDictionary(); - var nextId = new AtomicCounter(10_000); - - // Seed 500 items - for (var i = 1; i <= 500; i++) - { - var id = i.ToString(); - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - } - - var deletes = 0; - var creates = 0; - var notFounds = 0; - var errors = 0; - - // 1000 operations: 90% delete, 10% create - await Task.WhenAll(Enumerable.Range(0, 1000).Select(async _ => - { - try - { - if (Random.Shared.Next(100) < 90) - { - // Delete - var ids = knownIds.Keys.ToArray(); - if (ids.Length == 0) return; - var targetId = ids[Random.Shared.Next(ids.Length)]; - if (!knownIds.TryRemove(targetId, out var pk)) return; - try - { - await container.DeleteItemAsync(targetId, new PartitionKey(pk)); - Interlocked.Increment(ref deletes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Interlocked.Increment(ref notFounds); - } - catch - { - knownIds.TryAdd(targetId, pk); - throw; - } - } - else - { - // Create - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - Interlocked.Increment(ref creates); - } - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "no unexpected errors during delete-heavy load"); - - // Post-load integrity: container count should match tracked IDs - var allItems = await QueryAll(container); - allItems.Count.Should().Be(knownIds.Count, - "container item count should match tracked IDs after delete-heavy load"); - - output.WriteLine($"Delete-heavy: {deletes} deletes, {creates} creates, {notFounds} 404s, " + - $"{container.ItemCount} remaining items, {errors} errors"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category H: TTL Under Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact(Skip = "Real Cosmos DB proactively expires items via a background process, so items " + - "disappear from query results and point reads independently of any client " + - "activity. InMemoryContainer uses lazy eviction — items are only checked " + - "and removed when accessed (read/query/replace). Under sustained concurrent " + - "load this means write-only items may appear to survive past their TTL until " + - "a read touches them, producing different timing behavior than real Cosmos.")] - public async Task H1_TTLUnderLoad_ProactiveExpiration() - { - // This test expects items to be proactively expired (like real Cosmos DB), - // but InMemoryContainer only evicts lazily on read. See the sister test below - // for the actual emulator behavior. - await Task.CompletedTask; - } - - [Fact] - public async Task H1_TTLUnderLoad_LazyEviction_EmulatorBehavior() - { - // ────────────────────────────────────────────────────────────────────── - // DIVERGENT BEHAVIOR: InMemoryContainer TTL eviction is LAZY. - // - // Real Cosmos DB: Items are proactively expired by a background process. - // After DefaultTimeToLive seconds, the item disappears from ALL queries - // and point reads, regardless of whether any client reads it. - // - // InMemoryContainer: Items are checked for expiration ONLY when accessed - // (point read, query, replace, patch, etc.). An expired item that is - // never read will remain in _items until something touches it. - // This means: - // 1. container.ItemCount may include expired-but-unread items - // 2. A query or point read will evict the item and return NotFound/empty - // 3. Under concurrent write-only load, expired items linger - // - // This test demonstrates the lazy eviction behavior under load: - // - Create items with short TTL - // - Wait for TTL to pass - // - Verify items ARE evicted when read (lazy eviction works) - // - Show that unread items may linger in internal storage - // ────────────────────────────────────────────────────────────────────── - - var container = new InMemoryContainer("h1-lazy", "/partitionKey"); - container.DefaultTimeToLive = 2; // 2 second TTL - - // Create 100 items concurrently - var nextId = new AtomicCounter(0); - await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - })); - - container.ItemCount.Should().Be(100, "all 100 items should exist immediately after creation"); - - // Wait for TTL to expire - await Task.Delay(TimeSpan.FromSeconds(3)); - - // Now read items — lazy eviction should kick in and return 404 - var evicted = 0; - var survived = 0; - await Task.WhenAll(Enumerable.Range(0, 100).Select(async i => - { - try - { - await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); - Interlocked.Increment(ref survived); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Interlocked.Increment(ref evicted); - } - })); - - evicted.Should().Be(100, "all items should be evicted after TTL expires and they are read"); - survived.Should().Be(0, "no items should survive past their TTL when read"); - output.WriteLine($"TTL lazy eviction: {evicted} evicted on read, {survived} survived"); - } - - [Fact] - public async Task H2_TTLWithConcurrentWritesAndReads() - { - var container = new InMemoryContainer("h2", "/partitionKey"); - container.DefaultTimeToLive = 3; // 3 seconds TTL - - var nextId = new AtomicCounter(0); - var errors = 0; - - // Phase 1: Create items - for (var i = 0; i < 50; i++) - { - var pk = $"pk-{i}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - // Phase 2: Wait for partial TTL, then do concurrent reads + new writes - await Task.Delay(TimeSpan.FromSeconds(2)); - - var readNotFound = 0; - var readFound = 0; - var newCreates = 0; - - await Task.WhenAll( - // Read old items (may or may not be expired yet depending on timing) - Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); - Interlocked.Increment(ref readFound); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Interlocked.Increment(ref readNotFound); - } - catch - { - Interlocked.Increment(ref errors); - } - }).Concat( - // Create fresh items (these should survive because their TTL starts fresh) - Enumerable.Range(0, 50).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var pk = $"pk-new-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - Interlocked.Increment(ref newCreates); - } - catch - { - Interlocked.Increment(ref errors); - } - })) - ); - - errors.Should().Be(0, "no unexpected errors during TTL concurrent test"); - newCreates.Should().Be(50, "all new items should be created"); - - // New items should still be readable (their TTL just started) - var newItemErrors = 0; - await Task.WhenAll(Enumerable.Range(1, 50).Select(async i => - { - try - { - await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-new-{i}")); - } - catch - { - Interlocked.Increment(ref newItemErrors); - } - })); - - newItemErrors.Should().Be(0, "freshly created items should survive their TTL window"); - - output.WriteLine($"TTL concurrent: {readFound} old found, {readNotFound} old expired, " + - $"{newCreates} new created, {newItemErrors} new read errors"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category I: FakeCosmosHandler Load - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task I1_FakeCosmosHandlerLoad_CrudThroughHttpPipeline() - { - var container = new InMemoryContainer("i1", "/partitionKey"); - - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) } - }); - - var cosmosContainer = client.GetContainer("fakeDb", "i1"); - var nextId = new AtomicCounter(0); - var errors = 0; - - // Create 200 items through the HTTP pipeline - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await cosmosContainer.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "all creates through FakeCosmosHandler should succeed"); - container.ItemCount.Should().Be(200, "200 items should exist in the backing container"); - - // Read them back through the pipeline - var readErrors = 0; - await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => - { - try - { - await cosmosContainer.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); - } - catch (Exception) - { - Interlocked.Increment(ref readErrors); - } - })); - - readErrors.Should().Be(0, "all reads through FakeCosmosHandler should succeed"); - - // Query through the pipeline - var queryIterator = cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.id")); - var queryResults = new List(); - while (queryIterator.HasMoreResults) - { - var page = await queryIterator.ReadNextAsync(); - queryResults.AddRange(page); - } - - queryResults.Should().HaveCountLessThanOrEqualTo(10); - output.WriteLine($"FakeCosmosHandler: 200 creates, 200 reads, query returned {queryResults.Count} items"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category J: Empty Container & Edge Cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task J1_EmptyContainerQueryLoad_QueriesOnEmptyContainer() - { - var container = new InMemoryContainer("j1", "/partitionKey"); - var errors = 0; - - // 200 concurrent queries on a completely empty container - await Task.WhenAll(Enumerable.Range(0, 200).Select(async i => - { - try - { - switch (i % 4) - { - case 0: - var iter1 = container.GetItemQueryIterator("SELECT * FROM c"); - while (iter1.HasMoreResults) - { - var page = await iter1.ReadNextAsync(); - page.Count.Should().Be(0); - } - break; - case 1: - var iter2 = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - while (iter2.HasMoreResults) - { - var page = await iter2.ReadNextAsync(); - foreach (var count in page) count.Should().Be(0); - } - break; - case 2: - var iter3 = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 10 * FROM c WHERE c.counter > 0")); - while (iter3.HasMoreResults) - { - var page = await iter3.ReadNextAsync(); - page.Count.Should().Be(0); - } - break; - case 3: - try - { - await container.ReadItemAsync("nonexistent", - new PartitionKey("nonexistent")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected - } - break; - } - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "queries on empty container should not produce unexpected errors"); - container.ItemCount.Should().Be(0, "container should remain empty"); - output.WriteLine("200 queries on empty container completed without errors"); - } - - [Fact] - public async Task J2_NumericPartitionKeys_UnderLoad() - { - var container = new InMemoryContainer("j2", "/numericPk"); - var nextId = new AtomicCounter(0); - var errors = 0; - - // Create 200 items with numeric partition keys - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var numPk = int.Parse(id) % 20; - var doc = new JObject { ["id"] = id, ["numericPk"] = numPk, ["data"] = $"data-{id}" }; - await container.CreateItemAsync(doc, new PartitionKey(numPk)); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "all creates with numeric PKs should succeed"); - container.ItemCount.Should().Be(200); - - // Read back with numeric PKs - var readErrors = 0; - await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => - { - try - { - var numPk = i % 20; - await container.ReadItemAsync(i.ToString(), new PartitionKey(numPk)); - } - catch (Exception) - { - Interlocked.Increment(ref readErrors); - } - })); - - readErrors.Should().Be(0, "all reads with numeric PKs should succeed"); - output.WriteLine($"Numeric PKs: 200 creates, 200 reads, {errors + readErrors} errors"); - } - - [Fact] - public async Task J3_HierarchicalPartitionKeys_UnderLoad() - { - var container = new InMemoryContainer("j3", new[] { "/tenantId", "/userId" }); - var nextId = new AtomicCounter(0); - var errors = 0; - - // Create 200 items with hierarchical partition keys - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - try - { - var id = nextId.Increment().ToString(); - var tenantId = $"tenant-{int.Parse(id) % 5}"; - var userId = $"user-{int.Parse(id) % 20}"; - var doc = new JObject { ["id"] = id, ["tenantId"] = tenantId, ["userId"] = userId, ["data"] = $"data-{id}" }; - var pk = new PartitionKeyBuilder().Add(tenantId).Add(userId).Build(); - await container.CreateItemAsync(doc, pk); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "all creates with hierarchical PKs should succeed"); - container.ItemCount.Should().Be(200); - - // Read back with hierarchical PKs - var readErrors = 0; - await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => - { - try - { - var tenantId = $"tenant-{i % 5}"; - var userId = $"user-{i % 20}"; - var pk = new PartitionKeyBuilder().Add(tenantId).Add(userId).Build(); - await container.ReadItemAsync(i.ToString(), pk); - } - catch (Exception) - { - Interlocked.Increment(ref readErrors); - } - })); - - readErrors.Should().Be(0, "all reads with hierarchical PKs should succeed"); - output.WriteLine($"Hierarchical PKs: 200 creates, 200 reads, {errors + readErrors} errors"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Category K: Latency & Iterator Resilience - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task K1_LatencyDistribution_P99StableAcrossBatches() - { - var container = new InMemoryContainer("k1", "/partitionKey"); - - // Seed 500 items - for (var i = 0; i < 500; i++) - { - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - var batchP99s = new List(); - - // Run 5 batches of 200 ops each, collecting P99 per batch - for (var batch = 0; batch < 5; batch++) - { - var latencies = new ConcurrentBag(); - - await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - var id = Random.Shared.Next(500).ToString(); - var pk = $"pk-{int.Parse(id) % 20}"; - await container.ReadItemAsync(id, new PartitionKey(pk)); - sw.Stop(); - latencies.Add(sw.Elapsed.TotalMilliseconds); - })); - - var sorted = latencies.OrderBy(l => l).ToArray(); - var p99Index = (int)Math.Ceiling(0.99 * sorted.Length) - 1; - var p99 = sorted[Math.Max(0, p99Index)]; - batchP99s.Add(p99); - output.WriteLine($" Batch {batch + 1}: P99 = {p99:F3}ms"); - } - - // P99 should not vary wildly between batches (no more than 10x of the minimum) - var minP99 = batchP99s.Min(); - var maxP99 = batchP99s.Max(); - maxP99.Should().BeLessThan(Math.Max(minP99 * 10, 50), - "P99 latency should be stable across batches (no catastrophic regression)"); - output.WriteLine($"P99 range: {minP99:F3}ms - {maxP99:F3}ms"); - } - - [Fact] - public async Task K2_ConcurrentIteratorDrain_MultipleQueriesSimultaneously() - { - var container = new InMemoryContainer("k2", "/partitionKey"); - - // Seed 500 items - for (var i = 0; i < 500; i++) - { - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - var errors = 0; - var totalResults = new ConcurrentBag(); - - // 50 threads each drain a full SELECT * iterator concurrently - await Task.WhenAll(Enumerable.Range(0, 50).Select(async _ => - { - try - { - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var count = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - count += page.Count; - } - - totalResults.Add(count); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - })); - - errors.Should().Be(0, "concurrent iterator drains should not fail"); - - // Each iterator should see all 500 items (snapshot consistency) - foreach (var count in totalResults) - { - count.Should().Be(500, "each iterator should return all 500 items"); - } - - output.WriteLine($"50 concurrent iterator drains: all returned 500 items, {errors} errors"); - } - - [Fact] - public async Task K3_ConcurrentIteratorDrain_DuringWrites_NoCorruption() - { - var container = new InMemoryContainer("k3", "/partitionKey"); - - // Seed 200 items - for (var i = 0; i < 200; i++) - { - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); - } - - var nextId = new AtomicCounter(1000); - var errors = 0; - - // Concurrently: 20 iterators draining + 100 writes - var queryTasks = Enumerable.Range(0, 20).Select(async _ => - { - try - { - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Should get at least the seed count (may see some new writes too) - results.Count.Should().BeGreaterThanOrEqualTo(200); - // Every returned document should be structurally valid - results.Should().OnlyContain(d => d.Id != null && d.PartitionKey != null); - } - catch (Exception) - { - Interlocked.Increment(ref errors); - } - }); - - var writeTasks = Enumerable.Range(0, 100).Select(async _ => - { - var id = nextId.Increment().ToString(); - var pk = $"pk-{id}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - }); - - await Task.WhenAll(queryTasks.Concat(writeTasks)); - - errors.Should().Be(0, "no corruption during concurrent iterator drain + writes"); - container.ItemCount.Should().Be(300, "200 seed + 100 writes"); - output.WriteLine($"Concurrent drain during writes: {container.ItemCount} items, {errors} errors"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Shared Helpers - // ═══════════════════════════════════════════════════════════════════════════ - - private static async Task<(InMemoryContainer container, ConcurrentDictionary knownIds)> - SeedContainer(string name, int count) - { - var container = new InMemoryContainer(name, "/partitionKey"); - var knownIds = new ConcurrentDictionary(); - for (var i = 1; i <= count; i++) - { - var id = i.ToString(); - var pk = $"pk-{i % 20}"; - await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); - knownIds.TryAdd(id, pk); - } - - return (container, knownIds); - } - - private static LoadDocument MakeDoc(string id, string pk, int counter = -1) => new() - { - Id = id, - PartitionKey = pk, - Counter = counter >= 0 ? counter : Random.Shared.Next(1000), - Data = $"data-{Guid.NewGuid():N}", - Timestamp = DateTimeOffset.UtcNow.ToString("O") - }; - - private static async Task> QueryAll(InMemoryContainer container) - { - var results = new List(); - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return results; - } - - private static (string? id, string? pk) PickRandom(ConcurrentDictionary knownIds) - { - var keys = knownIds.Keys.ToArray(); - if (keys.Length == 0) return (null, null); - var key = keys[Random.Shared.Next(keys.Length)]; - return knownIds.TryGetValue(key, out var pk) ? (key, pk) : (null, null); - } + // ═══════════════════════════════════════════════════════════════════════════ + // Category E: Data Integrity Verification + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task E1_PostLoadStateVerification_NoOrphanedDocuments() + { + var (container, knownIds) = await SeedContainer("e1", 200); + var nextId = new AtomicCounter(1000); + + // Mixed load: creates + upserts + patches (no deletes keeps tracking deterministic) + await Task.WhenAll(Enumerable.Range(0, 500).Select(async _ => + { + switch (Random.Shared.Next(3)) + { + case 0: + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + break; + case 1: + var (uId, uPk) = PickRandom(knownIds); + if (uId != null) + await container.UpsertItemAsync(MakeDoc(uId, uPk!), new PartitionKey(uPk)); + break; + case 2: + var (pId, pPk) = PickRandom(knownIds); + if (pId != null) + await container.PatchItemAsync(pId, new PartitionKey(pPk), + [PatchOperation.Increment("/counter", 1)]); + break; + } + })); + + var allItems = await QueryAll(container); + var containerIds = allItems.Select(d => d.Id).ToHashSet(); + + knownIds.Keys.Should().BeSubsetOf(containerIds, "every tracked item should exist in the container"); + containerIds.Should().BeSubsetOf(knownIds.Keys.ToHashSet(), "every container item should be tracked"); + output.WriteLine($"Verified {allItems.Count} items, {knownIds.Count} tracked — no orphans"); + } + + [Fact] + public async Task E2_PatchCounterMonotonicity_CountersMatchPatchCount() + { + var container = new InMemoryContainer("e2", "/partitionKey"); + var itemIds = new List(); + + // Seed 20 items with counter=0 in the same partition for simplicity + for (var i = 1; i <= 20; i++) + { + var id = i.ToString(); + await container.CreateItemAsync(MakeDoc(id, "pk-e2", counter: 0), new PartitionKey("pk-e2")); + itemIds.Add(id); + } + + var patchCounts = new ConcurrentDictionary(); + + // Run 500 concurrent patch-increment operations on random items + await Task.WhenAll(Enumerable.Range(0, 500).Select(async _ => + { + var targetId = itemIds[Random.Shared.Next(itemIds.Count)]; + await container.PatchItemAsync(targetId, new PartitionKey("pk-e2"), + [PatchOperation.Increment("/counter", 1)]); + patchCounts.AddOrUpdate(targetId, 1, (_, count) => count + 1); + })); + + // Verify each item's counter matches the tracked patch count + foreach (var id in itemIds) + { + var response = await container.ReadItemAsync(id, new PartitionKey("pk-e2")); + var expectedPatches = patchCounts.GetValueOrDefault(id, 0); + response.Resource.Counter.Should().Be(expectedPatches, + $"item {id} should have counter={expectedPatches} after {expectedPatches} patch increments"); + } + + output.WriteLine($"Verified patch monotonicity across {itemIds.Count} items, {patchCounts.Values.Sum()} total patches"); + } + + [Fact(Skip = "Document size enforcement varies between InMemoryContainer (Newtonsoft.Json serialized byte count) " + + "and real Cosmos DB (binary transport encoding with different overhead). Under load, the overhead of " + + "generating/verifying 100KB+ documents makes the test flaky due to memory pressure and GC, not due " + + "to Cosmos behavior differences.")] + public async Task E3_LargeDocumentPayload_UnderLoad() + { + var container = new InMemoryContainer("e3", "/partitionKey"); + var nextId = new AtomicCounter(0); + + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var largeData = new string('x', 100_000); + var doc = new LoadDocument + { + Id = id, + PartitionKey = $"pk-{id}", + Counter = 0, + Data = largeData, + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }; + await container.CreateItemAsync(doc, new PartitionKey($"pk-{id}")); + })); + + container.ItemCount.Should().Be(100); + await Task.CompletedTask; + } + + [Fact] + public async Task E3_LargeDocumentPayload_DivergentBehavior() + { + // InMemoryContainer measures document size using Newtonsoft.Json serialized byte count. + // Real Cosmos DB uses a binary transport encoding with different overhead. + // Both enforce approximately 2MB limits, but the exact boundary point differs. + // + // InMemoryContainer: rejects documents where JsonConvert.SerializeObject(doc).Length > ~2MB + // Real Cosmos DB: measures the wire-format payload size which includes property name encoding, + // type markers, and partition key routing headers. A ~1.95MB JSON document may fail on real + // Cosmos but succeed in InMemoryContainer, or vice versa depending on property structure. + // + // This test demonstrates a document well under the limit (500KB) succeeds in the emulator, + // confirming no truncation or corruption of large payloads. + + var container = new InMemoryContainer("e3-divergent", "/partitionKey"); + + var largeData = new string('x', 500_000); + var doc = new LoadDocument + { + Id = "1", + PartitionKey = "pk-1", + Counter = 42, + Data = largeData, + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }; + + var response = await container.CreateItemAsync(doc, new PartitionKey("pk-1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk-1")); + readResponse.Resource.Data.Should().HaveLength(500_000, "document data should not be truncated"); + readResponse.Resource.Counter.Should().Be(42, "document fields should not be corrupted"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category A: Missing Operation Types Under Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task A1_ReadManyUnderLoad_VerifiesAllItemsReturned() + { + var (container, knownIds) = await SeedContainer("a1", 200); + var errors = 0; + + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + var snapshot = knownIds.ToArray(); + var selected = snapshot.OrderBy(_ => Random.Shared.Next()).Take(5).ToList(); + var items = selected.Select(kv => (kv.Key, new PartitionKey(kv.Value))).ToList(); + + var response = await container.ReadManyItemsAsync(items); + + if (response.Count != items.Count) + Interlocked.Increment(ref errors); + })); + + errors.Should().Be(0, "all ReadMany calls should return the requested number of items"); + output.WriteLine("200 ReadMany calls completed successfully"); + } + + [Fact] + public async Task A2_TransactionalBatchUnderLoad_AtomicCreateAndRead() + { + var container = new InMemoryContainer("a2", "/partitionKey"); + + // Pre-create one item per partition for batch read operations + for (var i = 0; i < 10; i++) + await container.CreateItemAsync(MakeDoc($"pre-{i}", $"pk-{i}"), new PartitionKey($"pk-{i}")); + + var nextId = new AtomicCounter(100); + var errors = 0; + + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var batchPk = $"pk-{Random.Shared.Next(10)}"; + var batch = container.CreateTransactionalBatch(new PartitionKey(batchPk)); + var id1 = nextId.Increment().ToString(); + var id2 = nextId.Increment().ToString(); + var id3 = nextId.Increment().ToString(); + batch.CreateItem(MakeDoc(id1, batchPk)); + batch.CreateItem(MakeDoc(id2, batchPk)); + batch.CreateItem(MakeDoc(id3, batchPk)); + using var response = await batch.ExecuteAsync(); + if (!response.IsSuccessStatusCode) + Interlocked.Increment(ref errors); + })); + + errors.Should().Be(0, "all batches should succeed"); + container.ItemCount.Should().BeGreaterThanOrEqualTo(310, "10 pre-created + 300 from batches"); + output.WriteLine($"100 batches completed, {container.ItemCount} total items"); + } + + [Fact] + public async Task A3_DeleteAllByPartitionKeyUnderLoad() + { + var container = new InMemoryContainer("a3", "/partitionKey"); + + // Seed items into pk-target (to be bulk-deleted) and pk-safe-0..pk-safe-4 (to survive) + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(MakeDoc($"target-{i}", "pk-target"), new PartitionKey("pk-target")); + for (var i = 0; i < 50; i++) + { + var safePk = $"pk-safe-{i % 5}"; + await container.CreateItemAsync(MakeDoc($"safe-{i}", safePk), new PartitionKey(safePk)); + } + + container.ItemCount.Should().Be(100); + + // Concurrently: delete all in pk-target + write new items to pk-safe partitions + var nextId = new AtomicCounter(1000); + var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk-target")); + var writeTasks = Enumerable.Range(0, 50).Select(async _ => + { + var id = nextId.Increment().ToString(); + var safePk = $"pk-safe-{Random.Shared.Next(5)}"; + await container.CreateItemAsync(MakeDoc(id, safePk), new PartitionKey(safePk)); + }); + + await Task.WhenAll(writeTasks.Append(deleteTask)); + + // Verify pk-target items are gone + var targetIterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk-target'")); + var targetResults = new List(); + while (targetIterator.HasMoreResults) + { + var page = await targetIterator.ReadNextAsync(); + targetResults.AddRange(page); + } + + targetResults.Should().BeEmpty("all pk-target items should be deleted"); + + // Verify safe items are intact (original 50 + new writes) + container.ItemCount.Should().BeGreaterThanOrEqualTo(100, "50 original safe + 50 new writes"); + output.WriteLine($"DeleteAllByPartitionKey verified: {container.ItemCount} items remain, pk-target cleared"); + } + + [Fact] + public async Task A4_StreamApiOperationsUnderLoad() + { + var container = new InMemoryContainer("a4", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + + // Concurrent stream create operations + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + var doc = MakeDoc(id, pk); + var json = JsonConvert.SerializeObject(doc); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + using var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pk)); + if (response.StatusCode != HttpStatusCode.Created) + Interlocked.Increment(ref errors); + })); + + errors.Should().Be(0, "all stream creates should succeed"); + container.ItemCount.Should().Be(100); + + // Concurrent stream read operations — IDs 1..100 match the creates above + await Task.WhenAll(Enumerable.Range(1, 100).Select(async i => + { + var id = i.ToString(); + var pk = $"pk-{id}"; + using var response = await container.ReadItemStreamAsync(id, new PartitionKey(pk)); + if (response.StatusCode != HttpStatusCode.OK) + { + Interlocked.Increment(ref errors); + return; + } + + using var reader = new StreamReader(response.Content); + var content = await reader.ReadToEndAsync(); + var readDoc = JsonConvert.DeserializeObject(content)!; + if (readDoc.Id != id) + Interlocked.Increment(ref errors); + })); + + errors.Should().Be(0, "all stream reads should succeed with correct data"); + output.WriteLine($"200 stream operations completed, {container.ItemCount} items"); + } + + [Fact] + public async Task A5_ChangeFeedReadDuringWrites() + { + var container = new InMemoryContainer("a5", "/partitionKey"); + + // Seed initial items + for (var i = 1; i <= 50; i++) + { + var pk = $"pk-{i % 10}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + // Write more items concurrently + var nextId = new AtomicCounter(1000); + var writtenIds = new ConcurrentBag(); + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + writtenIds.Add(id); + })); + + // Read entire change feed from beginning + var feedItems = new List(); + var feedIterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + while (feedIterator.HasMoreResults) + { + try + { + var response = await feedIterator.ReadNextAsync(); + feedItems.AddRange(response); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotModified) + { + break; + } + } + + var feedIds = feedItems.Select(d => d.Id).ToHashSet(); + var allExpectedIds = Enumerable.Range(1, 50).Select(i => i.ToString()).Concat(writtenIds).ToHashSet(); + allExpectedIds.Should().BeSubsetOf(feedIds, "all seeded and written items should appear in the change feed"); + output.WriteLine($"Change feed returned {feedItems.Count} items, expected {allExpectedIds.Count}"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category B: Query Diversity Under Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task B1_CrossPartitionQueryUnderLoad() + { + var (container, _) = await SeedContainer("b1", 200); + var errors = 0; + + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var threshold = Random.Shared.Next(500); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 20 * FROM c WHERE c.counter > @threshold") + .WithParameter("@threshold", threshold)); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Verify all returned documents actually satisfy the filter + if (results.Any(d => d.Counter <= threshold)) + Interlocked.Increment(ref errors); + + // Verify cross-partition: results should come from multiple PKs (with high probability) + // We don't assert this strictly since small result sets might come from one PK + })); + + errors.Should().Be(0, "all cross-partition query results should satisfy the WHERE clause"); + output.WriteLine("100 cross-partition queries completed successfully"); + } + + [Fact] + public async Task B2_AggregateQueryUnderLoad() + { + var (container, _) = await SeedContainer("b2", 200); + var nextId = new AtomicCounter(1000); + var errors = 0; + + // Concurrent: writes happening alongside aggregate queries + var writeTasks = Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + }); + + var queryTasks = Enumerable.Range(0, 50).Select(async _ => + { + // COUNT query + var countIterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + var counts = new List(); + while (countIterator.HasMoreResults) + { + var page = await countIterator.ReadNextAsync(); + counts.AddRange(page); + } + + var count = counts.Sum(); + // Count should be at least the seed count (200) since we never delete + if (count < 200) + Interlocked.Increment(ref errors); + }); + + await Task.WhenAll(writeTasks.Concat(queryTasks)); + + errors.Should().Be(0, "aggregate COUNT should always be >= seed count during write-only load"); + output.WriteLine($"50 aggregate queries during 100 writes — container has {container.ItemCount} items"); + } + + [Fact] + public async Task B3_DistinctQueryUnderLoad() + { + var (container, _) = await SeedContainer("b3", 200); + var nextId = new AtomicCounter(1000); + var errors = 0; + + var writeTasks = Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{Random.Shared.Next(20)}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + }); + + var queryTasks = Enumerable.Range(0, 50).Select(async _ => + { + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); + + var partitionKeys = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + partitionKeys.AddRange(page); + } + + // DISTINCT should have no duplicates + if (partitionKeys.Count != partitionKeys.Distinct().Count()) + Interlocked.Increment(ref errors); + }); + + await Task.WhenAll(writeTasks.Concat(queryTasks)); + + errors.Should().Be(0, "DISTINCT queries should never return duplicate values"); + output.WriteLine("50 DISTINCT queries during 100 writes completed without duplicates"); + } + + [Fact] + public async Task B4_OffsetLimitPaginationUnderLoad() + { + var (container, _) = await SeedContainer("b4", 200); + var errors = 0; + + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var offset = Random.Shared.Next(50); + var iterator = container.GetItemQueryIterator( + new QueryDefinition($"SELECT * FROM c ORDER BY c.id OFFSET {offset} LIMIT 10")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count > 10) + Interlocked.Increment(ref errors); + })); + + errors.Should().Be(0, "OFFSET/LIMIT queries should never return more than the LIMIT"); + output.WriteLine("100 OFFSET/LIMIT queries completed with correct page sizes"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category C: Concurrency Edge Cases Under Sustained Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task C1_ETagConflictsUnderLoad_ExactlyOneWinsPerRound() + { + var container = new InMemoryContainer("c1", "/partitionKey"); + var doc = MakeDoc("1", "pk-1"); + var created = await container.CreateItemAsync(doc, new PartitionKey("pk-1")); + var originalEtag = created.ETag; + + var successes = 0; + var preconditionFailures = 0; + + // 50 threads all try to replace using the SAME original ETag — exactly one should win + await Task.WhenAll(Enumerable.Range(0, 50).Select(async _ => + { + try + { + var replacement = MakeDoc("1", "pk-1"); + await container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk-1"), + new ItemRequestOptions { IfMatchEtag = originalEtag }); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + Interlocked.Increment(ref preconditionFailures); + } + })); + + successes.Should().Be(1, "exactly one thread should win the ETag race"); + preconditionFailures.Should().Be(49, "all other threads should get 412 PreconditionFailed"); + output.WriteLine($"ETag race: {successes} winner, {preconditionFailures} losers"); + } + + [Fact] + public async Task C2_CreateAfterDelete_SameId_Succeeds() + { + var container = new InMemoryContainer("c2", "/partitionKey"); + + // Pre-create 50 items + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c2"), new PartitionKey("pk-c2")); + + var errors = 0; + + // For each item: delete then immediately re-create with same ID + await Task.WhenAll(Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.DeleteItemAsync(i.ToString(), new PartitionKey("pk-c2")); + await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c2"), new PartitionKey("pk-c2")); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "delete-then-create on same ID should always succeed"); + container.ItemCount.Should().Be(50, "all 50 items should exist after delete+recreate"); + output.WriteLine("50 delete+recreate cycles completed successfully"); + } + + [Fact] + public async Task C3_DoubleDelete_Returns404OnSecond() + { + var container = new InMemoryContainer("c3", "/partitionKey"); + + // Pre-create 50 items + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(MakeDoc(i.ToString(), "pk-c3"), new PartitionKey("pk-c3")); + + var successes = 0; + var notFounds = 0; + + // Two threads each try to delete every item + await Task.WhenAll(Enumerable.Range(0, 50).SelectMany(i => Enumerable.Range(0, 2).Select(async _ => + { + try + { + await container.DeleteItemAsync(i.ToString(), new PartitionKey("pk-c3")); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + Interlocked.Increment(ref notFounds); + } + }))); + + successes.Should().Be(50, "exactly one delete per item should succeed"); + notFounds.Should().Be(50, "exactly one delete per item should get 404"); + container.ItemCount.Should().Be(0, "all items should be deleted"); + output.WriteLine($"Double delete: {successes} successes, {notFounds} not-founds"); + } + + [Fact] + public async Task C4_HotPartitionLoadTest_NoStarvation() + { + var container = new InMemoryContainer("c4", "/partitionKey"); + + // Seed items in hot partition and cold partitions + for (var i = 0; i < 100; i++) + await container.CreateItemAsync(MakeDoc($"hot-{i}", "pk-hot"), new PartitionKey("pk-hot")); + for (var i = 0; i < 50; i++) + { + var coldPk = $"pk-cold-{i % 5}"; + await container.CreateItemAsync(MakeDoc($"cold-{i}", coldPk), new PartitionKey(coldPk)); + } + + var hotErrors = 0; + var coldErrors = 0; + + // 80% operations target pk-hot, 20% target cold partitions + await Task.WhenAll(Enumerable.Range(0, 500).Select(async opIndex => + { + try + { + if (Random.Shared.Next(100) < 80) + { + var targetId = $"hot-{Random.Shared.Next(100)}"; + await container.ReadItemAsync(targetId, new PartitionKey("pk-hot")); + } + else + { + var coldPk = $"pk-cold-{Random.Shared.Next(5)}"; + var targetId = $"cold-{Random.Shared.Next(50)}"; + await container.ReadItemAsync(targetId, new PartitionKey(coldPk)); + } + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Item may not exist in the specific cold PK — that's OK + } + catch (Exception) + { + if (Random.Shared.Next(100) < 80) Interlocked.Increment(ref hotErrors); + else Interlocked.Increment(ref coldErrors); + } + })); + + hotErrors.Should().Be(0, "hot partition operations should not fail"); + coldErrors.Should().Be(0, "cold partition operations should not be starved"); + output.WriteLine("500 hot-partition-biased operations completed without errors"); + } + + [Fact] + public async Task C5_HighCardinalityPartitionKeys_ThousandsOfDistinctPKs() + { + var container = new InMemoryContainer("c5", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + + // Create 2000 items, each with a unique partition key + await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-unique-{id}"; + try + { + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "all creates with unique PKs should succeed"); + container.ItemCount.Should().Be(2000, "all 2000 unique-PK items should exist"); + + // Read back a random sample to verify + var readErrors = 0; + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + var id = Random.Shared.Next(2000).ToString(); + try + { + await container.ReadItemAsync(id, new PartitionKey($"pk-unique-{id}")); + } + catch (Exception) + { + Interlocked.Increment(ref readErrors); + } + })); + + readErrors.Should().Be(0, "reads across high-cardinality PKs should succeed"); + output.WriteLine("2000 unique PKs + 200 random reads completed successfully"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category D: Stress Patterns + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task D1_BurstTrafficPattern_SpikeThenCalm() + { + var container = new InMemoryContainer("d1", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + + for (var cycle = 0; cycle < 3; cycle++) + { + // Burst: 200 concurrent operations + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + // Calm: 20 sequential operations + for (var calm = 0; calm < 20; calm++) + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + } + + errors.Should().Be(0, "no errors should occur during burst/calm cycles"); + container.ItemCount.Should().Be(660, "3 cycles × (200 burst + 20 calm) = 660 items"); + output.WriteLine($"3 burst/calm cycles completed: {container.ItemCount} items, {errors} errors"); + } + + [Fact] + public async Task D2_GradualRampUp_LinearIncrease() + { + var container = new InMemoryContainer("d2", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + var totalOps = 0; + + // Ramp from 50 ops to 500 ops per "second" (10 levels) + for (var level = 1; level <= 10; level++) + { + var opsThisLevel = 50 * level; + await Task.WhenAll(Enumerable.Range(0, opsThisLevel).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + totalOps += opsThisLevel; + } + + errors.Should().Be(0, "no errors during gradual ramp-up"); + container.ItemCount.Should().Be(totalOps, $"all {totalOps} ramped operations should create items"); + output.WriteLine($"Ramp complete: {totalOps} ops across 10 levels, {errors} errors"); + } + + [Fact] + public async Task D3_WriteOnlyStress_MonotonicallyIncreasingItemCount() + { + var container = new InMemoryContainer("d3", "/partitionKey"); + var nextId = new AtomicCounter(0); + var errors = 0; + + await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "no errors during write-only stress"); + container.ItemCount.Should().Be(2000, "all 2000 write-only items should exist"); + output.WriteLine($"Write-only stress: {container.ItemCount} items, {errors} errors"); + } + + [Fact] + public async Task D4_ReadOnlyStress_LargeDataset() + { + var container = new InMemoryContainer("d4", "/partitionKey"); + + // Seed 5000 items + for (var i = 0; i < 5000; i++) + { + var pk = $"pk-{i % 50}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + var errors = 0; + + // 2000 concurrent read operations (point reads + queries) + await Task.WhenAll(Enumerable.Range(0, 2000).Select(async _ => + { + try + { + if (Random.Shared.Next(2) == 0) + { + // Point read + var id = Random.Shared.Next(5000).ToString(); + var pk = $"pk-{int.Parse(id) % 50}"; + var response = await container.ReadItemAsync(id, new PartitionKey(pk)); + if (response.StatusCode != HttpStatusCode.OK) + Interlocked.Increment(ref errors); + } + else + { + // Query + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 5 * FROM c WHERE c.counter > @t") + .WithParameter("@t", Random.Shared.Next(500))); + while (iterator.HasMoreResults) + { + await iterator.ReadNextAsync(); + } + } + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "no errors during read-only stress on large dataset"); + output.WriteLine($"Read-only stress: 2000 ops on {container.ItemCount} items, {errors} errors"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category F: Container/Database Lifecycle Under Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task F1_MultipleContainersConcurrentLoad_CompleteIsolation() + { + var container1 = new InMemoryContainer("f1-alpha", "/partitionKey"); + var container2 = new InMemoryContainer("f1-beta", "/partitionKey"); + var container3 = new InMemoryContainer("f1-gamma", "/partitionKey"); + + var errors = 0; + + // Run concurrent operations on all three containers simultaneously + async Task WriteToContainer(InMemoryContainer container, string prefix, int count) + { + var nextId = new AtomicCounter(0); + await Task.WhenAll(Enumerable.Range(0, count).Select(async _ => + { + try + { + var id = $"{prefix}-{nextId.Increment()}"; + var pk = $"pk-{prefix}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + } + + await Task.WhenAll( + WriteToContainer(container1, "alpha", 200), + WriteToContainer(container2, "beta", 200), + WriteToContainer(container3, "gamma", 200)); + + errors.Should().Be(0, "no errors across any container"); + + // Verify complete isolation: each container has exactly its own items + container1.ItemCount.Should().Be(200, "container alpha should have exactly 200 items"); + container2.ItemCount.Should().Be(200, "container beta should have exactly 200 items"); + container3.ItemCount.Should().Be(200, "container gamma should have exactly 200 items"); + + // Verify no cross-contamination by checking item prefixes + var items1 = await QueryAll(container1); + var items2 = await QueryAll(container2); + var items3 = await QueryAll(container3); + + items1.Should().OnlyContain(d => d.Id.StartsWith("alpha-"), "container1 should only have alpha items"); + items2.Should().OnlyContain(d => d.Id.StartsWith("beta-"), "container2 should only have beta items"); + items3.Should().OnlyContain(d => d.Id.StartsWith("gamma-"), "container3 should only have gamma items"); + output.WriteLine("3 containers × 200 items each — complete isolation verified"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category G: Delete-Heavy Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task G1_DeleteHeavyLoad_90PercentDeletes_ContainerDrainsWithoutCorruption() + { + var container = new InMemoryContainer("g1", "/partitionKey"); + var knownIds = new ConcurrentDictionary(); + var nextId = new AtomicCounter(10_000); + + // Seed 500 items + for (var i = 1; i <= 500; i++) + { + var id = i.ToString(); + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + } + + var deletes = 0; + var creates = 0; + var notFounds = 0; + var errors = 0; + + // 1000 operations: 90% delete, 10% create + await Task.WhenAll(Enumerable.Range(0, 1000).Select(async _ => + { + try + { + if (Random.Shared.Next(100) < 90) + { + // Delete + var ids = knownIds.Keys.ToArray(); + if (ids.Length == 0) return; + var targetId = ids[Random.Shared.Next(ids.Length)]; + if (!knownIds.TryRemove(targetId, out var pk)) return; + try + { + await container.DeleteItemAsync(targetId, new PartitionKey(pk)); + Interlocked.Increment(ref deletes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + Interlocked.Increment(ref notFounds); + } + catch + { + knownIds.TryAdd(targetId, pk); + throw; + } + } + else + { + // Create + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + Interlocked.Increment(ref creates); + } + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "no unexpected errors during delete-heavy load"); + + // Post-load integrity: container count should match tracked IDs + var allItems = await QueryAll(container); + allItems.Count.Should().Be(knownIds.Count, + "container item count should match tracked IDs after delete-heavy load"); + + output.WriteLine($"Delete-heavy: {deletes} deletes, {creates} creates, {notFounds} 404s, " + + $"{container.ItemCount} remaining items, {errors} errors"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category H: TTL Under Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact(Skip = "Real Cosmos DB proactively expires items via a background process, so items " + + "disappear from query results and point reads independently of any client " + + "activity. InMemoryContainer uses lazy eviction — items are only checked " + + "and removed when accessed (read/query/replace). Under sustained concurrent " + + "load this means write-only items may appear to survive past their TTL until " + + "a read touches them, producing different timing behavior than real Cosmos.")] + public async Task H1_TTLUnderLoad_ProactiveExpiration() + { + // This test expects items to be proactively expired (like real Cosmos DB), + // but InMemoryContainer only evicts lazily on read. See the sister test below + // for the actual emulator behavior. + await Task.CompletedTask; + } + + [Fact] + public async Task H1_TTLUnderLoad_LazyEviction_EmulatorBehavior() + { + // ────────────────────────────────────────────────────────────────────── + // DIVERGENT BEHAVIOR: InMemoryContainer TTL eviction is LAZY. + // + // Real Cosmos DB: Items are proactively expired by a background process. + // After DefaultTimeToLive seconds, the item disappears from ALL queries + // and point reads, regardless of whether any client reads it. + // + // InMemoryContainer: Items are checked for expiration ONLY when accessed + // (point read, query, replace, patch, etc.). An expired item that is + // never read will remain in _items until something touches it. + // This means: + // 1. container.ItemCount may include expired-but-unread items + // 2. A query or point read will evict the item and return NotFound/empty + // 3. Under concurrent write-only load, expired items linger + // + // This test demonstrates the lazy eviction behavior under load: + // - Create items with short TTL + // - Wait for TTL to pass + // - Verify items ARE evicted when read (lazy eviction works) + // - Show that unread items may linger in internal storage + // ────────────────────────────────────────────────────────────────────── + + var container = new InMemoryContainer("h1-lazy", "/partitionKey"); + container.DefaultTimeToLive = 2; // 2 second TTL + + // Create 100 items concurrently + var nextId = new AtomicCounter(0); + await Task.WhenAll(Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + })); + + container.ItemCount.Should().Be(100, "all 100 items should exist immediately after creation"); + + // Wait for TTL to expire + await Task.Delay(TimeSpan.FromSeconds(3)); + + // Now read items — lazy eviction should kick in and return 404 + var evicted = 0; + var survived = 0; + await Task.WhenAll(Enumerable.Range(0, 100).Select(async i => + { + try + { + await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); + Interlocked.Increment(ref survived); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + Interlocked.Increment(ref evicted); + } + })); + + evicted.Should().Be(100, "all items should be evicted after TTL expires and they are read"); + survived.Should().Be(0, "no items should survive past their TTL when read"); + output.WriteLine($"TTL lazy eviction: {evicted} evicted on read, {survived} survived"); + } + + [Fact] + public async Task H2_TTLWithConcurrentWritesAndReads() + { + var container = new InMemoryContainer("h2", "/partitionKey"); + container.DefaultTimeToLive = 3; // 3 seconds TTL + + var nextId = new AtomicCounter(0); + var errors = 0; + + // Phase 1: Create items + for (var i = 0; i < 50; i++) + { + var pk = $"pk-{i}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + // Phase 2: Wait for partial TTL, then do concurrent reads + new writes + await Task.Delay(TimeSpan.FromSeconds(2)); + + var readNotFound = 0; + var readFound = 0; + var newCreates = 0; + + await Task.WhenAll( + // Read old items (may or may not be expired yet depending on timing) + Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); + Interlocked.Increment(ref readFound); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + Interlocked.Increment(ref readNotFound); + } + catch + { + Interlocked.Increment(ref errors); + } + }).Concat( + // Create fresh items (these should survive because their TTL starts fresh) + Enumerable.Range(0, 50).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var pk = $"pk-new-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + Interlocked.Increment(ref newCreates); + } + catch + { + Interlocked.Increment(ref errors); + } + })) + ); + + errors.Should().Be(0, "no unexpected errors during TTL concurrent test"); + newCreates.Should().Be(50, "all new items should be created"); + + // New items should still be readable (their TTL just started) + var newItemErrors = 0; + await Task.WhenAll(Enumerable.Range(1, 50).Select(async i => + { + try + { + await container.ReadItemAsync(i.ToString(), new PartitionKey($"pk-new-{i}")); + } + catch + { + Interlocked.Increment(ref newItemErrors); + } + })); + + newItemErrors.Should().Be(0, "freshly created items should survive their TTL window"); + + output.WriteLine($"TTL concurrent: {readFound} old found, {readNotFound} old expired, " + + $"{newCreates} new created, {newItemErrors} new read errors"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category I: FakeCosmosHandler Load + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task I1_FakeCosmosHandlerLoad_CrudThroughHttpPipeline() + { + var container = new InMemoryContainer("i1", "/partitionKey"); + + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) } + }); + + var cosmosContainer = client.GetContainer("fakeDb", "i1"); + var nextId = new AtomicCounter(0); + var errors = 0; + + // Create 200 items through the HTTP pipeline + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await cosmosContainer.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "all creates through FakeCosmosHandler should succeed"); + container.ItemCount.Should().Be(200, "200 items should exist in the backing container"); + + // Read them back through the pipeline + var readErrors = 0; + await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => + { + try + { + await cosmosContainer.ReadItemAsync(i.ToString(), new PartitionKey($"pk-{i}")); + } + catch (Exception) + { + Interlocked.Increment(ref readErrors); + } + })); + + readErrors.Should().Be(0, "all reads through FakeCosmosHandler should succeed"); + + // Query through the pipeline + var queryIterator = cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 10 * FROM c ORDER BY c.id")); + var queryResults = new List(); + while (queryIterator.HasMoreResults) + { + var page = await queryIterator.ReadNextAsync(); + queryResults.AddRange(page); + } + + queryResults.Should().HaveCountLessThanOrEqualTo(10); + output.WriteLine($"FakeCosmosHandler: 200 creates, 200 reads, query returned {queryResults.Count} items"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category J: Empty Container & Edge Cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task J1_EmptyContainerQueryLoad_QueriesOnEmptyContainer() + { + var container = new InMemoryContainer("j1", "/partitionKey"); + var errors = 0; + + // 200 concurrent queries on a completely empty container + await Task.WhenAll(Enumerable.Range(0, 200).Select(async i => + { + try + { + switch (i % 4) + { + case 0: + var iter1 = container.GetItemQueryIterator("SELECT * FROM c"); + while (iter1.HasMoreResults) + { + var page = await iter1.ReadNextAsync(); + page.Count.Should().Be(0); + } + break; + case 1: + var iter2 = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + while (iter2.HasMoreResults) + { + var page = await iter2.ReadNextAsync(); + foreach (var count in page) count.Should().Be(0); + } + break; + case 2: + var iter3 = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 10 * FROM c WHERE c.counter > 0")); + while (iter3.HasMoreResults) + { + var page = await iter3.ReadNextAsync(); + page.Count.Should().Be(0); + } + break; + case 3: + try + { + await container.ReadItemAsync("nonexistent", + new PartitionKey("nonexistent")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected + } + break; + } + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "queries on empty container should not produce unexpected errors"); + container.ItemCount.Should().Be(0, "container should remain empty"); + output.WriteLine("200 queries on empty container completed without errors"); + } + + [Fact] + public async Task J2_NumericPartitionKeys_UnderLoad() + { + var container = new InMemoryContainer("j2", "/numericPk"); + var nextId = new AtomicCounter(0); + var errors = 0; + + // Create 200 items with numeric partition keys + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var numPk = int.Parse(id) % 20; + var doc = new JObject { ["id"] = id, ["numericPk"] = numPk, ["data"] = $"data-{id}" }; + await container.CreateItemAsync(doc, new PartitionKey(numPk)); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "all creates with numeric PKs should succeed"); + container.ItemCount.Should().Be(200); + + // Read back with numeric PKs + var readErrors = 0; + await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => + { + try + { + var numPk = i % 20; + await container.ReadItemAsync(i.ToString(), new PartitionKey(numPk)); + } + catch (Exception) + { + Interlocked.Increment(ref readErrors); + } + })); + + readErrors.Should().Be(0, "all reads with numeric PKs should succeed"); + output.WriteLine($"Numeric PKs: 200 creates, 200 reads, {errors + readErrors} errors"); + } + + [Fact] + public async Task J3_HierarchicalPartitionKeys_UnderLoad() + { + var container = new InMemoryContainer("j3", new[] { "/tenantId", "/userId" }); + var nextId = new AtomicCounter(0); + var errors = 0; + + // Create 200 items with hierarchical partition keys + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + try + { + var id = nextId.Increment().ToString(); + var tenantId = $"tenant-{int.Parse(id) % 5}"; + var userId = $"user-{int.Parse(id) % 20}"; + var doc = new JObject { ["id"] = id, ["tenantId"] = tenantId, ["userId"] = userId, ["data"] = $"data-{id}" }; + var pk = new PartitionKeyBuilder().Add(tenantId).Add(userId).Build(); + await container.CreateItemAsync(doc, pk); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "all creates with hierarchical PKs should succeed"); + container.ItemCount.Should().Be(200); + + // Read back with hierarchical PKs + var readErrors = 0; + await Task.WhenAll(Enumerable.Range(1, 200).Select(async i => + { + try + { + var tenantId = $"tenant-{i % 5}"; + var userId = $"user-{i % 20}"; + var pk = new PartitionKeyBuilder().Add(tenantId).Add(userId).Build(); + await container.ReadItemAsync(i.ToString(), pk); + } + catch (Exception) + { + Interlocked.Increment(ref readErrors); + } + })); + + readErrors.Should().Be(0, "all reads with hierarchical PKs should succeed"); + output.WriteLine($"Hierarchical PKs: 200 creates, 200 reads, {errors + readErrors} errors"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Category K: Latency & Iterator Resilience + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task K1_LatencyDistribution_P99StableAcrossBatches() + { + var container = new InMemoryContainer("k1", "/partitionKey"); + + // Seed 500 items + for (var i = 0; i < 500; i++) + { + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + var batchP99s = new List(); + + // Run 5 batches of 200 ops each, collecting P99 per batch + for (var batch = 0; batch < 5; batch++) + { + var latencies = new ConcurrentBag(); + + await Task.WhenAll(Enumerable.Range(0, 200).Select(async _ => + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var id = Random.Shared.Next(500).ToString(); + var pk = $"pk-{int.Parse(id) % 20}"; + await container.ReadItemAsync(id, new PartitionKey(pk)); + sw.Stop(); + latencies.Add(sw.Elapsed.TotalMilliseconds); + })); + + var sorted = latencies.OrderBy(l => l).ToArray(); + var p99Index = (int)Math.Ceiling(0.99 * sorted.Length) - 1; + var p99 = sorted[Math.Max(0, p99Index)]; + batchP99s.Add(p99); + output.WriteLine($" Batch {batch + 1}: P99 = {p99:F3}ms"); + } + + // P99 should not vary wildly between batches (no more than 10x of the minimum) + var minP99 = batchP99s.Min(); + var maxP99 = batchP99s.Max(); + maxP99.Should().BeLessThan(Math.Max(minP99 * 10, 50), + "P99 latency should be stable across batches (no catastrophic regression)"); + output.WriteLine($"P99 range: {minP99:F3}ms - {maxP99:F3}ms"); + } + + [Fact] + public async Task K2_ConcurrentIteratorDrain_MultipleQueriesSimultaneously() + { + var container = new InMemoryContainer("k2", "/partitionKey"); + + // Seed 500 items + for (var i = 0; i < 500; i++) + { + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + var errors = 0; + var totalResults = new ConcurrentBag(); + + // 50 threads each drain a full SELECT * iterator concurrently + await Task.WhenAll(Enumerable.Range(0, 50).Select(async _ => + { + try + { + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var count = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + count += page.Count; + } + + totalResults.Add(count); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + })); + + errors.Should().Be(0, "concurrent iterator drains should not fail"); + + // Each iterator should see all 500 items (snapshot consistency) + foreach (var count in totalResults) + { + count.Should().Be(500, "each iterator should return all 500 items"); + } + + output.WriteLine($"50 concurrent iterator drains: all returned 500 items, {errors} errors"); + } + + [Fact] + public async Task K3_ConcurrentIteratorDrain_DuringWrites_NoCorruption() + { + var container = new InMemoryContainer("k3", "/partitionKey"); + + // Seed 200 items + for (var i = 0; i < 200; i++) + { + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync(MakeDoc(i.ToString(), pk), new PartitionKey(pk)); + } + + var nextId = new AtomicCounter(1000); + var errors = 0; + + // Concurrently: 20 iterators draining + 100 writes + var queryTasks = Enumerable.Range(0, 20).Select(async _ => + { + try + { + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Should get at least the seed count (may see some new writes too) + results.Count.Should().BeGreaterThanOrEqualTo(200); + // Every returned document should be structurally valid + results.Should().OnlyContain(d => d.Id != null && d.PartitionKey != null); + } + catch (Exception) + { + Interlocked.Increment(ref errors); + } + }); + + var writeTasks = Enumerable.Range(0, 100).Select(async _ => + { + var id = nextId.Increment().ToString(); + var pk = $"pk-{id}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + }); + + await Task.WhenAll(queryTasks.Concat(writeTasks)); + + errors.Should().Be(0, "no corruption during concurrent iterator drain + writes"); + container.ItemCount.Should().Be(300, "200 seed + 100 writes"); + output.WriteLine($"Concurrent drain during writes: {container.ItemCount} items, {errors} errors"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Shared Helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private static async Task<(InMemoryContainer container, ConcurrentDictionary knownIds)> + SeedContainer(string name, int count) + { + var container = new InMemoryContainer(name, "/partitionKey"); + var knownIds = new ConcurrentDictionary(); + for (var i = 1; i <= count; i++) + { + var id = i.ToString(); + var pk = $"pk-{i % 20}"; + await container.CreateItemAsync(MakeDoc(id, pk), new PartitionKey(pk)); + knownIds.TryAdd(id, pk); + } + + return (container, knownIds); + } + + private static LoadDocument MakeDoc(string id, string pk, int counter = -1) => new() + { + Id = id, + PartitionKey = pk, + Counter = counter >= 0 ? counter : Random.Shared.Next(1000), + Data = $"data-{Guid.NewGuid():N}", + Timestamp = DateTimeOffset.UtcNow.ToString("O") + }; + + private static async Task> QueryAll(InMemoryContainer container) + { + var results = new List(); + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return results; + } + + private static (string? id, string? pk) PickRandom(ConcurrentDictionary knownIds) + { + var keys = knownIds.Keys.ToArray(); + if (keys.Length == 0) return (null, null); + var key = keys[Random.Shared.Next(keys.Length)]; + return knownIds.TryGetValue(key, out var pk) ? (key, pk) : (null, null); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorContainerManifest.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorContainerManifest.cs index 101a7c9..66f9895 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorContainerManifest.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorContainerManifest.cs @@ -11,70 +11,70 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// without updating the manifest fails loudly. /// public sealed record EmulatorContainerManifest( - [property: JsonPropertyName("containers")] IReadOnlyList Containers) + [property: JsonPropertyName("containers")] IReadOnlyList Containers) { - /// - /// Loads the manifest from . Throws on missing file or - /// malformed JSON — drift between the manifest and tests should fail loudly. - /// - public static EmulatorContainerManifest Load(string path) - { - using var stream = File.OpenRead(path); - return JsonSerializer.Deserialize(stream, JsonOpts) - ?? throw new InvalidOperationException( - $"Emulator container manifest at '{path}' deserialised to null."); - } + /// + /// Loads the manifest from . Throws on missing file or + /// malformed JSON — drift between the manifest and tests should fail loudly. + /// + public static EmulatorContainerManifest Load(string path) + { + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, JsonOpts) + ?? throw new InvalidOperationException( + $"Emulator container manifest at '{path}' deserialised to null."); + } - /// - /// Resolves the manifest path relative to the assembly location, walking up - /// the source tree until a tests/emulator-containers.json is found. - /// Used by tests that don't get the path injected from outside. - /// - public static string ResolveDefaultPath() - { - var dir = AppContext.BaseDirectory; - for (var i = 0; i < 10 && dir is not null; i++) - { - var candidate = Path.Combine(dir, "tests", "emulator-containers.json"); - if (File.Exists(candidate)) return candidate; - dir = Path.GetDirectoryName(dir); - } - throw new FileNotFoundException( - "Could not locate tests/emulator-containers.json by walking up from " + - AppContext.BaseDirectory); - } + /// + /// Resolves the manifest path relative to the assembly location, walking up + /// the source tree until a tests/emulator-containers.json is found. + /// Used by tests that don't get the path injected from outside. + /// + public static string ResolveDefaultPath() + { + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 10 && dir is not null; i++) + { + var candidate = Path.Combine(dir, "tests", "emulator-containers.json"); + if (File.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir); + } + throw new FileNotFoundException( + "Could not locate tests/emulator-containers.json by walking up from " + + AppContext.BaseDirectory); + } - public EmulatorContainerSpec Get(string name) => - Containers.FirstOrDefault(c => c.Name == name) - ?? throw new InvalidOperationException( - $"Container '{name}' is not in tests/emulator-containers.json. " + - "Add it to the manifest so the CI warmup pre-creates it; tests cannot " + - "lazy-create containers in the emulator path because cold-start 503s " + - "blow past the CI job timeout."); + public EmulatorContainerSpec Get(string name) => + Containers.FirstOrDefault(c => c.Name == name) + ?? throw new InvalidOperationException( + $"Container '{name}' is not in tests/emulator-containers.json. " + + "Add it to the manifest so the CI warmup pre-creates it; tests cannot " + + "lazy-create containers in the emulator path because cold-start 503s " + + "blow past the CI job timeout."); - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; } public sealed record EmulatorContainerSpec( - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("partitionKeyPath")] string? PartitionKeyPath = null, - [property: JsonPropertyName("partitionKeyPaths")] IReadOnlyList? PartitionKeyPaths = null, - [property: JsonPropertyName("defaultTimeToLive")] int? DefaultTimeToLive = null, - [property: JsonPropertyName("uniqueKeyPaths")] IReadOnlyList? UniqueKeyPaths = null, - [property: JsonPropertyName("skipDataPlaneProbe")] bool? SkipDataPlaneProbe = null) + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("partitionKeyPath")] string? PartitionKeyPath = null, + [property: JsonPropertyName("partitionKeyPaths")] IReadOnlyList? PartitionKeyPaths = null, + [property: JsonPropertyName("defaultTimeToLive")] int? DefaultTimeToLive = null, + [property: JsonPropertyName("uniqueKeyPaths")] IReadOnlyList? UniqueKeyPaths = null, + [property: JsonPropertyName("skipDataPlaneProbe")] bool? SkipDataPlaneProbe = null) { - /// - /// Returns the canonical list of partition key paths regardless of whether - /// the manifest used the flat or hierarchical form. - /// - public IReadOnlyList Paths => PartitionKeyPaths is { Count: > 0 } - ? PartitionKeyPaths - : new[] { PartitionKeyPath - ?? throw new InvalidOperationException( - $"Manifest entry '{Name}' has no partitionKeyPath or partitionKeyPaths.") }; + /// + /// Returns the canonical list of partition key paths regardless of whether + /// the manifest used the flat or hierarchical form. + /// + public IReadOnlyList Paths => PartitionKeyPaths is { Count: > 0 } + ? PartitionKeyPaths + : new[] { PartitionKeyPath + ?? throw new InvalidOperationException( + $"Manifest entry '{Name}' has no partitionKeyPath or partitionKeyPaths.") }; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorDetector.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorDetector.cs index 43803cc..9f237b2 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorDetector.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorDetector.cs @@ -12,24 +12,24 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public static class EmulatorDetector { - private static readonly Lazy IsAvailableLazy = new(() => - { - try - { - using var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(3) }; - var result = http.GetAsync("https://localhost:8081/").GetAwaiter().GetResult(); - return result.StatusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized; - } - catch - { - return false; - } - }); + private static readonly Lazy IsAvailableLazy = new(() => + { + try + { + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(3) }; + var result = http.GetAsync("https://localhost:8081/").GetAwaiter().GetResult(); + return result.StatusCode is HttpStatusCode.OK or HttpStatusCode.Unauthorized; + } + catch + { + return false; + } + }); - /// True if a Cosmos DB emulator is responding on localhost:8081. - public static bool IsAvailable => IsAvailableLazy.Value; + /// True if a Cosmos DB emulator is responding on localhost:8081. + public static bool IsAvailable => IsAvailableLazy.Value; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorSession.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorSession.cs index bead7ac..46c8c3a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorSession.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorSession.cs @@ -17,198 +17,198 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public sealed class EmulatorSession : IAsyncLifetime { - internal const string DefaultEndpoint = "https://localhost:8081"; - internal const string Key = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - internal const string DatabaseName = "parity-validation"; - - // Keep concurrent outbound HTTP requests below the Dockerized Linux - // emulator's comfortable saturation point. 8 is well below a 10-partition - // emulator's capacity and low enough for CI's 3-partition config to cope. - private const int MaxConcurrentHttpRequests = 8; - - private readonly SemaphoreSlim _httpGate = new(MaxConcurrentHttpRequests, MaxConcurrentHttpRequests); - - /// - /// Per-run cache of emulator containers keyed by a deterministic name. - /// Avoids per-test container create/delete churn that exhausts the - /// emulator's finite partition pool (PARTITION_COUNT) on CI runners. - /// - internal readonly ConcurrentDictionary ContainerCache = new(); - - public TestTarget Target { get; } - public string Endpoint { get; } - public bool IsEmulator => Target != TestTarget.InMemory; - - // Populated during InitializeAsync when targeting an emulator. - public CosmosClient? EmulatorClient { get; private set; } - public Database? EmulatorDatabase { get; private set; } - - /// - /// The container manifest read at session init. Source of truth for which - /// containers exist on the emulator. Empty manifest stub for in-memory runs. - /// - internal EmulatorContainerManifest Manifest { get; private set; } = - new(Array.Empty()); - - public EmulatorSession() - { - Target = Environment.GetEnvironmentVariable("COSMOS_TEST_TARGET")?.ToLowerInvariant() switch - { - "emulator-linux" => TestTarget.EmulatorLinux, - "emulator-windows" => TestTarget.EmulatorWindows, - _ => TestTarget.InMemory - }; - Endpoint = Environment.GetEnvironmentVariable("COSMOS_EMULATOR_ENDPOINT") ?? DefaultEndpoint; - } - - public async ValueTask InitializeAsync() - { - if (!IsEmulator) return; - - EmulatorClient = CreateEmulatorClient(Endpoint, _httpGate); - - // Database must already exist (warmup tool creates it). CreateIfNotExists - // is idempotent so this is cheap when the warmup already ran; useful safety - // net for local runs where someone forgot to invoke the warmup first. - var response = await EmulatorRetry.RunAsync( - () => EmulatorClient.CreateDatabaseIfNotExistsAsync(DatabaseName), - "CreateDatabase", maxRetries: 30, maxBackoffSeconds: 15); - EmulatorDatabase = response.Database; - - // Load the manifest and populate ContainerCache with lazy SDK Container - // references — these don't talk to the emulator until used. Tests then - // look up by name; nothing is lazy-created. - var manifestPath = Environment.GetEnvironmentVariable("COSMOS_EMULATOR_MANIFEST") - ?? EmulatorContainerManifest.ResolveDefaultPath(); - Manifest = EmulatorContainerManifest.Load(manifestPath); - foreach (var spec in Manifest.Containers) - { - ContainerCache.TryAdd(spec.Name, EmulatorDatabase.GetContainer(spec.Name)); - } - Console.WriteLine( - $"[EmulatorSession] Ready ({Manifest.Containers.Count} containers from manifest)."); - } - - public ValueTask DisposeAsync() - { - // Containers are not deleted here — the warmup tool creates them; CI - // emulators are ephemeral and torn down with the runner. Local dev - // emulators benefit from cached partitions across re-runs. - ContainerCache.Clear(); - EmulatorClient?.Dispose(); - _httpGate.Dispose(); - return ValueTask.CompletedTask; - } - - private static CosmosClient CreateEmulatorClient(string endpoint, SemaphoreSlim httpGate) - { - var isHttps = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase); - - var options = new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - // Bumped from the SDK defaults (9 attempts / 30s) to absorb 429/503 - // storms from the Linux emulator under concurrent test load. - MaxRetryAttemptsOnRateLimitedRequests = 15, - MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(60), - RequestTimeout = TimeSpan.FromSeconds(30), - HttpClientFactory = () => - { - HttpMessageHandler inner = isHttps - ? new HttpClientHandler - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - } - : new HttpClientHandler(); - - return new HttpClient(new ConcurrencyGateHandler(httpGate, inner)) - { - Timeout = TimeSpan.FromSeconds(30) - }; - } - }; - - return new CosmosClient(endpoint, Key, options); - } - - /// - /// Limits the number of in-flight HTTP requests to the emulator and - /// short-circuits all requests once the emulator is confirmed dead. - /// The Cosmos SDK fans out requests aggressively (bulk writes, query - /// parallelism) and the Dockerized Linux emulator 429s or 503s when - /// pushed past its partition-service capacity. Rather than tune each - /// test, we cap at the HTTP layer — transparent to both SDK retry and - /// individual tests. - /// - /// When the emulator crashes, the SDK's own retry loop (15 attempts, - /// 60s wait) burns through minutes per test. The circuit breaker here - /// detects consecutive fatal socket errors (connection refused before the - /// process has bound the port; connection aborted / reset / shutdown after - /// the process has died mid-connection) and immediately fails all - /// subsequent requests so the test run aborts quickly. - /// - private sealed class ConcurrencyGateHandler : DelegatingHandler - { - private const int CircuitBreakerThreshold = 3; - - private readonly SemaphoreSlim _gate; - private int _consecutiveDeadSocketErrors; - private volatile bool _emulatorDead; - - public ConcurrencyGateHandler(SemaphoreSlim gate, HttpMessageHandler inner) : base(inner) - { - _gate = gate; - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - if (_emulatorDead) - throw new HttpRequestException( - "Emulator circuit breaker tripped — the emulator process has crashed. " + - "All further requests are short-circuited."); - - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - Interlocked.Exchange(ref _consecutiveDeadSocketErrors, 0); - return response; - } - catch (HttpRequestException ex) when (IsEmulatorUnreachable(ex)) - { - if (Interlocked.Increment(ref _consecutiveDeadSocketErrors) >= CircuitBreakerThreshold) - { - _emulatorDead = true; - Console.WriteLine( - "[ConcurrencyGateHandler] Circuit breaker tripped after " + - $"{CircuitBreakerThreshold} consecutive dead-socket errors. " + - "The emulator process appears to have crashed."); - } - throw; - } - finally - { - _gate.Release(); - } - } - - private static bool IsEmulatorUnreachable(HttpRequestException ex) => - ex.InnerException is SocketException se && IsDeadSocketError(se.SocketErrorCode); - } - - /// - /// Socket errors that mean the emulator process is no longer reachable — - /// either the port has no listener (refused) or an established connection - /// was severed by the peer (aborted / reset / shutdown). Treated as - /// "emulator dead" by the circuit breakers. - /// - internal static bool IsDeadSocketError(SocketError code) => code is - SocketError.ConnectionRefused or - SocketError.ConnectionAborted or - SocketError.ConnectionReset or - SocketError.Shutdown; + internal const string DefaultEndpoint = "https://localhost:8081"; + internal const string Key = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + internal const string DatabaseName = "parity-validation"; + + // Keep concurrent outbound HTTP requests below the Dockerized Linux + // emulator's comfortable saturation point. 8 is well below a 10-partition + // emulator's capacity and low enough for CI's 3-partition config to cope. + private const int MaxConcurrentHttpRequests = 8; + + private readonly SemaphoreSlim _httpGate = new(MaxConcurrentHttpRequests, MaxConcurrentHttpRequests); + + /// + /// Per-run cache of emulator containers keyed by a deterministic name. + /// Avoids per-test container create/delete churn that exhausts the + /// emulator's finite partition pool (PARTITION_COUNT) on CI runners. + /// + internal readonly ConcurrentDictionary ContainerCache = new(); + + public TestTarget Target { get; } + public string Endpoint { get; } + public bool IsEmulator => Target != TestTarget.InMemory; + + // Populated during InitializeAsync when targeting an emulator. + public CosmosClient? EmulatorClient { get; private set; } + public Database? EmulatorDatabase { get; private set; } + + /// + /// The container manifest read at session init. Source of truth for which + /// containers exist on the emulator. Empty manifest stub for in-memory runs. + /// + internal EmulatorContainerManifest Manifest { get; private set; } = + new(Array.Empty()); + + public EmulatorSession() + { + Target = Environment.GetEnvironmentVariable("COSMOS_TEST_TARGET")?.ToLowerInvariant() switch + { + "emulator-linux" => TestTarget.EmulatorLinux, + "emulator-windows" => TestTarget.EmulatorWindows, + _ => TestTarget.InMemory + }; + Endpoint = Environment.GetEnvironmentVariable("COSMOS_EMULATOR_ENDPOINT") ?? DefaultEndpoint; + } + + public async ValueTask InitializeAsync() + { + if (!IsEmulator) return; + + EmulatorClient = CreateEmulatorClient(Endpoint, _httpGate); + + // Database must already exist (warmup tool creates it). CreateIfNotExists + // is idempotent so this is cheap when the warmup already ran; useful safety + // net for local runs where someone forgot to invoke the warmup first. + var response = await EmulatorRetry.RunAsync( + () => EmulatorClient.CreateDatabaseIfNotExistsAsync(DatabaseName), + "CreateDatabase", maxRetries: 30, maxBackoffSeconds: 15); + EmulatorDatabase = response.Database; + + // Load the manifest and populate ContainerCache with lazy SDK Container + // references — these don't talk to the emulator until used. Tests then + // look up by name; nothing is lazy-created. + var manifestPath = Environment.GetEnvironmentVariable("COSMOS_EMULATOR_MANIFEST") + ?? EmulatorContainerManifest.ResolveDefaultPath(); + Manifest = EmulatorContainerManifest.Load(manifestPath); + foreach (var spec in Manifest.Containers) + { + ContainerCache.TryAdd(spec.Name, EmulatorDatabase.GetContainer(spec.Name)); + } + Console.WriteLine( + $"[EmulatorSession] Ready ({Manifest.Containers.Count} containers from manifest)."); + } + + public ValueTask DisposeAsync() + { + // Containers are not deleted here — the warmup tool creates them; CI + // emulators are ephemeral and torn down with the runner. Local dev + // emulators benefit from cached partitions across re-runs. + ContainerCache.Clear(); + EmulatorClient?.Dispose(); + _httpGate.Dispose(); + return ValueTask.CompletedTask; + } + + private static CosmosClient CreateEmulatorClient(string endpoint, SemaphoreSlim httpGate) + { + var isHttps = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + var options = new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + // Bumped from the SDK defaults (9 attempts / 30s) to absorb 429/503 + // storms from the Linux emulator under concurrent test load. + MaxRetryAttemptsOnRateLimitedRequests = 15, + MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(60), + RequestTimeout = TimeSpan.FromSeconds(30), + HttpClientFactory = () => + { + HttpMessageHandler inner = isHttps + ? new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + } + : new HttpClientHandler(); + + return new HttpClient(new ConcurrencyGateHandler(httpGate, inner)) + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + }; + + return new CosmosClient(endpoint, Key, options); + } + + /// + /// Limits the number of in-flight HTTP requests to the emulator and + /// short-circuits all requests once the emulator is confirmed dead. + /// The Cosmos SDK fans out requests aggressively (bulk writes, query + /// parallelism) and the Dockerized Linux emulator 429s or 503s when + /// pushed past its partition-service capacity. Rather than tune each + /// test, we cap at the HTTP layer — transparent to both SDK retry and + /// individual tests. + /// + /// When the emulator crashes, the SDK's own retry loop (15 attempts, + /// 60s wait) burns through minutes per test. The circuit breaker here + /// detects consecutive fatal socket errors (connection refused before the + /// process has bound the port; connection aborted / reset / shutdown after + /// the process has died mid-connection) and immediately fails all + /// subsequent requests so the test run aborts quickly. + /// + private sealed class ConcurrencyGateHandler : DelegatingHandler + { + private const int CircuitBreakerThreshold = 3; + + private readonly SemaphoreSlim _gate; + private int _consecutiveDeadSocketErrors; + private volatile bool _emulatorDead; + + public ConcurrencyGateHandler(SemaphoreSlim gate, HttpMessageHandler inner) : base(inner) + { + _gate = gate; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_emulatorDead) + throw new HttpRequestException( + "Emulator circuit breaker tripped — the emulator process has crashed. " + + "All further requests are short-circuited."); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + Interlocked.Exchange(ref _consecutiveDeadSocketErrors, 0); + return response; + } + catch (HttpRequestException ex) when (IsEmulatorUnreachable(ex)) + { + if (Interlocked.Increment(ref _consecutiveDeadSocketErrors) >= CircuitBreakerThreshold) + { + _emulatorDead = true; + Console.WriteLine( + "[ConcurrencyGateHandler] Circuit breaker tripped after " + + $"{CircuitBreakerThreshold} consecutive dead-socket errors. " + + "The emulator process appears to have crashed."); + } + throw; + } + finally + { + _gate.Release(); + } + } + + private static bool IsEmulatorUnreachable(HttpRequestException ex) => + ex.InnerException is SocketException se && IsDeadSocketError(se.SocketErrorCode); + } + + /// + /// Socket errors that mean the emulator process is no longer reachable — + /// either the port has no listener (refused) or an established connection + /// was severed by the peer (aborted / reset / shutdown). Treated as + /// "emulator dead" by the circuit breakers. + /// + internal static bool IsDeadSocketError(SocketError code) => code is + SocketError.ConnectionRefused or + SocketError.ConnectionAborted or + SocketError.ConnectionReset or + SocketError.Shutdown; } /// @@ -218,7 +218,7 @@ SocketError.ConnectionReset or /// public static class IntegrationCollection { - public const string Name = "Integration"; + public const string Name = "Integration"; } /// @@ -229,81 +229,85 @@ public static class IntegrationCollection /// internal static class EmulatorRetry { - /// - /// Number of consecutive dead-socket errors before we assume the emulator - /// has crashed and abort immediately instead of burning through the full - /// retry budget against a dead process. - /// - private const int EmulatorDownCircuitBreakerThreshold = 3; - - public static async Task RunAsync( - Func> operation, string operationName, int maxRetries = 10, double maxBackoffSeconds = 10) - { - var consecutiveEmulatorDownErrors = 0; - - for (var attempt = 0; ; attempt++) - { - try - { - var result = await operation(); - consecutiveEmulatorDownErrors = 0; - return result; - } - catch (Exception ex) when (attempt < maxRetries && IsTransient(ex)) - { - if (IsEmulatorUnreachable(ex)) - { - consecutiveEmulatorDownErrors++; - if (consecutiveEmulatorDownErrors >= EmulatorDownCircuitBreakerThreshold) - { - throw new InvalidOperationException( - $"[EmulatorRetry] {operationName}: aborting after " + - $"{consecutiveEmulatorDownErrors} consecutive dead-socket errors. " + - "The emulator process appears to have crashed.", ex); - } - } - else - { - consecutiveEmulatorDownErrors = 0; - } - - var delay = TimeSpan.FromSeconds(Math.Min(Math.Pow(2, attempt), maxBackoffSeconds)); - Console.WriteLine( - $"[EmulatorRetry] {operationName} attempt {attempt + 1}/{maxRetries} failed " + - $"({ex.GetType().Name}), retrying in {delay.TotalSeconds:F0}s..."); - await Task.Delay(delay); - } - } - } - - private static bool IsTransient(Exception ex) => ex switch - { - // 403/1008 = "Database Account does not exist" — the Windows emulator's HTTP server - // can become reachable before its account is fully initialised. Retry until ready. - CosmosException ce when ce.StatusCode == System.Net.HttpStatusCode.Forbidden - && ce.SubStatusCode == 1008 => true, - CosmosException ce => ce.StatusCode is - System.Net.HttpStatusCode.ServiceUnavailable or - System.Net.HttpStatusCode.InternalServerError or - System.Net.HttpStatusCode.RequestTimeout or - System.Net.HttpStatusCode.TooManyRequests, - HttpRequestException hre => !IsEmulatorUnreachableCore(hre), - SocketException se => !EmulatorSession.IsDeadSocketError(se.SocketErrorCode), - _ => ex.InnerException != null && IsTransient(ex.InnerException), - }; - - /// - /// Checks the full exception chain for indicators that the emulator - /// process is unreachable (refused / aborted / reset / shutdown). - /// All of these mean the process is dead, not temporarily overloaded. - /// - private static bool IsEmulatorUnreachable(Exception ex) => ex switch - { - HttpRequestException hre => IsEmulatorUnreachableCore(hre), - SocketException se => EmulatorSession.IsDeadSocketError(se.SocketErrorCode), - _ => ex.InnerException != null && IsEmulatorUnreachable(ex.InnerException), - }; - - private static bool IsEmulatorUnreachableCore(HttpRequestException hre) => - hre.InnerException is SocketException se && EmulatorSession.IsDeadSocketError(se.SocketErrorCode); + /// + /// Number of consecutive dead-socket errors before we assume the emulator + /// has crashed and abort immediately instead of burning through the full + /// retry budget against a dead process. + /// + private const int EmulatorDownCircuitBreakerThreshold = 3; + + public static async Task RunAsync( + Func> operation, string operationName, int maxRetries = 10, double maxBackoffSeconds = 10) + { + var consecutiveEmulatorDownErrors = 0; + + for (var attempt = 0; ; attempt++) + { + try + { + var result = await operation(); + consecutiveEmulatorDownErrors = 0; + return result; + } + catch (Exception ex) when (attempt < maxRetries && IsTransient(ex)) + { + if (IsEmulatorUnreachable(ex)) + { + consecutiveEmulatorDownErrors++; + if (consecutiveEmulatorDownErrors >= EmulatorDownCircuitBreakerThreshold) + { + throw new InvalidOperationException( + $"[EmulatorRetry] {operationName}: aborting after " + + $"{consecutiveEmulatorDownErrors} consecutive dead-socket errors. " + + "The emulator process appears to have crashed.", ex); + } + } + else + { + consecutiveEmulatorDownErrors = 0; + } + + var delay = TimeSpan.FromSeconds(Math.Min(Math.Pow(2, attempt), maxBackoffSeconds)); + Console.WriteLine( + $"[EmulatorRetry] {operationName} attempt {attempt + 1}/{maxRetries} failed " + + $"({ex.GetType().Name}), retrying in {delay.TotalSeconds:F0}s..."); + await Task.Delay(delay); + } + } + } + + private static bool IsTransient(Exception ex) => ex switch + { + // 403/1008 = "Database Account does not exist" — the Windows emulator's HTTP server + // can become reachable before its account is fully initialised. Retry until ready. + CosmosException ce when ce.StatusCode == System.Net.HttpStatusCode.Forbidden + && ce.SubStatusCode == 1008 => true, + // 404/0 = Windows emulator intermediate startup state: HTTP server is reachable + // but internal metadata services haven't fully materialised yet. + CosmosException ce when ce.StatusCode == System.Net.HttpStatusCode.NotFound + && ce.SubStatusCode == 0 => true, + CosmosException ce => ce.StatusCode is + System.Net.HttpStatusCode.ServiceUnavailable or + System.Net.HttpStatusCode.InternalServerError or + System.Net.HttpStatusCode.RequestTimeout or + System.Net.HttpStatusCode.TooManyRequests, + HttpRequestException hre => !IsEmulatorUnreachableCore(hre), + SocketException se => !EmulatorSession.IsDeadSocketError(se.SocketErrorCode), + _ => ex.InnerException != null && IsTransient(ex.InnerException), + }; + + /// + /// Checks the full exception chain for indicators that the emulator + /// process is unreachable (refused / aborted / reset / shutdown). + /// All of these mean the process is dead, not temporarily overloaded. + /// + private static bool IsEmulatorUnreachable(Exception ex) => ex switch + { + HttpRequestException hre => IsEmulatorUnreachableCore(hre), + SocketException se => EmulatorSession.IsDeadSocketError(se.SocketErrorCode), + _ => ex.InnerException != null && IsEmulatorUnreachable(ex.InnerException), + }; + + private static bool IsEmulatorUnreachableCore(HttpRequestException hre) => + hre.InnerException is SocketException se && EmulatorSession.IsDeadSocketError(se.SocketErrorCode); } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorTestFixture.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorTestFixture.cs index af4410a..56695f5 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorTestFixture.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/EmulatorTestFixture.cs @@ -19,108 +19,108 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public sealed class EmulatorTestFixture : ITestContainerFixture { - private readonly EmulatorSession _session; + private readonly EmulatorSession _session; - public TestTarget Target => _session.Target; - public bool IsEmulator => true; + public TestTarget Target => _session.Target; + public bool IsEmulator => true; - public EmulatorTestFixture(EmulatorSession session) - { - _session = session; - if (!session.IsEmulator || session.EmulatorClient is null || session.EmulatorDatabase is null) - throw new InvalidOperationException( - $"EmulatorTestFixture requires an initialised emulator session. Target={session.Target}"); - } + public EmulatorTestFixture(EmulatorSession session) + { + _session = session; + if (!session.IsEmulator || session.EmulatorClient is null || session.EmulatorDatabase is null) + throw new InvalidOperationException( + $"EmulatorTestFixture requires an initialised emulator session. Target={session.Target}"); + } - public Task CreateContainerAsync( - string containerName, - string partitionKeyPath, - Action? configure = null) - => GetOrAssertAsync(containerName, new[] { partitionKeyPath }); + public Task CreateContainerAsync( + string containerName, + string partitionKeyPath, + Action? configure = null) + => GetOrAssertAsync(containerName, new[] { partitionKeyPath }); - public Task CreateContainerAsync( - string containerName, - IReadOnlyList partitionKeyPaths, - Action? configure = null) - => GetOrAssertAsync(containerName, partitionKeyPaths); + public Task CreateContainerAsync( + string containerName, + IReadOnlyList partitionKeyPaths, + Action? configure = null) + => GetOrAssertAsync(containerName, partitionKeyPaths); - private async Task GetOrAssertAsync( - string containerName, IReadOnlyList requestedPaths) - { - var spec = _session.Manifest.Get(containerName); - AssertPathsMatch(spec, requestedPaths); + private async Task GetOrAssertAsync( + string containerName, IReadOnlyList requestedPaths) + { + var spec = _session.Manifest.Get(containerName); + AssertPathsMatch(spec, requestedPaths); - if (!_session.ContainerCache.TryGetValue(containerName, out var existing)) - { - // The warmup tool should have created this; if it isn't in the cache - // something has gone wrong with the run-tests.ps1 warmup step. - throw new InvalidOperationException( - $"Container '{containerName}' is in the manifest but was not pre-created. " + - "Check that scripts/run-tests.ps1 invoked tests/EmulatorWarmup before tests started."); - } + if (!_session.ContainerCache.TryGetValue(containerName, out var existing)) + { + // The warmup tool should have created this; if it isn't in the cache + // something has gone wrong with the run-tests.ps1 warmup step. + throw new InvalidOperationException( + $"Container '{containerName}' is in the manifest but was not pre-created. " + + "Check that scripts/run-tests.ps1 invoked tests/EmulatorWarmup before tests started."); + } - await CleanContainerAsync(existing, spec.Paths[0]); - return existing; - } + await CleanContainerAsync(existing, spec.Paths[0]); + return existing; + } - private static void AssertPathsMatch(EmulatorContainerSpec spec, IReadOnlyList requested) - { - var manifestPaths = spec.Paths; - if (manifestPaths.Count != requested.Count - || !manifestPaths.SequenceEqual(requested, StringComparer.Ordinal)) - { - throw new InvalidOperationException( - $"Container '{spec.Name}' partition key path mismatch. " + - $"Manifest: [{string.Join(", ", manifestPaths)}]. " + - $"Test requested: [{string.Join(", ", requested)}]. " + - "Update tests/emulator-containers.json or the test to agree."); - } - } + private static void AssertPathsMatch(EmulatorContainerSpec spec, IReadOnlyList requested) + { + var manifestPaths = spec.Paths; + if (manifestPaths.Count != requested.Count + || !manifestPaths.SequenceEqual(requested, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Container '{spec.Name}' partition key path mismatch. " + + $"Manifest: [{string.Join(", ", manifestPaths)}]. " + + $"Test requested: [{string.Join(", ", requested)}]. " + + "Update tests/emulator-containers.json or the test to agree."); + } + } - /// - /// Removes all documents from a cached container to maintain per-test isolation. - /// Faster than dropping and recreating the container (which exhausts the partition pool). - /// - private static async Task CleanContainerAsync(Container container, string partitionKeyPath) - { - var pkProp = partitionKeyPath.TrimStart('/'); - // Use a small page size to avoid oversized responses - var query = container.GetItemQueryIterator( - $"SELECT c.id, c[\"{pkProp}\"] AS __pk FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); + /// + /// Removes all documents from a cached container to maintain per-test isolation. + /// Faster than dropping and recreating the container (which exhausts the partition pool). + /// + private static async Task CleanContainerAsync(Container container, string partitionKeyPath) + { + var pkProp = partitionKeyPath.TrimStart('/'); + // Use a small page size to avoid oversized responses + var query = container.GetItemQueryIterator( + $"SELECT c.id, c[\"{pkProp}\"] AS __pk FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - foreach (var item in page) - { - var id = item["id"]?.ToString(); - var pkValue = item["__pk"]; - if (id is null) continue; + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + foreach (var item in page) + { + var id = item["id"]?.ToString(); + var pkValue = item["__pk"]; + if (id is null) continue; - var pk = pkValue?.Type switch - { - JTokenType.String => new PartitionKey(pkValue.ToString()), - JTokenType.Integer => new PartitionKey(pkValue.Value()), - JTokenType.Float => new PartitionKey(pkValue.Value()), - JTokenType.Boolean => new PartitionKey(pkValue.Value()), - JTokenType.Null or null => PartitionKey.None, - _ => new PartitionKey(pkValue.ToString()) - }; + var pk = pkValue?.Type switch + { + JTokenType.String => new PartitionKey(pkValue.ToString()), + JTokenType.Integer => new PartitionKey(pkValue.Value()), + JTokenType.Float => new PartitionKey(pkValue.Value()), + JTokenType.Boolean => new PartitionKey(pkValue.Value()), + JTokenType.Null or null => PartitionKey.None, + _ => new PartitionKey(pkValue.ToString()) + }; - try - { - await container.DeleteItemAsync(id, pk); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Item may have been deleted by TTL or another concurrent operation - } - } - } - } + try + { + await container.DeleteItemAsync(id, pk); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Item may have been deleted by TTL or another concurrent operation + } + } + } + } - // No per-test container deletion needed: containers are managed by the - // warmup tool and cleaned at the end of the test run by EmulatorSession. - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + // No per-test container deletion needed: containers are managed by the + // warmup tool and cleaned at the end of the test run by EmulatorSession. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/ITestContainerFixture.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/ITestContainerFixture.cs index 52850c7..da5ca66 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/ITestContainerFixture.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/ITestContainerFixture.cs @@ -8,30 +8,30 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public interface ITestContainerFixture : IAsyncDisposable { - /// The backend this fixture targets. - TestTarget Target { get; } + /// The backend this fixture targets. + TestTarget Target { get; } - /// True when running against a real Cosmos DB emulator. - bool IsEmulator { get; } + /// True when running against a real Cosmos DB emulator. + bool IsEmulator { get; } - /// - /// Creates a container with a single partition key path. - /// - /// Logical container name (unique suffix added for emulator). - /// Partition key path, e.g. "/partitionKey". - /// Optional callback to configure - /// (e.g. TTL, unique keys, computed properties). - /// A backed by the selected target. - Task CreateContainerAsync( - string containerName, - string partitionKeyPath, - Action? configure = null); + /// + /// Creates a container with a single partition key path. + /// + /// Logical container name (unique suffix added for emulator). + /// Partition key path, e.g. "/partitionKey". + /// Optional callback to configure + /// (e.g. TTL, unique keys, computed properties). + /// A backed by the selected target. + Task CreateContainerAsync( + string containerName, + string partitionKeyPath, + Action? configure = null); - /// - /// Creates a container with hierarchical (composite) partition key paths. - /// - Task CreateContainerAsync( - string containerName, - IReadOnlyList partitionKeyPaths, - Action? configure = null); + /// + /// Creates a container with hierarchical (composite) partition key paths. + /// + Task CreateContainerAsync( + string containerName, + IReadOnlyList partitionKeyPaths, + Action? configure = null); } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs index a00054a..8569334 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs @@ -9,59 +9,59 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public sealed class InMemoryTestFixture : ITestContainerFixture { - private readonly List _tracked = []; + private readonly List _tracked = []; - public TestTarget Target => TestTarget.InMemory; - public bool IsEmulator => false; + public TestTarget Target => TestTarget.InMemory; + public bool IsEmulator => false; - public Task CreateContainerAsync( - string containerName, - string partitionKeyPath, - Action? configure = null) - { - var result = InMemoryCosmos.Create(containerName, partitionKeyPath, - configureContainer: setup => ApplyContainerProperties(setup, containerName, partitionKeyPath, configure)); - _tracked.Add(result); - return Task.FromResult(result.Container); - } + public Task CreateContainerAsync( + string containerName, + string partitionKeyPath, + Action? configure = null) + { + var result = InMemoryCosmos.Create(containerName, partitionKeyPath, + configureContainer: setup => ApplyContainerProperties(setup, containerName, partitionKeyPath, configure)); + _tracked.Add(result); + return Task.FromResult(result.Container); + } - public Task CreateContainerAsync( - string containerName, - IReadOnlyList partitionKeyPaths, - Action? configure = null) - { - var result = InMemoryCosmos.Create(containerName, partitionKeyPaths.ToArray(), - configureContainer: setup => ApplyContainerProperties(setup, containerName, partitionKeyPaths, configure)); - _tracked.Add(result); - return Task.FromResult(result.Container); - } + public Task CreateContainerAsync( + string containerName, + IReadOnlyList partitionKeyPaths, + Action? configure = null) + { + var result = InMemoryCosmos.Create(containerName, partitionKeyPaths.ToArray(), + configureContainer: setup => ApplyContainerProperties(setup, containerName, partitionKeyPaths, configure)); + _tracked.Add(result); + return Task.FromResult(result.Container); + } - private static void ApplyContainerProperties( - IContainerTestSetup setup, - string containerName, - object partitionKeyPaths, - Action? configure) - { - if (configure is null) return; + private static void ApplyContainerProperties( + IContainerTestSetup setup, + string containerName, + object partitionKeyPaths, + Action? configure) + { + if (configure is null) return; - // Create a ContainerProperties to capture what the configure callback wants to set - var props = partitionKeyPaths is IReadOnlyList paths - ? new ContainerProperties(containerName, paths) - : new ContainerProperties(containerName, (string)partitionKeyPaths); - configure(props); + // Create a ContainerProperties to capture what the configure callback wants to set + var props = partitionKeyPaths is IReadOnlyList paths + ? new ContainerProperties(containerName, paths) + : new ContainerProperties(containerName, (string)partitionKeyPaths); + configure(props); - // 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; - } + // 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() - { - foreach (var result in _tracked) - result.Dispose(); - _tracked.Clear(); - return ValueTask.CompletedTask; - } + public ValueTask DisposeAsync() + { + foreach (var result in _tracked) + result.Dispose(); + _tracked.Clear(); + return ValueTask.CompletedTask; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/PlatformAssert.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/PlatformAssert.cs index 49ff59f..a4ce64d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/PlatformAssert.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/PlatformAssert.cs @@ -8,25 +8,25 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public static class PlatformAssert { - /// - /// Asserts that matches the expected value for the current target. - /// - /// The fixture identifying the current target. - /// The actual value produced by the test. - /// Expected value when running against in-memory. - /// Expected value when running against the real emulator. - /// Human-readable explanation of why the values differ. - public static void AssertForTarget( - ITestContainerFixture fixture, - T actual, - T expectedInMemory, - T expectedEmulator, - string divergenceReason) - { - var expected = fixture.IsEmulator ? expectedEmulator : expectedInMemory; - actual.Should().Be(expected, - because: fixture.IsEmulator - ? "real emulator behavior" - : $"in-memory divergence: {divergenceReason}"); - } + /// + /// Asserts that matches the expected value for the current target. + /// + /// The fixture identifying the current target. + /// The actual value produced by the test. + /// Expected value when running against in-memory. + /// Expected value when running against the real emulator. + /// Human-readable explanation of why the values differ. + public static void AssertForTarget( + ITestContainerFixture fixture, + T actual, + T expectedInMemory, + T expectedEmulator, + string divergenceReason) + { + var expected = fixture.IsEmulator ? expectedEmulator : expectedInMemory; + actual.Should().Be(expected, + because: fixture.IsEmulator + ? "real emulator behavior" + : $"in-memory divergence: {divergenceReason}"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/RequiresEmulatorFactAttribute.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/RequiresEmulatorFactAttribute.cs index 9cbc4f8..3cc469a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/RequiresEmulatorFactAttribute.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/RequiresEmulatorFactAttribute.cs @@ -9,12 +9,12 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public sealed class RequiresEmulatorFactAttribute : FactAttribute { - public RequiresEmulatorFactAttribute( - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = -1) - : base(sourceFilePath, sourceLineNumber) - { - if (!EmulatorDetector.IsAvailable) - Skip = "Cosmos DB emulator not available at localhost:8081"; - } + public RequiresEmulatorFactAttribute( + [CallerFilePath] string? sourceFilePath = null, + [CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (!EmulatorDetector.IsAvailable) + Skip = "Cosmos DB emulator not available at localhost:8081"; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestFixtureFactory.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestFixtureFactory.cs index f76d7ff..6fa8a9e 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestFixtureFactory.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestFixtureFactory.cs @@ -6,13 +6,13 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public static class TestFixtureFactory { - /// - /// Creates a per-test-class fixture. The target (in-memory vs emulator) - /// is determined by the session, which reads COSMOS_TEST_TARGET - /// once at construction. - /// - public static ITestContainerFixture Create(EmulatorSession session) => - session.IsEmulator - ? new EmulatorTestFixture(session) - : new InMemoryTestFixture(); + /// + /// Creates a per-test-class fixture. The target (in-memory vs emulator) + /// is determined by the session, which reads COSMOS_TEST_TARGET + /// once at construction. + /// + public static ITestContainerFixture Create(EmulatorSession session) => + session.IsEmulator + ? new EmulatorTestFixture(session) + : new InMemoryTestFixture(); } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTarget.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTarget.cs index bc2ccf8..3c89337 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTarget.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTarget.cs @@ -6,22 +6,22 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public enum TestTarget { - /// - /// Default — FakeCosmosHandler backed by InMemoryContainer. - /// Full SDK HTTP pipeline without a real emulator. - /// - InMemory, + /// + /// Default — FakeCosmosHandler backed by InMemoryContainer. + /// Full SDK HTTP pipeline without a real emulator. + /// + InMemory, - /// - /// Linux Docker legacy emulator (mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest). - /// SDK falls back to gateway HTTP for query plans. - /// - EmulatorLinux, + /// + /// Linux Docker legacy emulator (mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest). + /// SDK falls back to gateway HTTP for query plans. + /// + EmulatorLinux, - /// - /// Windows Docker emulator (mcr.microsoft.com/cosmosdb/windows/azure-cosmos-emulator). - /// Requires Docker Desktop in Windows containers mode. - /// Highest feature parity with real Azure Cosmos DB. - /// - EmulatorWindows + /// + /// Windows Docker emulator (mcr.microsoft.com/cosmosdb/windows/azure-cosmos-emulator). + /// Requires Docker Desktop in Windows containers mode. + /// Highest feature parity with real Azure Cosmos DB. + /// + EmulatorWindows } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTraits.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTraits.cs index f76690b..ef59d2e 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTraits.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/TestTraits.cs @@ -6,24 +6,15 @@ namespace CosmosDB.InMemoryEmulator.Tests.Infrastructure; /// public static class TestTraits { - /// Trait name for test target scope. - public const string Target = "Target"; + /// Trait name for test target scope. + public const string Target = "Target"; - /// Runs against both in-memory and emulator (default for FakeCosmosHandler tests). - public const string All = "All"; + /// Runs against both in-memory and emulator (default for FakeCosmosHandler tests). + public const string All = "All"; - /// Only meaningful against in-memory (direct InMemoryContainer, fault injection, etc.). - public const string InMemoryOnly = "InMemoryOnly"; + /// Only meaningful against in-memory (direct InMemoryContainer, fault injection, etc.). + public const string InMemoryOnly = "InMemoryOnly"; - /// Documents a known divergence between in-memory and emulator. - public const string KnownDivergence = "KnownDivergence"; - - /// - /// Test is reproducibly flaky against the Linux Docker / Windows Cosmos DB - /// emulators due to emulator-side instability (typically 503 responses where - /// the in-memory backend returns the expected status). Excluded from - /// emulator-target runs in scripts/run-tests.ps1 to keep CI signal clean. - /// In-memory runs are unaffected — these tests still validate behaviour there. - /// - public const string EmulatorFlaky = "EmulatorFlaky"; + /// Documents a known divergence between in-memory and emulator. + public const string KnownDivergence = "KnownDivergence"; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/TestDocument.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/TestDocument.cs index c2b8bac..7da3894 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/TestDocument.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/TestDocument.cs @@ -4,105 +4,105 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class TestDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("name")] - public string Name { get; set; } = default!; + [JsonProperty("name")] + public string Name { get; set; } = default!; - [JsonProperty("value")] - public int Value { get; set; } + [JsonProperty("value")] + public int Value { get; set; } - [JsonProperty("isActive")] - public bool IsActive { get; set; } = true; + [JsonProperty("isActive")] + public bool IsActive { get; set; } = true; - [JsonProperty("tags")] - public string[] Tags { get; set; } = []; + [JsonProperty("tags")] + public string[] Tags { get; set; } = []; - [JsonProperty("nested")] - public NestedObject? Nested { get; set; } + [JsonProperty("nested")] + public NestedObject? Nested { get; set; } } public class NestedObject { - [JsonProperty("description")] - public string Description { get; set; } = default!; + [JsonProperty("description")] + public string Description { get; set; } = default!; - [JsonProperty("score")] - public double Score { get; set; } + [JsonProperty("score")] + public double Score { get; set; } } public class GeoDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("name")] - public string? Name { get; set; } + [JsonProperty("name")] + public string? Name { get; set; } - [JsonProperty("location")] - public GeoJsonGeometry? Location { get; set; } + [JsonProperty("location")] + public GeoJsonGeometry? Location { get; set; } - [JsonProperty("area")] - public GeoJsonGeometry? Area { get; set; } + [JsonProperty("area")] + public GeoJsonGeometry? Area { get; set; } } public class GeoJsonGeometry { - [JsonProperty("type")] - public string Type { get; set; } = default!; + [JsonProperty("type")] + public string Type { get; set; } = default!; - [JsonProperty("coordinates")] - public object Coordinates { get; set; } = default!; + [JsonProperty("coordinates")] + public object Coordinates { get; set; } = default!; } public class UdfDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("value")] - public double Value { get; set; } + [JsonProperty("value")] + public double Value { get; set; } - [JsonProperty("x")] - public double X { get; set; } + [JsonProperty("x")] + public double X { get; set; } - [JsonProperty("y")] - public double Y { get; set; } + [JsonProperty("y")] + public double Y { get; set; } } public class MultiJoinDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("pk")] - public string Pk { get; set; } = default!; + [JsonProperty("pk")] + public string Pk { get; set; } = default!; - [JsonProperty("colors")] - public string[] Colors { get; set; } = []; + [JsonProperty("colors")] + public string[] Colors { get; set; } = []; - [JsonProperty("sizes")] - public string[] Sizes { get; set; } = []; + [JsonProperty("sizes")] + public string[] Sizes { get; set; } = []; } public class CustomKeyDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("customKey")] - public string CustomKey { get; set; } = default!; + [JsonProperty("customKey")] + public string CustomKey { get; set; } = default!; - [JsonProperty("name")] - public string Name { get; set; } = default!; + [JsonProperty("name")] + public string Name { get; set; } = default!; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BehavioralDifferenceTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BehavioralDifferenceTests.cs index 96cc117..2b34334 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BehavioralDifferenceTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BehavioralDifferenceTests.cs @@ -1,10 +1,10 @@ using System.Globalization; using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -15,725 +15,725 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class BehavioralDifferenceTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - // ── Change Feed ────────────────────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB change feed returns only the latest - /// version of each document, ordered by modification time (_ts), and supports - /// continuation tokens for incremental reads. InMemoryContainer returns all - /// current items from the store in a single page, regardless of when they were - /// created or modified, and does not support incremental continuation. - /// - [Fact] - public async Task ChangeFeed_ReturnsAllCurrentItems_NotIncrementalChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - // InMemoryContainer returns all current items as a snapshot, not incremental changes - results.Should().HaveCount(2); - } - - // ── Delete Container ───────────────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB DeleteContainerAsync returns - /// HttpStatusCode.NoContent (204). InMemoryContainer also clears internal - /// state but the container object remains usable - you can still add items - /// after deletion. Real Cosmos DB would reject subsequent operations until - /// the container is recreated. - /// - [Fact] - public async Task DeleteContainer_ContainerRemainsUsable_UnlikeRealCosmos() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - await _container.DeleteContainerAsync(); - - // InMemoryContainer allows continued use after deletion - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("2", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Bob"); - } - - // ── Throughput ─────────────────────────────────────────────────────────── - - /// - /// FIXED: Throughput is now persisted. ReplaceThroughput stores the value - /// and ReadThroughput returns it, matching real Cosmos DB behavior. - /// - [Fact] - public async Task ReadThroughput_ReturnsPreviouslyReplacedValue() - { - await _container.ReplaceThroughputAsync(1000); - - var throughput = await _container.ReadThroughputAsync(); - - // Now correctly returns the replaced value - throughput.Should().Be(1000); - } - - // ── ETag format ────────────────────────────────────────────────────────── - - /// - /// ETags are quoted monotonically increasing hex counters. - /// The format differs from real Cosmos (opaque timestamp-based) but - /// the concurrency semantics (If-Match / If-None-Match) work correctly, - /// and monotonic ordering is preserved. - /// - [Fact] - public async Task ETag_IsQuotedMonotonicHex() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var etag = response.ETag; - etag.Should().NotBeNullOrEmpty(); - // InMemoryContainer uses quoted hex counter format - etag.Should().StartWith("\"").And.EndWith("\""); - etag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); - } - - // ── LINQ query ────────────────────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB LINQ queries are translated to SQL - /// and executed server-side. InMemoryContainer LINQ operates on an in-memory - /// IQueryable, meaning all LINQ-to-Objects operators work but there is no - /// SQL translation step. Some LINQ expressions that would fail on real Cosmos - /// (e.g. unsupported operators) will succeed against InMemoryContainer. - /// - [Fact] - public async Task LinqQuery_SupportsAllLinqToObjectsOperators() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - - // String.Contains works in LINQ-to-Objects but may not always translate to Cosmos SQL - var results = queryable.Where(d => d.Name.Contains("li")).ToList(); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - // ── Partition key extraction ───────────────────────────────────────────── - - /// - /// InMemoryContainer now correctly extracts the partition key value from - /// the document body using the configured partition key path when no - /// explicit PartitionKey is supplied — matching real Cosmos DB behaviour. - /// - [Fact] - public async Task PartitionKey_ExtractsFromConfiguredPath_WhenNotSupplied() - { - var doc = new TestDocument { Id = "my-id", PartitionKey = "my-pk", Name = "Alice" }; - - await _container.CreateItemAsync(doc); - - var response = await _container.ReadItemAsync("my-id", new PartitionKey("my-pk")); - response.Resource.Name.Should().Be("Alice"); - } - - // ── Stream CRUD enforces ETag checks ──────────────────────────── - - /// - /// Stream-based CRUD now enforces If-Match / If-None-Match like real Cosmos DB. - /// - [Fact] - public async Task StreamReplace_ChecksIfMatch_LikeRealCosmos() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var updatedDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(updatedDoc); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - var requestOptions = new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }; - using var response = await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1"), requestOptions); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - // ── Replace Container ──────────────────────────────────────────────────── - - /// - /// ReplaceContainerAsync should persist property changes so that - /// subsequent ReadContainerAsync calls return the updated values. - /// - [Fact] - public async Task ReplaceContainer_PersistsPropertyChanges() - { - var newProperties = new ContainerProperties("test-container", "/partitionKey") - { - DefaultTimeToLive = 600 - }; - await _container.ReplaceContainerAsync(newProperties); - - var readResponse = await _container.ReadContainerAsync(); - readResponse.Resource.DefaultTimeToLive.Should().Be(600); - } - - // ── Feed ranges ────────────────────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB returns feed ranges that correspond - /// to physical partitions and can be used for parallel processing. - /// InMemoryContainer returns a single NSubstitute mock FeedRange that has - /// no real partition routing behaviour. - /// - [Fact] - public async Task GetFeedRanges_ReturnsSingleMockRange() - { - var feedRanges = await _container.GetFeedRangesAsync(); - - // InMemoryContainer always returns exactly one mocked FeedRange - feedRanges.Should().HaveCount(1); - } - - // ── ChangeFeed processor builders ──────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB ChangeFeedProcessorBuilder creates a - /// processor that monitors the container for changes. InMemoryContainer returns - /// an that polls a list-based change - /// feed. The builder itself is functional but the processing model is simplified. - /// - [Fact] - public void ChangeFeedProcessorBuilder_ReturnsMock_NoRealProcessing() - { - var builder = _container.GetChangeFeedProcessorBuilder( - "testProcessor", - (IReadOnlyCollection changes, CancellationToken token) => Task.CompletedTask); - - builder.Should().NotBeNull(); - } - - // ── Change Feed — empty container ──────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB change feed returns 304 NotModified - /// when there are no new changes. InMemoryContainer returns 200 OK with an - /// empty result set. This is because InMemoryContainer uses a simple list-based - /// change feed that doesn't support the NotModified status code pattern. - /// - [Fact] - public async Task ChangeFeed_EmptyContainer_ReturnsOk_NotNotModified() - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().BeEmpty(); - } - - // ── Change Feed — tombstones ───────────────────────────────────────────── - - /// - /// Deletes are recorded in the change feed as tombstone entries. - /// The checkpoint advances after a delete. - /// - [Fact] - public async Task ChangeFeed_DeletesRecordedAsTombstone() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - var checkpointAfterDelete = _container.GetChangeFeedCheckpoint(); - - checkpointAfterDelete.Should().Be(checkpointAfterCreate + 1); - } - - // ── Aggregate queries ──────────────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB aggregates like COUNT without - /// GROUP BY return a single aggregated value. InMemoryContainer also returns - /// a single aggregated value for COUNT(1). - /// - [Fact] - public async Task Aggregate_Count_WithoutGroupBy_ReturnsCount() - { - for (var i = 0; i < 3; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Value().Should().Be(3); - } - - // ── Null-coalescing operator ───────────────────────────────────────────── - - /// - /// BEHAVIORAL DIFFERENCE: CosmosSqlParser handles the null-coalescing operator (??). - /// SELECT VALUE returns raw scalar values so JToken must be used, not JObject. - /// - [Fact] - public async Task Query_NullCoalesce_ProducesScalarResult_NotJObject() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT VALUE (c.name ?? "default") FROM c"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 1: System Properties & Response Metadata - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB documents always have a _rid - /// Real Cosmos DB always generates a _rid - /// (resource ID) property. Now also set by the emulator. - /// - [Fact] - public async Task SystemProperties_RidPresent_OnDocuments() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.ContainsKey("_rid").Should().BeTrue(); - } - - /// - /// Real Cosmos DB documents always have a _self - /// link. Now also set by the emulator. - /// - [Fact] - public async Task SystemProperties_SelfPresent_OnDocuments() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.ContainsKey("_self").Should().BeTrue(); - } - - /// - /// Real Cosmos DB documents always have an _attachments - /// property. Now also set by the emulator. - /// - [Fact] - public async Task SystemProperties_AttachmentsPresent_OnDocuments() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.ContainsKey("_attachments").Should().BeTrue(); - } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB request charges vary by operation. - /// InMemoryContainer always returns 1.0 RU for every operation. - /// - [Fact] - public async Task RequestCharge_AlwaysReturns1RU_ForAllOperations() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - // Create - var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - createResponse.RequestCharge.Should().Be(1.0); - - // Read - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.RequestCharge.Should().Be(1.0); - - // Query - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - page.RequestCharge.Should().Be(1.0); - } - - // Delete - var deleteResponse = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - deleteResponse.RequestCharge.Should().Be(1.0); - } - - [Fact(Skip = "Real Cosmos returns varying RU charges per operation (reads ~1, writes ~5-10, queries vary).")] - public void RequestCharge_ShouldVaryByOperation_RealCosmos() { } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos session tokens encode partition key range IDs - /// and logical sequence numbers. InMemoryContainer uses 0:{N}#{N} where N increments with each write. - /// - [Fact] - public async Task SessionToken_IsSyntheticGuidFormat() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var sessionToken = response.Headers["x-ms-session-token"]; - sessionToken.Should().StartWith("0:"); - sessionToken.Should().MatchRegex(@"^0:\d+#\d+$"); - } - - [Fact(Skip = "Real Cosmos session tokens use format like '0:-1#12345' with partition range and LSN.")] - public void SessionToken_ShouldContainPartitionAndLSN_RealCosmos() { } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos diagnostics contain timing, endpoint info, - /// and request latency. InMemoryContainer returns a mock with empty ToString() and - /// zero elapsed time. - /// - [Fact] - public async Task Diagnostics_ReturnsMock_EmptyToString() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.Diagnostics.Should().NotBeNull(); - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } - - [Fact(Skip = "Real Cosmos diagnostics contain detailed timing, latency, and endpoint information.")] - public void Diagnostics_ShouldContainTimingInfo_RealCosmos() { } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 2: Consistency - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos respects ConsistencyLevel on request - /// options. InMemoryContainer ignores it — all reads are immediately consistent. - /// - [Fact] - public async Task ConsistencyLevel_Ignored_AlwaysStrongSemantics() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "Updated"; - await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - - // Read with Eventual consistency — should still see latest (strong in emulator) - var options = new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual }; - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), options); - response.Resource.Name.Should().Be("Updated"); - } - - [Fact(Skip = "Real Cosmos with Eventual consistency may return stale data.")] - public void ConsistencyLevel_ShouldAffectReadBehavior_RealCosmos() { } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 3: Container Lifecycle - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos enforces IndexingPolicy exclusion paths - /// so excluded paths are not indexed and cannot be queried efficiently. - /// InMemoryContainer stores the policy but all queries scan every item regardless. - /// - [Fact] - public async Task ReplaceContainer_IndexingPolicy_StoredButNotEnforced() - { - var newProps = new ContainerProperties("test-container", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = true, - IndexingMode = IndexingMode.Consistent, - ExcludedPaths = { new ExcludedPath { Path = "/name/*" } } - } - }; - await _container.ReplaceContainerAsync(newProps); - - // Verify policy is stored - var readResponse = await _container.ReadContainerAsync(); - readResponse.Resource.IndexingPolicy.ExcludedPaths - .Should().Contain(p => p.Path == "/name/*"); - - // Create an item and query on the "excluded" path — still works in emulator - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(1); - } - - [Fact(Skip = "Real Cosmos would return 0 results or an error when querying an excluded path without a scan.")] - public void ReplaceContainer_IndexingPolicyShouldAffectQueries_RealCosmos() { } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos Container.Conflicts provides conflict - /// resolution. InMemoryContainer returns a new NSubstitute mock each access. - /// - [Fact] - public void Conflicts_ReturnsMock_NoRealConflictResolution() - { - var conflicts = _container.Conflicts; - conflicts.Should().NotBeNull(); - - // New mock instance each access - var conflicts2 = _container.Conflicts; - conflicts2.Should().NotBeSameAs(conflicts); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 4: Change Feed - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// In incremental change feed mode, multiple updates to the same document - /// should result in only the latest version being returned. - /// - [Fact] - public async Task ChangeFeed_IncrementalMode_UpdatesReturnOnlyLatestVersion() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v1" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "v2"; - await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - - doc.Name = "v3"; - await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(response); - } - - // Change feed returns all entries (create + 2 replaces = 3 entries) - // but the last entry for id "1" should have the latest name - results.Last(r => r.Id == "1").Name.Should().Be("v3"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 5: Error Formatting - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos sub-status codes provide fine-grained - /// error classification (e.g. 1001, 1003). InMemoryContainer always returns 0. - /// - [Fact] - public async Task CosmosException_SubStatusCode_AlwaysZero() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Trigger 409 Conflict by inserting duplicate - var ex = await Assert.ThrowsAnyAsync(() => - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Dup" }, - new PartitionKey("pk1"))); - - ex.StatusCode.Should().Be(HttpStatusCode.Conflict); - ex.SubStatusCode.Should().Be(0); - } - - [Fact(Skip = "Real Cosmos sub-status codes provide fine-grained classification (e.g. 1001 timeout, 1003 rate limiting).")] - public void CosmosException_SubStatusCodeShouldBeSpecific_RealCosmos() { } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos error messages include activity ID, - /// request URI, and detailed status info. InMemoryContainer uses minimal messages. - /// - [Fact] - public async Task CosmosException_MessageFormat_SimplerThanRealCosmos() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAnyAsync(() => - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Dup" }, - new PartitionKey("pk1"))); - - // InMemoryContainer message is short — no ActivityId or request URI - ex.Message.Should().NotContain("ActivityId"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 6: Continuation Tokens - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos continuation tokens are opaque - /// base64-encoded structures. InMemoryContainer uses plain integer offsets. - /// - [Fact] - public async Task ContinuationToken_IsPlainInteger_NotOpaqueBase64() - { - for (var i = 0; i < 3; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var options = new QueryRequestOptions { MaxItemCount = 1 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); - - var response = await iterator.ReadNextAsync(); - var token = response.ContinuationToken; - - // InMemoryContainer uses integer offset as continuation token - int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out _).Should().BeTrue(); - } - - /// - /// Invalid continuation tokens now throw 400 BadRequest (matching real Cosmos). - /// - [Fact] - public async Task ContinuationToken_Invalid_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Pass garbage continuation token — should throw BadRequest - var act = () => _container.GetItemQueryIterator( - "SELECT * FROM c", - continuationToken: "invalid-garbage-token"); - act.Should().Throw() - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 7: LINQ Enhancements - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: LINQ operators like GroupJoin and TakeWhile that - /// are not translatable to Cosmos SQL work in InMemoryContainer because it - /// runs LINQ-to-Objects. - /// - [Fact] - public async Task LinqQuery_UnsupportedOperators_SucceedInMemory_WouldFailRealCosmos() - { - for (var i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - } - - var queryable = _container.GetItemLinqQueryable(true); - - // TakeWhile is not supported by Cosmos SQL - var results = queryable.OrderBy(d => d.Value).TakeWhile(d => d.Value < 3).ToList(); - results.Should().HaveCount(3); - - // Aggregate (reduce) is not supported by Cosmos SQL - var sum = queryable.Sum(d => d.Value); - sum.Should().Be(10); - } - - [Fact(Skip = "Real Cosmos would reject TakeWhile, Aggregate, and other LINQ-to-Objects-only operators.")] - public void LinqQuery_UnsupportedOperators_ShouldThrow_RealCosmos() { } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 8: Partition Key Edge Cases - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos treats PartitionKey.None and PartitionKey.Null - /// differently (None = no partition key in document, Null = explicit null). - /// InMemoryContainer treats them as resolving to the same internal key for storage, - /// so items stored with either key end up in the same partition. - /// - [Fact] - public async Task PartitionKey_NoneVsNull_TreatedIdentically() - { - var container = new InMemoryContainer("pk-test", "/pk"); - - // Create with explicit PartitionKey.Null - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = (string?)null, name = "Test" }), - PartitionKey.Null); - - // Read with PartitionKey.None — succeeds because emulator treats them the same - var response = await container.ReadItemAsync("1", PartitionKey.None); - response.Resource["name"]!.Value().Should().Be("Test"); - } - - [Fact(Skip = "Real Cosmos distinguishes PartitionKey.None (missing) from PartitionKey.Null (explicit null).")] - public void PartitionKey_NoneVsNull_ShouldDiffer_RealCosmos() { } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + // ── Change Feed ────────────────────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB change feed returns only the latest + /// version of each document, ordered by modification time (_ts), and supports + /// continuation tokens for incremental reads. InMemoryContainer returns all + /// current items from the store in a single page, regardless of when they were + /// created or modified, and does not support incremental continuation. + /// + [Fact] + public async Task ChangeFeed_ReturnsAllCurrentItems_NotIncrementalChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + // InMemoryContainer returns all current items as a snapshot, not incremental changes + results.Should().HaveCount(2); + } + + // ── Delete Container ───────────────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB DeleteContainerAsync returns + /// HttpStatusCode.NoContent (204). InMemoryContainer also clears internal + /// state but the container object remains usable - you can still add items + /// after deletion. Real Cosmos DB would reject subsequent operations until + /// the container is recreated. + /// + [Fact] + public async Task DeleteContainer_ContainerRemainsUsable_UnlikeRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + await _container.DeleteContainerAsync(); + + // InMemoryContainer allows continued use after deletion + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("2", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Bob"); + } + + // ── Throughput ─────────────────────────────────────────────────────────── + + /// + /// FIXED: Throughput is now persisted. ReplaceThroughput stores the value + /// and ReadThroughput returns it, matching real Cosmos DB behavior. + /// + [Fact] + public async Task ReadThroughput_ReturnsPreviouslyReplacedValue() + { + await _container.ReplaceThroughputAsync(1000); + + var throughput = await _container.ReadThroughputAsync(); + + // Now correctly returns the replaced value + throughput.Should().Be(1000); + } + + // ── ETag format ────────────────────────────────────────────────────────── + + /// + /// ETags are quoted monotonically increasing hex counters. + /// The format differs from real Cosmos (opaque timestamp-based) but + /// the concurrency semantics (If-Match / If-None-Match) work correctly, + /// and monotonic ordering is preserved. + /// + [Fact] + public async Task ETag_IsQuotedMonotonicHex() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var etag = response.ETag; + etag.Should().NotBeNullOrEmpty(); + // InMemoryContainer uses quoted hex counter format + etag.Should().StartWith("\"").And.EndWith("\""); + etag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); + } + + // ── LINQ query ────────────────────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB LINQ queries are translated to SQL + /// and executed server-side. InMemoryContainer LINQ operates on an in-memory + /// IQueryable, meaning all LINQ-to-Objects operators work but there is no + /// SQL translation step. Some LINQ expressions that would fail on real Cosmos + /// (e.g. unsupported operators) will succeed against InMemoryContainer. + /// + [Fact] + public async Task LinqQuery_SupportsAllLinqToObjectsOperators() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + + // String.Contains works in LINQ-to-Objects but may not always translate to Cosmos SQL + var results = queryable.Where(d => d.Name.Contains("li")).ToList(); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + // ── Partition key extraction ───────────────────────────────────────────── + + /// + /// InMemoryContainer now correctly extracts the partition key value from + /// the document body using the configured partition key path when no + /// explicit PartitionKey is supplied — matching real Cosmos DB behaviour. + /// + [Fact] + public async Task PartitionKey_ExtractsFromConfiguredPath_WhenNotSupplied() + { + var doc = new TestDocument { Id = "my-id", PartitionKey = "my-pk", Name = "Alice" }; + + await _container.CreateItemAsync(doc); + + var response = await _container.ReadItemAsync("my-id", new PartitionKey("my-pk")); + response.Resource.Name.Should().Be("Alice"); + } + + // ── Stream CRUD enforces ETag checks ──────────────────────────── + + /// + /// Stream-based CRUD now enforces If-Match / If-None-Match like real Cosmos DB. + /// + [Fact] + public async Task StreamReplace_ChecksIfMatch_LikeRealCosmos() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var updatedDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(updatedDoc); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + var requestOptions = new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }; + using var response = await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1"), requestOptions); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + // ── Replace Container ──────────────────────────────────────────────────── + + /// + /// ReplaceContainerAsync should persist property changes so that + /// subsequent ReadContainerAsync calls return the updated values. + /// + [Fact] + public async Task ReplaceContainer_PersistsPropertyChanges() + { + var newProperties = new ContainerProperties("test-container", "/partitionKey") + { + DefaultTimeToLive = 600 + }; + await _container.ReplaceContainerAsync(newProperties); + + var readResponse = await _container.ReadContainerAsync(); + readResponse.Resource.DefaultTimeToLive.Should().Be(600); + } + + // ── Feed ranges ────────────────────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB returns feed ranges that correspond + /// to physical partitions and can be used for parallel processing. + /// InMemoryContainer returns a single NSubstitute mock FeedRange that has + /// no real partition routing behaviour. + /// + [Fact] + public async Task GetFeedRanges_ReturnsSingleMockRange() + { + var feedRanges = await _container.GetFeedRangesAsync(); + + // InMemoryContainer always returns exactly one mocked FeedRange + feedRanges.Should().HaveCount(1); + } + + // ── ChangeFeed processor builders ──────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB ChangeFeedProcessorBuilder creates a + /// processor that monitors the container for changes. InMemoryContainer returns + /// an that polls a list-based change + /// feed. The builder itself is functional but the processing model is simplified. + /// + [Fact] + public void ChangeFeedProcessorBuilder_ReturnsMock_NoRealProcessing() + { + var builder = _container.GetChangeFeedProcessorBuilder( + "testProcessor", + (IReadOnlyCollection changes, CancellationToken token) => Task.CompletedTask); + + builder.Should().NotBeNull(); + } + + // ── Change Feed — empty container ──────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB change feed returns 304 NotModified + /// when there are no new changes. InMemoryContainer returns 200 OK with an + /// empty result set. This is because InMemoryContainer uses a simple list-based + /// change feed that doesn't support the NotModified status code pattern. + /// + [Fact] + public async Task ChangeFeed_EmptyContainer_ReturnsOk_NotNotModified() + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().BeEmpty(); + } + + // ── Change Feed — tombstones ───────────────────────────────────────────── + + /// + /// Deletes are recorded in the change feed as tombstone entries. + /// The checkpoint advances after a delete. + /// + [Fact] + public async Task ChangeFeed_DeletesRecordedAsTombstone() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + var checkpointAfterDelete = _container.GetChangeFeedCheckpoint(); + + checkpointAfterDelete.Should().Be(checkpointAfterCreate + 1); + } + + // ── Aggregate queries ──────────────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB aggregates like COUNT without + /// GROUP BY return a single aggregated value. InMemoryContainer also returns + /// a single aggregated value for COUNT(1). + /// + [Fact] + public async Task Aggregate_Count_WithoutGroupBy_ReturnsCount() + { + for (var i = 0; i < 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Value().Should().Be(3); + } + + // ── Null-coalescing operator ───────────────────────────────────────────── + + /// + /// BEHAVIORAL DIFFERENCE: CosmosSqlParser handles the null-coalescing operator (??). + /// SELECT VALUE returns raw scalar values so JToken must be used, not JObject. + /// + [Fact] + public async Task Query_NullCoalesce_ProducesScalarResult_NotJObject() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT VALUE (c.name ?? "default") FROM c"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 1: System Properties & Response Metadata + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB documents always have a _rid + /// Real Cosmos DB always generates a _rid + /// (resource ID) property. Now also set by the emulator. + /// + [Fact] + public async Task SystemProperties_RidPresent_OnDocuments() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.ContainsKey("_rid").Should().BeTrue(); + } + + /// + /// Real Cosmos DB documents always have a _self + /// link. Now also set by the emulator. + /// + [Fact] + public async Task SystemProperties_SelfPresent_OnDocuments() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.ContainsKey("_self").Should().BeTrue(); + } + + /// + /// Real Cosmos DB documents always have an _attachments + /// property. Now also set by the emulator. + /// + [Fact] + public async Task SystemProperties_AttachmentsPresent_OnDocuments() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.ContainsKey("_attachments").Should().BeTrue(); + } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB request charges vary by operation. + /// InMemoryContainer always returns 1.0 RU for every operation. + /// + [Fact] + public async Task RequestCharge_AlwaysReturns1RU_ForAllOperations() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + // Create + var createResponse = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + createResponse.RequestCharge.Should().Be(1.0); + + // Read + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.RequestCharge.Should().Be(1.0); + + // Query + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + page.RequestCharge.Should().Be(1.0); + } + + // Delete + var deleteResponse = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + deleteResponse.RequestCharge.Should().Be(1.0); + } + + [Fact(Skip = "Real Cosmos returns varying RU charges per operation (reads ~1, writes ~5-10, queries vary).")] + public void RequestCharge_ShouldVaryByOperation_RealCosmos() { } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos session tokens encode partition key range IDs + /// and logical sequence numbers. InMemoryContainer uses 0:{N}#{N} where N increments with each write. + /// + [Fact] + public async Task SessionToken_IsSyntheticGuidFormat() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var sessionToken = response.Headers["x-ms-session-token"]; + sessionToken.Should().StartWith("0:"); + sessionToken.Should().MatchRegex(@"^0:\d+#\d+$"); + } + + [Fact(Skip = "Real Cosmos session tokens use format like '0:-1#12345' with partition range and LSN.")] + public void SessionToken_ShouldContainPartitionAndLSN_RealCosmos() { } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos diagnostics contain timing, endpoint info, + /// and request latency. InMemoryContainer returns a mock with empty ToString() and + /// zero elapsed time. + /// + [Fact] + public async Task Diagnostics_ReturnsMock_EmptyToString() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.Diagnostics.Should().NotBeNull(); + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } + + [Fact(Skip = "Real Cosmos diagnostics contain detailed timing, latency, and endpoint information.")] + public void Diagnostics_ShouldContainTimingInfo_RealCosmos() { } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 2: Consistency + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos respects ConsistencyLevel on request + /// options. InMemoryContainer ignores it — all reads are immediately consistent. + /// + [Fact] + public async Task ConsistencyLevel_Ignored_AlwaysStrongSemantics() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "Updated"; + await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + + // Read with Eventual consistency — should still see latest (strong in emulator) + var options = new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual }; + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), options); + response.Resource.Name.Should().Be("Updated"); + } + + [Fact(Skip = "Real Cosmos with Eventual consistency may return stale data.")] + public void ConsistencyLevel_ShouldAffectReadBehavior_RealCosmos() { } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 3: Container Lifecycle + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos enforces IndexingPolicy exclusion paths + /// so excluded paths are not indexed and cannot be queried efficiently. + /// InMemoryContainer stores the policy but all queries scan every item regardless. + /// + [Fact] + public async Task ReplaceContainer_IndexingPolicy_StoredButNotEnforced() + { + var newProps = new ContainerProperties("test-container", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = true, + IndexingMode = IndexingMode.Consistent, + ExcludedPaths = { new ExcludedPath { Path = "/name/*" } } + } + }; + await _container.ReplaceContainerAsync(newProps); + + // Verify policy is stored + var readResponse = await _container.ReadContainerAsync(); + readResponse.Resource.IndexingPolicy.ExcludedPaths + .Should().Contain(p => p.Path == "/name/*"); + + // Create an item and query on the "excluded" path — still works in emulator + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(1); + } + + [Fact(Skip = "Real Cosmos would return 0 results or an error when querying an excluded path without a scan.")] + public void ReplaceContainer_IndexingPolicyShouldAffectQueries_RealCosmos() { } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos Container.Conflicts provides conflict + /// resolution. InMemoryContainer returns a new NSubstitute mock each access. + /// + [Fact] + public void Conflicts_ReturnsMock_NoRealConflictResolution() + { + var conflicts = _container.Conflicts; + conflicts.Should().NotBeNull(); + + // New mock instance each access + var conflicts2 = _container.Conflicts; + conflicts2.Should().NotBeSameAs(conflicts); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 4: Change Feed + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// In incremental change feed mode, multiple updates to the same document + /// should result in only the latest version being returned. + /// + [Fact] + public async Task ChangeFeed_IncrementalMode_UpdatesReturnOnlyLatestVersion() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v1" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "v2"; + await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + + doc.Name = "v3"; + await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(response); + } + + // Change feed returns all entries (create + 2 replaces = 3 entries) + // but the last entry for id "1" should have the latest name + results.Last(r => r.Id == "1").Name.Should().Be("v3"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 5: Error Formatting + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos sub-status codes provide fine-grained + /// error classification (e.g. 1001, 1003). InMemoryContainer always returns 0. + /// + [Fact] + public async Task CosmosException_SubStatusCode_AlwaysZero() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Trigger 409 Conflict by inserting duplicate + var ex = await Assert.ThrowsAnyAsync(() => + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Dup" }, + new PartitionKey("pk1"))); + + ex.StatusCode.Should().Be(HttpStatusCode.Conflict); + ex.SubStatusCode.Should().Be(0); + } + + [Fact(Skip = "Real Cosmos sub-status codes provide fine-grained classification (e.g. 1001 timeout, 1003 rate limiting).")] + public void CosmosException_SubStatusCodeShouldBeSpecific_RealCosmos() { } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos error messages include activity ID, + /// request URI, and detailed status info. InMemoryContainer uses minimal messages. + /// + [Fact] + public async Task CosmosException_MessageFormat_SimplerThanRealCosmos() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAnyAsync(() => + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Dup" }, + new PartitionKey("pk1"))); + + // InMemoryContainer message is short — no ActivityId or request URI + ex.Message.Should().NotContain("ActivityId"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 6: Continuation Tokens + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos continuation tokens are opaque + /// base64-encoded structures. InMemoryContainer uses plain integer offsets. + /// + [Fact] + public async Task ContinuationToken_IsPlainInteger_NotOpaqueBase64() + { + for (var i = 0; i < 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var options = new QueryRequestOptions { MaxItemCount = 1 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); + + var response = await iterator.ReadNextAsync(); + var token = response.ContinuationToken; + + // InMemoryContainer uses integer offset as continuation token + int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out _).Should().BeTrue(); + } + + /// + /// Invalid continuation tokens now throw 400 BadRequest (matching real Cosmos). + /// + [Fact] + public async Task ContinuationToken_Invalid_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Pass garbage continuation token — should throw BadRequest + var act = () => _container.GetItemQueryIterator( + "SELECT * FROM c", + continuationToken: "invalid-garbage-token"); + act.Should().Throw() + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 7: LINQ Enhancements + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: LINQ operators like GroupJoin and TakeWhile that + /// are not translatable to Cosmos SQL work in InMemoryContainer because it + /// runs LINQ-to-Objects. + /// + [Fact] + public async Task LinqQuery_UnsupportedOperators_SucceedInMemory_WouldFailRealCosmos() + { + for (var i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + } + + var queryable = _container.GetItemLinqQueryable(true); + + // TakeWhile is not supported by Cosmos SQL + var results = queryable.OrderBy(d => d.Value).TakeWhile(d => d.Value < 3).ToList(); + results.Should().HaveCount(3); + + // Aggregate (reduce) is not supported by Cosmos SQL + var sum = queryable.Sum(d => d.Value); + sum.Should().Be(10); + } + + [Fact(Skip = "Real Cosmos would reject TakeWhile, Aggregate, and other LINQ-to-Objects-only operators.")] + public void LinqQuery_UnsupportedOperators_ShouldThrow_RealCosmos() { } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 8: Partition Key Edge Cases + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos treats PartitionKey.None and PartitionKey.Null + /// differently (None = no partition key in document, Null = explicit null). + /// InMemoryContainer treats them as resolving to the same internal key for storage, + /// so items stored with either key end up in the same partition. + /// + [Fact] + public async Task PartitionKey_NoneVsNull_TreatedIdentically() + { + var container = new InMemoryContainer("pk-test", "/pk"); + + // Create with explicit PartitionKey.Null + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = (string?)null, name = "Test" }), + PartitionKey.Null); + + // Read with PartitionKey.None — succeeds because emulator treats them the same + var response = await container.ReadItemAsync("1", PartitionKey.None); + response.Resource["name"]!.Value().Should().Be("Test"); + } + + [Fact(Skip = "Real Cosmos distinguishes PartitionKey.None (missing) from PartitionKey.Null (explicit null).")] + public void PartitionKey_NoneVsNull_ShouldDiffer_RealCosmos() { } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs index 24768b5..3736a1d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs @@ -1,63 +1,63 @@ -using Xunit; using AwesomeAssertions; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class BuildSelectTokenPathTests { - [Fact] - public void NoSegments_ReturnsEmpty() - { - InMemoryContainer.BuildSelectTokenPath(Array.Empty()) - .Should().BeEmpty(); - } - - [Fact] - public void SingleNameSegment_ReturnsName() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "name" }) - .Should().Be("name"); - } - - [Fact] - public void SingleNumericSegment_ReturnsBracketNotation() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "0" }) - .Should().Be("[0]"); - } - - [Fact] - public void MixedSegments_CorrectNotation() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "items", "0", "name" }) - .Should().Be("items[0].name"); - } - - [Fact] - public void ConsecutiveNumericSegments_CorrectNotation() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "matrix", "0", "1" }) - .Should().Be("matrix[0][1]"); - } - - [Fact] - public void AllNameSegments_DotSeparated() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "a", "b", "c" }) - .Should().Be("a.b.c"); - } - - [Fact] - public void NumericFirst_BracketNotation() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "0", "name" }) - .Should().Be("[0].name"); - } - - [Fact] - public void TrailingNumeric_CorrectNotation() - { - InMemoryContainer.BuildSelectTokenPath(new[] { "items", "0" }) - .Should().Be("items[0]"); - } + [Fact] + public void NoSegments_ReturnsEmpty() + { + InMemoryContainer.BuildSelectTokenPath(Array.Empty()) + .Should().BeEmpty(); + } + + [Fact] + public void SingleNameSegment_ReturnsName() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "name" }) + .Should().Be("name"); + } + + [Fact] + public void SingleNumericSegment_ReturnsBracketNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "0" }) + .Should().Be("[0]"); + } + + [Fact] + public void MixedSegments_CorrectNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "items", "0", "name" }) + .Should().Be("items[0].name"); + } + + [Fact] + public void ConsecutiveNumericSegments_CorrectNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "matrix", "0", "1" }) + .Should().Be("matrix[0][1]"); + } + + [Fact] + public void AllNameSegments_DotSeparated() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "a", "b", "c" }) + .Should().Be("a.b.c"); + } + + [Fact] + public void NumericFirst_BracketNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "0", "name" }) + .Should().Be("[0].name"); + } + + [Fact] + public void TrailingNumeric_CorrectNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "items", "0" }) + .Should().Be("items[0]"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BulkOperationTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BulkOperationTests.cs index a294199..3cce3f4 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BulkOperationTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BulkOperationTests.cs @@ -15,2935 +15,2935 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class BulkOperationTests { - [Fact] - public async Task BulkCreate_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 500).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(500); - - // Verify all items are readable - for (var i = 0; i < 500; i++) - { - var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - response.Resource.Name.Should().Be($"Item{i}"); - } - } - - [Fact] - public async Task BulkUpsert_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // First round: all new items - var createTasks = Enumerable.Range(0, 500).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var createResults = await Task.WhenAll(createTasks); - createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(500); - - // Second round: all updates - var updateTasks = Enumerable.Range(0, 500).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - - var updateResults = await Task.WhenAll(updateTasks); - updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - container.ItemCount.Should().Be(500); - } - - [Fact] - public async Task BulkDelete_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - container.ItemCount.Should().Be(200); - - // Bulk delete - var tasks = Enumerable.Range(0, 200).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkReplace_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Bulk replace - var tasks = Enumerable.Range(0, 200).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"{i}", - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - // Verify all items were replaced - for (var i = 0; i < 200; i++) - { - var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - response.Resource.Name.Should().Be($"Replaced{i}"); - } - } - - [Fact] - public async Task BulkPatch_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, - new PartitionKey("pk1")); - - // Bulk patch - var tasks = Enumerable.Range(0, 200).Select(i => - container.PatchItemAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - // Verify all items were patched - for (var i = 0; i < 200; i++) - { - var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - response.Resource.Name.Should().Be($"Patched{i}"); - } - } - - [Fact] - public async Task BulkRead_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Bulk read - var tasks = Enumerable.Range(0, 200).Select(i => - container.ReadItemAsync($"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - for (var i = 0; i < 200; i++) - results[i].Resource.Name.Should().Be($"Item{i}"); - } - - [Fact] - public async Task BulkCreate_MixedPartitionKeys_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 500).Select(i => - { - var pk = $"pk{i % 10}"; - return container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = pk, Name = $"Item{i}" }, - new PartitionKey(pk)); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(500); - - // Verify distribution: each PK has 50 items - for (var pk = 0; pk < 10; pk++) - { - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", $"pk{pk}"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey($"pk{pk}") }); - - var items = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - items.AddRange(page); - } - - items.Should().HaveCount(50); - } - } - - [Fact] - public async Task BulkCreate_DuplicateIds_SomeConflict() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 100 items where IDs 0-49 are unique, and IDs 50-99 duplicate IDs 0-49 - var tasks = Enumerable.Range(0, 100).Select(i => - { - var id = $"{i % 50}"; // IDs 0-49 used twice - return Task.Run(async () => - { - try - { - var response = await container.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - return (StatusCode: response.StatusCode, IsSuccess: true); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - return (StatusCode: HttpStatusCode.Conflict, IsSuccess: false); - } - }); - }); - - var results = await Task.WhenAll(tasks); - - var successes = results.Count(r => r.IsSuccess); - var conflicts = results.Count(r => !r.IsSuccess); - - successes.Should().Be(50); - conflicts.Should().Be(50); - container.ItemCount.Should().Be(50); - } - - [Fact] - public async Task BulkMixedOperations_CreateUpsertDeleteReplace_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items for replace and delete - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Existing{i}" }, - new PartitionKey("pk1")); - - // Mixed concurrent operations - var createTasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var upsertTasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var deleteTasks = Enumerable.Range(0, 25).Select(i => - container.DeleteItemAsync($"existing-{i}", new PartitionKey("pk1")) - .ContinueWith(t => t.Result.StatusCode)); - - var replaceTasks = Enumerable.Range(25, 25).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"existing-{i}", - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var allTasks = createTasks - .Concat(upsertTasks) - .Concat(deleteTasks) - .Concat(replaceTasks); - - var results = await Task.WhenAll(allTasks); - - // Verify individual operation status codes - var createResults = results.Take(50).ToList(); - createResults.Should().OnlyContain(s => s == HttpStatusCode.Created); - - var upsertResults = results.Skip(50).Take(50).ToList(); - upsertResults.Should().OnlyContain(s => s == HttpStatusCode.Created); - - var deleteResults = results.Skip(100).Take(25).ToList(); - deleteResults.Should().OnlyContain(s => s == HttpStatusCode.NoContent); - - var replaceResults = results.Skip(125).Take(25).ToList(); - replaceResults.Should().OnlyContain(s => s == HttpStatusCode.OK); - - // Final count: 50 pre-created - 25 deleted + 50 new + 50 upserted = 125 - container.ItemCount.Should().Be(125); - } - - [Fact] - public void InMemoryCosmosClient_ClientOptions_AllowBulkExecution_CanBeSet() - { - var client = new InMemoryCosmosClient(); - - // AllowBulkExecution is a property on CosmosClientOptions — should be settable - client.ClientOptions.AllowBulkExecution = true; - client.ClientOptions.AllowBulkExecution.Should().BeTrue(); - - client.ClientOptions.AllowBulkExecution = false; - client.ClientOptions.AllowBulkExecution.Should().BeFalse(); - } - - [Fact] - public async Task BulkCreate_ViaCosmosClient_WithAllowBulkExecution_AllSucceed() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.AllowBulkExecution = true; - - var database = client.GetDatabase("test-db"); - await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); - var container = database.GetContainer("test-container"); - - var tasks = Enumerable.Range(0, 200).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } - - [Fact] - public async Task BulkOperations_ChangeFeed_RecordsAllChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var checkpoint = container.GetChangeFeedCheckpoint(); - - // Bulk create - var tasks = Enumerable.Range(0, 200).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - await Task.WhenAll(tasks); - - // Read change feed - var iterator = container.GetChangeFeedIterator(checkpoint); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - changes.AddRange(page); - } - - changes.Should().HaveCount(200); - } - - [Fact] - public async Task BulkOperations_UniqueKeyViolation_ReturnsConflict() - { - var properties = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - // Create items with unique names concurrently, but some share the same name - var tasks = Enumerable.Range(0, 100).Select(i => - { - var name = $"Name{i % 50}"; // 50 unique names, each used twice - return Task.Run(async () => - { - try - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = name }, - new PartitionKey("pk1")); - return true; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - return false; - } - }); - }); - - var results = await Task.WhenAll(tasks); - results.Count(r => r).Should().Be(50); - results.Count(r => !r).Should().Be(50); - } - - [Fact] - public async Task BulkOperations_ETags_UpdatedPerOperation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Bulk create - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - // Each item should have a unique ETag - var etags = results.Select(r => r.ETag).ToList(); - etags.Should().OnlyHaveUniqueItems(); - etags.Should().NotContain(string.Empty); - etags.Should().NotContainNulls(); - } - - [Fact] - public async Task BulkCreate_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 200).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - return container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(200); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 1: Stream Variant Coverage - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkUpsert_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 200).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - return container.UpsertItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - // Re-upsert all (updates) - var updateTasks = Enumerable.Range(0, 200).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }); - return container.UpsertItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - }); - - var updateResults = await Task.WhenAll(updateTasks); - updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkReplace_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 200).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }); - return container.ReplaceItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), $"{i}", new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkDelete_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 200).Select(i => - container.DeleteItemStreamAsync($"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkPatch_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 200).Select(i => - container.PatchItemStreamAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkRead_StreamVariant_ManyItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 200).Select(i => - container.ReadItemStreamAsync($"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 2: Error Handling / Negative Paths - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReplace_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.ReplaceItemAsync( - new TestDocument { Id = $"missing-{i}", PartitionKey = "pk1", Name = "X" }, - $"missing-{i}", new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkDelete_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.DeleteItemAsync($"missing-{i}", new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkPatch_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.PatchItemAsync($"missing-{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "X") })); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkRead_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.ReadItemAsync($"missing-{i}", new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 3: Request Options & Conditional Operations - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReplace_WithCorrectIfMatchEtag_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var createResults = new List>(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - createResults.Add(r); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResults[i].ETag })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkReplace_WithStaleIfMatchEtag_AllFail412() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var etags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - // Mutate all items so ETags change - for (var i = 0; i < 100; i++) - await container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, - $"{i}", new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Stale{i}" }, - $"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etags[i] })); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task BulkCreate_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - results.Should().OnlyContain(r => r.Resource == null); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 4: Concurrency Edge Cases - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_SameIdDifferentPartitionKeys_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = "shared-id", PartitionKey = $"pk{i}", Name = $"Item{i}" }, - new PartitionKey($"pk{i}"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(50); - } - - [Fact] - public async Task BulkUpsert_ConcurrentOnSameItem_LastWriterWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = "same-id", PartitionKey = "pk1", Name = $"Version{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - // All should succeed (mix of 201 Created and 200 OK) - results.Should().OnlyContain(r => - r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - - // Only one item should exist - container.ItemCount.Should().Be(1); - - // Final document should be consistent - var final = await container.ReadItemAsync("same-id", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Version"); - } - - [Fact] - public async Task BulkCreate_EmptyBatch_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Empty>>(); - var results = await Task.WhenAll(tasks); - - results.Should().BeEmpty(); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkCreate_SingleItem_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = new[] - { - container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Solo" }, - new PartitionKey("pk1")) - }; - - var results = await Task.WhenAll(tasks); - results.Should().ContainSingle().Which.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 5: Data Integrity & Metadata - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_SystemMetadata_TsAndEtagSetOnAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - await Task.WhenAll(tasks); - - for (var i = 0; i < 100; i++) - { - var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - var doc = response.Resource; - doc["_etag"]!.ToString().Should().NotBeNullOrEmpty(); - ((long)doc["_ts"]!).Should().BeGreaterThan(0); - } - } - - [Fact] - public async Task BulkUpsert_ETags_ChangeOnUpdate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var createTasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var originalEtags = (await Task.WhenAll(createTasks)).Select(r => r.ETag).ToList(); - - var upsertTasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - - var newEtags = (await Task.WhenAll(upsertTasks)).Select(r => r.ETag).ToList(); - - for (var i = 0; i < 100; i++) - newEtags[i].Should().NotBe(originalEtags[i]); - } - - [Fact] - public async Task BulkCreate_ResponseMetadata_RequestChargeAndHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - foreach (var r in results) - { - r.RequestCharge.Should().BeGreaterThan(0); - r.Headers.Should().NotBeNull(); - } - } - - [Fact] - public async Task BulkCreate_SpecialCharactersInIds_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var ids = new[] { "hello world", "café", "item-1.0", "a/b", "emoji\u2764", "dot.dash-under_score" }; - var tasks = ids.Select(id => - container.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = id }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - foreach (var id in ids) - { - var response = await container.ReadItemAsync(id, new PartitionKey("pk1")); - response.Resource.Id.Should().Be(id); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 6: Change Feed Interactions - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkUpsert_ChangeFeed_RecordsAllUpdates() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Bulk create (100 new items) - var createTasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(createTasks); - - // Bulk update same items - var updateTasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(updateTasks); - - // Incremental change feed deduplicates to the latest version per item, - // so 100 items each updated once = 100 entries (not 200) - var iterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - // Incremental change feed returns latest version per item - changes.Should().HaveCount(100); - changes.Should().OnlyContain(c => c.Name.StartsWith("Updated")); - } - - [Fact] - public async Task BulkDelete_ChangeFeed_RecordsTombstones() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - // Bulk delete all - var deleteTasks = Enumerable.Range(0, 100).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - await Task.WhenAll(deleteTasks); - - var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); - - // Each delete creates a tombstone entry - (checkpointAfterDelete - checkpointAfterCreate).Should().Be(100); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 7: Partition Key Variants - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_WithPartitionKeyNone_ExtractsFromDocument() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk{i % 5}", Name = $"Item{i}" })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(100); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Phase 8: DeleteAllItemsByPartitionKey Integration - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_ThenDeleteAllByPartitionKey_ContainerEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 500).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(tasks); - container.ItemCount.Should().Be(500); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - container.ItemCount.Should().Be(0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 1: Stream Error Handling - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReplace_StreamVariant_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"missing-{i}", PartitionKey = "pk1", Name = "X" }); - return container.ReplaceItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - $"missing-{i}", new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkDelete_StreamVariant_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.DeleteItemStreamAsync($"missing-{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkRead_StreamVariant_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.ReadItemStreamAsync($"missing-{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkPatch_StreamVariant_NonExistentItems_AllReturn404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.PatchItemStreamAsync($"missing-{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "X") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task BulkCreate_StreamVariant_DuplicateIds_SomeConflict() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var id = $"{i % 50}"; - var json = JsonConvert.SerializeObject( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Item{i}" }); - return container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - - var successes = results.Count(r => r.StatusCode == HttpStatusCode.Created); - var conflicts = results.Count(r => r.StatusCode == HttpStatusCode.Conflict); - - successes.Should().Be(50); - conflicts.Should().Be(50); - container.ItemCount.Should().Be(50); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 2: Document Size Limits - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_OversizedDocuments_Typed_AllReturn413() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create a string that makes the document > 2MB - var bigValue = new string('x', 2 * 1024 * 1024); - - var tasks = Enumerable.Range(0, 10).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = bigValue }), - new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkCreate_OversizedDocuments_Stream_AllReturn413() - { - // Stream methods return a ResponseMessage with 413 status code - // instead of throwing CosmosException. - var container = new InMemoryContainer("test", "/partitionKey"); - - var bigValue = new string('x', 2 * 1024 * 1024); - - var tasks = Enumerable.Range(0, 10).Select(i => - Task.Run(async () => - { - var json = JsonConvert.SerializeObject( - new { id = $"{i}", partitionKey = "pk1", data = bigValue }); - var response = await container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1")); - return response.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task BulkUpsert_OversizedDocuments_AllReturn413() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var bigValue = new string('x', 2 * 1024 * 1024); - - var tasks = Enumerable.Range(0, 10).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.UpsertItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = bigValue }), - new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task BulkCreate_DocumentAtExactSizeLimit_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // The JSON overhead is: {"id":"0","partitionKey":"pk1","data":"..."} ~50 bytes - // We want total to be just under 2MB - var padding = 2 * 1024 * 1024 - 200; // leave room for JSON envelope - var value = new string('a', padding); - - var tasks = Enumerable.Range(0, 5).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = value }), - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(5); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 3: ETag & Conditional Operations - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkUpsert_WithIfMatchEtag_Star_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkCreate_WithIfNoneMatchEtag_Star_DuplicateIds_AllReturn409() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // IfNoneMatch * on read means "only if NOT cached" — on create it's not a standard - // operation, but the emulator's CheckIfNoneMatch will throw 304 NotModified when an item - // with a matching etag exists. For creates, the 409 Conflict from duplicate ID will fire first. - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Dup{i}" }, - new PartitionKey("pk1"))); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.Conflict); - } - - [Fact] - public async Task BulkDelete_WithIfMatchEtag_Correct_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var createResults = new List>(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - createResults.Add(r); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResults[i].ETag })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkDelete_WithIfMatchEtag_Stale_AllFail412() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var oldEtags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - oldEtags.Add(r.ETag); - } - - // Mutate all items so ETags change - for (var i = 0; i < 100; i++) - await container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, - $"{i}", new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = oldEtags[i] })); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.PreconditionFailed); - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task BulkRead_WithIfNoneMatchEtag_Current_AllReturn304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var createResults = new List>(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - createResults.Add(r); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - Task.Run(async () => - { - var ex = await Assert.ThrowsAnyAsync(() => - container.ReadItemAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = createResults[i].ETag })); - return ex.StatusCode; - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.NotModified); - } - - [Fact] - public async Task BulkRead_WithIfNoneMatchEtag_Stale_AllReturnData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var oldEtags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - oldEtags.Add(r.ETag); - } - - // Mutate all items so ETags change - for (var i = 0; i < 100; i++) - await container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - $"{i}", new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReadItemAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = oldEtags[i] })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - for (var i = 0; i < 100; i++) - results[i].Resource.Name.Should().Be($"Updated{i}"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 4: EnableContentResponseOnWrite (All Operations) - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkUpsert_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - results.Should().OnlyContain(r => r.Resource == null); - } - - [Fact] - public async Task BulkReplace_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - results.Should().OnlyContain(r => r.Resource == null); - } - - [Fact] - public async Task BulkPatch_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.PatchItemAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") }, - new PatchItemRequestOptions { EnableContentResponseOnWrite = false })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - results.Should().OnlyContain(r => r.Resource == null); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 5: CancellationToken Handling - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_WithAlreadyCancelledToken_AllThrowOperationCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - await Assert.ThrowsAnyAsync(() => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - cancellationToken: cts.Token)); - })); - - await Task.WhenAll(tasks); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkRead_WithAlreadyCancelledToken_AllThrowOperationCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - Task.Run(async () => - { - await Assert.ThrowsAnyAsync(() => - container.ReadItemAsync($"{i}", new PartitionKey("pk1"), - cancellationToken: cts.Token)); - })); - - await Task.WhenAll(tasks); - } - - [Fact(Skip = "InMemoryContainer operations are synchronous and complete instantly — " + - "ThrowIfCancellationRequested() fires at the start of each method so mid-flight " + - "cancellation cannot be observed. All tasks complete before the token fires.")] - public async Task BulkCreate_CancelDuringExecution_SomeSucceedSomeCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var cts = new CancellationTokenSource(); - - var tasks = Enumerable.Range(0, 200).Select(i => - Task.Run(async () => - { - try - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - cancellationToken: cts.Token); - return true; - } - catch (OperationCanceledException) - { - return false; - } - })); - - _ = Task.Delay(1).ContinueWith(_ => cts.Cancel()); - var results = await Task.WhenAll(tasks); - - var successes = results.Count(r => r); - var cancellations = results.Count(r => !r); - (successes + cancellations).Should().Be(200); - container.ItemCount.Should().Be(successes); - } - - /// - /// Divergent behaviour: Mid-flight cancellation is not observable because InMemoryContainer - /// operations are synchronous. All 200 items are created successfully. - /// - [Fact] - public async Task BulkCreate_CancelDuringExecution_SomeSucceedSomeCancelled_DivergentBehaviour() - { - // Real Cosmos DB: CancellationToken can interrupt network I/O mid-flight, leading to - // a mix of succeeded and cancelled operations. - // InMemoryContainer: Operations are in-memory and complete synchronously. The - // ThrowIfCancellationRequested() check at the top of each method only fires if the - // token was ALREADY cancelled before the method is entered. Since tasks are dispatched - // near-simultaneously and complete instantly, all 200 succeed. - var container = new InMemoryContainer("test", "/partitionKey"); - var cts = new CancellationTokenSource(); - - var tasks = Enumerable.Range(0, 200).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - cancellationToken: cts.Token)); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(200); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 6: Concurrency Race Conditions - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReplace_ConcurrentOnSameItem_LastWriterWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk1", Name = $"Version{i}" }, - "target", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - container.ItemCount.Should().Be(1); - - var final = await container.ReadItemAsync("target", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Version"); - } - - [Fact] - public async Task BulkDelete_ConcurrentOnSameItem_OneSucceedsRestFail() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(_ => - Task.Run(async () => - { - try - { - await container.DeleteItemAsync("target", new PartitionKey("pk1")); - return true; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return false; - } - })); - - var results = await Task.WhenAll(tasks); - results.Count(r => r).Should().BeGreaterThanOrEqualTo(1); - results.Count(r => !r).Should().BeGreaterThanOrEqualTo(0); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkPatch_ConcurrentOnSameItem_AtomicIncrement() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "counter", PartitionKey = "pk1", Name = "Counter", Value = 0 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(_ => - container.PatchItemAsync("counter", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) })); - - await Task.WhenAll(tasks); - var final = await container.ReadItemAsync("counter", new PartitionKey("pk1")); - final.Resource.Value.Should().Be(50); - } - - [Fact] - public async Task BulkCreate_ThenImmediateDelete_SameItems_NoCrash() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // For each ID 0-49: one task creates, another task deletes - var tasks = Enumerable.Range(0, 50).SelectMany(i => new[] - { - Task.Run(async () => - { - try - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - catch (Exception) { /* Create can fail due to race with delete or conflict */ } - }), - Task.Run(async () => - { - try - { - await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); - } - catch (Exception) { /* Delete can fail if item not yet created or already deleted */ } - }) - }); - - await Task.WhenAll(tasks); - - // Each item is either present or not — container state should be consistent - container.ItemCount.Should().BeGreaterThanOrEqualTo(0); - container.ItemCount.Should().BeLessThanOrEqualTo(50); - } - - [Fact] - public async Task BulkUpsert_MixOfNewAndExisting_StatusCodesCorrect() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create IDs 0-49 - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Upsert IDs 0-99 (0-49 are updates, 50-99 are creates) - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - var updates = results.Where(r => r.StatusCode == HttpStatusCode.OK).ToList(); - var creates = results.Where(r => r.StatusCode == HttpStatusCode.Created).ToList(); - - updates.Count.Should().Be(50); - creates.Count.Should().Be(50); - container.ItemCount.Should().Be(100); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 7: Unique Key Edge Cases - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkUpsert_UniqueKeyViolation_SomeConflict() - { - var properties = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - // 100 items where 50 pairs share the same name but different IDs - var tasks = Enumerable.Range(0, 100).Select(i => - { - var name = $"Name{i % 50}"; - return Task.Run(async () => - { - try - { - await container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = name }, - new PartitionKey("pk1")); - return true; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - return false; - } - }); - }); - - var results = await Task.WhenAll(tasks); - results.Count(r => r).Should().Be(50); - results.Count(r => !r).Should().Be(50); - } - - [Fact] - public async Task BulkUpsert_SameIdSameUniqueKey_AlwaysSucceeds() - { - var properties = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - // Upsert same ID with same name 50 times — self-update should not violate unique key - var tasks = Enumerable.Range(0, 50).Select(_ => - container.UpsertItemAsync( - new TestDocument { Id = "same-id", PartitionKey = "pk1", Name = "SameName" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => - r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - container.ItemCount.Should().Be(1); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 8: Null/Missing Partition Key Variants - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_UnicodeInPartitionKeys_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var unicodePKs = new[] { "🎉", "中文", "café", "عربي", "🚀🌍" }; - - var tasks = unicodePKs.SelectMany((pk, pkIdx) => - Enumerable.Range(0, 10).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{pkIdx}-{i}", PartitionKey = pk, Name = $"Item{pkIdx}-{i}" }, - new PartitionKey(pk)))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(50); - - // Verify each PK group is queryable - foreach (var pk in unicodePKs) - { - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(pk) }); - - var items = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - items.AddRange(page); - } - - items.Should().HaveCount(10); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 9: Hierarchical Partition Keys - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_HierarchicalPartitionKey_AllSucceed() - { - var properties = new ContainerProperties("test", new List { "/tenantId", "/customerId" }); - var container = new InMemoryContainer(properties); - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var tenantId = $"tenant{i % 5}"; - var customerId = $"customer{i % 20}"; - var doc = JObject.FromObject(new { id = $"{i}", tenantId, customerId, name = $"Item{i}" }); - var pk = new PartitionKeyBuilder().Add(tenantId).Add(customerId).Build(); - return container.CreateItemAsync(doc, pk); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task BulkUpsert_HierarchicalPartitionKey_MixedCreateAndUpdate() - { - var properties = new ContainerProperties("test", new List { "/tenantId", "/customerId" }); - var container = new InMemoryContainer(properties); - - // First round: all new - var createTasks = Enumerable.Range(0, 50).Select(i => - { - var pk = new PartitionKeyBuilder().Add("t1").Add($"c{i}").Build(); - return container.UpsertItemAsync( - JObject.FromObject(new { id = $"{i}", tenantId = "t1", customerId = $"c{i}", name = $"Item{i}" }), pk); - }); - - var createResults = await Task.WhenAll(createTasks); - createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - // Second round: updates - var updateTasks = Enumerable.Range(0, 50).Select(i => - { - var pk = new PartitionKeyBuilder().Add("t1").Add($"c{i}").Build(); - return container.UpsertItemAsync( - JObject.FromObject(new { id = $"{i}", tenantId = "t1", customerId = $"c{i}", name = $"Updated{i}" }), pk); - }); - - var updateResults = await Task.WhenAll(updateTasks); - updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - container.ItemCount.Should().Be(50); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 10: Response Metadata & Diagnostics - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_ActivityId_UniquePerOperation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - var activityIds = results.Select(r => r.ActivityId).ToList(); - activityIds.Should().OnlyHaveUniqueItems(); - activityIds.Should().NotContain(string.Empty); - activityIds.Should().NotContainNulls(); - } - - [Fact] - public async Task BulkUpsert_ResponseMetadata_RequestChargeAndHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - foreach (var r in results) - { - r.RequestCharge.Should().BeGreaterThan(0); - r.Headers.Should().NotBeNull(); - } - } - - [Fact] - public async Task BulkReplace_ResponseMetadata_RequestChargeAndHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - foreach (var r in results) - { - r.RequestCharge.Should().BeGreaterThan(0); - r.Headers.Should().NotBeNull(); - } - } - - [Fact] - public async Task BulkDelete_ResponseMetadata_StatusCodeConsistent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - foreach (var r in results) - { - r.StatusCode.Should().Be(HttpStatusCode.NoContent); - r.Headers.Should().NotBeNull(); - } - } - - [Fact] - public async Task BulkPatch_ResponseMetadata_RequestChargeAndHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.PatchItemAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - - foreach (var r in results) - { - r.RequestCharge.Should().BeGreaterThan(0); - r.Headers.Should().NotBeNull(); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 11: Change Feed Interaction Gaps - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkMixedOps_ChangeFeed_AllOperationsRecorded() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 50 - var createTasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(createTasks); - - // Upsert 50 (updates) - var upsertTasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(upsertTasks); - - var checkpointBeforeDelete = container.GetChangeFeedCheckpoint(); - - // Delete 25 - var deleteTasks = Enumerable.Range(0, 25).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - await Task.WhenAll(deleteTasks); - - var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); - - // Verify 125 total change feed entries: 50 creates + 50 updates + 25 deletes - checkpointAfterDelete.Should().Be(125); - - // Verify delete tombstones - (checkpointAfterDelete - checkpointBeforeDelete).Should().Be(25); - } - - [Fact] - public async Task BulkCreate_ChangeFeed_StreamIterator_HasContent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(tasks); - - var iterator = container.GetChangeFeedStreamIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var totalBytes = 0; - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - response.IsSuccessStatusCode.Should().BeTrue(); - if (response.Content is not null) - { - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - totalBytes += body.Length; - } - } - - totalBytes.Should().BeGreaterThan(0); - } - - [Fact] - public async Task BulkDelete_ChangeFeed_VerifyTombstoneContent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - - var deleteTasks = Enumerable.Range(0, 50).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - await Task.WhenAll(deleteTasks); - - // Read change feed from checkpoint — should see tombstones - var iterator = container.GetChangeFeedIterator(checkpoint); - var tombstones = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - tombstones.AddRange(page); - } - - tombstones.Should().HaveCount(50); - foreach (var tombstone in tombstones) - { - tombstone["id"]!.ToString().Should().NotBeNullOrEmpty(); - // Tombstones should contain the deleted indicator - var hasDeletedFlag = tombstone["_deleted"] != null; - var hasTtlExpired = tombstone["_ttlExpired"] != null; - (hasDeletedFlag || hasTtlExpired).Should().BeTrue( - "tombstone should indicate deletion via _deleted or _ttlExpired"); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 12: TransactionalBatch Interleaving - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_ConcurrentWithTransactionalBatch_NoDeadlock() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // TransactionalBatch on "batchPK" - var batchTask = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("batchPK")); - for (var i = 0; i < 5; i++) - batch.CreateItem(new TestDocument { Id = $"batch-{i}", PartitionKey = "batchPK", Name = $"Batch{i}" }); - return await batch.ExecuteAsync(); - }); - - // Concurrent bulk creates on "bulkPK" - var bulkTasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"bulk-{i}", PartitionKey = "bulkPK", Name = $"Bulk{i}" }, - new PartitionKey("bulkPK"))); - - var allTasks = bulkTasks.ToList(); - allTasks.Add(batchTask); - await Task.WhenAll(allTasks); - - var batchResult = await batchTask; - batchResult.IsSuccessStatusCode.Should().BeTrue(); - container.ItemCount.Should().Be(105); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 13: Bulk with TTL - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_WithContainerTTL_ItemsExpireAfterRead() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = 1 // 1 second - }; - - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(tasks); - - container.ItemCount.Should().Be(50); - - // Wait for TTL to expire - await Task.Delay(2000); - - // Reading triggers lazy eviction - for (var i = 0; i < 50; i++) - { - try - { - await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected — item expired - } - } - - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task BulkCreate_WithPerItemTTL_ItemsExpireIndependently() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = -1 // TTL enabled, but no default expiry - }; - - // Half with short TTL, half with long TTL - var shortTtlTasks = Enumerable.Range(0, 25).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"short-{i}", partitionKey = "pk1", name = $"Short{i}", _ttl = 1 }), - new PartitionKey("pk1"))); - - var longTtlTasks = Enumerable.Range(0, 25).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"long-{i}", partitionKey = "pk1", name = $"Long{i}", _ttl = 3600 }), - new PartitionKey("pk1"))); - - await Task.WhenAll(shortTtlTasks.Concat(longTtlTasks)); - container.ItemCount.Should().Be(50); - - await Task.Delay(2000); - - // Trigger lazy eviction by reading short-TTL items - for (var i = 0; i < 25; i++) - { - try - { - await container.ReadItemAsync($"short-{i}", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected - } - } - - // Long-TTL items should still be present - for (var i = 0; i < 25; i++) - { - var response = await container.ReadItemAsync($"long-{i}", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 14: CosmosClient Integration (Typed + Stream) - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_ViaCosmosClient_StreamVariant_AllSucceed() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.AllowBulkExecution = true; - - var database = client.GetDatabase("test-db"); - await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); - var container = database.GetContainer("test-container"); - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - return container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } - - [Fact] - public async Task BulkUpsert_ViaCosmosClient_AllSucceed() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.AllowBulkExecution = true; - - var database = client.GetDatabase("test-db"); - await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); - var container = database.GetContainer("test-container"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } - - [Fact] - public async Task BulkMixedOps_ViaCosmosClient_AllSucceed() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.AllowBulkExecution = true; - - var database = client.GetDatabase("test-db"); - await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); - var container = database.GetContainer("test-container"); - - // Create 50 via client - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Existing{i}" }, - new PartitionKey("pk1")); - - // Mixed concurrent operations - var createTasks = Enumerable.Range(0, 25).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var upsertTasks = Enumerable.Range(0, 25).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var deleteTasks = Enumerable.Range(0, 25).Select(i => - container.DeleteItemAsync($"existing-{i}", new PartitionKey("pk1")) - .ContinueWith(t => t.Result.StatusCode)); - - var allTasks = createTasks.Concat(upsertTasks).Concat(deleteTasks); - var results = await Task.WhenAll(allTasks); - - var createResults = results.Take(25).ToList(); - createResults.Should().OnlyContain(s => s == HttpStatusCode.Created); - - var upsertResults = results.Skip(25).Take(25).ToList(); - upsertResults.Should().OnlyContain(s => s == HttpStatusCode.Created); - - var deleteResults = results.Skip(50).Take(25).ToList(); - deleteResults.Should().OnlyContain(s => s == HttpStatusCode.NoContent); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 15: Patch Conditional Operations in Bulk - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkPatch_WithFilterPredicate_OnlyPatchesMatchingItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 100 items: 50 active, 50 inactive - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", IsActive = i < 50 }, - new PartitionKey("pk1")); - - // Patch all 100 with filter predicate - var tasks = Enumerable.Range(0, 100).Select(i => - Task.Run(async () => - { - try - { - var response = await container.PatchItemAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - return (StatusCode: response.StatusCode, Success: true); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - return (StatusCode: ex.StatusCode, Success: false); - } - })); - - var results = await Task.WhenAll(tasks); - - results.Count(r => r.Success).Should().Be(50); - results.Count(r => !r.Success).Should().Be(50); - } - - [Fact] - public async Task BulkPatch_WithFilterPredicate_Stream_OnlyPatchesMatching() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", IsActive = i < 50 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.PatchItemStreamAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" })); - - var results = await Task.WhenAll(tasks); - - results.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(50); - results.Count(r => r.StatusCode == HttpStatusCode.PreconditionFailed).Should().Be(50); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 16: Large Batch Stress Tests - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_1000Items_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 1000).Select(i => - { - var pk = $"pk{i % 20}"; - return container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = pk, Name = $"Item{i}" }, - new PartitionKey(pk)); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(1000); - } - - [Fact] - public async Task BulkUpsert_1000Items_CreateThenUpdate_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create round - var createTasks = Enumerable.Range(0, 1000).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - var createResults = await Task.WhenAll(createTasks); - createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - // Update round - var updateTasks = Enumerable.Range(0, 1000).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - var updateResults = await Task.WhenAll(updateTasks); - updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - container.ItemCount.Should().Be(1000); - } - - [Fact] - public async Task BulkMixedOps_1000Items_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items for replace and delete - for (var i = 0; i < 500; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, - new PartitionKey("pk1")); - - var createTasks = Enumerable.Range(0, 250).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var upsertTasks = Enumerable.Range(0, 250).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, - new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var replaceTasks = Enumerable.Range(0, 250).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"pre-{i}", new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); - - var deleteTasks = Enumerable.Range(250, 250).Select(i => - container.DeleteItemAsync($"pre-{i}", new PartitionKey("pk1")) - .ContinueWith(t => t.Result.StatusCode)); - - var allTasks = createTasks.Concat(upsertTasks).Concat(replaceTasks).Concat(deleteTasks); - var results = await Task.WhenAll(allTasks); - - // 500 pre-created - 250 deleted + 250 new + 250 upserted = 750 - container.ItemCount.Should().Be(750); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase 17: DeleteAllItemsByPartitionKey Concurrency - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DeleteAllItemsByPartitionKey_ConcurrentWithBulkCreate_DifferentPKs_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-populate PK "B" with 200 items - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"b-{i}", PartitionKey = "B", Name = $"B-Item{i}" }, - new PartitionKey("B")); - - // Concurrently: create on PK "A" + delete all on PK "B" - var createTasks = Enumerable.Range(0, 200).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"a-{i}", PartitionKey = "A", Name = $"A-Item{i}" }, - new PartitionKey("A"))); - - var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("B")); - - var allConcurrentTasks = createTasks.ToList(); - allConcurrentTasks.Add(deleteTask); - await Task.WhenAll(allConcurrentTasks); - - // PK "A" items should all be present - var iterA = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("A") }); - - var aItems = new List(); - while (iterA.HasMoreResults) - { - var page = await iterA.ReadNextAsync(); - aItems.AddRange(page); - } - - aItems.Should().HaveCount(200); - - // PK "B" items should be gone - var iterB = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("B") }); - - var bItems = new List(); - while (iterB.HasMoreResults) - { - var page = await iterB.ReadNextAsync(); - bItems.AddRange(page); - } - - bItems.Should().BeEmpty(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase A: ReadMany Bulk Operations - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReadMany_Typed_AllItemsFound() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 10).Select(batch => - { - var items = Enumerable.Range(batch * 20, 20) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - return container.ReadManyItemsAsync(items); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.Count == 20); - results.SelectMany(r => r).Select(d => d.Id).Distinct().Should().HaveCount(200); - } - - [Fact] - public async Task BulkReadMany_Stream_AllItemsFound() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 10).Select(batch => - { - var items = Enumerable.Range(batch * 20, 20) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - return container.ReadManyItemsStreamAsync(items); - }); - - var results = await Task.WhenAll(tasks); - foreach (var r in results) - { - r.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(r.Content); - var envelope = JObject.Parse(await reader.ReadToEndAsync()); - ((int)envelope["_count"]!).Should().Be(20); - } - } - - [Fact] - public async Task BulkReadMany_Typed_SomeItemsMissing_ReturnsFoundOnly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - // Only create even IDs - for (var i = 0; i < 100; i += 2) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var allIds = Enumerable.Range(0, 100) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - - var response = await container.ReadManyItemsAsync(allIds); - response.Count.Should().Be(50); // only the even ones - } - - [Fact] - public async Task BulkReadMany_Typed_ConcurrentWithWrites_NoCrash() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var readTasks = Enumerable.Range(0, 10).Select(_ => - { - var items = Enumerable.Range(0, 100) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - return container.ReadManyItemsAsync(items); - }); - - var createTasks = Enumerable.Range(100, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1"))); - - var upsertTasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, - new PartitionKey("pk1"))); - - var allTasks = new List(); - allTasks.AddRange(readTasks); - allTasks.AddRange(createTasks); - allTasks.AddRange(upsertTasks); - - await Task.WhenAll(allTasks); - container.ItemCount.Should().Be(200); - } - - [Fact] - public async Task BulkReadMany_Typed_WithIfNoneMatchEtag_Returns304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var allIds = Enumerable.Range(0, 10) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - - // First read to get composite ETag - var firstResponse = await container.ReadManyItemsAsync(allIds); - firstResponse.Count.Should().Be(10); - var compositeEtag = firstResponse.ETag; - compositeEtag.Should().NotBeNullOrEmpty(); - - // Second read with IfNoneMatchEtag = composite -> 304 - var secondResponse = await container.ReadManyItemsAsync(allIds, - new ReadManyRequestOptions { IfNoneMatchEtag = compositeEtag }); - secondResponse.StatusCode.Should().Be(HttpStatusCode.NotModified); - secondResponse.Count.Should().Be(0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase B: PatchItemStreamAsync Concurrency - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkPatch_StreamVariant_ConcurrentIncrement_AllApplied() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "counter", PartitionKey = "pk1", Value = 0 }, - new PartitionKey("pk1")); - - var gate = new ManualResetEventSlim(false); - var tasks = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => - { - gate.Wait(); - var patchOps = new[] { PatchOperation.Increment("/value", 1) }; - var json = JsonConvert.SerializeObject(patchOps); - return await container.PatchItemStreamAsync( - "counter", new PartitionKey("pk1"), patchOps); - })); - - var taskList = tasks.ToList(); - gate.Set(); - var results = await Task.WhenAll(taskList); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("counter", new PartitionKey("pk1")); - final.Resource.Value.Should().Be(50); - } - - [Fact] - public async Task BulkPatch_StreamVariant_ConcurrentSet_LastWriterWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => Task.Run(async () => - { - var patchOps = new[] { PatchOperation.Set("/name", $"Version{i}") }; - return await container.PatchItemStreamAsync( - "target", new PartitionKey("pk1"), patchOps); - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("target", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Version"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase C: CancellationToken Regression Guards - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReadMany_StreamVariant_CancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var allIds = Enumerable.Range(0, 10) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - - await Assert.ThrowsAsync(() => - container.ReadManyItemsStreamAsync(allIds, cancellationToken: cts.Token)); - } - - [Fact] - public async Task BulkReadMany_Typed_CancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var allIds = Enumerable.Range(0, 10) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - - await Assert.ThrowsAsync(() => - container.ReadManyItemsAsync(allIds, cancellationToken: cts.Token)); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase D: ReadMany Concurrent with Deletes - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReadMany_ConcurrentWithDeletes_NoException() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var readTasks = Enumerable.Range(0, 20).Select(_ => - { - var items = Enumerable.Range(0, 100) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - return container.ReadManyItemsAsync(items); - }); - - var deleteTasks = Enumerable.Range(0, 100).Select(i => - container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); - - var allTasks = new List(); - allTasks.AddRange(readTasks); - allTasks.AddRange(deleteTasks); - - await Task.WhenAll(allTasks); - container.ItemCount.Should().Be(0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase E: Stream Conditional ETag Operations - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkReplace_StreamVariant_WithCorrectIfMatchEtag_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var etags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }); - return container.ReplaceItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - $"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etags[i] }); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkReplace_StreamVariant_WithStaleIfMatchEtag_AllFail412() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var etags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - // Mutate all to make ETags stale - for (var i = 0; i < 100; i++) - await container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, - $"{i}", new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Stale{i}" }); - return container.ReplaceItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - $"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etags[i] }); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task BulkRead_StreamVariant_WithIfNoneMatchEtag_Returns304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var etags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReadItemStreamAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etags[i] })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotModified); - } - - [Fact] - public async Task BulkUpsert_StreamVariant_WithIfMatchEtag_Star_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }); - return container.UpsertItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task BulkDelete_StreamVariant_WithCorrectIfMatchEtag_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var etags = new List(); - for (var i = 0; i < 100; i++) - { - var r = await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - var tasks = Enumerable.Range(0, 100).Select(i => - container.DeleteItemStreamAsync($"{i}", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etags[i] })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); - container.ItemCount.Should().Be(0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase F: State Export/Import During Bulk Operations - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ExportState_DuringBulkWrites_DoesNotThrow() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var cts = new CancellationTokenSource(); - - var createTask = Task.Run(async () => - { - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - }); - - var exportTask = Task.Run(() => - { - var exports = new List(); - while (!createTask.IsCompleted) - { - var state = container.ExportState(); - state.Should().NotBeNullOrEmpty(); - exports.Add(state); - } - return exports; - }); - - await createTask; - var allExports = await exportTask; - allExports.Should().NotBeEmpty(); - } - - [Fact] - public async Task ImportState_AfterBulkWrites_RestoresCorrectly() - { - var source = new InMemoryContainer("source", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - source.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(tasks); - - var state = source.ExportState(); - - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(state); - - for (var i = 0; i < 100; i++) - { - var r = await target.ReadItemAsync($"{i}", new PartitionKey("pk1")); - r.Resource.Name.Should().Be($"Item{i}"); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase G: Stream EnableContentResponseOnWrite - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BulkCreate_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - return container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - foreach (var r in results) - { - if (r.Content != null) - { - using var reader = new StreamReader(r.Content); - var body = await reader.ReadToEndAsync(); - body.Should().BeNullOrEmpty(); - } - } - } - - [Fact] - public async Task BulkUpsert_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - return container.UpsertItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - foreach (var r in results) - { - if (r.Content != null) - { - using var reader = new StreamReader(r.Content); - var body = await reader.ReadToEndAsync(); - body.Should().BeNullOrEmpty(); - } - } - } - - [Fact] - public async Task BulkPatch_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.PatchItemStreamAsync( - $"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") }, - new PatchItemRequestOptions { EnableContentResponseOnWrite = false })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - foreach (var r in results) - { - if (r.Content != null) - { - using var reader = new StreamReader(r.Content); - var body = await reader.ReadToEndAsync(); - body.Should().BeNullOrEmpty(); - } - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Phase H: ReadMany Composite ETag Mismatch - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ReadMany_CompositeEtag_MismatchReturnsData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var allIds = Enumerable.Range(0, 10) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); - - // Get composite ETag - var firstResponse = await container.ReadManyItemsAsync(allIds); - var compositeEtag = firstResponse.ETag; - - // Mutate one item to change composite ETag - await container.UpsertItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Mutated" }, - new PartitionKey("pk1")); - - // ReadMany with old composite ETag -> should return data (200) since etag changed - var secondResponse = await container.ReadManyItemsAsync(allIds, - new ReadManyRequestOptions { IfNoneMatchEtag = compositeEtag }); - secondResponse.StatusCode.Should().Be(HttpStatusCode.OK); - secondResponse.Count.Should().Be(10); - } + [Fact] + public async Task BulkCreate_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 500).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(500); + + // Verify all items are readable + for (var i = 0; i < 500; i++) + { + var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + response.Resource.Name.Should().Be($"Item{i}"); + } + } + + [Fact] + public async Task BulkUpsert_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // First round: all new items + var createTasks = Enumerable.Range(0, 500).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var createResults = await Task.WhenAll(createTasks); + createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(500); + + // Second round: all updates + var updateTasks = Enumerable.Range(0, 500).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + + var updateResults = await Task.WhenAll(updateTasks); + updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + container.ItemCount.Should().Be(500); + } + + [Fact] + public async Task BulkDelete_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + container.ItemCount.Should().Be(200); + + // Bulk delete + var tasks = Enumerable.Range(0, 200).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkReplace_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Bulk replace + var tasks = Enumerable.Range(0, 200).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"{i}", + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + // Verify all items were replaced + for (var i = 0; i < 200; i++) + { + var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + response.Resource.Name.Should().Be($"Replaced{i}"); + } + } + + [Fact] + public async Task BulkPatch_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, + new PartitionKey("pk1")); + + // Bulk patch + var tasks = Enumerable.Range(0, 200).Select(i => + container.PatchItemAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + // Verify all items were patched + for (var i = 0; i < 200; i++) + { + var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + response.Resource.Name.Should().Be($"Patched{i}"); + } + } + + [Fact] + public async Task BulkRead_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Bulk read + var tasks = Enumerable.Range(0, 200).Select(i => + container.ReadItemAsync($"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + for (var i = 0; i < 200; i++) + results[i].Resource.Name.Should().Be($"Item{i}"); + } + + [Fact] + public async Task BulkCreate_MixedPartitionKeys_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 500).Select(i => + { + var pk = $"pk{i % 10}"; + return container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = pk, Name = $"Item{i}" }, + new PartitionKey(pk)); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(500); + + // Verify distribution: each PK has 50 items + for (var pk = 0; pk < 10; pk++) + { + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", $"pk{pk}"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey($"pk{pk}") }); + + var items = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + items.AddRange(page); + } + + items.Should().HaveCount(50); + } + } + + [Fact] + public async Task BulkCreate_DuplicateIds_SomeConflict() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 100 items where IDs 0-49 are unique, and IDs 50-99 duplicate IDs 0-49 + var tasks = Enumerable.Range(0, 100).Select(i => + { + var id = $"{i % 50}"; // IDs 0-49 used twice + return Task.Run(async () => + { + try + { + var response = await container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + return (StatusCode: response.StatusCode, IsSuccess: true); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + return (StatusCode: HttpStatusCode.Conflict, IsSuccess: false); + } + }); + }); + + var results = await Task.WhenAll(tasks); + + var successes = results.Count(r => r.IsSuccess); + var conflicts = results.Count(r => !r.IsSuccess); + + successes.Should().Be(50); + conflicts.Should().Be(50); + container.ItemCount.Should().Be(50); + } + + [Fact] + public async Task BulkMixedOperations_CreateUpsertDeleteReplace_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items for replace and delete + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Existing{i}" }, + new PartitionKey("pk1")); + + // Mixed concurrent operations + var createTasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var upsertTasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var deleteTasks = Enumerable.Range(0, 25).Select(i => + container.DeleteItemAsync($"existing-{i}", new PartitionKey("pk1")) + .ContinueWith(t => t.Result.StatusCode)); + + var replaceTasks = Enumerable.Range(25, 25).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"existing-{i}", + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var allTasks = createTasks + .Concat(upsertTasks) + .Concat(deleteTasks) + .Concat(replaceTasks); + + var results = await Task.WhenAll(allTasks); + + // Verify individual operation status codes + var createResults = results.Take(50).ToList(); + createResults.Should().OnlyContain(s => s == HttpStatusCode.Created); + + var upsertResults = results.Skip(50).Take(50).ToList(); + upsertResults.Should().OnlyContain(s => s == HttpStatusCode.Created); + + var deleteResults = results.Skip(100).Take(25).ToList(); + deleteResults.Should().OnlyContain(s => s == HttpStatusCode.NoContent); + + var replaceResults = results.Skip(125).Take(25).ToList(); + replaceResults.Should().OnlyContain(s => s == HttpStatusCode.OK); + + // Final count: 50 pre-created - 25 deleted + 50 new + 50 upserted = 125 + container.ItemCount.Should().Be(125); + } + + [Fact] + public void InMemoryCosmosClient_ClientOptions_AllowBulkExecution_CanBeSet() + { + var client = new InMemoryCosmosClient(); + + // AllowBulkExecution is a property on CosmosClientOptions — should be settable + client.ClientOptions.AllowBulkExecution = true; + client.ClientOptions.AllowBulkExecution.Should().BeTrue(); + + client.ClientOptions.AllowBulkExecution = false; + client.ClientOptions.AllowBulkExecution.Should().BeFalse(); + } + + [Fact] + public async Task BulkCreate_ViaCosmosClient_WithAllowBulkExecution_AllSucceed() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.AllowBulkExecution = true; + + var database = client.GetDatabase("test-db"); + await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); + var container = database.GetContainer("test-container"); + + var tasks = Enumerable.Range(0, 200).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } + + [Fact] + public async Task BulkOperations_ChangeFeed_RecordsAllChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var checkpoint = container.GetChangeFeedCheckpoint(); + + // Bulk create + var tasks = Enumerable.Range(0, 200).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + await Task.WhenAll(tasks); + + // Read change feed + var iterator = container.GetChangeFeedIterator(checkpoint); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + changes.AddRange(page); + } + + changes.Should().HaveCount(200); + } + + [Fact] + public async Task BulkOperations_UniqueKeyViolation_ReturnsConflict() + { + var properties = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + // Create items with unique names concurrently, but some share the same name + var tasks = Enumerable.Range(0, 100).Select(i => + { + var name = $"Name{i % 50}"; // 50 unique names, each used twice + return Task.Run(async () => + { + try + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = name }, + new PartitionKey("pk1")); + return true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + return false; + } + }); + }); + + var results = await Task.WhenAll(tasks); + results.Count(r => r).Should().Be(50); + results.Count(r => !r).Should().Be(50); + } + + [Fact] + public async Task BulkOperations_ETags_UpdatedPerOperation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Bulk create + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + // Each item should have a unique ETag + var etags = results.Select(r => r.ETag).ToList(); + etags.Should().OnlyHaveUniqueItems(); + etags.Should().NotContain(string.Empty); + etags.Should().NotContainNulls(); + } + + [Fact] + public async Task BulkCreate_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 200).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + return container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(200); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 1: Stream Variant Coverage + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkUpsert_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 200).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + return container.UpsertItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + // Re-upsert all (updates) + var updateTasks = Enumerable.Range(0, 200).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }); + return container.UpsertItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + }); + + var updateResults = await Task.WhenAll(updateTasks); + updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkReplace_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 200).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }); + return container.ReplaceItemStreamAsync(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), $"{i}", new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkDelete_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 200).Select(i => + container.DeleteItemStreamAsync($"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkPatch_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 200).Select(i => + container.PatchItemStreamAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkRead_StreamVariant_ManyItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 200).Select(i => + container.ReadItemStreamAsync($"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 2: Error Handling / Negative Paths + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReplace_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.ReplaceItemAsync( + new TestDocument { Id = $"missing-{i}", PartitionKey = "pk1", Name = "X" }, + $"missing-{i}", new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkDelete_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.DeleteItemAsync($"missing-{i}", new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkPatch_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.PatchItemAsync($"missing-{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "X") })); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkRead_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.ReadItemAsync($"missing-{i}", new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.NotFound); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 3: Request Options & Conditional Operations + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReplace_WithCorrectIfMatchEtag_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var createResults = new List>(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + createResults.Add(r); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResults[i].ETag })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkReplace_WithStaleIfMatchEtag_AllFail412() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var etags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + // Mutate all items so ETags change + for (var i = 0; i < 100; i++) + await container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, + $"{i}", new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Stale{i}" }, + $"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etags[i] })); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task BulkCreate_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + results.Should().OnlyContain(r => r.Resource == null); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 4: Concurrency Edge Cases + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_SameIdDifferentPartitionKeys_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = "shared-id", PartitionKey = $"pk{i}", Name = $"Item{i}" }, + new PartitionKey($"pk{i}"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(50); + } + + [Fact] + public async Task BulkUpsert_ConcurrentOnSameItem_LastWriterWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = "same-id", PartitionKey = "pk1", Name = $"Version{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + // All should succeed (mix of 201 Created and 200 OK) + results.Should().OnlyContain(r => + r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + + // Only one item should exist + container.ItemCount.Should().Be(1); + + // Final document should be consistent + var final = await container.ReadItemAsync("same-id", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Version"); + } + + [Fact] + public async Task BulkCreate_EmptyBatch_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Empty>>(); + var results = await Task.WhenAll(tasks); + + results.Should().BeEmpty(); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkCreate_SingleItem_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = new[] + { + container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Solo" }, + new PartitionKey("pk1")) + }; + + var results = await Task.WhenAll(tasks); + results.Should().ContainSingle().Which.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 5: Data Integrity & Metadata + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_SystemMetadata_TsAndEtagSetOnAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + await Task.WhenAll(tasks); + + for (var i = 0; i < 100; i++) + { + var response = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + var doc = response.Resource; + doc["_etag"]!.ToString().Should().NotBeNullOrEmpty(); + ((long)doc["_ts"]!).Should().BeGreaterThan(0); + } + } + + [Fact] + public async Task BulkUpsert_ETags_ChangeOnUpdate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var createTasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var originalEtags = (await Task.WhenAll(createTasks)).Select(r => r.ETag).ToList(); + + var upsertTasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + + var newEtags = (await Task.WhenAll(upsertTasks)).Select(r => r.ETag).ToList(); + + for (var i = 0; i < 100; i++) + newEtags[i].Should().NotBe(originalEtags[i]); + } + + [Fact] + public async Task BulkCreate_ResponseMetadata_RequestChargeAndHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + foreach (var r in results) + { + r.RequestCharge.Should().BeGreaterThan(0); + r.Headers.Should().NotBeNull(); + } + } + + [Fact] + public async Task BulkCreate_SpecialCharactersInIds_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var ids = new[] { "hello world", "café", "item-1.0", "a/b", "emoji\u2764", "dot.dash-under_score" }; + var tasks = ids.Select(id => + container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = id }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + foreach (var id in ids) + { + var response = await container.ReadItemAsync(id, new PartitionKey("pk1")); + response.Resource.Id.Should().Be(id); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 6: Change Feed Interactions + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkUpsert_ChangeFeed_RecordsAllUpdates() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Bulk create (100 new items) + var createTasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(createTasks); + + // Bulk update same items + var updateTasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(updateTasks); + + // Incremental change feed deduplicates to the latest version per item, + // so 100 items each updated once = 100 entries (not 200) + var iterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + // Incremental change feed returns latest version per item + changes.Should().HaveCount(100); + changes.Should().OnlyContain(c => c.Name.StartsWith("Updated")); + } + + [Fact] + public async Task BulkDelete_ChangeFeed_RecordsTombstones() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + // Bulk delete all + var deleteTasks = Enumerable.Range(0, 100).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + await Task.WhenAll(deleteTasks); + + var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); + + // Each delete creates a tombstone entry + (checkpointAfterDelete - checkpointAfterCreate).Should().Be(100); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 7: Partition Key Variants + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_WithPartitionKeyNone_ExtractsFromDocument() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk{i % 5}", Name = $"Item{i}" })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(100); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Phase 8: DeleteAllItemsByPartitionKey Integration + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_ThenDeleteAllByPartitionKey_ContainerEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 500).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(tasks); + container.ItemCount.Should().Be(500); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + container.ItemCount.Should().Be(0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 1: Stream Error Handling + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReplace_StreamVariant_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"missing-{i}", PartitionKey = "pk1", Name = "X" }); + return container.ReplaceItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + $"missing-{i}", new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkDelete_StreamVariant_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.DeleteItemStreamAsync($"missing-{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkRead_StreamVariant_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.ReadItemStreamAsync($"missing-{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkPatch_StreamVariant_NonExistentItems_AllReturn404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.PatchItemStreamAsync($"missing-{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "X") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task BulkCreate_StreamVariant_DuplicateIds_SomeConflict() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var id = $"{i % 50}"; + var json = JsonConvert.SerializeObject( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Item{i}" }); + return container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + + var successes = results.Count(r => r.StatusCode == HttpStatusCode.Created); + var conflicts = results.Count(r => r.StatusCode == HttpStatusCode.Conflict); + + successes.Should().Be(50); + conflicts.Should().Be(50); + container.ItemCount.Should().Be(50); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 2: Document Size Limits + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_OversizedDocuments_Typed_AllReturn413() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create a string that makes the document > 2MB + var bigValue = new string('x', 2 * 1024 * 1024); + + var tasks = Enumerable.Range(0, 10).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = bigValue }), + new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkCreate_OversizedDocuments_Stream_AllReturn413() + { + // Stream methods return a ResponseMessage with 413 status code + // instead of throwing CosmosException. + var container = new InMemoryContainer("test", "/partitionKey"); + + var bigValue = new string('x', 2 * 1024 * 1024); + + var tasks = Enumerable.Range(0, 10).Select(i => + Task.Run(async () => + { + var json = JsonConvert.SerializeObject( + new { id = $"{i}", partitionKey = "pk1", data = bigValue }); + var response = await container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1")); + return response.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task BulkUpsert_OversizedDocuments_AllReturn413() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var bigValue = new string('x', 2 * 1024 * 1024); + + var tasks = Enumerable.Range(0, 10).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.UpsertItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = bigValue }), + new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task BulkCreate_DocumentAtExactSizeLimit_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // The JSON overhead is: {"id":"0","partitionKey":"pk1","data":"..."} ~50 bytes + // We want total to be just under 2MB + var padding = 2 * 1024 * 1024 - 200; // leave room for JSON envelope + var value = new string('a', padding); + + var tasks = Enumerable.Range(0, 5).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", data = value }), + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(5); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 3: ETag & Conditional Operations + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkUpsert_WithIfMatchEtag_Star_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkCreate_WithIfNoneMatchEtag_Star_DuplicateIds_AllReturn409() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // IfNoneMatch * on read means "only if NOT cached" — on create it's not a standard + // operation, but the emulator's CheckIfNoneMatch will throw 304 NotModified when an item + // with a matching etag exists. For creates, the 409 Conflict from duplicate ID will fire first. + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Dup{i}" }, + new PartitionKey("pk1"))); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.Conflict); + } + + [Fact] + public async Task BulkDelete_WithIfMatchEtag_Correct_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var createResults = new List>(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + createResults.Add(r); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResults[i].ETag })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkDelete_WithIfMatchEtag_Stale_AllFail412() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var oldEtags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + oldEtags.Add(r.ETag); + } + + // Mutate all items so ETags change + for (var i = 0; i < 100; i++) + await container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, + $"{i}", new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = oldEtags[i] })); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.PreconditionFailed); + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task BulkRead_WithIfNoneMatchEtag_Current_AllReturn304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var createResults = new List>(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + createResults.Add(r); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + Task.Run(async () => + { + var ex = await Assert.ThrowsAnyAsync(() => + container.ReadItemAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = createResults[i].ETag })); + return ex.StatusCode; + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.NotModified); + } + + [Fact] + public async Task BulkRead_WithIfNoneMatchEtag_Stale_AllReturnData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var oldEtags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + oldEtags.Add(r.ETag); + } + + // Mutate all items so ETags change + for (var i = 0; i < 100; i++) + await container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + $"{i}", new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReadItemAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = oldEtags[i] })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + for (var i = 0; i < 100; i++) + results[i].Resource.Name.Should().Be($"Updated{i}"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 4: EnableContentResponseOnWrite (All Operations) + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkUpsert_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + results.Should().OnlyContain(r => r.Resource == null); + } + + [Fact] + public async Task BulkReplace_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + results.Should().OnlyContain(r => r.Resource == null); + } + + [Fact] + public async Task BulkPatch_WithEnableContentResponseOnWriteFalse_ResponseResourceIsNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.PatchItemAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") }, + new PatchItemRequestOptions { EnableContentResponseOnWrite = false })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + results.Should().OnlyContain(r => r.Resource == null); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 5: CancellationToken Handling + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_WithAlreadyCancelledToken_AllThrowOperationCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + await Assert.ThrowsAnyAsync(() => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + cancellationToken: cts.Token)); + })); + + await Task.WhenAll(tasks); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkRead_WithAlreadyCancelledToken_AllThrowOperationCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(async () => + { + await Assert.ThrowsAnyAsync(() => + container.ReadItemAsync($"{i}", new PartitionKey("pk1"), + cancellationToken: cts.Token)); + })); + + await Task.WhenAll(tasks); + } + + [Fact(Skip = "InMemoryContainer operations are synchronous and complete instantly — " + + "ThrowIfCancellationRequested() fires at the start of each method so mid-flight " + + "cancellation cannot be observed. All tasks complete before the token fires.")] + public async Task BulkCreate_CancelDuringExecution_SomeSucceedSomeCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var cts = new CancellationTokenSource(); + + var tasks = Enumerable.Range(0, 200).Select(i => + Task.Run(async () => + { + try + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + cancellationToken: cts.Token); + return true; + } + catch (OperationCanceledException) + { + return false; + } + })); + + _ = Task.Delay(1).ContinueWith(_ => cts.Cancel()); + var results = await Task.WhenAll(tasks); + + var successes = results.Count(r => r); + var cancellations = results.Count(r => !r); + (successes + cancellations).Should().Be(200); + container.ItemCount.Should().Be(successes); + } + + /// + /// Divergent behaviour: Mid-flight cancellation is not observable because InMemoryContainer + /// operations are synchronous. All 200 items are created successfully. + /// + [Fact] + public async Task BulkCreate_CancelDuringExecution_SomeSucceedSomeCancelled_DivergentBehaviour() + { + // Real Cosmos DB: CancellationToken can interrupt network I/O mid-flight, leading to + // a mix of succeeded and cancelled operations. + // InMemoryContainer: Operations are in-memory and complete synchronously. The + // ThrowIfCancellationRequested() check at the top of each method only fires if the + // token was ALREADY cancelled before the method is entered. Since tasks are dispatched + // near-simultaneously and complete instantly, all 200 succeed. + var container = new InMemoryContainer("test", "/partitionKey"); + var cts = new CancellationTokenSource(); + + var tasks = Enumerable.Range(0, 200).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + cancellationToken: cts.Token)); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(200); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 6: Concurrency Race Conditions + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReplace_ConcurrentOnSameItem_LastWriterWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk1", Name = $"Version{i}" }, + "target", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + container.ItemCount.Should().Be(1); + + var final = await container.ReadItemAsync("target", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Version"); + } + + [Fact] + public async Task BulkDelete_ConcurrentOnSameItem_OneSucceedsRestFail() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(_ => + Task.Run(async () => + { + try + { + await container.DeleteItemAsync("target", new PartitionKey("pk1")); + return true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + })); + + var results = await Task.WhenAll(tasks); + results.Count(r => r).Should().BeGreaterThanOrEqualTo(1); + results.Count(r => !r).Should().BeGreaterThanOrEqualTo(0); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkPatch_ConcurrentOnSameItem_AtomicIncrement() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "counter", PartitionKey = "pk1", Name = "Counter", Value = 0 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(_ => + container.PatchItemAsync("counter", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) })); + + await Task.WhenAll(tasks); + var final = await container.ReadItemAsync("counter", new PartitionKey("pk1")); + final.Resource.Value.Should().Be(50); + } + + [Fact] + public async Task BulkCreate_ThenImmediateDelete_SameItems_NoCrash() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // For each ID 0-49: one task creates, another task deletes + var tasks = Enumerable.Range(0, 50).SelectMany(i => new[] + { + Task.Run(async () => + { + try + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + catch (Exception) { /* Create can fail due to race with delete or conflict */ } + }), + Task.Run(async () => + { + try + { + await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); + } + catch (Exception) { /* Delete can fail if item not yet created or already deleted */ } + }) + }); + + await Task.WhenAll(tasks); + + // Each item is either present or not — container state should be consistent + container.ItemCount.Should().BeGreaterThanOrEqualTo(0); + container.ItemCount.Should().BeLessThanOrEqualTo(50); + } + + [Fact] + public async Task BulkUpsert_MixOfNewAndExisting_StatusCodesCorrect() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create IDs 0-49 + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Upsert IDs 0-99 (0-49 are updates, 50-99 are creates) + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + var updates = results.Where(r => r.StatusCode == HttpStatusCode.OK).ToList(); + var creates = results.Where(r => r.StatusCode == HttpStatusCode.Created).ToList(); + + updates.Count.Should().Be(50); + creates.Count.Should().Be(50); + container.ItemCount.Should().Be(100); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 7: Unique Key Edge Cases + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkUpsert_UniqueKeyViolation_SomeConflict() + { + var properties = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + // 100 items where 50 pairs share the same name but different IDs + var tasks = Enumerable.Range(0, 100).Select(i => + { + var name = $"Name{i % 50}"; + return Task.Run(async () => + { + try + { + await container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = name }, + new PartitionKey("pk1")); + return true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + return false; + } + }); + }); + + var results = await Task.WhenAll(tasks); + results.Count(r => r).Should().Be(50); + results.Count(r => !r).Should().Be(50); + } + + [Fact] + public async Task BulkUpsert_SameIdSameUniqueKey_AlwaysSucceeds() + { + var properties = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + // Upsert same ID with same name 50 times — self-update should not violate unique key + var tasks = Enumerable.Range(0, 50).Select(_ => + container.UpsertItemAsync( + new TestDocument { Id = "same-id", PartitionKey = "pk1", Name = "SameName" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => + r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + container.ItemCount.Should().Be(1); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 8: Null/Missing Partition Key Variants + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_UnicodeInPartitionKeys_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var unicodePKs = new[] { "🎉", "中文", "café", "عربي", "🚀🌍" }; + + var tasks = unicodePKs.SelectMany((pk, pkIdx) => + Enumerable.Range(0, 10).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{pkIdx}-{i}", PartitionKey = pk, Name = $"Item{pkIdx}-{i}" }, + new PartitionKey(pk)))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(50); + + // Verify each PK group is queryable + foreach (var pk in unicodePKs) + { + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(pk) }); + + var items = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + items.AddRange(page); + } + + items.Should().HaveCount(10); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 9: Hierarchical Partition Keys + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_HierarchicalPartitionKey_AllSucceed() + { + var properties = new ContainerProperties("test", new List { "/tenantId", "/customerId" }); + var container = new InMemoryContainer(properties); + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var tenantId = $"tenant{i % 5}"; + var customerId = $"customer{i % 20}"; + var doc = JObject.FromObject(new { id = $"{i}", tenantId, customerId, name = $"Item{i}" }); + var pk = new PartitionKeyBuilder().Add(tenantId).Add(customerId).Build(); + return container.CreateItemAsync(doc, pk); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task BulkUpsert_HierarchicalPartitionKey_MixedCreateAndUpdate() + { + var properties = new ContainerProperties("test", new List { "/tenantId", "/customerId" }); + var container = new InMemoryContainer(properties); + + // First round: all new + var createTasks = Enumerable.Range(0, 50).Select(i => + { + var pk = new PartitionKeyBuilder().Add("t1").Add($"c{i}").Build(); + return container.UpsertItemAsync( + JObject.FromObject(new { id = $"{i}", tenantId = "t1", customerId = $"c{i}", name = $"Item{i}" }), pk); + }); + + var createResults = await Task.WhenAll(createTasks); + createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + // Second round: updates + var updateTasks = Enumerable.Range(0, 50).Select(i => + { + var pk = new PartitionKeyBuilder().Add("t1").Add($"c{i}").Build(); + return container.UpsertItemAsync( + JObject.FromObject(new { id = $"{i}", tenantId = "t1", customerId = $"c{i}", name = $"Updated{i}" }), pk); + }); + + var updateResults = await Task.WhenAll(updateTasks); + updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + container.ItemCount.Should().Be(50); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 10: Response Metadata & Diagnostics + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_ActivityId_UniquePerOperation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + var activityIds = results.Select(r => r.ActivityId).ToList(); + activityIds.Should().OnlyHaveUniqueItems(); + activityIds.Should().NotContain(string.Empty); + activityIds.Should().NotContainNulls(); + } + + [Fact] + public async Task BulkUpsert_ResponseMetadata_RequestChargeAndHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + foreach (var r in results) + { + r.RequestCharge.Should().BeGreaterThan(0); + r.Headers.Should().NotBeNull(); + } + } + + [Fact] + public async Task BulkReplace_ResponseMetadata_RequestChargeAndHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + foreach (var r in results) + { + r.RequestCharge.Should().BeGreaterThan(0); + r.Headers.Should().NotBeNull(); + } + } + + [Fact] + public async Task BulkDelete_ResponseMetadata_StatusCodeConsistent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + foreach (var r in results) + { + r.StatusCode.Should().Be(HttpStatusCode.NoContent); + r.Headers.Should().NotBeNull(); + } + } + + [Fact] + public async Task BulkPatch_ResponseMetadata_RequestChargeAndHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.PatchItemAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + + foreach (var r in results) + { + r.RequestCharge.Should().BeGreaterThan(0); + r.Headers.Should().NotBeNull(); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 11: Change Feed Interaction Gaps + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkMixedOps_ChangeFeed_AllOperationsRecorded() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 50 + var createTasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(createTasks); + + // Upsert 50 (updates) + var upsertTasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(upsertTasks); + + var checkpointBeforeDelete = container.GetChangeFeedCheckpoint(); + + // Delete 25 + var deleteTasks = Enumerable.Range(0, 25).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + await Task.WhenAll(deleteTasks); + + var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); + + // Verify 125 total change feed entries: 50 creates + 50 updates + 25 deletes + checkpointAfterDelete.Should().Be(125); + + // Verify delete tombstones + (checkpointAfterDelete - checkpointBeforeDelete).Should().Be(25); + } + + [Fact] + public async Task BulkCreate_ChangeFeed_StreamIterator_HasContent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(tasks); + + var iterator = container.GetChangeFeedStreamIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var totalBytes = 0; + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + response.IsSuccessStatusCode.Should().BeTrue(); + if (response.Content is not null) + { + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + totalBytes += body.Length; + } + } + + totalBytes.Should().BeGreaterThan(0); + } + + [Fact] + public async Task BulkDelete_ChangeFeed_VerifyTombstoneContent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + + var deleteTasks = Enumerable.Range(0, 50).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + await Task.WhenAll(deleteTasks); + + // Read change feed from checkpoint — should see tombstones + var iterator = container.GetChangeFeedIterator(checkpoint); + var tombstones = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + tombstones.AddRange(page); + } + + tombstones.Should().HaveCount(50); + foreach (var tombstone in tombstones) + { + tombstone["id"]!.ToString().Should().NotBeNullOrEmpty(); + // Tombstones should contain the deleted indicator + var hasDeletedFlag = tombstone["_deleted"] != null; + var hasTtlExpired = tombstone["_ttlExpired"] != null; + (hasDeletedFlag || hasTtlExpired).Should().BeTrue( + "tombstone should indicate deletion via _deleted or _ttlExpired"); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 12: TransactionalBatch Interleaving + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_ConcurrentWithTransactionalBatch_NoDeadlock() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // TransactionalBatch on "batchPK" + var batchTask = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("batchPK")); + for (var i = 0; i < 5; i++) + batch.CreateItem(new TestDocument { Id = $"batch-{i}", PartitionKey = "batchPK", Name = $"Batch{i}" }); + return await batch.ExecuteAsync(); + }); + + // Concurrent bulk creates on "bulkPK" + var bulkTasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"bulk-{i}", PartitionKey = "bulkPK", Name = $"Bulk{i}" }, + new PartitionKey("bulkPK"))); + + var allTasks = bulkTasks.ToList(); + allTasks.Add(batchTask); + await Task.WhenAll(allTasks); + + var batchResult = await batchTask; + batchResult.IsSuccessStatusCode.Should().BeTrue(); + container.ItemCount.Should().Be(105); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 13: Bulk with TTL + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_WithContainerTTL_ItemsExpireAfterRead() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = 1 // 1 second + }; + + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(tasks); + + container.ItemCount.Should().Be(50); + + // Wait for TTL to expire + await Task.Delay(2000); + + // Reading triggers lazy eviction + for (var i = 0; i < 50; i++) + { + try + { + await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected — item expired + } + } + + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task BulkCreate_WithPerItemTTL_ItemsExpireIndependently() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = -1 // TTL enabled, but no default expiry + }; + + // Half with short TTL, half with long TTL + var shortTtlTasks = Enumerable.Range(0, 25).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"short-{i}", partitionKey = "pk1", name = $"Short{i}", _ttl = 1 }), + new PartitionKey("pk1"))); + + var longTtlTasks = Enumerable.Range(0, 25).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"long-{i}", partitionKey = "pk1", name = $"Long{i}", _ttl = 3600 }), + new PartitionKey("pk1"))); + + await Task.WhenAll(shortTtlTasks.Concat(longTtlTasks)); + container.ItemCount.Should().Be(50); + + await Task.Delay(2000); + + // Trigger lazy eviction by reading short-TTL items + for (var i = 0; i < 25; i++) + { + try + { + await container.ReadItemAsync($"short-{i}", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected + } + } + + // Long-TTL items should still be present + for (var i = 0; i < 25; i++) + { + var response = await container.ReadItemAsync($"long-{i}", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 14: CosmosClient Integration (Typed + Stream) + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_ViaCosmosClient_StreamVariant_AllSucceed() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.AllowBulkExecution = true; + + var database = client.GetDatabase("test-db"); + await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); + var container = database.GetContainer("test-container"); + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + return container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } + + [Fact] + public async Task BulkUpsert_ViaCosmosClient_AllSucceed() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.AllowBulkExecution = true; + + var database = client.GetDatabase("test-db"); + await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); + var container = database.GetContainer("test-container"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } + + [Fact] + public async Task BulkMixedOps_ViaCosmosClient_AllSucceed() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.AllowBulkExecution = true; + + var database = client.GetDatabase("test-db"); + await database.CreateContainerIfNotExistsAsync("test-container", "/partitionKey"); + var container = database.GetContainer("test-container"); + + // Create 50 via client + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"existing-{i}", PartitionKey = "pk1", Name = $"Existing{i}" }, + new PartitionKey("pk1")); + + // Mixed concurrent operations + var createTasks = Enumerable.Range(0, 25).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var upsertTasks = Enumerable.Range(0, 25).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var deleteTasks = Enumerable.Range(0, 25).Select(i => + container.DeleteItemAsync($"existing-{i}", new PartitionKey("pk1")) + .ContinueWith(t => t.Result.StatusCode)); + + var allTasks = createTasks.Concat(upsertTasks).Concat(deleteTasks); + var results = await Task.WhenAll(allTasks); + + var createResults = results.Take(25).ToList(); + createResults.Should().OnlyContain(s => s == HttpStatusCode.Created); + + var upsertResults = results.Skip(25).Take(25).ToList(); + upsertResults.Should().OnlyContain(s => s == HttpStatusCode.Created); + + var deleteResults = results.Skip(50).Take(25).ToList(); + deleteResults.Should().OnlyContain(s => s == HttpStatusCode.NoContent); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 15: Patch Conditional Operations in Bulk + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkPatch_WithFilterPredicate_OnlyPatchesMatchingItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 100 items: 50 active, 50 inactive + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", IsActive = i < 50 }, + new PartitionKey("pk1")); + + // Patch all 100 with filter predicate + var tasks = Enumerable.Range(0, 100).Select(i => + Task.Run(async () => + { + try + { + var response = await container.PatchItemAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + return (StatusCode: response.StatusCode, Success: true); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + return (StatusCode: ex.StatusCode, Success: false); + } + })); + + var results = await Task.WhenAll(tasks); + + results.Count(r => r.Success).Should().Be(50); + results.Count(r => !r.Success).Should().Be(50); + } + + [Fact] + public async Task BulkPatch_WithFilterPredicate_Stream_OnlyPatchesMatching() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", IsActive = i < 50 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.PatchItemStreamAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" })); + + var results = await Task.WhenAll(tasks); + + results.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(50); + results.Count(r => r.StatusCode == HttpStatusCode.PreconditionFailed).Should().Be(50); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 16: Large Batch Stress Tests + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_1000Items_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 1000).Select(i => + { + var pk = $"pk{i % 20}"; + return container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = pk, Name = $"Item{i}" }, + new PartitionKey(pk)); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(1000); + } + + [Fact] + public async Task BulkUpsert_1000Items_CreateThenUpdate_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create round + var createTasks = Enumerable.Range(0, 1000).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + var createResults = await Task.WhenAll(createTasks); + createResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + // Update round + var updateTasks = Enumerable.Range(0, 1000).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + var updateResults = await Task.WhenAll(updateTasks); + updateResults.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + container.ItemCount.Should().Be(1000); + } + + [Fact] + public async Task BulkMixedOps_1000Items_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items for replace and delete + for (var i = 0; i < 500; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, + new PartitionKey("pk1")); + + var createTasks = Enumerable.Range(0, 250).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"new-{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var upsertTasks = Enumerable.Range(0, 250).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"upserted-{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }, + new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var replaceTasks = Enumerable.Range(0, 250).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"pre-{i}", new PartitionKey("pk1")).ContinueWith(t => t.Result.StatusCode)); + + var deleteTasks = Enumerable.Range(250, 250).Select(i => + container.DeleteItemAsync($"pre-{i}", new PartitionKey("pk1")) + .ContinueWith(t => t.Result.StatusCode)); + + var allTasks = createTasks.Concat(upsertTasks).Concat(replaceTasks).Concat(deleteTasks); + var results = await Task.WhenAll(allTasks); + + // 500 pre-created - 250 deleted + 250 new + 250 upserted = 750 + container.ItemCount.Should().Be(750); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase 17: DeleteAllItemsByPartitionKey Concurrency + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeleteAllItemsByPartitionKey_ConcurrentWithBulkCreate_DifferentPKs_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-populate PK "B" with 200 items + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"b-{i}", PartitionKey = "B", Name = $"B-Item{i}" }, + new PartitionKey("B")); + + // Concurrently: create on PK "A" + delete all on PK "B" + var createTasks = Enumerable.Range(0, 200).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"a-{i}", PartitionKey = "A", Name = $"A-Item{i}" }, + new PartitionKey("A"))); + + var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("B")); + + var allConcurrentTasks = createTasks.ToList(); + allConcurrentTasks.Add(deleteTask); + await Task.WhenAll(allConcurrentTasks); + + // PK "A" items should all be present + var iterA = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("A") }); + + var aItems = new List(); + while (iterA.HasMoreResults) + { + var page = await iterA.ReadNextAsync(); + aItems.AddRange(page); + } + + aItems.Should().HaveCount(200); + + // PK "B" items should be gone + var iterB = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("B") }); + + var bItems = new List(); + while (iterB.HasMoreResults) + { + var page = await iterB.ReadNextAsync(); + bItems.AddRange(page); + } + + bItems.Should().BeEmpty(); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase A: ReadMany Bulk Operations + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReadMany_Typed_AllItemsFound() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 10).Select(batch => + { + var items = Enumerable.Range(batch * 20, 20) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + return container.ReadManyItemsAsync(items); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.Count == 20); + results.SelectMany(r => r).Select(d => d.Id).Distinct().Should().HaveCount(200); + } + + [Fact] + public async Task BulkReadMany_Stream_AllItemsFound() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 10).Select(batch => + { + var items = Enumerable.Range(batch * 20, 20) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + return container.ReadManyItemsStreamAsync(items); + }); + + var results = await Task.WhenAll(tasks); + foreach (var r in results) + { + r.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(r.Content); + var envelope = JObject.Parse(await reader.ReadToEndAsync()); + ((int)envelope["_count"]!).Should().Be(20); + } + } + + [Fact] + public async Task BulkReadMany_Typed_SomeItemsMissing_ReturnsFoundOnly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Only create even IDs + for (var i = 0; i < 100; i += 2) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var allIds = Enumerable.Range(0, 100) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + + var response = await container.ReadManyItemsAsync(allIds); + response.Count.Should().Be(50); // only the even ones + } + + [Fact] + public async Task BulkReadMany_Typed_ConcurrentWithWrites_NoCrash() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var readTasks = Enumerable.Range(0, 10).Select(_ => + { + var items = Enumerable.Range(0, 100) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + return container.ReadManyItemsAsync(items); + }); + + var createTasks = Enumerable.Range(100, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1"))); + + var upsertTasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Updated{i}" }, + new PartitionKey("pk1"))); + + var allTasks = new List(); + allTasks.AddRange(readTasks); + allTasks.AddRange(createTasks); + allTasks.AddRange(upsertTasks); + + await Task.WhenAll(allTasks); + container.ItemCount.Should().Be(200); + } + + [Fact] + public async Task BulkReadMany_Typed_WithIfNoneMatchEtag_Returns304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var allIds = Enumerable.Range(0, 10) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + + // First read to get composite ETag + var firstResponse = await container.ReadManyItemsAsync(allIds); + firstResponse.Count.Should().Be(10); + var compositeEtag = firstResponse.ETag; + compositeEtag.Should().NotBeNullOrEmpty(); + + // Second read with IfNoneMatchEtag = composite -> 304 + var secondResponse = await container.ReadManyItemsAsync(allIds, + new ReadManyRequestOptions { IfNoneMatchEtag = compositeEtag }); + secondResponse.StatusCode.Should().Be(HttpStatusCode.NotModified); + secondResponse.Count.Should().Be(0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase B: PatchItemStreamAsync Concurrency + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkPatch_StreamVariant_ConcurrentIncrement_AllApplied() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "counter", PartitionKey = "pk1", Value = 0 }, + new PartitionKey("pk1")); + + var gate = new ManualResetEventSlim(false); + var tasks = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => + { + gate.Wait(); + var patchOps = new[] { PatchOperation.Increment("/value", 1) }; + var json = JsonConvert.SerializeObject(patchOps); + return await container.PatchItemStreamAsync( + "counter", new PartitionKey("pk1"), patchOps); + })); + + var taskList = tasks.ToList(); + gate.Set(); + var results = await Task.WhenAll(taskList); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("counter", new PartitionKey("pk1")); + final.Resource.Value.Should().Be(50); + } + + [Fact] + public async Task BulkPatch_StreamVariant_ConcurrentSet_LastWriterWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => Task.Run(async () => + { + var patchOps = new[] { PatchOperation.Set("/name", $"Version{i}") }; + return await container.PatchItemStreamAsync( + "target", new PartitionKey("pk1"), patchOps); + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("target", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Version"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase C: CancellationToken Regression Guards + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReadMany_StreamVariant_CancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var allIds = Enumerable.Range(0, 10) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + + await Assert.ThrowsAsync(() => + container.ReadManyItemsStreamAsync(allIds, cancellationToken: cts.Token)); + } + + [Fact] + public async Task BulkReadMany_Typed_CancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var allIds = Enumerable.Range(0, 10) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + + await Assert.ThrowsAsync(() => + container.ReadManyItemsAsync(allIds, cancellationToken: cts.Token)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase D: ReadMany Concurrent with Deletes + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReadMany_ConcurrentWithDeletes_NoException() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var readTasks = Enumerable.Range(0, 20).Select(_ => + { + var items = Enumerable.Range(0, 100) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + return container.ReadManyItemsAsync(items); + }); + + var deleteTasks = Enumerable.Range(0, 100).Select(i => + container.DeleteItemAsync($"{i}", new PartitionKey("pk1"))); + + var allTasks = new List(); + allTasks.AddRange(readTasks); + allTasks.AddRange(deleteTasks); + + await Task.WhenAll(allTasks); + container.ItemCount.Should().Be(0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase E: Stream Conditional ETag Operations + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkReplace_StreamVariant_WithCorrectIfMatchEtag_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var etags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }); + return container.ReplaceItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + $"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etags[i] }); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkReplace_StreamVariant_WithStaleIfMatchEtag_AllFail412() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var etags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + // Mutate all to make ETags stale + for (var i = 0; i < 100; i++) + await container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Mutated{i}" }, + $"{i}", new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Stale{i}" }); + return container.ReplaceItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + $"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etags[i] }); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task BulkRead_StreamVariant_WithIfNoneMatchEtag_Returns304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var etags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReadItemStreamAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etags[i] })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NotModified); + } + + [Fact] + public async Task BulkUpsert_StreamVariant_WithIfMatchEtag_Star_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Upserted{i}" }); + return container.UpsertItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task BulkDelete_StreamVariant_WithCorrectIfMatchEtag_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var etags = new List(); + for (var i = 0; i < 100; i++) + { + var r = await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + var tasks = Enumerable.Range(0, 100).Select(i => + container.DeleteItemStreamAsync($"{i}", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etags[i] })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); + container.ItemCount.Should().Be(0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase F: State Export/Import During Bulk Operations + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ExportState_DuringBulkWrites_DoesNotThrow() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var cts = new CancellationTokenSource(); + + var createTask = Task.Run(async () => + { + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + }); + + var exportTask = Task.Run(() => + { + var exports = new List(); + while (!createTask.IsCompleted) + { + var state = container.ExportState(); + state.Should().NotBeNullOrEmpty(); + exports.Add(state); + } + return exports; + }); + + await createTask; + var allExports = await exportTask; + allExports.Should().NotBeEmpty(); + } + + [Fact] + public async Task ImportState_AfterBulkWrites_RestoresCorrectly() + { + var source = new InMemoryContainer("source", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + source.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(tasks); + + var state = source.ExportState(); + + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(state); + + for (var i = 0; i < 100; i++) + { + var r = await target.ReadItemAsync($"{i}", new PartitionKey("pk1")); + r.Resource.Name.Should().Be($"Item{i}"); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase G: Stream EnableContentResponseOnWrite + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BulkCreate_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + return container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + foreach (var r in results) + { + if (r.Content != null) + { + using var reader = new StreamReader(r.Content); + var body = await reader.ReadToEndAsync(); + body.Should().BeNullOrEmpty(); + } + } + } + + [Fact] + public async Task BulkUpsert_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + return container.UpsertItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + foreach (var r in results) + { + if (r.Content != null) + { + using var reader = new StreamReader(r.Content); + var body = await reader.ReadToEndAsync(); + body.Should().BeNullOrEmpty(); + } + } + } + + [Fact] + public async Task BulkPatch_StreamVariant_EnableContentResponseOnWriteFalse_EmptyBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.PatchItemStreamAsync( + $"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") }, + new PatchItemRequestOptions { EnableContentResponseOnWrite = false })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + foreach (var r in results) + { + if (r.Content != null) + { + using var reader = new StreamReader(r.Content); + var body = await reader.ReadToEndAsync(); + body.Should().BeNullOrEmpty(); + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Phase H: ReadMany Composite ETag Mismatch + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ReadMany_CompositeEtag_MismatchReturnsData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var allIds = Enumerable.Range(0, 10) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); + + // Get composite ETag + var firstResponse = await container.ReadManyItemsAsync(allIds); + var compositeEtag = firstResponse.ETag; + + // Mutate one item to change composite ETag + await container.UpsertItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Mutated" }, + new PartitionKey("pk1")); + + // ReadMany with old composite ETag -> should return data (200) since etag changed + var secondResponse = await container.ReadManyItemsAsync(allIds, + new ReadManyRequestOptions { IfNoneMatchEtag = compositeEtag }); + secondResponse.StatusCode.Should().Be(HttpStatusCode.OK); + secondResponse.Count.Should().Be(10); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CancellationTokenBugReproduction.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CancellationTokenBugReproduction.cs index 3e29075..e550df8 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CancellationTokenBugReproduction.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CancellationTokenBugReproduction.cs @@ -13,81 +13,81 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class CancellationTokenBugReproduction { - [Fact] - public async Task QueryIterator_ShouldThrowWhenCancellationTokenIsCancelled() - { - var container = new InMemoryContainer("cancel-test", "/partitionKey"); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); // Pre-cancel the token - - var query = new QueryDefinition("SELECT * FROM c"); - var iterator = container.GetItemQueryIterator(query); - - // Real Cosmos throws OperationCanceledException when given a cancelled token - var act = async () => - { - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(cts.Token); - }; - - await act.Should().ThrowAsync( - "A pre-cancelled CancellationToken should cause OperationCanceledException"); - } - - [Fact] - public async Task StreamIterator_ShouldThrowWhenCancellationTokenIsCancelled() - { - var container = new InMemoryContainer("cancel-stream-test", "/partitionKey"); - - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var iterator = container.GetItemQueryStreamIterator(new QueryDefinition("SELECT * FROM c")); - - var act = async () => - { - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(cts.Token); - }; - - await act.Should().ThrowAsync( - "Stream iterator should also respect CancellationToken"); - } - - [Fact] - public async Task ReadFeedIterator_ShouldThrowWhenCancellationTokenIsCancelled() - { - var container = new InMemoryContainer("cancel-readfeed-test", "/partitionKey"); - - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - // GetItemQueryIterator with null query text creates a read-all feed iterator - var iterator = container.GetItemQueryIterator((string?)null); - - var act = async () => - { - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(cts.Token); - }; - - await act.Should().ThrowAsync( - "Read-all feed iterator should also respect CancellationToken"); - } + [Fact] + public async Task QueryIterator_ShouldThrowWhenCancellationTokenIsCancelled() + { + var container = new InMemoryContainer("cancel-test", "/partitionKey"); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Pre-cancel the token + + var query = new QueryDefinition("SELECT * FROM c"); + var iterator = container.GetItemQueryIterator(query); + + // Real Cosmos throws OperationCanceledException when given a cancelled token + var act = async () => + { + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(cts.Token); + }; + + await act.Should().ThrowAsync( + "A pre-cancelled CancellationToken should cause OperationCanceledException"); + } + + [Fact] + public async Task StreamIterator_ShouldThrowWhenCancellationTokenIsCancelled() + { + var container = new InMemoryContainer("cancel-stream-test", "/partitionKey"); + + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var iterator = container.GetItemQueryStreamIterator(new QueryDefinition("SELECT * FROM c")); + + var act = async () => + { + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(cts.Token); + }; + + await act.Should().ThrowAsync( + "Stream iterator should also respect CancellationToken"); + } + + [Fact] + public async Task ReadFeedIterator_ShouldThrowWhenCancellationTokenIsCancelled() + { + var container = new InMemoryContainer("cancel-readfeed-test", "/partitionKey"); + + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // GetItemQueryIterator with null query text creates a read-all feed iterator + var iterator = container.GetItemQueryIterator((string?)null); + + var act = async () => + { + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(cts.Token); + }; + + await act.Should().ThrowAsync( + "Read-all feed iterator should also respect CancellationToken"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CaseSensitivityTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CaseSensitivityTests.cs index 97a08cf..de1c202 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CaseSensitivityTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CaseSensitivityTests.cs @@ -14,2006 +14,2006 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class CaseSensitivityTests { - // ═══════════════════════════════════════════════════════════════════════════ - // UDF case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Udf_RegisteredWithCasing_IsNotCallableWithDifferentCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 2); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), - new PartitionKey("pk1")); - - // Query with WRONG casing — should throw because "MYFUNC" is not registered, only "myFunc" - var query = new QueryDefinition("SELECT VALUE udf.MYFUNC(c.value) FROM c"); - - var act = async () => - { - var iter = container.GetItemQueryIterator(query); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Udf_RegisteredWithCasing_IsCallableWithExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 2); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), - new PartitionKey("pk1")); - - // Query with CORRECT casing — should work - var query = new QueryDefinition("SELECT VALUE udf.myFunc(c.value) FROM c"); - var iter = container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Should().Be(20); - } - - [Fact] - public async Task Udf_TwoUdfs_DifferingOnlyByCasing_AreSeparate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => "lower"); - container.RegisterUdf("MYFUNC", args => "upper"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1" }), - new PartitionKey("pk1")); - - var queryLower = new QueryDefinition("SELECT VALUE udf.myFunc(c.id) FROM c"); - var queryUpper = new QueryDefinition("SELECT VALUE udf.MYFUNC(c.id) FROM c"); - - var iterLower = container.GetItemQueryIterator(queryLower); - var resultsLower = new List(); - while (iterLower.HasMoreResults) resultsLower.AddRange(await iterLower.ReadNextAsync()); - - var iterUpper = container.GetItemQueryIterator(queryUpper); - var resultsUpper = new List(); - while (iterUpper.HasMoreResults) resultsUpper.AddRange(await iterUpper.ReadNextAsync()); - - resultsLower.Should().ContainSingle().Which.Should().Be("lower"); - resultsUpper.Should().ContainSingle().Which.Should().Be("upper"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Stored Procedure case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task StoredProcedure_RegisteredWithCasing_IsNotCallableWithDifferentCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterStoredProcedure("myProc", (pk, args) => "result"); - - var scripts = container.Scripts; - - // Call with WRONG casing — should not find the handler (throws 404) - var act = () => scripts.ExecuteStoredProcedureAsync( - "MYPROC", - new PartitionKey("pk1"), - Array.Empty()); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StoredProcedure_TwoProcedures_DifferingOnlyByCasing_AreSeparate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterStoredProcedure("myProc", (pk, args) => "lower"); - container.RegisterStoredProcedure("MYPROC", (pk, args) => "upper"); - - var scripts = container.Scripts; - - var responseLower = await scripts.ExecuteStoredProcedureAsync( - "myProc", new PartitionKey("pk1"), Array.Empty()); - var responseUpper = await scripts.ExecuteStoredProcedureAsync( - "MYPROC", new PartitionKey("pk1"), Array.Empty()); - - responseLower.Resource.Should().Be("lower"); - responseUpper.Resource.Should().Be("upper"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Trigger case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Trigger_RegisteredWithCasing_DoesNotFireWithDifferentCasing() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["triggered"] = true; - return doc; - })); - - // Reference trigger with WRONG casing — should throw because "MYTRIGGER" is not registered - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "MYTRIGGER" } }); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Trigger_TwoTriggers_DifferingOnlyByCasing_AreSeparate() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["source"] = "lower"; - return doc; - })); - container.RegisterTrigger("MYTRIGGER", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["source"] = "upper"; - return doc; - })); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "MYTRIGGER" } }); - - // Read back the items to verify each trigger applied its own value - var readLower = await container.ReadItemAsync("1", new PartitionKey("a")); - var readUpper = await container.ReadItemAsync("2", new PartitionKey("a")); - - readLower.Resource["source"]!.Value().Should().Be("lower"); - readUpper.Resource["source"]!.Value().Should().Be("upper"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Container routing case sensitivity (FakeCosmosHandler.CreateRouter) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Router_ContainerNames_AreCaseSensitive() - { - var container1 = new InMemoryContainer("MyData", "/partitionKey"); - var container2 = new InMemoryContainer("mydata", "/partitionKey"); - - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UpperCase" }, - new PartitionKey("pk1")); - await container2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "LowerCase" }, - new PartitionKey("pk1")); - - using var handler1 = new FakeCosmosHandler(container1); - using var handler2 = new FakeCosmosHandler(container2); - - // Register both containers — they differ only in casing and should be separate - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["MyData"] = handler1, - ["mydata"] = handler2, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c1 = client.GetContainer("db", "MyData"); - var c2 = client.GetContainer("db", "mydata"); - - var results1 = new List(); - var iter1 = c1.GetItemQueryIterator("SELECT * FROM c"); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - - var results2 = new List(); - var iter2 = c2.GetItemQueryIterator("SELECT * FROM c"); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - - results1.Should().ContainSingle().Which.Name.Should().Be("UpperCase"); - results2.Should().ContainSingle().Which.Name.Should().Be("LowerCase"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section C: Item ID Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ItemId_DifferentCasings_AreSeparateDocuments() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), - new PartitionKey("pk1")); - - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task ItemId_ReadItem_RequiresExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), - new PartitionKey("pk1")); - - var readUpper = await container.ReadItemAsync("Item1", new PartitionKey("pk1")); - var readLower = await container.ReadItemAsync("item1", new PartitionKey("pk1")); - - readUpper.Resource["name"]!.ToString().Should().Be("Upper"); - readLower.Resource["name"]!.ToString().Should().Be("Lower"); - } - - [Fact] - public async Task ItemId_DeleteItem_OnlyAffectsExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), - new PartitionKey("pk1")); - - await container.DeleteItemAsync("Item1", new PartitionKey("pk1")); - - container.ItemCount.Should().Be(1); - var remaining = await container.ReadItemAsync("item1", new PartitionKey("pk1")); - remaining.Resource["name"]!.ToString().Should().Be("Lower"); - } - - [Fact] - public async Task ItemId_ReplaceItem_OnlyAffectsExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), - new PartitionKey("pk1")); - - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Replaced" }), - "Item1", new PartitionKey("pk1")); - - var readUpper = await container.ReadItemAsync("Item1", new PartitionKey("pk1")); - var readLower = await container.ReadItemAsync("item1", new PartitionKey("pk1")); - - readUpper.Resource["name"]!.ToString().Should().Be("Replaced"); - readLower.Resource["name"]!.ToString().Should().Be("Lower"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section D: Partition Key Value Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task PartitionKey_DifferentCasings_TreatedAsSeparatePartitions() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "doc1", partitionKey = "pkA", name = "Upper" }), - new PartitionKey("pkA")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "doc1", partitionKey = "pka", name = "Lower" }), - new PartitionKey("pka")); - - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task PartitionKey_ReadItem_RequiresExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "doc1", partitionKey = "pkA", name = "Upper" }), - new PartitionKey("pkA")); - - var read = await container.ReadItemAsync("doc1", new PartitionKey("pkA")); - read.Resource["name"]!.ToString().Should().Be("Upper"); - - var act = () => container.ReadItemAsync("doc1", new PartitionKey("pka")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PartitionKey_QueryWithPartitionKey_OnlyReturnsExactMatch() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pkA", name = "Upper" }), - new PartitionKey("pkA")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pka", name = "Lower" }), - new PartitionKey("pka")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pkA") }); - - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Upper"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section A: Database Name Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Database_NamesAreCaseSensitive_SeparateInstances() - { - var client = new InMemoryCosmosClient(); - - await client.CreateDatabaseAsync("MyDB"); - await client.CreateDatabaseAsync("mydb"); - - var db1 = client.GetDatabase("MyDB"); - var db2 = client.GetDatabase("mydb"); - - db1.Id.Should().Be("MyDB"); - db2.Id.Should().Be("mydb"); - } - - [Fact] - public async Task Database_CreateAsync_DifferentCasings_NoConflict() - { - var client = new InMemoryCosmosClient(); - - var r1 = await client.CreateDatabaseAsync("MyDB"); - var r2 = await client.CreateDatabaseAsync("mydb"); - - r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - } - - [Fact] - public async Task Database_CreateIfNotExists_DifferentCasings_BothCreated() - { - var client = new InMemoryCosmosClient(); - - var r1 = await client.CreateDatabaseIfNotExistsAsync("MyDB"); - var r2 = await client.CreateDatabaseIfNotExistsAsync("mydb"); - - r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - } - - [Fact] - public async Task Database_GetDatabaseQueryIterator_ListsBothCasings() - { - var client = new InMemoryCosmosClient(); - - await client.CreateDatabaseAsync("MyDB"); - await client.CreateDatabaseAsync("mydb"); - - var iter = client.GetDatabaseQueryIterator(); - var databases = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - databases.AddRange(page); - } - - databases.Select(d => d.Id).Should().Contain("MyDB").And.Contain("mydb"); - } - - [Fact] - public async Task Database_CreateIfNotExists_ExactCasing_ReturnsOk() - { - var client = new InMemoryCosmosClient(); - - await client.CreateDatabaseAsync("MyDB"); - var r2 = await client.CreateDatabaseIfNotExistsAsync("MyDB"); - - r2.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section B: Container Name Case Sensitivity via InMemoryDatabase - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Container_NamesAreCaseSensitive_SeparateInstances() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - await db.CreateContainerAsync("MyContainer", "/pk"); - await db.CreateContainerAsync("mycontainer", "/pk"); - - var c1 = db.GetContainer("MyContainer"); - var c2 = db.GetContainer("mycontainer"); - - c1.Id.Should().Be("MyContainer"); - c2.Id.Should().Be("mycontainer"); - } - - [Fact] - public async Task Container_CreateAsync_DifferentCasings_NoConflict() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var r1 = await db.CreateContainerAsync("Foo", "/pk"); - var r2 = await db.CreateContainerAsync("foo", "/pk"); - - r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - } - - [Fact] - public async Task Container_CreateIfNotExists_DifferentCasings_BothCreated() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var r1 = await db.CreateContainerIfNotExistsAsync("Foo", "/pk"); - var r2 = await db.CreateContainerIfNotExistsAsync("foo", "/pk"); - - r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - } - - [Fact] - public async Task Container_GetContainerQueryIterator_ListsBothCasings() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - await db.CreateContainerAsync("Foo", "/pk"); - await db.CreateContainerAsync("foo", "/pk"); - - var iter = db.GetContainerQueryIterator(); - var containers = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Select(c => c.Id).Should().Contain("Foo").And.Contain("foo"); - } - - [Fact] - public async Task Container_DifferentCasings_StoreDataSeparately() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - await db.CreateContainerAsync("Foo", "/pk"); - await db.CreateContainerAsync("foo", "/pk"); - - var c1 = db.GetContainer("Foo"); - var c2 = db.GetContainer("foo"); - - await c1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "UpperContainer" }, - new PartitionKey("a")); - await c2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "LowerContainer" }, - new PartitionKey("a")); - - var read1 = await c1.ReadItemAsync("1", new PartitionKey("a")); - var read2 = await c2.ReadItemAsync("1", new PartitionKey("a")); - - read1.Resource.Name.Should().Be("UpperContainer"); - read2.Resource.Name.Should().Be("LowerContainer"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section E: User Name Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task User_NamesAreCaseSensitive_SeparateInstances() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var r1 = await db.CreateUserAsync("Admin"); - var r2 = await db.CreateUserAsync("admin"); - - r1.Resource.Id.Should().Be("Admin"); - r2.Resource.Id.Should().Be("admin"); - } - - [Fact] - public async Task User_GetUser_RequiresExactCasing() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - await db.CreateUserAsync("Admin"); - await db.CreateUserAsync("admin"); - - var u1 = db.GetUser("Admin"); - var u2 = db.GetUser("admin"); - - u1.Id.Should().Be("Admin"); - u2.Id.Should().Be("admin"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section F: Permission ID Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Permission_IdsAreCaseSensitive_SeparateEntries() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - - var r1 = await user.CreatePermissionAsync( - new PermissionProperties("ReadAll", PermissionMode.Read, db.GetContainer("c"))); - var r2 = await user.CreatePermissionAsync( - new PermissionProperties("readall", PermissionMode.Read, db.GetContainer("c"))); - - r1.Resource.Id.Should().Be("ReadAll"); - r2.Resource.Id.Should().Be("readall"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section G: Scripts API Sproc CRUD Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Sproc_Scripts_CreateAndRead_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var scripts = container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "MyProc", - Body = "function() { return true; }" - }); - - var read = await scripts.ReadStoredProcedureAsync("MyProc"); - read.Resource.Id.Should().Be("MyProc"); - - var act = () => scripts.ReadStoredProcedureAsync("myproc"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Sproc_Scripts_Delete_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var scripts = container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "MyProc", - Body = "function() { return true; }" - }); - - var act = () => scripts.DeleteStoredProcedureAsync("myproc"); - await act.Should().ThrowAsync(); - - // Exact casing should work - await scripts.DeleteStoredProcedureAsync("MyProc"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section H: Scripts API Trigger CRUD Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Trigger_Scripts_CreateAndRead_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var scripts = container.Scripts; - - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "MyTrig", - Body = "function() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create - }); - - var read = await scripts.ReadTriggerAsync("MyTrig"); - read.Resource.Id.Should().Be("MyTrig"); - - var act = () => scripts.ReadTriggerAsync("mytrig"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Trigger_Scripts_Delete_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var scripts = container.Scripts; - - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "MyTrig", - Body = "function() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create - }); - - var act = () => scripts.DeleteTriggerAsync("mytrig"); - await act.Should().ThrowAsync(); - - await scripts.DeleteTriggerAsync("MyTrig"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section I: Scripts API UDF CRUD Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Udf_Scripts_CreateTwoDifferentCasings_NoConflict() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var scripts = container.Scripts; - - var r1 = await scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "MyFunc", Body = "function(x) { return x; }" }); - var r2 = await scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "MYFUNC", Body = "function(x) { return x; }" }); - - r1.Resource.Id.Should().Be("MyFunc"); - r2.Resource.Id.Should().Be("MYFUNC"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section J: SQL Keyword Case Insensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlKeywords_AllUppercase_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task SqlKeywords_AllLowercase_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("select * from c where c.name = 'Alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task SqlKeywords_MixedCase_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SeLeCt * FrOm c WhErE c.name = 'Alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section K: SQL Function Name Case Insensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlFunction_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Hello World" }), - new PartitionKey("pk1")); - - // Test CONTAINS with various casings - var casings = new[] { "CONTAINS", "contains", "Contains" }; - foreach (var fn in casings) - { - var iter = container.GetItemQueryIterator( - new QueryDefinition($"SELECT * FROM c WHERE {fn}(c.name, 'Hello')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle($"{fn} should find the item"); - } - } - - [Fact] - public async Task SqlFunction_Aggregate_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", value = i }), - new PartitionKey("pk1")); - - var casings = new[] { "COUNT", "count", "Count" }; - foreach (var fn in casings) - { - var iter = container.GetItemQueryIterator( - new QueryDefinition($"SELECT VALUE {fn}(1) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(5, $"{fn}(1) should return 5"); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section L: SQL Property Path Case Sensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlProperty_WrongCasing_ReturnsNoResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // JSON has "name" (lowercase), query uses "Name" (PascalCase) — should not match - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.Name = 'Alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task SqlProperty_SelectWrongCasing_ReturnsUndefined() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // SELECT c.Name when JSON has "name" — should return object without "Name" value - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.Name FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - var doc = results[0]; - // "Name" should be missing/null since JSON property is "name" (lowercase) - (doc["Name"] == null || doc["Name"]!.Type == Newtonsoft.Json.Linq.JTokenType.Null) - .Should().BeTrue(); - } - - [Fact] - public async Task SqlProperty_NestedPath_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", address = new { city = "London" } }), - new PartitionKey("pk1")); - - // Correct casing - var iterCorrect = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.address.city = 'London'")); - var correct = new List(); - while (iterCorrect.HasMoreResults) correct.AddRange(await iterCorrect.ReadNextAsync()); - correct.Should().ContainSingle(); - - // Wrong casing - var iterWrong = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.address.City = 'London'")); - var wrong = new List(); - while (iterWrong.HasMoreResults) wrong.AddRange(await iterWrong.ReadNextAsync()); - wrong.Should().BeEmpty(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section M: SQL FROM Alias Case Insensitivity - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlAlias_UppercaseAlias_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // Default alias is "c", use uppercase "C" in SELECT - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT C.name FROM C")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task SqlAlias_MixedCaseRef_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // FROM c AS x, reference as X - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT X.name FROM c AS x WHERE X.name = 'Alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Deep-Dive Section N: UDF SQL Prefix Normalization - // ══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Udf_SqlPrefix_Lowercase_udf_ResolvesCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.myFunc(c.value) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(50); - } - - [Fact] - public async Task Udf_SqlPrefix_Uppercase_UDF_ResolvesCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE UDF.myFunc(c.value) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(50); - } - - [Fact] - public async Task Udf_SqlPrefix_MixedCase_Udf_ResolvesCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE Udf.myFunc(c.value) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(50); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B1: DISTINCT with case-variant string values - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Distinct_CaseVariantValues_AreSeparate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - foreach (var (i, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "ALICE") }) - await container.CreateItemAsync( - JObject.FromObject(new { id = i, partitionKey = "pk1", name }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT VALUE c.name FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - results.Should().Contain(new[] { "Alice", "alice", "ALICE" }); - } - - [Fact] - public async Task Distinct_CaseVariantObjectValues_AreSeparate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - foreach (var (i, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "ALICE") }) - await container.CreateItemAsync( - JObject.FromObject(new { id = i, partitionKey = "pk1", name }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT c.name FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B2: ORDER BY with mixed-case string values - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task OrderBy_MixedCaseStrings_OrdersByCodePoint() - { - var container = new InMemoryContainer("test", "/partitionKey"); - foreach (var (i, name) in new[] { ("1", "banana"), ("2", "Apple"), ("3", "cherry"), ("4", "apple") }) - await container.CreateItemAsync( - JObject.FromObject(new { id = i, partitionKey = "pk1", name }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.name FROM c ORDER BY c.name ASC")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var names = results.Select(r => r["name"]!.ToString()).ToList(); - names.Should().Equal("Apple", "apple", "banana", "cherry"); - } - - [Fact] - public async Task OrderBy_MixedCaseStrings_Desc_ReversesOrder() - { - var container = new InMemoryContainer("test", "/partitionKey"); - foreach (var (i, name) in new[] { ("1", "banana"), ("2", "Apple"), ("3", "cherry"), ("4", "apple") }) - await container.CreateItemAsync( - JObject.FromObject(new { id = i, partitionKey = "pk1", name }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.name FROM c ORDER BY c.name DESC")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var names = results.Select(r => r["name"]!.ToString()).ToList(); - names.Should().Equal("cherry", "banana", "apple", "Apple"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B3: GROUP BY with case-variant grouping keys - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task GroupBy_CaseVariantKeys_CreateSeparateGroups() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "4", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "5", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - var alice = results.First(r => r["name"]!.ToString() == "Alice"); - var aliceLower = results.First(r => r["name"]!.ToString() == "alice"); - ((int)alice["cnt"]!).Should().Be(2); - ((int)aliceLower["cnt"]!).Should().Be(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B4: LIKE operator case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Like_UppercasePattern_DoesNotMatchLowercase() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'A%'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Like_LowercasePattern_DoesNotMatchUppercase() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'a%'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task Like_ExactCasePattern_MatchesExactly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'Ali_e'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B5: IN operator case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task In_CaseSensitive_OnlyMatchesExactCase() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task In_MultipleValues_EachCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice', 'alice')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(new[] { "1", "2" }); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B6: CONTAINS/STARTSWITH/ENDSWITH string matching case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Contains_StringMatch_IsCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ali')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task StartsWith_StringMatch_IsCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, 'ali')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task EndsWith_StringMatch_IsCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, 'CE')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B7: CONTAINS/STARTSWITH/ENDSWITH with case-insensitive 3rd argument - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Contains_ThirdArgTrue_CaseInsensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', true)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task StartsWith_ThirdArgTrue_CaseInsensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, 'ALI', true)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task EndsWith_ThirdArgTrue_CaseInsensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, 'ICE', true)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Contains_ThirdArgFalse_StaysCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', false)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B8: STRING_EQUALS with case-insensitive 3rd argument - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task StringEquals_ThirdArgTrue_CaseInsensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE', true)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task StringEquals_ThirdArgFalse_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE', false)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task StringEquals_NoThirdArg_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B9: LOWER/UPPER for case-insensitive comparison pattern - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Lower_EnablesCaseInsensitiveComparison() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE LOWER(c.name) = 'alice'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Upper_EnablesCaseInsensitiveComparison() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE UPPER(c.name) = 'ALICE'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B10: Parameterized query case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ParameterizedQuery_ValueIsCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = @name").WithParameter("@name", "Alice")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B11: BETWEEN with string values case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Between_StringValues_CaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Bob" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Only uppercase-starting "Alice" and "Bob" are in A-Z range - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo(new[] { "Alice", "Bob" }); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B12: Null coalesce (??) preserves case - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task NullCoalesce_PreservesCaseOfFallback() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.missing ?? 'DEFAULT' FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("DEFAULT"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B13: JOIN alias case insensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task JoinAlias_IsCaseSensitive_InEmulator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { new { name = "x" }, new { name = "y" } } }), - new PartitionKey("pk1")); - - // Same case alias works - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE t FROM c JOIN t IN c.tags WHERE t.name = 'x'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("x"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B14: Additional built-in function name case insensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task SqlFunction_StringFunctions_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Hello" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT UPPER(c.name) AS u, lower(c.name) AS l, Length(c.name) AS len FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var r = results.Should().ContainSingle().Subject; - r["u"]!.ToString().Should().Be("HELLO"); - r["l"]!.ToString().Should().Be("hello"); - ((int)r["len"]!).Should().Be(5); - } - - [Fact] - public async Task SqlFunction_MathFunctions_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = -3.7 }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT ABS(c.value) AS a, floor(c.value) AS f, Ceiling(c.value) AS ce FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var r = results.Should().ContainSingle().Subject; - ((double)r["a"]!).Should().Be(3.7); - ((double)r["f"]!).Should().Be(-4); - ((double)r["ce"]!).Should().Be(-3); - } - - [Fact] - public async Task SqlFunction_TypeChecking_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "hello" }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT IS_STRING(c.name) AS a, is_number(c.name) AS b, Is_Defined(c.name) AS d FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var r = results.Should().ContainSingle().Subject; - ((bool)r["a"]!).Should().BeTrue(); - ((bool)r["b"]!).Should().BeFalse(); - ((bool)r["d"]!).Should().BeTrue(); - } - - [Fact] - public async Task SqlFunction_ArrayFunctions_MixedCasing_Works() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT ARRAY_LENGTH(c.tags) AS al, array_contains(c.tags, 'a') AS ac FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var r = results.Should().ContainSingle().Subject; - ((int)r["al"]!).Should().Be(3); - ((bool)r["ac"]!).Should().BeTrue(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C1: ReplaceItemAsync case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ReplaceItem_RequiresExactIdCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAnyAsync(() => - container.ReplaceItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), "item1", new PartitionKey("pk1"))); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItem_RequiresExactPartitionKeyCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "PkA" }), new PartitionKey("PkA")); - - var ex = await Assert.ThrowsAnyAsync(() => - container.ReplaceItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pka" }), "1", new PartitionKey("pka"))); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C2: UpsertItemAsync case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task UpsertItem_DifferentIdCasing_CreatesNewDocument() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "first" }), new PartitionKey("pk1")); - await container.UpsertItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "second" }), new PartitionKey("pk1")); - - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task UpsertItem_ExactCasing_UpdatesExistingDocument() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "first" }), new PartitionKey("pk1")); - await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "second" }), new PartitionKey("pk1")); - - container.ItemCount.Should().Be(1); - var r = await container.ReadItemAsync("item1", new PartitionKey("pk1")); - r.Resource["name"]!.ToString().Should().Be("second"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C3: ReadManyItemsAsync case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ReadMany_MixedCaseIds_OnlyReturnsExactMatches() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var response = await container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("Item1", new PartitionKey("pk1")), ("item1", new PartitionKey("pk1")) }); - response.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_WrongCaseId_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var response = await container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("ITEM1", new PartitionKey("pk1")) }); - response.Count.Should().Be(0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C4: TransactionalBatch case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Batch_Create_CaseSensitiveIds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" })); - batch.CreateItem(JObject.FromObject(new { id = "item1", partitionKey = "pk1" })); - var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task Batch_Read_RequiresExactIdCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("ITEM1"); - var response = await batch.ExecuteAsync(); - - // Individual operation should be 404 - response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Batch_Delete_RequiresExactIdCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("ITEM1"); - var response = await batch.ExecuteAsync(); - - response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - container.ItemCount.Should().Be(1); // Item not deleted - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C5: DeleteAllItemsByPartitionKeyStreamAsync case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DeleteAllByPK_RequiresExactCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pkA" }), new PartitionKey("pkA")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pka" }), new PartitionKey("pka")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pkA")); - - container.ItemCount.Should().Be(1); - var remaining = await container.ReadItemAsync("2", new PartitionKey("pka")); - remaining.Resource["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task DeleteAllByPK_OnlyDeletesExactPK() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"a{i}", partitionKey = "PK" }), new PartitionKey("PK")); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"b{i}", partitionKey = "pk" }), new PartitionKey("pk")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("PK")); - container.ItemCount.Should().Be(5); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C6: PatchItemAsync path case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Patch_UserPropertyPath_IsCaseSensitive() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "old" }), new PartitionKey("pk1")); - - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/Name", "new") }); - - var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); - result.Resource["name"]!.ToString().Should().Be("old"); // lowercase "name" untouched - result.Resource["Name"]!.ToString().Should().Be("new"); // new "Name" property created - } - - [Fact] - public async Task Patch_WrongPropertyPathCasing_CreatesNewProperty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "value" }), new PartitionKey("pk1")); - - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/NAME", "val") }); - - var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); - result.Resource["name"]!.ToString().Should().Be("value"); // original untouched - result.Resource["NAME"]!.ToString().Should().Be("val"); // new property - } - - [Fact] - public async Task Patch_ReservedPath_Id_CaseInsensitiveValidation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAnyAsync(() => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/ID", "new-id") })); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_ReservedPath_PartitionKey_CaseInsensitiveValidation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAnyAsync(() => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/PartitionKey", "new-pk") })); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C7: Hierarchical/composite partition key case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task HierarchicalPK_EachComponent_IsCaseSensitive() - { - var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "TenantA", region = "RegionX" }), - new PartitionKeyBuilder().Add("TenantA").Add("RegionX").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenant = "tenantA", region = "RegionX" }), - new PartitionKeyBuilder().Add("tenantA").Add("RegionX").Build()); - - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task HierarchicalPK_ReadItem_RequiresExactComponentCasing() - { - var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "TenantA", region = "RegionX" }), - new PartitionKeyBuilder().Add("TenantA").Add("RegionX").Build()); - - var ex = await Assert.ThrowsAnyAsync(() => - container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("tenantA").Add("RegionX").Build())); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D1: Database delete case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Database_DeleteAsync_RequiresExactCasing() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("mydb"); - await client.CreateDatabaseAsync("MyDB"); - - await client.GetDatabase("mydb").DeleteAsync(); - - // "MyDB" should still exist - var response = await client.GetDatabase("MyDB").ReadAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D2: Container delete case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Container_DeleteAsync_RequiresExactCasing() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("db")).Database; - await db.CreateContainerAsync("mycontainer", "/pk"); - await db.CreateContainerAsync("MyContainer", "/pk"); - - await db.GetContainer("mycontainer").DeleteContainerAsync(); - - var response = await db.GetContainer("MyContainer").ReadContainerAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D3: Container/Database properties readback - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Container_Properties_PreserveExactCasing() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("db")).Database; - await db.CreateContainerAsync("MyContainer", "/pk"); - - var response = await db.GetContainer("MyContainer").ReadContainerAsync(); - response.Resource.Id.Should().Be("MyContainer"); - } - - [Fact] - public async Task Database_Properties_PreserveExactCasing() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("MyDatabase"); - - var response = await client.GetDatabase("MyDatabase").ReadAsync(); - response.Resource.Id.Should().Be("MyDatabase"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // E1: Change feed preserves exact casing - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ChangeFeed_PreservesExactPropertyCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice", lastName = "Smith" }), - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - JObject? change = null; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - change = page.First(); - } - - change!["FirstName"]!.ToString().Should().Be("Alice"); - change!["lastName"]!.ToString().Should().Be("Smith"); - } - - [Fact] - public async Task ChangeFeed_PreservesExactIdCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk2" }), new PartitionKey("pk2")); - - var iter = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().HaveCount(2); - changes.Select(c => c["id"]!.ToString()).Should().BeEquivalentTo(new[] { "Item1", "item1" }); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // E2: State export/import preserves casing - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task StateExport_PreservesPropertyCasing() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice" }), - new PartitionKey("pk1")); - - var state = container.ExportState(); - state.Should().Contain("FirstName"); - } - - [Fact] - public async Task StateImport_PreservesPropertyCasing() - { - var source = new InMemoryContainer("source", "/partitionKey"); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice" }), - new PartitionKey("pk1")); - var state = source.ExportState(); - - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(state); - - var iter = target.GetItemQueryIterator( - new QueryDefinition("SELECT c.FirstName FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["FirstName"]!.ToString().Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // F1: Unique key policy case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task UniqueKeyPolicy_PathIsCaseInsensitive_InEmulator() - { - // Note: The emulator uses JToken.SelectToken which is case-sensitive for property lookup, - // but the unique key path resolution normalizes paths. We test that unique keys - // with different property casings work as expected in the emulator. - var props = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/userName" } } } - } - }; - var container = new InMemoryContainer(props); - - // Same userName value on same PK should conflict - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", userName = "alice" }), - new PartitionKey("pk1")); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk1", userName = "alice" }), - new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task UniqueKeyPolicy_ValueIsCaseSensitive() - { - var props = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(props); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", email = "Alice@example.com" }), - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk1", email = "alice@example.com" }), - new PartitionKey("pk1")); - - container.ItemCount.Should().Be(2); // Different case = different value - } - - // ═══════════════════════════════════════════════════════════════════════════ - // G1: Computed property name case sensitivity in queries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_QueryByName_IsCaseSensitive() - { - var props = new ContainerProperties("test", "/partitionKey") - { - ComputedProperties = new System.Collections.ObjectModel.Collection - { - new() { Name = "cp_fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } - } - }; - var container = new InMemoryContainer(props); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", first = "Alice", last = "Smith" }), - new PartitionKey("pk1")); - - // Correct casing - var iter1 = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_fullName FROM c")); - var results1 = new List(); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - results1.Should().ContainSingle().Which["cp_fullName"]!.ToString().Should().Be("Alice Smith"); - - // Wrong casing — property not found - var iter2 = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_fullname FROM c")); - var results2 = new List(); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - results2.Should().ContainSingle().Which["cp_fullname"].Should().BeNull(); - } - - [Fact] - public async Task ComputedProperty_DifferentCaseNames_AreSeparateProperties() - { - // The emulator allows "Foo" and "foo" as separate computed properties - var props = new ContainerProperties("test", "/partitionKey") - { - ComputedProperties = new System.Collections.ObjectModel.Collection - { - new() { Name = "Foo", Query = "SELECT VALUE c.a FROM c" }, - new() { Name = "foo", Query = "SELECT VALUE c.b FROM c" } - } - }; - var container = new InMemoryContainer(props); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", a = "alpha", b = "beta" }), - new PartitionKey("pk1")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.Foo, c.foo FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var r = results.Should().ContainSingle().Subject; - r["Foo"]!.ToString().Should().Be("alpha"); - r["foo"]!.ToString().Should().Be("beta"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // H1: Partition key path case sensitivity - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task PartitionKeyPath_IsCaseSensitive() - { - var container = new InMemoryContainer("test", "/pk"); - - // Document has "PK" (wrong case) — partition key lookup should NOT find it - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", PK = "wrongCase" }), - new PartitionKey("wrongCase")); // Must supply PK explicitly since auto-extract won't find /pk - - // Query scoped to partition should work with explicitly supplied PK - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("wrongCase") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } + // ═══════════════════════════════════════════════════════════════════════════ + // UDF case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Udf_RegisteredWithCasing_IsNotCallableWithDifferentCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 2); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), + new PartitionKey("pk1")); + + // Query with WRONG casing — should throw because "MYFUNC" is not registered, only "myFunc" + var query = new QueryDefinition("SELECT VALUE udf.MYFUNC(c.value) FROM c"); + + var act = async () => + { + var iter = container.GetItemQueryIterator(query); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Udf_RegisteredWithCasing_IsCallableWithExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 2); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), + new PartitionKey("pk1")); + + // Query with CORRECT casing — should work + var query = new QueryDefinition("SELECT VALUE udf.myFunc(c.value) FROM c"); + var iter = container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Should().Be(20); + } + + [Fact] + public async Task Udf_TwoUdfs_DifferingOnlyByCasing_AreSeparate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => "lower"); + container.RegisterUdf("MYFUNC", args => "upper"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1" }), + new PartitionKey("pk1")); + + var queryLower = new QueryDefinition("SELECT VALUE udf.myFunc(c.id) FROM c"); + var queryUpper = new QueryDefinition("SELECT VALUE udf.MYFUNC(c.id) FROM c"); + + var iterLower = container.GetItemQueryIterator(queryLower); + var resultsLower = new List(); + while (iterLower.HasMoreResults) resultsLower.AddRange(await iterLower.ReadNextAsync()); + + var iterUpper = container.GetItemQueryIterator(queryUpper); + var resultsUpper = new List(); + while (iterUpper.HasMoreResults) resultsUpper.AddRange(await iterUpper.ReadNextAsync()); + + resultsLower.Should().ContainSingle().Which.Should().Be("lower"); + resultsUpper.Should().ContainSingle().Which.Should().Be("upper"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Stored Procedure case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StoredProcedure_RegisteredWithCasing_IsNotCallableWithDifferentCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterStoredProcedure("myProc", (pk, args) => "result"); + + var scripts = container.Scripts; + + // Call with WRONG casing — should not find the handler (throws 404) + var act = () => scripts.ExecuteStoredProcedureAsync( + "MYPROC", + new PartitionKey("pk1"), + Array.Empty()); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StoredProcedure_TwoProcedures_DifferingOnlyByCasing_AreSeparate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterStoredProcedure("myProc", (pk, args) => "lower"); + container.RegisterStoredProcedure("MYPROC", (pk, args) => "upper"); + + var scripts = container.Scripts; + + var responseLower = await scripts.ExecuteStoredProcedureAsync( + "myProc", new PartitionKey("pk1"), Array.Empty()); + var responseUpper = await scripts.ExecuteStoredProcedureAsync( + "MYPROC", new PartitionKey("pk1"), Array.Empty()); + + responseLower.Resource.Should().Be("lower"); + responseUpper.Resource.Should().Be("upper"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Trigger case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Trigger_RegisteredWithCasing_DoesNotFireWithDifferentCasing() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["triggered"] = true; + return doc; + })); + + // Reference trigger with WRONG casing — should throw because "MYTRIGGER" is not registered + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "MYTRIGGER" } }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Trigger_TwoTriggers_DifferingOnlyByCasing_AreSeparate() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["source"] = "lower"; + return doc; + })); + container.RegisterTrigger("MYTRIGGER", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["source"] = "upper"; + return doc; + })); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "MYTRIGGER" } }); + + // Read back the items to verify each trigger applied its own value + var readLower = await container.ReadItemAsync("1", new PartitionKey("a")); + var readUpper = await container.ReadItemAsync("2", new PartitionKey("a")); + + readLower.Resource["source"]!.Value().Should().Be("lower"); + readUpper.Resource["source"]!.Value().Should().Be("upper"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Container routing case sensitivity (FakeCosmosHandler.CreateRouter) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Router_ContainerNames_AreCaseSensitive() + { + var container1 = new InMemoryContainer("MyData", "/partitionKey"); + var container2 = new InMemoryContainer("mydata", "/partitionKey"); + + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UpperCase" }, + new PartitionKey("pk1")); + await container2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "LowerCase" }, + new PartitionKey("pk1")); + + using var handler1 = new FakeCosmosHandler(container1); + using var handler2 = new FakeCosmosHandler(container2); + + // Register both containers — they differ only in casing and should be separate + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["MyData"] = handler1, + ["mydata"] = handler2, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c1 = client.GetContainer("db", "MyData"); + var c2 = client.GetContainer("db", "mydata"); + + var results1 = new List(); + var iter1 = c1.GetItemQueryIterator("SELECT * FROM c"); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + + var results2 = new List(); + var iter2 = c2.GetItemQueryIterator("SELECT * FROM c"); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + + results1.Should().ContainSingle().Which.Name.Should().Be("UpperCase"); + results2.Should().ContainSingle().Which.Name.Should().Be("LowerCase"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section C: Item ID Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ItemId_DifferentCasings_AreSeparateDocuments() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), + new PartitionKey("pk1")); + + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task ItemId_ReadItem_RequiresExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), + new PartitionKey("pk1")); + + var readUpper = await container.ReadItemAsync("Item1", new PartitionKey("pk1")); + var readLower = await container.ReadItemAsync("item1", new PartitionKey("pk1")); + + readUpper.Resource["name"]!.ToString().Should().Be("Upper"); + readLower.Resource["name"]!.ToString().Should().Be("Lower"); + } + + [Fact] + public async Task ItemId_DeleteItem_OnlyAffectsExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), + new PartitionKey("pk1")); + + await container.DeleteItemAsync("Item1", new PartitionKey("pk1")); + + container.ItemCount.Should().Be(1); + var remaining = await container.ReadItemAsync("item1", new PartitionKey("pk1")); + remaining.Resource["name"]!.ToString().Should().Be("Lower"); + } + + [Fact] + public async Task ItemId_ReplaceItem_OnlyAffectsExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Upper" }), + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "Lower" }), + new PartitionKey("pk1")); + + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "Replaced" }), + "Item1", new PartitionKey("pk1")); + + var readUpper = await container.ReadItemAsync("Item1", new PartitionKey("pk1")); + var readLower = await container.ReadItemAsync("item1", new PartitionKey("pk1")); + + readUpper.Resource["name"]!.ToString().Should().Be("Replaced"); + readLower.Resource["name"]!.ToString().Should().Be("Lower"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section D: Partition Key Value Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task PartitionKey_DifferentCasings_TreatedAsSeparatePartitions() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "doc1", partitionKey = "pkA", name = "Upper" }), + new PartitionKey("pkA")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "doc1", partitionKey = "pka", name = "Lower" }), + new PartitionKey("pka")); + + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task PartitionKey_ReadItem_RequiresExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "doc1", partitionKey = "pkA", name = "Upper" }), + new PartitionKey("pkA")); + + var read = await container.ReadItemAsync("doc1", new PartitionKey("pkA")); + read.Resource["name"]!.ToString().Should().Be("Upper"); + + var act = () => container.ReadItemAsync("doc1", new PartitionKey("pka")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PartitionKey_QueryWithPartitionKey_OnlyReturnsExactMatch() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pkA", name = "Upper" }), + new PartitionKey("pkA")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pka", name = "Lower" }), + new PartitionKey("pka")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pkA") }); + + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Upper"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section A: Database Name Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Database_NamesAreCaseSensitive_SeparateInstances() + { + var client = new InMemoryCosmosClient(); + + await client.CreateDatabaseAsync("MyDB"); + await client.CreateDatabaseAsync("mydb"); + + var db1 = client.GetDatabase("MyDB"); + var db2 = client.GetDatabase("mydb"); + + db1.Id.Should().Be("MyDB"); + db2.Id.Should().Be("mydb"); + } + + [Fact] + public async Task Database_CreateAsync_DifferentCasings_NoConflict() + { + var client = new InMemoryCosmosClient(); + + var r1 = await client.CreateDatabaseAsync("MyDB"); + var r2 = await client.CreateDatabaseAsync("mydb"); + + r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + } + + [Fact] + public async Task Database_CreateIfNotExists_DifferentCasings_BothCreated() + { + var client = new InMemoryCosmosClient(); + + var r1 = await client.CreateDatabaseIfNotExistsAsync("MyDB"); + var r2 = await client.CreateDatabaseIfNotExistsAsync("mydb"); + + r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + } + + [Fact] + public async Task Database_GetDatabaseQueryIterator_ListsBothCasings() + { + var client = new InMemoryCosmosClient(); + + await client.CreateDatabaseAsync("MyDB"); + await client.CreateDatabaseAsync("mydb"); + + var iter = client.GetDatabaseQueryIterator(); + var databases = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + databases.AddRange(page); + } + + databases.Select(d => d.Id).Should().Contain("MyDB").And.Contain("mydb"); + } + + [Fact] + public async Task Database_CreateIfNotExists_ExactCasing_ReturnsOk() + { + var client = new InMemoryCosmosClient(); + + await client.CreateDatabaseAsync("MyDB"); + var r2 = await client.CreateDatabaseIfNotExistsAsync("MyDB"); + + r2.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section B: Container Name Case Sensitivity via InMemoryDatabase + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Container_NamesAreCaseSensitive_SeparateInstances() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + await db.CreateContainerAsync("MyContainer", "/pk"); + await db.CreateContainerAsync("mycontainer", "/pk"); + + var c1 = db.GetContainer("MyContainer"); + var c2 = db.GetContainer("mycontainer"); + + c1.Id.Should().Be("MyContainer"); + c2.Id.Should().Be("mycontainer"); + } + + [Fact] + public async Task Container_CreateAsync_DifferentCasings_NoConflict() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var r1 = await db.CreateContainerAsync("Foo", "/pk"); + var r2 = await db.CreateContainerAsync("foo", "/pk"); + + r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + } + + [Fact] + public async Task Container_CreateIfNotExists_DifferentCasings_BothCreated() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var r1 = await db.CreateContainerIfNotExistsAsync("Foo", "/pk"); + var r2 = await db.CreateContainerIfNotExistsAsync("foo", "/pk"); + + r1.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + r2.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + } + + [Fact] + public async Task Container_GetContainerQueryIterator_ListsBothCasings() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + await db.CreateContainerAsync("Foo", "/pk"); + await db.CreateContainerAsync("foo", "/pk"); + + var iter = db.GetContainerQueryIterator(); + var containers = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Select(c => c.Id).Should().Contain("Foo").And.Contain("foo"); + } + + [Fact] + public async Task Container_DifferentCasings_StoreDataSeparately() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + await db.CreateContainerAsync("Foo", "/pk"); + await db.CreateContainerAsync("foo", "/pk"); + + var c1 = db.GetContainer("Foo"); + var c2 = db.GetContainer("foo"); + + await c1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "UpperContainer" }, + new PartitionKey("a")); + await c2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "LowerContainer" }, + new PartitionKey("a")); + + var read1 = await c1.ReadItemAsync("1", new PartitionKey("a")); + var read2 = await c2.ReadItemAsync("1", new PartitionKey("a")); + + read1.Resource.Name.Should().Be("UpperContainer"); + read2.Resource.Name.Should().Be("LowerContainer"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section E: User Name Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task User_NamesAreCaseSensitive_SeparateInstances() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var r1 = await db.CreateUserAsync("Admin"); + var r2 = await db.CreateUserAsync("admin"); + + r1.Resource.Id.Should().Be("Admin"); + r2.Resource.Id.Should().Be("admin"); + } + + [Fact] + public async Task User_GetUser_RequiresExactCasing() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + await db.CreateUserAsync("Admin"); + await db.CreateUserAsync("admin"); + + var u1 = db.GetUser("Admin"); + var u2 = db.GetUser("admin"); + + u1.Id.Should().Be("Admin"); + u2.Id.Should().Be("admin"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section F: Permission ID Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Permission_IdsAreCaseSensitive_SeparateEntries() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + + var r1 = await user.CreatePermissionAsync( + new PermissionProperties("ReadAll", PermissionMode.Read, db.GetContainer("c"))); + var r2 = await user.CreatePermissionAsync( + new PermissionProperties("readall", PermissionMode.Read, db.GetContainer("c"))); + + r1.Resource.Id.Should().Be("ReadAll"); + r2.Resource.Id.Should().Be("readall"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section G: Scripts API Sproc CRUD Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Sproc_Scripts_CreateAndRead_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var scripts = container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "MyProc", + Body = "function() { return true; }" + }); + + var read = await scripts.ReadStoredProcedureAsync("MyProc"); + read.Resource.Id.Should().Be("MyProc"); + + var act = () => scripts.ReadStoredProcedureAsync("myproc"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Sproc_Scripts_Delete_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var scripts = container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "MyProc", + Body = "function() { return true; }" + }); + + var act = () => scripts.DeleteStoredProcedureAsync("myproc"); + await act.Should().ThrowAsync(); + + // Exact casing should work + await scripts.DeleteStoredProcedureAsync("MyProc"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section H: Scripts API Trigger CRUD Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Trigger_Scripts_CreateAndRead_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var scripts = container.Scripts; + + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "MyTrig", + Body = "function() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create + }); + + var read = await scripts.ReadTriggerAsync("MyTrig"); + read.Resource.Id.Should().Be("MyTrig"); + + var act = () => scripts.ReadTriggerAsync("mytrig"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Trigger_Scripts_Delete_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var scripts = container.Scripts; + + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "MyTrig", + Body = "function() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create + }); + + var act = () => scripts.DeleteTriggerAsync("mytrig"); + await act.Should().ThrowAsync(); + + await scripts.DeleteTriggerAsync("MyTrig"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section I: Scripts API UDF CRUD Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Udf_Scripts_CreateTwoDifferentCasings_NoConflict() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var scripts = container.Scripts; + + var r1 = await scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "MyFunc", Body = "function(x) { return x; }" }); + var r2 = await scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "MYFUNC", Body = "function(x) { return x; }" }); + + r1.Resource.Id.Should().Be("MyFunc"); + r2.Resource.Id.Should().Be("MYFUNC"); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section J: SQL Keyword Case Insensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlKeywords_AllUppercase_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task SqlKeywords_AllLowercase_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("select * from c where c.name = 'Alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task SqlKeywords_MixedCase_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SeLeCt * FrOm c WhErE c.name = 'Alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section K: SQL Function Name Case Insensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlFunction_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Hello World" }), + new PartitionKey("pk1")); + + // Test CONTAINS with various casings + var casings = new[] { "CONTAINS", "contains", "Contains" }; + foreach (var fn in casings) + { + var iter = container.GetItemQueryIterator( + new QueryDefinition($"SELECT * FROM c WHERE {fn}(c.name, 'Hello')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle($"{fn} should find the item"); + } + } + + [Fact] + public async Task SqlFunction_Aggregate_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", value = i }), + new PartitionKey("pk1")); + + var casings = new[] { "COUNT", "count", "Count" }; + foreach (var fn in casings) + { + var iter = container.GetItemQueryIterator( + new QueryDefinition($"SELECT VALUE {fn}(1) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(5, $"{fn}(1) should return 5"); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section L: SQL Property Path Case Sensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlProperty_WrongCasing_ReturnsNoResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // JSON has "name" (lowercase), query uses "Name" (PascalCase) — should not match + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.Name = 'Alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task SqlProperty_SelectWrongCasing_ReturnsUndefined() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // SELECT c.Name when JSON has "name" — should return object without "Name" value + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.Name FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + var doc = results[0]; + // "Name" should be missing/null since JSON property is "name" (lowercase) + (doc["Name"] == null || doc["Name"]!.Type == Newtonsoft.Json.Linq.JTokenType.Null) + .Should().BeTrue(); + } + + [Fact] + public async Task SqlProperty_NestedPath_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", address = new { city = "London" } }), + new PartitionKey("pk1")); + + // Correct casing + var iterCorrect = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.address.city = 'London'")); + var correct = new List(); + while (iterCorrect.HasMoreResults) correct.AddRange(await iterCorrect.ReadNextAsync()); + correct.Should().ContainSingle(); + + // Wrong casing + var iterWrong = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.address.City = 'London'")); + var wrong = new List(); + while (iterWrong.HasMoreResults) wrong.AddRange(await iterWrong.ReadNextAsync()); + wrong.Should().BeEmpty(); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section M: SQL FROM Alias Case Insensitivity + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlAlias_UppercaseAlias_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // Default alias is "c", use uppercase "C" in SELECT + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT C.name FROM C")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task SqlAlias_MixedCaseRef_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // FROM c AS x, reference as X + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT X.name FROM c AS x WHERE X.name = 'Alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deep-Dive Section N: UDF SQL Prefix Normalization + // ══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Udf_SqlPrefix_Lowercase_udf_ResolvesCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.myFunc(c.value) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(50); + } + + [Fact] + public async Task Udf_SqlPrefix_Uppercase_UDF_ResolvesCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE UDF.myFunc(c.value) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(50); + } + + [Fact] + public async Task Udf_SqlPrefix_MixedCase_Udf_ResolvesCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("myFunc", args => Convert.ToDouble(args[0]) * 10); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE Udf.myFunc(c.value) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(50); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B1: DISTINCT with case-variant string values + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Distinct_CaseVariantValues_AreSeparate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + foreach (var (i, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "ALICE") }) + await container.CreateItemAsync( + JObject.FromObject(new { id = i, partitionKey = "pk1", name }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT VALUE c.name FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + results.Should().Contain(new[] { "Alice", "alice", "ALICE" }); + } + + [Fact] + public async Task Distinct_CaseVariantObjectValues_AreSeparate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + foreach (var (i, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "ALICE") }) + await container.CreateItemAsync( + JObject.FromObject(new { id = i, partitionKey = "pk1", name }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT c.name FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B2: ORDER BY with mixed-case string values + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task OrderBy_MixedCaseStrings_OrdersByCodePoint() + { + var container = new InMemoryContainer("test", "/partitionKey"); + foreach (var (i, name) in new[] { ("1", "banana"), ("2", "Apple"), ("3", "cherry"), ("4", "apple") }) + await container.CreateItemAsync( + JObject.FromObject(new { id = i, partitionKey = "pk1", name }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.name FROM c ORDER BY c.name ASC")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var names = results.Select(r => r["name"]!.ToString()).ToList(); + names.Should().Equal("Apple", "apple", "banana", "cherry"); + } + + [Fact] + public async Task OrderBy_MixedCaseStrings_Desc_ReversesOrder() + { + var container = new InMemoryContainer("test", "/partitionKey"); + foreach (var (i, name) in new[] { ("1", "banana"), ("2", "Apple"), ("3", "cherry"), ("4", "apple") }) + await container.CreateItemAsync( + JObject.FromObject(new { id = i, partitionKey = "pk1", name }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.name FROM c ORDER BY c.name DESC")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var names = results.Select(r => r["name"]!.ToString()).ToList(); + names.Should().Equal("cherry", "banana", "apple", "Apple"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B3: GROUP BY with case-variant grouping keys + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GroupBy_CaseVariantKeys_CreateSeparateGroups() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "4", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "5", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + var alice = results.First(r => r["name"]!.ToString() == "Alice"); + var aliceLower = results.First(r => r["name"]!.ToString() == "alice"); + ((int)alice["cnt"]!).Should().Be(2); + ((int)aliceLower["cnt"]!).Should().Be(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B4: LIKE operator case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Like_UppercasePattern_DoesNotMatchLowercase() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'A%'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Like_LowercasePattern_DoesNotMatchUppercase() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'a%'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task Like_ExactCasePattern_MatchesExactly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'Ali_e'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B5: IN operator case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task In_CaseSensitive_OnlyMatchesExactCase() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task In_MultipleValues_EachCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice', 'alice')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(new[] { "1", "2" }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B6: CONTAINS/STARTSWITH/ENDSWITH string matching case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Contains_StringMatch_IsCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ali')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task StartsWith_StringMatch_IsCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, 'ali')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task EndsWith_StringMatch_IsCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, 'CE')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B7: CONTAINS/STARTSWITH/ENDSWITH with case-insensitive 3rd argument + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Contains_ThirdArgTrue_CaseInsensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', true)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task StartsWith_ThirdArgTrue_CaseInsensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, 'ALI', true)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task EndsWith_ThirdArgTrue_CaseInsensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, 'ICE', true)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Contains_ThirdArgFalse_StaysCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', false)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B8: STRING_EQUALS with case-insensitive 3rd argument + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StringEquals_ThirdArgTrue_CaseInsensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE', true)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task StringEquals_ThirdArgFalse_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE', false)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task StringEquals_NoThirdArg_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'ALICE')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B9: LOWER/UPPER for case-insensitive comparison pattern + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Lower_EnablesCaseInsensitiveComparison() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE LOWER(c.name) = 'alice'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Upper_EnablesCaseInsensitiveComparison() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "ALICE" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE UPPER(c.name) = 'ALICE'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B10: Parameterized query case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ParameterizedQuery_ValueIsCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = @name").WithParameter("@name", "Alice")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B11: BETWEEN with string values case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Between_StringValues_CaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "alice" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Bob" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Only uppercase-starting "Alice" and "Bob" are in A-Z range + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo(new[] { "Alice", "Bob" }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B12: Null coalesce (??) preserves case + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task NullCoalesce_PreservesCaseOfFallback() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.missing ?? 'DEFAULT' FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("DEFAULT"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B13: JOIN alias case insensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task JoinAlias_IsCaseSensitive_InEmulator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { new { name = "x" }, new { name = "y" } } }), + new PartitionKey("pk1")); + + // Same case alias works + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE t FROM c JOIN t IN c.tags WHERE t.name = 'x'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("x"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B14: Additional built-in function name case insensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task SqlFunction_StringFunctions_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Hello" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT UPPER(c.name) AS u, lower(c.name) AS l, Length(c.name) AS len FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var r = results.Should().ContainSingle().Subject; + r["u"]!.ToString().Should().Be("HELLO"); + r["l"]!.ToString().Should().Be("hello"); + ((int)r["len"]!).Should().Be(5); + } + + [Fact] + public async Task SqlFunction_MathFunctions_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = -3.7 }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT ABS(c.value) AS a, floor(c.value) AS f, Ceiling(c.value) AS ce FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var r = results.Should().ContainSingle().Subject; + ((double)r["a"]!).Should().Be(3.7); + ((double)r["f"]!).Should().Be(-4); + ((double)r["ce"]!).Should().Be(-3); + } + + [Fact] + public async Task SqlFunction_TypeChecking_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "hello" }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT IS_STRING(c.name) AS a, is_number(c.name) AS b, Is_Defined(c.name) AS d FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var r = results.Should().ContainSingle().Subject; + ((bool)r["a"]!).Should().BeTrue(); + ((bool)r["b"]!).Should().BeFalse(); + ((bool)r["d"]!).Should().BeTrue(); + } + + [Fact] + public async Task SqlFunction_ArrayFunctions_MixedCasing_Works() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT ARRAY_LENGTH(c.tags) AS al, array_contains(c.tags, 'a') AS ac FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var r = results.Should().ContainSingle().Subject; + ((int)r["al"]!).Should().Be(3); + ((bool)r["ac"]!).Should().BeTrue(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C1: ReplaceItemAsync case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ReplaceItem_RequiresExactIdCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAnyAsync(() => + container.ReplaceItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), "item1", new PartitionKey("pk1"))); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItem_RequiresExactPartitionKeyCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "PkA" }), new PartitionKey("PkA")); + + var ex = await Assert.ThrowsAnyAsync(() => + container.ReplaceItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pka" }), "1", new PartitionKey("pka"))); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C2: UpsertItemAsync case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpsertItem_DifferentIdCasing_CreatesNewDocument() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "first" }), new PartitionKey("pk1")); + await container.UpsertItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1", name = "second" }), new PartitionKey("pk1")); + + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task UpsertItem_ExactCasing_UpdatesExistingDocument() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "first" }), new PartitionKey("pk1")); + await container.UpsertItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1", name = "second" }), new PartitionKey("pk1")); + + container.ItemCount.Should().Be(1); + var r = await container.ReadItemAsync("item1", new PartitionKey("pk1")); + r.Resource["name"]!.ToString().Should().Be("second"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C3: ReadManyItemsAsync case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ReadMany_MixedCaseIds_OnlyReturnsExactMatches() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var response = await container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("Item1", new PartitionKey("pk1")), ("item1", new PartitionKey("pk1")) }); + response.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_WrongCaseId_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var response = await container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("ITEM1", new PartitionKey("pk1")) }); + response.Count.Should().Be(0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C4: TransactionalBatch case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Batch_Create_CaseSensitiveIds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" })); + batch.CreateItem(JObject.FromObject(new { id = "item1", partitionKey = "pk1" })); + var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task Batch_Read_RequiresExactIdCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("ITEM1"); + var response = await batch.ExecuteAsync(); + + // Individual operation should be 404 + response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Batch_Delete_RequiresExactIdCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("ITEM1"); + var response = await batch.ExecuteAsync(); + + response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + container.ItemCount.Should().Be(1); // Item not deleted + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C5: DeleteAllItemsByPartitionKeyStreamAsync case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeleteAllByPK_RequiresExactCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pkA" }), new PartitionKey("pkA")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pka" }), new PartitionKey("pka")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pkA")); + + container.ItemCount.Should().Be(1); + var remaining = await container.ReadItemAsync("2", new PartitionKey("pka")); + remaining.Resource["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task DeleteAllByPK_OnlyDeletesExactPK() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"a{i}", partitionKey = "PK" }), new PartitionKey("PK")); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"b{i}", partitionKey = "pk" }), new PartitionKey("pk")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("PK")); + container.ItemCount.Should().Be(5); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C6: PatchItemAsync path case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Patch_UserPropertyPath_IsCaseSensitive() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "old" }), new PartitionKey("pk1")); + + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/Name", "new") }); + + var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); + result.Resource["name"]!.ToString().Should().Be("old"); // lowercase "name" untouched + result.Resource["Name"]!.ToString().Should().Be("new"); // new "Name" property created + } + + [Fact] + public async Task Patch_WrongPropertyPathCasing_CreatesNewProperty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "value" }), new PartitionKey("pk1")); + + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/NAME", "val") }); + + var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); + result.Resource["name"]!.ToString().Should().Be("value"); // original untouched + result.Resource["NAME"]!.ToString().Should().Be("val"); // new property + } + + [Fact] + public async Task Patch_ReservedPath_Id_CaseInsensitiveValidation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAnyAsync(() => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/ID", "new-id") })); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_ReservedPath_PartitionKey_CaseInsensitiveValidation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAnyAsync(() => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/PartitionKey", "new-pk") })); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C7: Hierarchical/composite partition key case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task HierarchicalPK_EachComponent_IsCaseSensitive() + { + var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "TenantA", region = "RegionX" }), + new PartitionKeyBuilder().Add("TenantA").Add("RegionX").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenant = "tenantA", region = "RegionX" }), + new PartitionKeyBuilder().Add("tenantA").Add("RegionX").Build()); + + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task HierarchicalPK_ReadItem_RequiresExactComponentCasing() + { + var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "TenantA", region = "RegionX" }), + new PartitionKeyBuilder().Add("TenantA").Add("RegionX").Build()); + + var ex = await Assert.ThrowsAnyAsync(() => + container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("tenantA").Add("RegionX").Build())); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D1: Database delete case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Database_DeleteAsync_RequiresExactCasing() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("mydb"); + await client.CreateDatabaseAsync("MyDB"); + + await client.GetDatabase("mydb").DeleteAsync(); + + // "MyDB" should still exist + var response = await client.GetDatabase("MyDB").ReadAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D2: Container delete case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Container_DeleteAsync_RequiresExactCasing() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("db")).Database; + await db.CreateContainerAsync("mycontainer", "/pk"); + await db.CreateContainerAsync("MyContainer", "/pk"); + + await db.GetContainer("mycontainer").DeleteContainerAsync(); + + var response = await db.GetContainer("MyContainer").ReadContainerAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D3: Container/Database properties readback + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Container_Properties_PreserveExactCasing() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("db")).Database; + await db.CreateContainerAsync("MyContainer", "/pk"); + + var response = await db.GetContainer("MyContainer").ReadContainerAsync(); + response.Resource.Id.Should().Be("MyContainer"); + } + + [Fact] + public async Task Database_Properties_PreserveExactCasing() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("MyDatabase"); + + var response = await client.GetDatabase("MyDatabase").ReadAsync(); + response.Resource.Id.Should().Be("MyDatabase"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // E1: Change feed preserves exact casing + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangeFeed_PreservesExactPropertyCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice", lastName = "Smith" }), + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + JObject? change = null; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + change = page.First(); + } + + change!["FirstName"]!.ToString().Should().Be("Alice"); + change!["lastName"]!.ToString().Should().Be("Smith"); + } + + [Fact] + public async Task ChangeFeed_PreservesExactIdCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(JObject.FromObject(new { id = "Item1", partitionKey = "pk1" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "item1", partitionKey = "pk2" }), new PartitionKey("pk2")); + + var iter = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().HaveCount(2); + changes.Select(c => c["id"]!.ToString()).Should().BeEquivalentTo(new[] { "Item1", "item1" }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // E2: State export/import preserves casing + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StateExport_PreservesPropertyCasing() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice" }), + new PartitionKey("pk1")); + + var state = container.ExportState(); + state.Should().Contain("FirstName"); + } + + [Fact] + public async Task StateImport_PreservesPropertyCasing() + { + var source = new InMemoryContainer("source", "/partitionKey"); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", FirstName = "Alice" }), + new PartitionKey("pk1")); + var state = source.ExportState(); + + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(state); + + var iter = target.GetItemQueryIterator( + new QueryDefinition("SELECT c.FirstName FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["FirstName"]!.ToString().Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // F1: Unique key policy case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UniqueKeyPolicy_PathIsCaseInsensitive_InEmulator() + { + // Note: The emulator uses JToken.SelectToken which is case-sensitive for property lookup, + // but the unique key path resolution normalizes paths. We test that unique keys + // with different property casings work as expected in the emulator. + var props = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/userName" } } } + } + }; + var container = new InMemoryContainer(props); + + // Same userName value on same PK should conflict + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", userName = "alice" }), + new PartitionKey("pk1")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk1", userName = "alice" }), + new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task UniqueKeyPolicy_ValueIsCaseSensitive() + { + var props = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", email = "Alice@example.com" }), + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk1", email = "alice@example.com" }), + new PartitionKey("pk1")); + + container.ItemCount.Should().Be(2); // Different case = different value + } + + // ═══════════════════════════════════════════════════════════════════════════ + // G1: Computed property name case sensitivity in queries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_QueryByName_IsCaseSensitive() + { + var props = new ContainerProperties("test", "/partitionKey") + { + ComputedProperties = new System.Collections.ObjectModel.Collection + { + new() { Name = "cp_fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } + } + }; + var container = new InMemoryContainer(props); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", first = "Alice", last = "Smith" }), + new PartitionKey("pk1")); + + // Correct casing + var iter1 = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_fullName FROM c")); + var results1 = new List(); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + results1.Should().ContainSingle().Which["cp_fullName"]!.ToString().Should().Be("Alice Smith"); + + // Wrong casing — property not found + var iter2 = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_fullname FROM c")); + var results2 = new List(); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + results2.Should().ContainSingle().Which["cp_fullname"].Should().BeNull(); + } + + [Fact] + public async Task ComputedProperty_DifferentCaseNames_AreSeparateProperties() + { + // The emulator allows "Foo" and "foo" as separate computed properties + var props = new ContainerProperties("test", "/partitionKey") + { + ComputedProperties = new System.Collections.ObjectModel.Collection + { + new() { Name = "Foo", Query = "SELECT VALUE c.a FROM c" }, + new() { Name = "foo", Query = "SELECT VALUE c.b FROM c" } + } + }; + var container = new InMemoryContainer(props); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", a = "alpha", b = "beta" }), + new PartitionKey("pk1")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.Foo, c.foo FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var r = results.Should().ContainSingle().Subject; + r["Foo"]!.ToString().Should().Be("alpha"); + r["foo"]!.ToString().Should().Be("beta"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // H1: Partition key path case sensitivity + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task PartitionKeyPath_IsCaseSensitive() + { + var container = new InMemoryContainer("test", "/pk"); + + // Document has "PK" (wrong case) — partition key lookup should NOT find it + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", PK = "wrongCase" }), + new PartitionKey("wrongCase")); // Must supply PK explicitly since auto-extract won't find /pk + + // Query scoped to partition should work with explicitly supplied PK + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("wrongCase") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedCapTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedCapTests.cs index c37df01..c4ce389 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedCapTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedCapTests.cs @@ -11,231 +11,231 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ChangeFeedCapTests { - [Fact] - public void DefaultMaxChangeFeedSize_Is1000() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.MaxChangeFeedSize.Should().Be(1000); - } - - [Fact] - public void MaxChangeFeedSize_CanBeSetToCustomValue() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 50 - }; - container.MaxChangeFeedSize.Should().Be(50); - } - - [Fact] - public async Task ChangeFeed_EvictsOldestEntries_WhenCapExceeded() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 5 - }; - - // Write 8 items — should keep only the last 5 - for (var i = 1; i <= 8; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - container.GetChangeFeedCheckpoint().Should().Be(5); - - // Read all entries — should be items 4-8 (oldest 3 evicted) - var iterator = container.GetChangeFeedIterator(0); - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - items.AddRange(response); - } - - items.Should().HaveCount(5); - items[0].Id.Should().Be("4"); - items[4].Id.Should().Be("8"); - } - - [Fact] - public async Task ChangeFeed_DoesNotEvict_WhenUnderCap() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 10 - }; - - for (var i = 1; i <= 5; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - container.GetChangeFeedCheckpoint().Should().Be(5); - - var iterator = container.GetChangeFeedIterator(0); - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - items.AddRange(response); - } - - items.Should().HaveCount(5); - items[0].Id.Should().Be("1"); - } - - [Fact] - public async Task ChangeFeed_EvictsCorrectly_WithDeleteTombstones() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 5 - }; - - // Create 3 items, delete 1, create 3 more = 7 entries total, cap at 5 - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, - new PartitionKey("pk")); - await container.DeleteItemAsync("1", new PartitionKey("pk")); - // 4 entries so far, all under cap - await container.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk", Name = "D" }, - new PartitionKey("pk")); - // 5 entries — at cap - await container.CreateItemAsync( - new TestDocument { Id = "5", PartitionKey = "pk", Name = "E" }, - new PartitionKey("pk")); - // 6th entry — evicts oldest (create "1") - await container.CreateItemAsync( - new TestDocument { Id = "6", PartitionKey = "pk", Name = "F" }, - new PartitionKey("pk")); - // 7th entry — evicts 2nd oldest (create "2") - - container.GetChangeFeedCheckpoint().Should().Be(5); - } - - [Fact] - public async Task ChangeFeedIterator_WorksCorrectly_AfterEviction() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 3 - }; - - // Get checkpoint before adding items - var checkpoint = container.GetChangeFeedCheckpoint(); - checkpoint.Should().Be(0); - - // Write 5 items, cap at 3 → last 3 survive - for (var i = 1; i <= 5; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - // Read from beginning (checkpoint 0) - var iterator = container.GetChangeFeedIterator(0); - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - items.AddRange(response); - } - - items.Should().HaveCount(3); - items[0].Id.Should().Be("3"); - items[1].Id.Should().Be("4"); - items[2].Id.Should().Be("5"); - } - - [Fact] - public async Task ClearItems_ResetsChangeFeed_RegardlessOfCap() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 5 - }; - - for (var i = 1; i <= 3; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - container.ClearItems(); - - container.GetChangeFeedCheckpoint().Should().Be(0); - } - - [Fact] - public async Task ChangeFeed_EvictsCorrectly_WithUpserts() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 4 - }; - - // Create 2 items, upsert 1, create 2 more = 5 entries - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A-updated" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk", Name = "D" }, - new PartitionKey("pk")); - - // 5 entries, cap 4 → oldest evicted (create "1") - container.GetChangeFeedCheckpoint().Should().Be(4); - - var iterator = container.GetChangeFeedIterator(0); - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - items.AddRange(response); - } - - items.Should().HaveCount(4); - // First remaining entry should be create "2" - items[0].Id.Should().Be("2"); - } - - [Fact] - public async Task MaxChangeFeedSize_SetToZero_DisablesEviction() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - MaxChangeFeedSize = 0 - }; - - for (var i = 1; i <= 100; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - container.GetChangeFeedCheckpoint().Should().Be(100); - } + [Fact] + public void DefaultMaxChangeFeedSize_Is1000() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.MaxChangeFeedSize.Should().Be(1000); + } + + [Fact] + public void MaxChangeFeedSize_CanBeSetToCustomValue() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 50 + }; + container.MaxChangeFeedSize.Should().Be(50); + } + + [Fact] + public async Task ChangeFeed_EvictsOldestEntries_WhenCapExceeded() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 5 + }; + + // Write 8 items — should keep only the last 5 + for (var i = 1; i <= 8; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + container.GetChangeFeedCheckpoint().Should().Be(5); + + // Read all entries — should be items 4-8 (oldest 3 evicted) + var iterator = container.GetChangeFeedIterator(0); + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + items.AddRange(response); + } + + items.Should().HaveCount(5); + items[0].Id.Should().Be("4"); + items[4].Id.Should().Be("8"); + } + + [Fact] + public async Task ChangeFeed_DoesNotEvict_WhenUnderCap() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 10 + }; + + for (var i = 1; i <= 5; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + container.GetChangeFeedCheckpoint().Should().Be(5); + + var iterator = container.GetChangeFeedIterator(0); + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + items.AddRange(response); + } + + items.Should().HaveCount(5); + items[0].Id.Should().Be("1"); + } + + [Fact] + public async Task ChangeFeed_EvictsCorrectly_WithDeleteTombstones() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 5 + }; + + // Create 3 items, delete 1, create 3 more = 7 entries total, cap at 5 + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, + new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); + // 4 entries so far, all under cap + await container.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk", Name = "D" }, + new PartitionKey("pk")); + // 5 entries — at cap + await container.CreateItemAsync( + new TestDocument { Id = "5", PartitionKey = "pk", Name = "E" }, + new PartitionKey("pk")); + // 6th entry — evicts oldest (create "1") + await container.CreateItemAsync( + new TestDocument { Id = "6", PartitionKey = "pk", Name = "F" }, + new PartitionKey("pk")); + // 7th entry — evicts 2nd oldest (create "2") + + container.GetChangeFeedCheckpoint().Should().Be(5); + } + + [Fact] + public async Task ChangeFeedIterator_WorksCorrectly_AfterEviction() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 3 + }; + + // Get checkpoint before adding items + var checkpoint = container.GetChangeFeedCheckpoint(); + checkpoint.Should().Be(0); + + // Write 5 items, cap at 3 → last 3 survive + for (var i = 1; i <= 5; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + // Read from beginning (checkpoint 0) + var iterator = container.GetChangeFeedIterator(0); + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + items.AddRange(response); + } + + items.Should().HaveCount(3); + items[0].Id.Should().Be("3"); + items[1].Id.Should().Be("4"); + items[2].Id.Should().Be("5"); + } + + [Fact] + public async Task ClearItems_ResetsChangeFeed_RegardlessOfCap() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 5 + }; + + for (var i = 1; i <= 3; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + container.ClearItems(); + + container.GetChangeFeedCheckpoint().Should().Be(0); + } + + [Fact] + public async Task ChangeFeed_EvictsCorrectly_WithUpserts() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 4 + }; + + // Create 2 items, upsert 1, create 2 more = 5 entries + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A-updated" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk", Name = "D" }, + new PartitionKey("pk")); + + // 5 entries, cap 4 → oldest evicted (create "1") + container.GetChangeFeedCheckpoint().Should().Be(4); + + var iterator = container.GetChangeFeedIterator(0); + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + items.AddRange(response); + } + + items.Should().HaveCount(4); + // First remaining entry should be create "2" + items[0].Id.Should().Be("2"); + } + + [Fact] + public async Task MaxChangeFeedSize_SetToZero_DisablesEviction() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + MaxChangeFeedSize = 0 + }; + + for (var i = 1; i <= 100; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + container.GetChangeFeedCheckpoint().Should().Be(100); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedDeepDiveTests.cs index dcdb76f..15daec2 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedDeepDiveTests.cs @@ -13,53 +13,53 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ChangeFeedReadNoEntryTests { - [Fact] - public async Task ReadItemAsync_DoesNotProduceChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - await container.ReadItemAsync("1", new PartitionKey("pk1")); - - container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); - } - - [Fact] - public async Task ReadManyItemsAsync_DoesNotProduceChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - await container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")), ("2", new PartitionKey("pk1")) }); - - container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); - } - - [Fact] - public async Task QueryAsync_DoesNotProduceChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - - container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); - } + [Fact] + public async Task ReadItemAsync_DoesNotProduceChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + await container.ReadItemAsync("1", new PartitionKey("pk1")); + + container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); + } + + [Fact] + public async Task ReadManyItemsAsync_DoesNotProduceChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + await container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")), ("2", new PartitionKey("pk1")) }); + + container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); + } + + [Fact] + public async Task QueryAsync_DoesNotProduceChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + + container.GetChangeFeedCheckpoint().Should().Be(checkpointAfterCreate); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -68,39 +68,39 @@ await container.CreateItemAsync( public class ChangeFeedDeleteAllByPKTests { - [Fact] - public async Task DeleteAllByPK_RecordsTombstonesForAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 3; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "other", PartitionKey = "pk2", Name = "Other" }, - new PartitionKey("pk2")); - - var checkpointBefore = container.GetChangeFeedCheckpoint(); - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - var checkpointAfter = container.GetChangeFeedCheckpoint(); - (checkpointAfter - checkpointBefore).Should().Be(3); - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task DeleteAllByPK_EmptyPartition_NoChangeFeedEntries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk2")); - - container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore); - } + [Fact] + public async Task DeleteAllByPK_RecordsTombstonesForAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 3; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "other", PartitionKey = "pk2", Name = "Other" }, + new PartitionKey("pk2")); + + var checkpointBefore = container.GetChangeFeedCheckpoint(); + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + var checkpointAfter = container.GetChangeFeedCheckpoint(); + (checkpointAfter - checkpointBefore).Should().Be(3); + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task DeleteAllByPK_EmptyPartition_NoChangeFeedEntries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk2")); + + container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -109,72 +109,72 @@ await container.CreateItemAsync( public class ChangeFeedBatchTests { - [Fact] - public async Task Batch_MixedCreateAndDelete_AllRecorded() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, - new PartitionKey("pk1")); - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }); - batch.DeleteItem("1"); - await batch.ExecuteAsync(); - - var checkpointAfter = container.GetChangeFeedCheckpoint(); - (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task Batch_Upsert_RecordsEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); - await batch.ExecuteAsync(); - - var checkpointAfter = container.GetChangeFeedCheckpoint(); - (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(1); - } - - [Fact] - public async Task Batch_Replace_RecordsEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); - await batch.ExecuteAsync(); - - var checkpointAfter = container.GetChangeFeedCheckpoint(); - (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(1); - } - - [Fact] - public async Task Batch_Failure_NoChangeFeedEntries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "Good" }); - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); - var response = await batch.ExecuteAsync(); - - // Batch should fail - response.IsSuccessStatusCode.Should().BeFalse(); - - // No change feed entries should be produced - container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore); - } + [Fact] + public async Task Batch_MixedCreateAndDelete_AllRecorded() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, + new PartitionKey("pk1")); + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }); + batch.DeleteItem("1"); + await batch.ExecuteAsync(); + + var checkpointAfter = container.GetChangeFeedCheckpoint(); + (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task Batch_Upsert_RecordsEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); + await batch.ExecuteAsync(); + + var checkpointAfter = container.GetChangeFeedCheckpoint(); + (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task Batch_Replace_RecordsEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); + await batch.ExecuteAsync(); + + var checkpointAfter = container.GetChangeFeedCheckpoint(); + (checkpointAfter - checkpointBefore).Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task Batch_Failure_NoChangeFeedEntries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "Good" }); + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); + var response = await batch.ExecuteAsync(); + + // Batch should fail + response.IsSuccessStatusCode.Should().BeFalse(); + + // No change feed entries should be produced + container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -183,63 +183,63 @@ public async Task Batch_Failure_NoChangeFeedEntries() public class ChangeFeedPartitionKeyEdgeCaseTests { - [Fact] - public async Task ChangeFeed_PartitionKeyNone_ItemAppearsInFeed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoExplicitPK" }); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task ChangeFeed_NestedPartitionKeyPath_RecordsCorrectly() - { - var container = new InMemoryContainer("test", "/address/city"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", address = new { city = "London" }, name = "A" }), - new PartitionKey("London")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().ContainSingle(); - changes[0]["address"]!["city"]!.ToString().Should().Be("London"); - } - - [Fact] - public async Task ChangeFeed_ThreeLevelCompositeKey_TombstoneCorrect() - { - var container = new InMemoryContainer("test", new[] { "/a", "/b", "/c" }); - var pk = new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z" }), pk); - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - await container.DeleteItemAsync("1", pk); - - // Verify tombstone recorded - var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); - (checkpointAfterDelete - checkpointAfterCreate).Should().Be(1); - } + [Fact] + public async Task ChangeFeed_PartitionKeyNone_ItemAppearsInFeed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoExplicitPK" }); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task ChangeFeed_NestedPartitionKeyPath_RecordsCorrectly() + { + var container = new InMemoryContainer("test", "/address/city"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", address = new { city = "London" }, name = "A" }), + new PartitionKey("London")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().ContainSingle(); + changes[0]["address"]!["city"]!.ToString().Should().Be("London"); + } + + [Fact] + public async Task ChangeFeed_ThreeLevelCompositeKey_TombstoneCorrect() + { + var container = new InMemoryContainer("test", new[] { "/a", "/b", "/c" }); + var pk = new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z" }), pk); + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + await container.DeleteItemAsync("1", pk); + + // Verify tombstone recorded + var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); + (checkpointAfterDelete - checkpointAfterCreate).Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -248,55 +248,55 @@ await container.CreateItemAsync( public class ChangeFeedProcessorAdvancedScenarioTests { - [Fact] - public async Task MultipleConcurrentProcessors_BothReceiveChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var received1 = new List(); - var received2 = new List(); - - var processor1 = container.GetChangeFeedProcessorBuilder( - "proc1", (ctx, changes, ct) => - { - received1.AddRange(changes); - return Task.CompletedTask; - }).WithInMemoryLeaseContainer().Build(); - - var processor2 = container.GetChangeFeedProcessorBuilder( - "proc2", (ctx, changes, ct) => - { - received2.AddRange(changes); - return Task.CompletedTask; - }).WithInMemoryLeaseContainer().Build(); - - await processor1.StartAsync(); - await processor2.StartAsync(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - - await processor1.StopAsync(); - await processor2.StopAsync(); - - received1.Should().ContainSingle().Which.Id.Should().Be("1"); - received2.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task Processor_DoubleStop_IsIdempotent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var processor = container.GetChangeFeedProcessorBuilder( - "proc", (ctx, changes, ct) => Task.CompletedTask) - .WithInMemoryLeaseContainer().Build(); - - await processor.StartAsync(); - await processor.StopAsync(); - await processor.StopAsync(); // Should not throw - } + [Fact] + public async Task MultipleConcurrentProcessors_BothReceiveChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var received1 = new List(); + var received2 = new List(); + + var processor1 = container.GetChangeFeedProcessorBuilder( + "proc1", (ctx, changes, ct) => + { + received1.AddRange(changes); + return Task.CompletedTask; + }).WithInMemoryLeaseContainer().Build(); + + var processor2 = container.GetChangeFeedProcessorBuilder( + "proc2", (ctx, changes, ct) => + { + received2.AddRange(changes); + return Task.CompletedTask; + }).WithInMemoryLeaseContainer().Build(); + + await processor1.StartAsync(); + await processor2.StartAsync(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + + await processor1.StopAsync(); + await processor2.StopAsync(); + + received1.Should().ContainSingle().Which.Id.Should().Be("1"); + received2.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task Processor_DoubleStop_IsIdempotent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var processor = container.GetChangeFeedProcessorBuilder( + "proc", (ctx, changes, ct) => Task.CompletedTask) + .WithInMemoryLeaseContainer().Build(); + + await processor.StartAsync(); + await processor.StopAsync(); + await processor.StopAsync(); // Should not throw + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -305,74 +305,74 @@ public async Task Processor_DoubleStop_IsIdempotent() public class ChangeFeedStateManagementTests { - [Fact] - public async Task ImportState_ClearsChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - checkpoint.Should().Be(5); - - // Import empty state - container.ImportState("{\"items\":[]}"); - - // Change feed should be reset - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - // After import, the imported items appear as new change feed entries - changes.Should().BeEmpty(); - } - - [Fact] - public async Task ExportImportRoundtrip_ChangeFeedReset() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var state = container.ExportState(); - container.ImportState(state); - - // After roundtrip, change feed entries from original writes are gone - // but imported items create new change feed entries - var postImportCheckpoint = container.GetChangeFeedCheckpoint(); - // ImportState replaces all data — the checkpoint behavior depends on implementation - postImportCheckpoint.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task AfterImportState_NewWritesProduceFreshChangeFeed() - { - var source = new InMemoryContainer("source", "/partitionKey"); - await source.CreateItemAsync( - new TestDocument { Id = "seed", PartitionKey = "pk1", Name = "Seed" }, - new PartitionKey("pk1")); - var state = source.ExportState(); - - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(state); - var checkpointAfterImport = target.GetChangeFeedCheckpoint(); - - await target.CreateItemAsync( - new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var checkpointAfterWrite = target.GetChangeFeedCheckpoint(); - (checkpointAfterWrite - checkpointAfterImport).Should().Be(1); - } + [Fact] + public async Task ImportState_ClearsChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + checkpoint.Should().Be(5); + + // Import empty state + container.ImportState("{\"items\":[]}"); + + // Change feed should be reset + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + // After import, the imported items appear as new change feed entries + changes.Should().BeEmpty(); + } + + [Fact] + public async Task ExportImportRoundtrip_ChangeFeedReset() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var state = container.ExportState(); + container.ImportState(state); + + // After roundtrip, change feed entries from original writes are gone + // but imported items create new change feed entries + var postImportCheckpoint = container.GetChangeFeedCheckpoint(); + // ImportState replaces all data — the checkpoint behavior depends on implementation + postImportCheckpoint.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task AfterImportState_NewWritesProduceFreshChangeFeed() + { + var source = new InMemoryContainer("source", "/partitionKey"); + await source.CreateItemAsync( + new TestDocument { Id = "seed", PartitionKey = "pk1", Name = "Seed" }, + new PartitionKey("pk1")); + var state = source.ExportState(); + + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(state); + var checkpointAfterImport = target.GetChangeFeedCheckpoint(); + + await target.CreateItemAsync( + new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var checkpointAfterWrite = target.GetChangeFeedCheckpoint(); + (checkpointAfterWrite - checkpointAfterImport).Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -381,118 +381,118 @@ await target.CreateItemAsync( public class ChangeFeedEdgeCaseDeepTests { - [Fact] - public async Task ReCreateAfterDelete_IncrementalShowsOnlyNewVersion() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - // Incremental deduplicates to latest version - changes.Should().ContainSingle().Which.Name.Should().Be("V2"); - } - - [Fact] - public async Task ItemLevelTTL_StillAppearsInChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Short", ttl = 1 }), - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task LargeChangeFeed_1000Entries_AllReturned() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 1000; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().HaveCount(1000); - } - - [Fact] - public async Task ChangeFeedEntry_HasSystemProperties() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - JObject entry = null!; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - entry = page.First(); - } - - entry["_ts"].Should().NotBeNull(); - entry["_etag"].Should().NotBeNull(); - entry["_rid"].Should().NotBeNull(); - entry["_self"].Should().NotBeNull(); - } - - [Fact] - public async Task CheckpointBasedIterator_EmptyContainer_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var iter = container.GetChangeFeedIterator(0); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - changes.Should().BeEmpty(); - } + [Fact] + public async Task ReCreateAfterDelete_IncrementalShowsOnlyNewVersion() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + // Incremental deduplicates to latest version + changes.Should().ContainSingle().Which.Name.Should().Be("V2"); + } + + [Fact] + public async Task ItemLevelTTL_StillAppearsInChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Short", ttl = 1 }), + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task LargeChangeFeed_1000Entries_AllReturned() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 1000; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().HaveCount(1000); + } + + [Fact] + public async Task ChangeFeedEntry_HasSystemProperties() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + JObject entry = null!; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + entry = page.First(); + } + + entry["_ts"].Should().NotBeNull(); + entry["_etag"].Should().NotBeNull(); + entry["_rid"].Should().NotBeNull(); + entry["_self"].Should().NotBeNull(); + } + + [Fact] + public async Task CheckpointBasedIterator_EmptyContainer_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var iter = container.GetChangeFeedIterator(0); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + changes.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -501,46 +501,46 @@ public async Task CheckpointBasedIterator_EmptyContainer_ReturnsEmpty() public class ChangeFeedStreamIteratorDeepTests { - [Fact] - public async Task StreamIterator_MultiplePagesExhausted_HasMoreResultsFalse() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var hasContent = false; - while (iter.HasMoreResults) - { - using var response = await iter.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - hasContent = true; - } - - hasContent.Should().BeTrue(); - } - - [Fact] - public async Task StreamIterator_RequestChargeHeader_Present() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var iter = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - using var response = await iter.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - response.Headers.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - } + [Fact] + public async Task StreamIterator_MultiplePagesExhausted_HasMoreResultsFalse() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var hasContent = false; + while (iter.HasMoreResults) + { + using var response = await iter.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + hasContent = true; + } + + hasContent.Should().BeTrue(); + } + + [Fact] + public async Task StreamIterator_RequestChargeHeader_Present() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var iter = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + using var response = await iter.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + response.Headers.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -549,33 +549,33 @@ await container.CreateItemAsync( public class ChangeFeedProcessorDeliverySemanticsTests { - [Fact] - public async Task Processor_ItemsHaveSystemProperties() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var received = new List(); - - var processor = container.GetChangeFeedProcessorBuilder( - "proc", (ctx, changes, ct) => - { - received.AddRange(changes); - return Task.CompletedTask; - }).WithInMemoryLeaseContainer().Build(); - - await processor.StartAsync(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - await processor.StopAsync(); - - received.Should().ContainSingle(); - var item = received[0]; - item["_ts"].Should().NotBeNull(); - item["_etag"].Should().NotBeNull(); - } + [Fact] + public async Task Processor_ItemsHaveSystemProperties() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var received = new List(); + + var processor = container.GetChangeFeedProcessorBuilder( + "proc", (ctx, changes, ct) => + { + received.AddRange(changes); + return Task.CompletedTask; + }).WithInMemoryLeaseContainer().Build(); + + await processor.StartAsync(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + await processor.StopAsync(); + + received.Should().ContainSingle(); + var item = received[0]; + item["_ts"].Should().NotBeNull(); + item["_etag"].Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -584,99 +584,99 @@ await container.CreateItemAsync( public class ChangeFeedStreamProcessorBugFixTests { - [Fact] - public async Task StreamProcessor_ShouldDeduplicate_DeliversOnlyLatestVersion() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var streams = new List(); - - var processor = container.GetChangeFeedProcessorBuilder( - "proc", (ChangeFeedProcessorContext ctx, Stream stream, CancellationToken ct) => - { - using var ms = new MemoryStream(); - stream.CopyTo(ms); - streams.Add(ms.ToArray()); - return Task.CompletedTask; - }).WithInMemoryLeaseContainer().Build(); - - await processor.StartAsync(); - - // Create item then upsert 3 times - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - await processor.StopAsync(); - - // Combine all delivered streams and parse - var allItems = new List(); - foreach (var data in streams) - { - var json = System.Text.Encoding.UTF8.GetString(data); - var parsed = JToken.Parse(json); - if (parsed is JArray arr) - { - allItems.AddRange(arr.OfType()); - } - else if (parsed is JObject obj && obj["Documents"] is JArray docs) - { - allItems.AddRange(docs.OfType()); - } - } - - // After deduplication, item "1" should appear only in its latest version - var finalVersions = allItems - .GroupBy(i => i["id"]?.ToString()) - .Select(g => g.Last()) - .ToList(); - - finalVersions.Should().ContainSingle(); - finalVersions[0]["name"]!.ToString().Should().Be("V3"); - } - - [Fact] - public async Task StreamProcessor_ShouldDeliverDocumentsEnvelope() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var streams = new List(); - - var processor = container.GetChangeFeedProcessorBuilder( - "proc", (ChangeFeedProcessorContext ctx, Stream stream, CancellationToken ct) => - { - using var reader = new StreamReader(stream); - streams.Add(reader.ReadToEnd()); - return Task.CompletedTask; - }).WithInMemoryLeaseContainer().Build(); - - await processor.StartAsync(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - await processor.StopAsync(); - - streams.Should().NotBeEmpty(); - // Parse the delivered JSON — should either be an array or an envelope with Documents - var lastStream = streams.Last(); - var parsed = JToken.Parse(lastStream); - - // Accept both formats for now — the test verifies the processor delivers parseable JSON - if (parsed is JArray arr) - { - arr.Should().NotBeEmpty(); - } - else if (parsed is JObject obj) - { - obj["Documents"].Should().NotBeNull(); - } - } + [Fact] + public async Task StreamProcessor_ShouldDeduplicate_DeliversOnlyLatestVersion() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var streams = new List(); + + var processor = container.GetChangeFeedProcessorBuilder( + "proc", (ChangeFeedProcessorContext ctx, Stream stream, CancellationToken ct) => + { + using var ms = new MemoryStream(); + stream.CopyTo(ms); + streams.Add(ms.ToArray()); + return Task.CompletedTask; + }).WithInMemoryLeaseContainer().Build(); + + await processor.StartAsync(); + + // Create item then upsert 3 times + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + await processor.StopAsync(); + + // Combine all delivered streams and parse + var allItems = new List(); + foreach (var data in streams) + { + var json = System.Text.Encoding.UTF8.GetString(data); + var parsed = JToken.Parse(json); + if (parsed is JArray arr) + { + allItems.AddRange(arr.OfType()); + } + else if (parsed is JObject obj && obj["Documents"] is JArray docs) + { + allItems.AddRange(docs.OfType()); + } + } + + // After deduplication, item "1" should appear only in its latest version + var finalVersions = allItems + .GroupBy(i => i["id"]?.ToString()) + .Select(g => g.Last()) + .ToList(); + + finalVersions.Should().ContainSingle(); + finalVersions[0]["name"]!.ToString().Should().Be("V3"); + } + + [Fact] + public async Task StreamProcessor_ShouldDeliverDocumentsEnvelope() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var streams = new List(); + + var processor = container.GetChangeFeedProcessorBuilder( + "proc", (ChangeFeedProcessorContext ctx, Stream stream, CancellationToken ct) => + { + using var reader = new StreamReader(stream); + streams.Add(reader.ReadToEnd()); + return Task.CompletedTask; + }).WithInMemoryLeaseContainer().Build(); + + await processor.StartAsync(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + await processor.StopAsync(); + + streams.Should().NotBeEmpty(); + // Parse the delivered JSON — should either be an array or an envelope with Documents + var lastStream = streams.Last(); + var parsed = JToken.Parse(lastStream); + + // Accept both formats for now — the test verifies the processor delivers parseable JSON + if (parsed is JArray arr) + { + arr.Should().NotBeEmpty(); + } + else if (parsed is JObject obj) + { + obj["Documents"].Should().NotBeNull(); + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedTests.cs index 176a476..7d67870 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ChangeFeedTests.cs @@ -1,473 +1,473 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; using Xunit; -using System.Text; namespace CosmosDB.InMemoryEmulator.Tests; public class ChangeFeedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_AfterCreate_ContainsCreatedItem() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - results.Should().Contain(item => item.Id == "1"); - } - - [Fact] - public async Task ChangeFeed_AfterUpsert_ContainsUpdatedItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alicia" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - results.Should().Contain(item => item.Id == "1" && item.Name == "Alicia"); - } - - [Fact] - public async Task ChangeFeed_MultipleCreates_ContainsAllItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - results.Should().HaveCountGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task ChangeFeedStream_AfterCreate_ReturnsStream() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - if (response.StatusCode != HttpStatusCode.NotModified) - { - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - jObj["Documents"].Should().NotBeNull(); - } - } - } - - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB returns NotModified (304) for an empty - /// change feed. InMemoryContainer returns OK (200) with an empty result set. - /// - [Fact] - public async Task ChangeFeedIterator_EmptyContainer_ReturnsOkWithEmptyResults() - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().BeEmpty(); - } - - // ── Incremental change feed (change log) ── - - [Fact] - public async Task ChangeFeed_FromBeginning_ReturnsAllChangesInOrder() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FirstUpdated" }, - new PartitionKey("pk1")); - - var results = await ReadAllChangeFeed(); - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo("FirstUpdated", "Second"); - } - - [Fact] - public async Task ChangeFeed_FromNow_ReturnsOnlySubsequentChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var results = await ReadChangeFeedFrom(checkpoint); - - results.Should().ContainSingle().Which.Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_Replace_RecordsChange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", - new PartitionKey("pk1")); - - var results = await ReadAllChangeFeed(); - - results.Should().ContainSingle().Which.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task ChangeFeed_Patch_RecordsChange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Patched") }); - - var results = await ReadAllChangeFeed(); - - results.Should().ContainSingle().Which.Name.Should().Be("Patched"); - } - - [Fact] - public async Task ChangeFeed_Incremental_DeletedItem_IsFilteredOut() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var results = await ReadAllChangeFeed(); - - // Incremental mode filters out items whose last change is a delete - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeedProcessor_CanBeBuilt_AndStartedStopped() - { - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => Task.CompletedTask) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - await processor.StopAsync(); - } - - [Fact] - public void ChangeFeedProcessor_BuilderMethodsReturnBuilder() - { - var builder = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => Task.CompletedTask); - - var chained = builder - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .WithPollInterval(TimeSpan.FromMilliseconds(50)) - .WithMaxItems(10); - - chained.Should().NotBeNull(); - } - - [Fact] - public async Task ChangeFeedProcessor_InvokesHandler_WhenItemsCreated() - { - var received = new List(); - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - received.AddRange(changes); - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Processor-Test" }, - new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - - await processor.StopAsync(); - - received.Should().ContainSingle().Which.Name.Should().Be("Processor-Test"); - } - - [Fact] - public async Task ChangeFeedProcessor_InvokesHandler_MultipleTimesForMultipleChanges() - { - var allReceived = new List(); - var secondBatchReceived = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - allReceived.AddRange(changes); - if (allReceived.Count >= 2) - { - secondBatchReceived.TrySetResult(true); - } - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondBatchReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); - - await processor.StopAsync(); - - allReceived.Should().HaveCountGreaterThanOrEqualTo(2); - allReceived.Should().Contain(doc => doc.Name == "First"); - allReceived.Should().Contain(doc => doc.Name == "Second"); - } - - [Fact] - public async Task ChangeFeedProcessor_LegacyChangesHandler_InvokesHandler() - { - var received = new List(); - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (IReadOnlyCollection changes, CancellationToken token) => - { - received.AddRange(changes); - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Legacy-Test" }, - new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - - await processor.StopAsync(); - - received.Should().ContainSingle().Which.Name.Should().Be("Legacy-Test"); - } - - [Fact] - public async Task ChangeFeedProcessor_Context_HasLeaseToken() - { - ChangeFeedProcessorContext capturedContext = null!; - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - capturedContext = context; - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Context-Test" }, - new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - - await processor.StopAsync(); - - capturedContext.Should().NotBeNull(); - capturedContext.LeaseToken.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ChangeFeedProcessor_StopAsync_StopsPolling() - { - var callCount = 0; - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - Interlocked.Increment(ref callCount); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StopTest" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromMilliseconds(500)); - await processor.StopAsync(); - - var countAfterStop = callCount; - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterStop" }, - new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromMilliseconds(300)); - - callCount.Should().Be(countAfterStop); - } - - [Fact] - public async Task GetFeedRanges_ReturnsSingleRange() - { - var ranges = await _container.GetFeedRangesAsync(); - - ranges.Should().ContainSingle(); - } - - // ── Helpers ── - - private async Task> ReadAllChangeFeed() - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - return results; - } - - private async Task> ReadChangeFeedFrom(long checkpoint) - { - var iterator = _container.GetChangeFeedIterator(checkpoint); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) - { - break; - } - - results.AddRange(response); - } - - return results; - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_AfterCreate_ContainsCreatedItem() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + results.Should().Contain(item => item.Id == "1"); + } + + [Fact] + public async Task ChangeFeed_AfterUpsert_ContainsUpdatedItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alicia" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + results.Should().Contain(item => item.Id == "1" && item.Name == "Alicia"); + } + + [Fact] + public async Task ChangeFeed_MultipleCreates_ContainsAllItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + results.Should().HaveCountGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task ChangeFeedStream_AfterCreate_ReturnsStream() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + if (response.StatusCode != HttpStatusCode.NotModified) + { + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + jObj["Documents"].Should().NotBeNull(); + } + } + } + + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB returns NotModified (304) for an empty + /// change feed. InMemoryContainer returns OK (200) with an empty result set. + /// + [Fact] + public async Task ChangeFeedIterator_EmptyContainer_ReturnsOkWithEmptyResults() + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().BeEmpty(); + } + + // ── Incremental change feed (change log) ── + + [Fact] + public async Task ChangeFeed_FromBeginning_ReturnsAllChangesInOrder() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FirstUpdated" }, + new PartitionKey("pk1")); + + var results = await ReadAllChangeFeed(); + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo("FirstUpdated", "Second"); + } + + [Fact] + public async Task ChangeFeed_FromNow_ReturnsOnlySubsequentChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var results = await ReadChangeFeedFrom(checkpoint); + + results.Should().ContainSingle().Which.Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_Replace_RecordsChange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", + new PartitionKey("pk1")); + + var results = await ReadAllChangeFeed(); + + results.Should().ContainSingle().Which.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task ChangeFeed_Patch_RecordsChange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Patched") }); + + var results = await ReadAllChangeFeed(); + + results.Should().ContainSingle().Which.Name.Should().Be("Patched"); + } + + [Fact] + public async Task ChangeFeed_Incremental_DeletedItem_IsFilteredOut() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var results = await ReadAllChangeFeed(); + + // Incremental mode filters out items whose last change is a delete + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeedProcessor_CanBeBuilt_AndStartedStopped() + { + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => Task.CompletedTask) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + await processor.StopAsync(); + } + + [Fact] + public void ChangeFeedProcessor_BuilderMethodsReturnBuilder() + { + var builder = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => Task.CompletedTask); + + var chained = builder + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .WithPollInterval(TimeSpan.FromMilliseconds(50)) + .WithMaxItems(10); + + chained.Should().NotBeNull(); + } + + [Fact] + public async Task ChangeFeedProcessor_InvokesHandler_WhenItemsCreated() + { + var received = new List(); + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + received.AddRange(changes); + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Processor-Test" }, + new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + await processor.StopAsync(); + + received.Should().ContainSingle().Which.Name.Should().Be("Processor-Test"); + } + + [Fact] + public async Task ChangeFeedProcessor_InvokesHandler_MultipleTimesForMultipleChanges() + { + var allReceived = new List(); + var secondBatchReceived = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + allReceived.AddRange(changes); + if (allReceived.Count >= 2) + { + secondBatchReceived.TrySetResult(true); + } + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondBatchReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + await processor.StopAsync(); + + allReceived.Should().HaveCountGreaterThanOrEqualTo(2); + allReceived.Should().Contain(doc => doc.Name == "First"); + allReceived.Should().Contain(doc => doc.Name == "Second"); + } + + [Fact] + public async Task ChangeFeedProcessor_LegacyChangesHandler_InvokesHandler() + { + var received = new List(); + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (IReadOnlyCollection changes, CancellationToken token) => + { + received.AddRange(changes); + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Legacy-Test" }, + new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + await processor.StopAsync(); + + received.Should().ContainSingle().Which.Name.Should().Be("Legacy-Test"); + } + + [Fact] + public async Task ChangeFeedProcessor_Context_HasLeaseToken() + { + ChangeFeedProcessorContext capturedContext = null!; + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + capturedContext = context; + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Context-Test" }, + new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + await processor.StopAsync(); + + capturedContext.Should().NotBeNull(); + capturedContext.LeaseToken.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ChangeFeedProcessor_StopAsync_StopsPolling() + { + var callCount = 0; + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + Interlocked.Increment(ref callCount); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StopTest" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromMilliseconds(500)); + await processor.StopAsync(); + + var countAfterStop = callCount; + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterStop" }, + new PartitionKey("pk1")); + await Task.Delay(TimeSpan.FromMilliseconds(300)); + + callCount.Should().Be(countAfterStop); + } + + [Fact] + public async Task GetFeedRanges_ReturnsSingleRange() + { + var ranges = await _container.GetFeedRangesAsync(); + + ranges.Should().ContainSingle(); + } + + // ── Helpers ── + + private async Task> ReadAllChangeFeed() + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + return results; + } + + private async Task> ReadChangeFeedFrom(long checkpoint) + { + var iterator = _container.GetChangeFeedIterator(checkpoint); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) + { + break; + } + + results.AddRange(response); + } + + return results; + } } @@ -480,596 +480,596 @@ private async Task> ReadChangeFeedFrom(long checkpoint) /// public class ChangeFeedStreamProcessorDivergentTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task ChangeFeedProcessorBuilder_StreamHandler_ShouldInvokeHandler() - { - var handlerCalled = new TaskCompletionSource(); - Container.ChangeFeedStreamHandler handler = - (ChangeFeedProcessorContext context, Stream changes, CancellationToken token) => - { - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }; + [Fact] + public async Task ChangeFeedProcessorBuilder_StreamHandler_ShouldInvokeHandler() + { + var handlerCalled = new TaskCompletionSource(); + Container.ChangeFeedStreamHandler handler = + (ChangeFeedProcessorContext context, Stream changes, CancellationToken token) => + { + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }; - var processor = _container.GetChangeFeedProcessorBuilder("test-processor", handler) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); + var processor = _container.GetChangeFeedProcessorBuilder("test-processor", handler) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); - await processor.StartAsync(); + await processor.StartAsync(); - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamTest" }, - new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamTest" }, + new PartitionKey("pk1")); - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(2))); - await processor.StopAsync(); + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(2))); + await processor.StopAsync(); - handlerCalled.Task.IsCompleted.Should().BeTrue(); - } + handlerCalled.Task.IsCompleted.Should().BeTrue(); + } } public class ChangeFeedFeedRangeDivergentBehaviorTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; - - /// - /// FeedRange filtering is now supported. When FeedRangeCount > 1, - /// ChangeFeedStartFrom.Beginning(feedRange) scopes the change feed to the specified range. - /// Iterating all ranges and unioning results yields the full dataset. - /// With the default FeedRangeCount=1, the single range covers the entire hash space and - /// Beginning() without a FeedRange returns all changes. - /// - [Fact] - public async Task ChangeFeed_FeedRange_ScopesCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all ranges returns all items - allResults.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; + + /// + /// FeedRange filtering is now supported. When FeedRangeCount > 1, + /// ChangeFeedStartFrom.Beginning(feedRange) scopes the change feed to the specified range. + /// Iterating all ranges and unioning results yields the full dataset. + /// With the default FeedRangeCount=1, the single range covers the entire hash space and + /// Beginning() without a FeedRange returns all changes. + /// + [Fact] + public async Task ChangeFeed_FeedRange_ScopesCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all ranges returns all items + allResults.Should().HaveCount(2); + } } public class ChangeFeedProcessorDivergentBehaviorTests { - /// - /// BEHAVIORAL DIFFERENCE: The real Cosmos SDK's ChangeFeedProcessorBuilder.WithLeaseContainer() - /// internally casts the provided Container to ContainerInternal (an internal abstract class that - /// extends Container). InMemoryContainer only extends the public Container class and cannot be cast - /// to ContainerInternal, causing an InvalidCastException. - /// - /// This means the ChangeFeedProcessorBuilder flow (WithLeaseContainer + Build + Start/Stop) cannot - /// be used with InMemoryContainer for lease management. The InMemoryChangeFeedProcessor provides a - /// separate mechanism for testing change feed processor scenarios without requiring the internal - /// SDK types. - /// - /// Impact: Tests that build a ChangeFeedProcessor using GetChangeFeedProcessorBuilder and then call - /// WithLeaseContainer will fail. Use InMemoryChangeFeedProcessor directly for change feed testing. - /// - [Fact] - public void ChangeFeedProcessorBuilder_WithLeaseContainer_ThrowsInvalidCast() - { - var container = new InMemoryContainer("source", "/partitionKey"); - var leaseContainer = new InMemoryContainer("leases", "/id"); - - var act = () => container.GetChangeFeedProcessorBuilder( - "processor", - (ChangeFeedProcessorContext ctx, IReadOnlyCollection changes, CancellationToken ct) => - Task.CompletedTask) - .WithInstanceName("instance") - .WithLeaseContainer(leaseContainer); - - act.Should().Throw(); - } + /// + /// BEHAVIORAL DIFFERENCE: The real Cosmos SDK's ChangeFeedProcessorBuilder.WithLeaseContainer() + /// internally casts the provided Container to ContainerInternal (an internal abstract class that + /// extends Container). InMemoryContainer only extends the public Container class and cannot be cast + /// to ContainerInternal, causing an InvalidCastException. + /// + /// This means the ChangeFeedProcessorBuilder flow (WithLeaseContainer + Build + Start/Stop) cannot + /// be used with InMemoryContainer for lease management. The InMemoryChangeFeedProcessor provides a + /// separate mechanism for testing change feed processor scenarios without requiring the internal + /// SDK types. + /// + /// Impact: Tests that build a ChangeFeedProcessor using GetChangeFeedProcessorBuilder and then call + /// WithLeaseContainer will fail. Use InMemoryChangeFeedProcessor directly for change feed testing. + /// + [Fact] + public void ChangeFeedProcessorBuilder_WithLeaseContainer_ThrowsInvalidCast() + { + var container = new InMemoryContainer("source", "/partitionKey"); + var leaseContainer = new InMemoryContainer("leases", "/id"); + + var act = () => container.GetChangeFeedProcessorBuilder( + "processor", + (ChangeFeedProcessorContext ctx, IReadOnlyCollection changes, CancellationToken ct) => + Task.CompletedTask) + .WithInstanceName("instance") + .WithLeaseContainer(leaseContainer); + + act.Should().Throw(); + } } public class ChangeFeedManualCheckpointDivergentBehaviorTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - /// - /// Manual checkpoint processor now works. The handler receives changes and can call - /// checkpointAsync to save progress. The processor polls the in-memory change feed - /// every 50ms, same as the automatic checkpoint variant. - /// - [Fact] - public async Task ManualCheckpoint_ProcessorInvokesHandler() - { - var receivedChanges = new List(); - var checkpointCalled = false; - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "processor", - async (context, changes, checkpointAsync, ct) => - { - receivedChanges.AddRange(changes); - checkpointCalled = true; - await checkpointAsync(); - }) - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ManualCp" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - await processor.StopAsync(); - - receivedChanges.Should().ContainSingle().Which.Name.Should().Be("ManualCp"); - checkpointCalled.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + /// + /// Manual checkpoint processor now works. The handler receives changes and can call + /// checkpointAsync to save progress. The processor polls the in-memory change feed + /// every 50ms, same as the automatic checkpoint variant. + /// + [Fact] + public async Task ManualCheckpoint_ProcessorInvokesHandler() + { + var receivedChanges = new List(); + var checkpointCalled = false; + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "processor", + async (context, changes, checkpointAsync, ct) => + { + receivedChanges.AddRange(changes); + checkpointCalled = true; + await checkpointAsync(); + }) + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ManualCp" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + await processor.StopAsync(); + + receivedChanges.Should().ContainSingle().Which.Name.Should().Be("ManualCp"); + checkpointCalled.Should().BeTrue(); + } } public class ChangeFeedManualCheckpointStreamTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ManualCheckpoint_StreamHandler_InvokesHandlerAndCheckpoints() - { - var handlerCalled = new TaskCompletionSource(); - var checkpointCalled = false; - string receivedJson = null!; - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "stream-processor", - async (context, stream, checkpointAsync, ct) => - { - using var reader = new StreamReader(stream); - receivedJson = await reader.ReadToEndAsync(ct); - checkpointCalled = true; - await checkpointAsync(); - handlerCalled.TrySetResult(true); - }) - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamMC" }, - new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(2))); - await processor.StopAsync(); - - handlerCalled.Task.IsCompleted.Should().BeTrue(); - checkpointCalled.Should().BeTrue(); - receivedJson.Should().Contain("StreamMC"); - } - - [Fact] - public async Task ManualCheckpoint_WithoutCallingCheckpoint_RedeliversChanges() - { - var deliveryCount = 0; - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "processor", - (context, changes, checkpointAsync, ct) => - { - Interlocked.Increment(ref deliveryCount); - // Deliberately NOT calling checkpointAsync — changes should be redelivered - return Task.CompletedTask; - }) - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Redelivery" }, - new PartitionKey("pk1")); - - // Wait until at least 2 deliveries occur (poll interval is 50ms, so this - // should happen quickly, but CI runners may be slow — use generous timeout) - var deadline = DateTime.UtcNow.AddSeconds(5); - while (Volatile.Read(ref deliveryCount) < 2 && DateTime.UtcNow < deadline) - await Task.Delay(50); - - await processor.StopAsync(); - - deliveryCount.Should().BeGreaterThan(1, - "when checkpoint is not called, the same changes should be redelivered on subsequent polls"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ManualCheckpoint_StreamHandler_InvokesHandlerAndCheckpoints() + { + var handlerCalled = new TaskCompletionSource(); + var checkpointCalled = false; + string receivedJson = null!; + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "stream-processor", + async (context, stream, checkpointAsync, ct) => + { + using var reader = new StreamReader(stream); + receivedJson = await reader.ReadToEndAsync(ct); + checkpointCalled = true; + await checkpointAsync(); + handlerCalled.TrySetResult(true); + }) + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamMC" }, + new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(2))); + await processor.StopAsync(); + + handlerCalled.Task.IsCompleted.Should().BeTrue(); + checkpointCalled.Should().BeTrue(); + receivedJson.Should().Contain("StreamMC"); + } + + [Fact] + public async Task ManualCheckpoint_WithoutCallingCheckpoint_RedeliversChanges() + { + var deliveryCount = 0; + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "processor", + (context, changes, checkpointAsync, ct) => + { + Interlocked.Increment(ref deliveryCount); + // Deliberately NOT calling checkpointAsync — changes should be redelivered + return Task.CompletedTask; + }) + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Redelivery" }, + new PartitionKey("pk1")); + + // Wait until at least 2 deliveries occur (poll interval is 50ms, so this + // should happen quickly, but CI runners may be slow — use generous timeout) + var deadline = DateTime.UtcNow.AddSeconds(5); + while (Volatile.Read(ref deliveryCount) < 2 && DateTime.UtcNow < deadline) + await Task.Delay(50); + + await processor.StopAsync(); + + deliveryCount.Should().BeGreaterThan(1, + "when checkpoint is not called, the same changes should be redelivered on subsequent polls"); + } } public class ChangeFeedGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_FromCheckpoint_ReturnsOnlyNewChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_OrderPreserved_AcrossWrites() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FirstUpdated" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results[0].Name.Should().Be("First"); - results[1].Name.Should().Be("Second"); - results[2].Name.Should().Be("FirstUpdated"); - } - - [Fact] - public async Task ChangeFeed_Delete_AppearsTombstoneViaCheckpoint() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpointAfterCreate); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - results[0]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_Patch_RecordsChange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Patched"); - } - - [Fact] - public async Task ChangeFeed_StreamIterator_ReturnsJsonEnvelope() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - if (response.StatusCode != HttpStatusCode.NotModified) - { - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - jObj["Documents"].Should().NotBeNull(); - ((JArray)jObj["Documents"]!).Should().HaveCountGreaterThan(0); - } - } - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_FromCheckpoint_ReturnsOnlyNewChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_OrderPreserved_AcrossWrites() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FirstUpdated" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results[0].Name.Should().Be("First"); + results[1].Name.Should().Be("Second"); + results[2].Name.Should().Be("FirstUpdated"); + } + + [Fact] + public async Task ChangeFeed_Delete_AppearsTombstoneViaCheckpoint() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpointAfterCreate); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + results[0]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_Patch_RecordsChange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Patched"); + } + + [Fact] + public async Task ChangeFeed_StreamIterator_ReturnsJsonEnvelope() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + if (response.StatusCode != HttpStatusCode.NotModified) + { + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + jObj["Documents"].Should().NotBeNull(); + ((JArray)jObj["Documents"]!).Should().HaveCountGreaterThan(0); + } + } + } } public class ChangeFeedAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void GetChangeFeedEstimator_ReturnsNonNull() - { - var leaseContainer = new InMemoryContainer("leases", "/id"); - var estimator = _container.GetChangeFeedEstimator("estimator", leaseContainer); - estimator.Should().NotBeNull(); - } - - [Fact] - public void GetChangeFeedEstimatorBuilder_ReturnsBuilder() - { - var leaseContainer = new InMemoryContainer("leases", "/id"); - var builder = _container.GetChangeFeedEstimatorBuilder( - "estimator", - (long estimation, CancellationToken ct) => Task.CompletedTask); - builder.Should().NotBeNull(); - } - - [Fact] - public async Task GetChangeFeedStreamIterator_FromBeginning_ReturnsStream() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - iterator.HasMoreResults.Should().BeTrue(); - - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public void GetChangeFeedEstimator_ReturnsNonNull() + { + var leaseContainer = new InMemoryContainer("leases", "/id"); + var estimator = _container.GetChangeFeedEstimator("estimator", leaseContainer); + estimator.Should().NotBeNull(); + } + + [Fact] + public void GetChangeFeedEstimatorBuilder_ReturnsBuilder() + { + var leaseContainer = new InMemoryContainer("leases", "/id"); + var builder = _container.GetChangeFeedEstimatorBuilder( + "estimator", + (long estimation, CancellationToken ct) => Task.CompletedTask); + builder.Should().NotBeNull(); + } + + [Fact] + public async Task GetChangeFeedStreamIterator_FromBeginning_ReturnsStream() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + iterator.HasMoreResults.Should().BeTrue(); + + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError); + } } public class ChangeFeedGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_IncrementalMode_OnlyLatestVersionPerItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Incremental should only return latest version - results.Should().ContainSingle().Which.Name.Should().Be("V3"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_ViaCheckpoint() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - - // Checkpoint-based iterator returns all versions (including intermediates) - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Name.Should().Be("V1"); - results[1].Name.Should().Be("V2"); - } - - [Fact] - public async Task ChangeFeed_DeletedItems_InFullFidelity_ShowsTombstone() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Checkpoint-based iterator includes delete tombstones - results.Should().ContainSingle(); - var tombstone = results[0]; - tombstone["id"]!.Value().Should().Be("1"); - tombstone["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_FromTimestamp_FiltersOlderItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - await Task.Delay(100); - var checkpoint = DateTime.UtcNow; - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(checkpoint), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("New"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_IncrementalMode_OnlyLatestVersionPerItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Incremental should only return latest version + results.Should().ContainSingle().Which.Name.Should().Be("V3"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_ViaCheckpoint() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + + // Checkpoint-based iterator returns all versions (including intermediates) + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Name.Should().Be("V1"); + results[1].Name.Should().Be("V2"); + } + + [Fact] + public async Task ChangeFeed_DeletedItems_InFullFidelity_ShowsTombstone() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Checkpoint-based iterator includes delete tombstones + results.Should().ContainSingle(); + var tombstone = results[0]; + tombstone["id"]!.Value().Should().Be("1"); + tombstone["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_FromTimestamp_FiltersOlderItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + await Task.Delay(100); + var checkpoint = DateTime.UtcNow; + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(checkpoint), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("New"); + } } public class ChangeFeedGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_PageSizeHint_LimitsResults() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 2 }); - - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().BeLessThanOrEqualTo(2); - } - - [Fact] - public async Task ChangeFeed_FromFeedRange_ScopesToRange() - { - _container.FeedRangeCount = 4; - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Each range returns a subset; union of all ranges returns all items - allResults.Should().HaveCount(2); - - // At least one range should return fewer than all items (proving scoping works) - var perRangeCounts = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - var rangeResults = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - rangeResults.AddRange(page); - } - perRangeCounts.Add(rangeResults.Count); - } - perRangeCounts.Should().Contain(c => c < 2, "at least one range should scope to a subset"); - } - - [Fact] - public async Task ChangeFeed_ManualCheckpoint_InvokesHandler() - { - var invoked = false; - var builder = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "processor", - async (context, changes, checkpointAsync, ct) => - { - invoked = true; - await checkpointAsync(); - }); - - var processor = builder.WithInstanceName("instance").WithInMemoryLeaseContainer().Build(); - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(500); - await processor.StopAsync(); - - invoked.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_PageSizeHint_LimitsResults() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 2 }); + + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().BeLessThanOrEqualTo(2); + } + + [Fact] + public async Task ChangeFeed_FromFeedRange_ScopesToRange() + { + _container.FeedRangeCount = 4; + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Each range returns a subset; union of all ranges returns all items + allResults.Should().HaveCount(2); + + // At least one range should return fewer than all items (proving scoping works) + var perRangeCounts = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + var rangeResults = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + rangeResults.AddRange(page); + } + perRangeCounts.Add(rangeResults.Count); + } + perRangeCounts.Should().Contain(c => c < 2, "at least one range should scope to a subset"); + } + + [Fact] + public async Task ChangeFeed_ManualCheckpoint_InvokesHandler() + { + var invoked = false; + var builder = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "processor", + async (context, changes, checkpointAsync, ct) => + { + invoked = true; + await checkpointAsync(); + }); + + var processor = builder.WithInstanceName("instance").WithInMemoryLeaseContainer().Build(); + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(500); + await processor.StopAsync(); + + invoked.Should().BeTrue(); + } } @@ -1079,144 +1079,144 @@ await _container.CreateItemAsync( public class ChangeFeedProcessorHandlerExceptionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - /// - /// Bug fix: If the handler throws an exception, the processor should continue polling - /// rather than crashing. Real Cosmos DB retries the batch. The in-memory processor should - /// catch handler exceptions and NOT advance the checkpoint, causing the same batch to be - /// redelivered on the next poll cycle. - /// - [Fact] - public async Task ChangeFeedProcessor_HandlerThrows_ContinuesPolling() - { - var callCount = 0; - var secondCallReceived = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - var count = Interlocked.Increment(ref callCount); - if (count == 1) - throw new InvalidOperationException("Simulated handler failure"); - secondCallReceived.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Resilience" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - // Handler should have been called at least twice — first call threw, processor retried - callCount.Should().BeGreaterThanOrEqualTo(2, - "processor should continue polling after handler exception and redeliver the batch"); - } - - [Fact] - public async Task ChangeFeedStreamProcessor_HandlerThrows_ContinuesPolling() - { - var callCount = 0; - var secondCallReceived = new TaskCompletionSource(); - - Container.ChangeFeedStreamHandler handler = - (context, changes, token) => - { - var count = Interlocked.Increment(ref callCount); - if (count == 1) - throw new InvalidOperationException("Simulated handler failure"); - secondCallReceived.TrySetResult(true); - return Task.CompletedTask; - }; - - var processor = _container.GetChangeFeedProcessorBuilder("test-processor", handler) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamResilience" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - callCount.Should().BeGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task ManualCheckpointProcessor_HandlerThrows_ContinuesPolling() - { - var callCount = 0; - var secondCallReceived = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "test-processor", - (context, changes, checkpointAsync, ct) => - { - var count = Interlocked.Increment(ref callCount); - if (count == 1) - throw new InvalidOperationException("Simulated handler failure"); - secondCallReceived.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ManualResilience" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - callCount.Should().BeGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task ManualCheckpointStreamProcessor_HandlerThrows_ContinuesPolling() - { - var callCount = 0; - var secondCallReceived = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "test-processor", - (context, stream, checkpointAsync, ct) => - { - var count = Interlocked.Increment(ref callCount); - if (count == 1) - throw new InvalidOperationException("Simulated handler failure"); - secondCallReceived.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "MCStreamResilience" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - callCount.Should().BeGreaterThanOrEqualTo(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + /// + /// Bug fix: If the handler throws an exception, the processor should continue polling + /// rather than crashing. Real Cosmos DB retries the batch. The in-memory processor should + /// catch handler exceptions and NOT advance the checkpoint, causing the same batch to be + /// redelivered on the next poll cycle. + /// + [Fact] + public async Task ChangeFeedProcessor_HandlerThrows_ContinuesPolling() + { + var callCount = 0; + var secondCallReceived = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + var count = Interlocked.Increment(ref callCount); + if (count == 1) + throw new InvalidOperationException("Simulated handler failure"); + secondCallReceived.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Resilience" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + // Handler should have been called at least twice — first call threw, processor retried + callCount.Should().BeGreaterThanOrEqualTo(2, + "processor should continue polling after handler exception and redeliver the batch"); + } + + [Fact] + public async Task ChangeFeedStreamProcessor_HandlerThrows_ContinuesPolling() + { + var callCount = 0; + var secondCallReceived = new TaskCompletionSource(); + + Container.ChangeFeedStreamHandler handler = + (context, changes, token) => + { + var count = Interlocked.Increment(ref callCount); + if (count == 1) + throw new InvalidOperationException("Simulated handler failure"); + secondCallReceived.TrySetResult(true); + return Task.CompletedTask; + }; + + var processor = _container.GetChangeFeedProcessorBuilder("test-processor", handler) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamResilience" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + callCount.Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task ManualCheckpointProcessor_HandlerThrows_ContinuesPolling() + { + var callCount = 0; + var secondCallReceived = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "test-processor", + (context, changes, checkpointAsync, ct) => + { + var count = Interlocked.Increment(ref callCount); + if (count == 1) + throw new InvalidOperationException("Simulated handler failure"); + secondCallReceived.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ManualResilience" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + callCount.Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task ManualCheckpointStreamProcessor_HandlerThrows_ContinuesPolling() + { + var callCount = 0; + var secondCallReceived = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "test-processor", + (context, stream, checkpointAsync, ct) => + { + var count = Interlocked.Increment(ref callCount); + if (count == 1) + throw new InvalidOperationException("Simulated handler failure"); + secondCallReceived.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "MCStreamResilience" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondCallReceived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + callCount.Should().BeGreaterThanOrEqualTo(2); + } } @@ -1226,136 +1226,136 @@ await _container.CreateItemAsync( public class ChangeFeedIteratorLifecycleTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_FromBeginning_IsEager_DoesNotIncludeItemsAddedAfterCreation() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - // Add another item AFTER iterator creation - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Beginning() uses eager evaluation — only "Before" should appear - results.Should().ContainSingle().Which.Name.Should().Be("Before"); - } - - [Fact] - public async Task ChangeFeed_FromNow_IsLazy_IncludesItemsAddedAfterCreation() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); - - // Add another item AFTER iterator creation but BEFORE reading - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Now() uses lazy evaluation — "After" should appear - results.Should().ContainSingle().Which.Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_ContinuationToken_ResumesFromLastPosition() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 2 }); - - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().Be(2); - var continuationToken = firstPage.ContinuationToken; - continuationToken.Should().NotBeNullOrEmpty(); - - // Read remaining items via new pages - var remaining = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - remaining.AddRange(page); - } - - remaining.Should().HaveCount(3); - } - - [Fact] - public async Task ChangeFeed_ContinuationToken_NullWhenExhausted() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var page = await iterator.ReadNextAsync(); - page.ContinuationToken.Should().BeNull(); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task ChangeFeed_MultiplePages_PaginatesThroughAllItems() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 3 }); - - var allResults = new List(); - var pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - pageCount++; - } - - allResults.Should().HaveCount(10); - pageCount.Should().BeGreaterThan(1); - } - - [Fact] - public void ChangeFeed_EmptyFeed_HasMoreResults_IsFalse() - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - iterator.HasMoreResults.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_FromBeginning_IsEager_DoesNotIncludeItemsAddedAfterCreation() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + // Add another item AFTER iterator creation + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Beginning() uses eager evaluation — only "Before" should appear + results.Should().ContainSingle().Which.Name.Should().Be("Before"); + } + + [Fact] + public async Task ChangeFeed_FromNow_IsLazy_IncludesItemsAddedAfterCreation() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); + + // Add another item AFTER iterator creation but BEFORE reading + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Now() uses lazy evaluation — "After" should appear + results.Should().ContainSingle().Which.Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_ContinuationToken_ResumesFromLastPosition() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 2 }); + + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().Be(2); + var continuationToken = firstPage.ContinuationToken; + continuationToken.Should().NotBeNullOrEmpty(); + + // Read remaining items via new pages + var remaining = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + remaining.AddRange(page); + } + + remaining.Should().HaveCount(3); + } + + [Fact] + public async Task ChangeFeed_ContinuationToken_NullWhenExhausted() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var page = await iterator.ReadNextAsync(); + page.ContinuationToken.Should().BeNull(); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task ChangeFeed_MultiplePages_PaginatesThroughAllItems() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 3 }); + + var allResults = new List(); + var pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + pageCount++; + } + + allResults.Should().HaveCount(10); + pageCount.Should().BeGreaterThan(1); + } + + [Fact] + public void ChangeFeed_EmptyFeed_HasMoreResults_IsFalse() + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + iterator.HasMoreResults.Should().BeFalse(); + } } @@ -1365,121 +1365,121 @@ public void ChangeFeed_EmptyFeed_HasMoreResults_IsFalse() public class ChangeFeedStreamCrudTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_CreateItemStream_RecordsChange() - { - var json = """{"id":"1","partitionKey":"pk1","name":"StreamCreate"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - await _container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); - - var results = await ReadAllChangeFeed(); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("StreamCreate"); - } - - [Fact] - public async Task ChangeFeed_UpsertItemStream_RecordsChange() - { - var json = """{"id":"1","partitionKey":"pk1","name":"StreamUpsert"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - await _container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); - - var results = await ReadAllChangeFeed(); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("StreamUpsert"); - } - - [Fact] - public async Task ChangeFeed_ReplaceItemStream_RecordsChange() - { - var createJson = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - using var createStream = new MemoryStream(Encoding.UTF8.GetBytes(createJson)); - await _container.CreateItemStreamAsync(createStream, new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var replaceJson = """{"id":"1","partitionKey":"pk1","name":"StreamReplaced"}"""; - using var replaceStream = new MemoryStream(Encoding.UTF8.GetBytes(replaceJson)); - await _container.ReplaceItemStreamAsync(replaceStream, "1", new PartitionKey("pk1")); - - var results = await ReadChangeFeedFrom(checkpoint); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("StreamReplaced"); - } - - [Fact] - public async Task ChangeFeed_DeleteItemStream_RecordsTombstone() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - var results = await ReadChangeFeedFrom(checkpoint); - results.Should().ContainSingle(); - results[0]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_PatchItemStream_RecordsChange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "StreamPatched") }); - - var results = await ReadChangeFeedFrom(checkpoint); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("StreamPatched"); - } - - [Fact] - public async Task ChangeFeed_TransactionalBatch_RecordsAllChanges() - { - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "Batch1" }); - batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "Batch2" }); - await batch.ExecuteAsync(); - - var results = await ReadChangeFeedFrom(checkpoint); - results.Should().HaveCount(2); - } - - private async Task> ReadAllChangeFeed() - { - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - private async Task> ReadChangeFeedFrom(long checkpoint) - { - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_CreateItemStream_RecordsChange() + { + var json = """{"id":"1","partitionKey":"pk1","name":"StreamCreate"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + await _container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); + + var results = await ReadAllChangeFeed(); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("StreamCreate"); + } + + [Fact] + public async Task ChangeFeed_UpsertItemStream_RecordsChange() + { + var json = """{"id":"1","partitionKey":"pk1","name":"StreamUpsert"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + await _container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); + + var results = await ReadAllChangeFeed(); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("StreamUpsert"); + } + + [Fact] + public async Task ChangeFeed_ReplaceItemStream_RecordsChange() + { + var createJson = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + using var createStream = new MemoryStream(Encoding.UTF8.GetBytes(createJson)); + await _container.CreateItemStreamAsync(createStream, new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var replaceJson = """{"id":"1","partitionKey":"pk1","name":"StreamReplaced"}"""; + using var replaceStream = new MemoryStream(Encoding.UTF8.GetBytes(replaceJson)); + await _container.ReplaceItemStreamAsync(replaceStream, "1", new PartitionKey("pk1")); + + var results = await ReadChangeFeedFrom(checkpoint); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("StreamReplaced"); + } + + [Fact] + public async Task ChangeFeed_DeleteItemStream_RecordsTombstone() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + var results = await ReadChangeFeedFrom(checkpoint); + results.Should().ContainSingle(); + results[0]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_PatchItemStream_RecordsChange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "StreamPatched") }); + + var results = await ReadChangeFeedFrom(checkpoint); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("StreamPatched"); + } + + [Fact] + public async Task ChangeFeed_TransactionalBatch_RecordsAllChanges() + { + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "Batch1" }); + batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "Batch2" }); + await batch.ExecuteAsync(); + + var results = await ReadChangeFeedFrom(checkpoint); + results.Should().HaveCount(2); + } + + private async Task> ReadAllChangeFeed() + { + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> ReadChangeFeedFrom(long checkpoint) + { + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -1489,79 +1489,79 @@ private async Task> ReadChangeFeedFrom(long checkpoint) public class ChangeFeedPartitionKeyTests { - [Fact] - public async Task ChangeFeed_MultiplePartitionKeys_ReturnsAllChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C" }, new PartitionKey("pk3")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task ChangeFeed_CompositePartitionKey_RecordsCorrectly() - { - var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); - - var json = """{"id":"1","tenantId":"t1","userId":"u1","name":"Composite"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemStreamAsync(stream, pk); - - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Composite"); - } - - [Fact] - public async Task ChangeFeed_DeleteTombstone_CompositeKey_HasCorrectPkFields() - { - var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); - - var json = """{"id":"1","tenantId":"t1","userId":"u1","name":"ToDelete"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemStreamAsync(stream, pk); - - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemStreamAsync("1", pk); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - var tombstone = results[0]; - tombstone["_deleted"]!.Value().Should().BeTrue(); - tombstone["tenantId"]!.Value().Should().Be("t1"); - tombstone["userId"]!.Value().Should().Be("u1"); - } + [Fact] + public async Task ChangeFeed_MultiplePartitionKeys_ReturnsAllChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C" }, new PartitionKey("pk3")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task ChangeFeed_CompositePartitionKey_RecordsCorrectly() + { + var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); + + var json = """{"id":"1","tenantId":"t1","userId":"u1","name":"Composite"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemStreamAsync(stream, pk); + + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Composite"); + } + + [Fact] + public async Task ChangeFeed_DeleteTombstone_CompositeKey_HasCorrectPkFields() + { + var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); + + var json = """{"id":"1","tenantId":"t1","userId":"u1","name":"ToDelete"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemStreamAsync(stream, pk); + + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemStreamAsync("1", pk); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + var tombstone = results[0]; + tombstone["_deleted"]!.Value().Should().BeTrue(); + tombstone["tenantId"]!.Value().Should().Be("t1"); + tombstone["userId"]!.Value().Should().Be("u1"); + } } @@ -1571,87 +1571,87 @@ public async Task ChangeFeed_DeleteTombstone_CompositeKey_HasCorrectPkFields() public class ChangeFeedTombstoneTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_DeleteTombstone_HasTimestamp() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var tombstone = results.Should().ContainSingle().Subject; - tombstone["_ts"].Should().NotBeNull(); - tombstone["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task ChangeFeed_DeleteTombstone_HasPartitionKeyField() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var tombstone = results.Should().ContainSingle().Subject; - tombstone["partitionKey"]!.Value().Should().Be("pk1"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_CreateThenDelete_ShowsBothEntries() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0]["name"]!.Value().Should().Be("Created"); - results[1]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_DeleteNonexistentItem_NothingRecorded() - { - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var act = async () => - await _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - - var newCheckpoint = _container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().Be(checkpoint, "no change feed entry for a failed delete"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_DeleteTombstone_HasTimestamp() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var tombstone = results.Should().ContainSingle().Subject; + tombstone["_ts"].Should().NotBeNull(); + tombstone["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task ChangeFeed_DeleteTombstone_HasPartitionKeyField() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var tombstone = results.Should().ContainSingle().Subject; + tombstone["partitionKey"]!.Value().Should().Be("pk1"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_CreateThenDelete_ShowsBothEntries() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0]["name"]!.Value().Should().Be("Created"); + results[1]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_DeleteNonexistentItem_NothingRecorded() + { + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var act = async () => + await _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + + var newCheckpoint = _container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().Be(checkpoint, "no change feed entry for a failed delete"); + } } @@ -1661,168 +1661,168 @@ public async Task ChangeFeed_DeleteNonexistentItem_NothingRecorded() public class ChangeFeedProcessorAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeedProcessor_DoesNotSeeItemsCreatedBeforeStart() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "BeforeStart" }, - new PartitionKey("pk1")); - - var receivedNames = new List(); - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - receivedNames.AddRange(changes.Select(c => c.Name)); - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - // Add item after start - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterStart" }, - new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - receivedNames.Should().Contain("AfterStart"); - receivedNames.Should().NotContain("BeforeStart"); - } - - [Fact] - public async Task ChangeFeedProcessor_MultipleStartStop_NoDuplicates() - { - var allReceived = new List(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - allReceived.AddRange(changes.Select(c => c.Name)); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - // First cycle - await processor.StartAsync(); - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - await Task.Delay(300); - await processor.StopAsync(); - - // Second cycle - await processor.StartAsync(); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - await Task.Delay(300); - await processor.StopAsync(); - - allReceived.Count(n => n == "First").Should().Be(1, "First should not be redelivered"); - allReceived.Should().Contain("Second"); - } - - [Fact] - public async Task ChangeFeedProcessor_ConcurrentCreates_AllDelivered() - { - var received = new List(); - var allReceived = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - lock (received) { received.AddRange(changes.Select(c => c.Name)); } - if (received.Count >= 10) - allReceived.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - var tasks = Enumerable.Range(0, 10).Select(i => - _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Concurrent{i}" }, - new PartitionKey("pk1"))); - await Task.WhenAll(tasks); - - await Task.WhenAny(allReceived.Task, Task.Delay(TimeSpan.FromSeconds(10))); - await processor.StopAsync(); - - received.Should().HaveCount(10); - } - - [Fact] - public async Task ChangeFeedProcessor_Context_HasFeedRange() - { - ChangeFeedProcessorContext capturedContext = null!; - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - capturedContext = context; - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Context" }, - new PartitionKey("pk1")); - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - capturedContext.FeedRange.Should().NotBeNull(); - } - - [Fact] - public async Task ChangeFeedProcessor_Context_HasHeaders() - { - ChangeFeedProcessorContext capturedContext = null!; - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - capturedContext = context; - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Headers" }, - new PartitionKey("pk1")); - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - capturedContext.Headers.Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeedProcessor_DoesNotSeeItemsCreatedBeforeStart() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "BeforeStart" }, + new PartitionKey("pk1")); + + var receivedNames = new List(); + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + receivedNames.AddRange(changes.Select(c => c.Name)); + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + // Add item after start + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterStart" }, + new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + receivedNames.Should().Contain("AfterStart"); + receivedNames.Should().NotContain("BeforeStart"); + } + + [Fact] + public async Task ChangeFeedProcessor_MultipleStartStop_NoDuplicates() + { + var allReceived = new List(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + allReceived.AddRange(changes.Select(c => c.Name)); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + // First cycle + await processor.StartAsync(); + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + await Task.Delay(300); + await processor.StopAsync(); + + // Second cycle + await processor.StartAsync(); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + await Task.Delay(300); + await processor.StopAsync(); + + allReceived.Count(n => n == "First").Should().Be(1, "First should not be redelivered"); + allReceived.Should().Contain("Second"); + } + + [Fact] + public async Task ChangeFeedProcessor_ConcurrentCreates_AllDelivered() + { + var received = new List(); + var allReceived = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + lock (received) { received.AddRange(changes.Select(c => c.Name)); } + if (received.Count >= 10) + allReceived.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + var tasks = Enumerable.Range(0, 10).Select(i => + _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Concurrent{i}" }, + new PartitionKey("pk1"))); + await Task.WhenAll(tasks); + + await Task.WhenAny(allReceived.Task, Task.Delay(TimeSpan.FromSeconds(10))); + await processor.StopAsync(); + + received.Should().HaveCount(10); + } + + [Fact] + public async Task ChangeFeedProcessor_Context_HasFeedRange() + { + ChangeFeedProcessorContext capturedContext = null!; + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + capturedContext = context; + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Context" }, + new PartitionKey("pk1")); + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + capturedContext.FeedRange.Should().NotBeNull(); + } + + [Fact] + public async Task ChangeFeedProcessor_Context_HasHeaders() + { + ChangeFeedProcessorContext capturedContext = null!; + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + capturedContext = context; + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Headers" }, + new PartitionKey("pk1")); + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + capturedContext.Headers.Should().NotBeNull(); + } } @@ -1832,117 +1832,117 @@ await _container.CreateItemAsync( public class ChangeFeedStreamIteratorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeedStream_EmptyContainer_ReturnsEmptyDocumentsArray() - { - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().BeEmpty(); - } - } - - [Fact] - public async Task ChangeFeedStream_MultipleItems_AllInDocumentsArray() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().HaveCount(3); - } - - [Fact] - public async Task ChangeFeedStream_HasMoreResults_IsFalseAfterRead() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - iterator.HasMoreResults.Should().BeTrue(); - await iterator.ReadNextAsync(); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task ChangeFeedStream_ResponseStatusCode_IsOK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - using var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ChangeFeedStream_PageSizeHint_LimitsResults() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 2 }); - - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().HaveCount(2); - } - - [Fact] - public async Task ChangeFeedStream_PageSizeHint_PagesAllItems() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 2 }); - - var totalItems = 0; - var pages = 0; - while (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - totalItems += ((JArray)jObj["Documents"]!).Count; - pages++; - } - - totalItems.Should().Be(5); - pages.Should().BeGreaterThan(1, "PageSizeHint=2 should require multiple pages for 5 items"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeedStream_EmptyContainer_ReturnsEmptyDocumentsArray() + { + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().BeEmpty(); + } + } + + [Fact] + public async Task ChangeFeedStream_MultipleItems_AllInDocumentsArray() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().HaveCount(3); + } + + [Fact] + public async Task ChangeFeedStream_HasMoreResults_IsFalseAfterRead() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + iterator.HasMoreResults.Should().BeTrue(); + await iterator.ReadNextAsync(); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task ChangeFeedStream_ResponseStatusCode_IsOK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + using var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangeFeedStream_PageSizeHint_LimitsResults() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 2 }); + + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().HaveCount(2); + } + + [Fact] + public async Task ChangeFeedStream_PageSizeHint_PagesAllItems() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 2 }); + + var totalItems = 0; + var pages = 0; + while (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + totalItems += ((JArray)jObj["Documents"]!).Count; + pages++; + } + + totalItems.Should().Be(5); + pages.Should().BeGreaterThan(1, "PageSizeHint=2 should require multiple pages for 5 items"); + } } @@ -1952,154 +1952,154 @@ await _container.CreateItemAsync( public class ChangeFeedStartFromTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_FromBeginning_WithNullFeedRange_ReturnsAllItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ChangeFeed_FromTime_ExactTimestamp_IncludesItemAtThatTime() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - // Small delay to ensure timestamp separation - await Task.Delay(50); - var midpoint = DateTime.UtcNow; - await Task.Delay(50); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(midpoint), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Time uses >= semantics, so "New" should be included - results.Should().ContainSingle().Which.Name.Should().Be("New"); - } - - [Fact] - public async Task ChangeFeed_FromNow_NothingAdded_ReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_FromTime_FutureTimestamp_ReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, - new PartitionKey("pk1")); - - var futureTime = DateTime.UtcNow.AddHours(1); - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(futureTime), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_FromTime_DistantPast_ReturnsAll() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - - var distantPast = DateTime.UtcNow.AddYears(-1); - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(distantPast), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact(Skip = "ChangeFeed FromNow with FeedRange interaction requires deeper investigation " + - "into lazy evaluation timing with range filtering.")] - public async Task ChangeFeed_FromNow_WithFeedRange_ScopesToRange() - { - _container.FeedRangeCount = 4; - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var ranges = await _container.GetFeedRangesAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - // Use the range that contains pk1 - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Only "After" should appear (Now() filters out items before creation) - allResults.Should().ContainSingle().Which.Name.Should().Be("After"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_FromBeginning_WithNullFeedRange_ReturnsAllItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ChangeFeed_FromTime_ExactTimestamp_IncludesItemAtThatTime() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + // Small delay to ensure timestamp separation + await Task.Delay(50); + var midpoint = DateTime.UtcNow; + await Task.Delay(50); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(midpoint), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Time uses >= semantics, so "New" should be included + results.Should().ContainSingle().Which.Name.Should().Be("New"); + } + + [Fact] + public async Task ChangeFeed_FromNow_NothingAdded_ReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_FromTime_FutureTimestamp_ReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, + new PartitionKey("pk1")); + + var futureTime = DateTime.UtcNow.AddHours(1); + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(futureTime), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_FromTime_DistantPast_ReturnsAll() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + + var distantPast = DateTime.UtcNow.AddYears(-1); + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(distantPast), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact(Skip = "ChangeFeed FromNow with FeedRange interaction requires deeper investigation " + + "into lazy evaluation timing with range filtering.")] + public async Task ChangeFeed_FromNow_WithFeedRange_ScopesToRange() + { + _container.FeedRangeCount = 4; + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var ranges = await _container.GetFeedRangesAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + // Use the range that contains pk1 + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Only "After" should appear (Now() filters out items before creation) + allResults.Should().ContainSingle().Which.Name.Should().Be("After"); + } } @@ -2109,60 +2109,60 @@ await _container.CreateItemAsync( public class ChangeFeedConcurrencyTests { - [Fact] - public async Task ChangeFeed_ConcurrentWritesAndReads_NoExceptions() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - // Writer task - var writer = Task.Run(async () => - { - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"w{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - }, cts.Token); - - // Reader task - var reader = Task.Run(async () => - { - for (var i = 0; i < 20; i++) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - await iterator.ReadNextAsync(cts.Token); - } - await Task.Delay(10, cts.Token); - } - }, cts.Token); - - // Both should complete without exceptions - await Task.WhenAll(writer, reader); - } - - [Fact] - public async Task ChangeFeed_Checkpoint_ThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create items concurrently and read checkpoint — no exceptions - var tasks = Enumerable.Range(0, 20).Select(async i => - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - _ = container.GetChangeFeedCheckpoint(); - }); - - await Task.WhenAll(tasks); - - container.GetChangeFeedCheckpoint().Should().Be(20); - } + [Fact] + public async Task ChangeFeed_ConcurrentWritesAndReads_NoExceptions() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Writer task + var writer = Task.Run(async () => + { + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"w{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + }, cts.Token); + + // Reader task + var reader = Task.Run(async () => + { + for (var i = 0; i < 20; i++) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + await iterator.ReadNextAsync(cts.Token); + } + await Task.Delay(10, cts.Token); + } + }, cts.Token); + + // Both should complete without exceptions + await Task.WhenAll(writer, reader); + } + + [Fact] + public async Task ChangeFeed_Checkpoint_ThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create items concurrently and read checkpoint — no exceptions + var tasks = Enumerable.Range(0, 20).Select(async i => + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + _ = container.GetChangeFeedCheckpoint(); + }); + + await Task.WhenAll(tasks); + + container.GetChangeFeedCheckpoint().Should().Be(20); + } } @@ -2172,61 +2172,61 @@ await container.CreateItemAsync( public class ChangeFeedClearItemsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_ClearItems_ResetsChangeFeed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - _container.ClearItems(); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_ClearItems_ThenAddItems_OnlyNewItemsAppear() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - _container.ClearItems(); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("New"); - } - - [Fact] - public void ChangeFeed_Checkpoint_AfterClearItems_IsZero() - { - _container.ClearItems(); - _container.GetChangeFeedCheckpoint().Should().Be(0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_ClearItems_ResetsChangeFeed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + _container.ClearItems(); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_ClearItems_ThenAddItems_OnlyNewItemsAppear() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + _container.ClearItems(); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("New"); + } + + [Fact] + public void ChangeFeed_Checkpoint_AfterClearItems_IsZero() + { + _container.ClearItems(); + _container.GetChangeFeedCheckpoint().Should().Be(0); + } } @@ -2236,130 +2236,130 @@ public void ChangeFeed_Checkpoint_AfterClearItems_IsZero() public class ChangeFeedSkippedWithSisterTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact(Skip = "ChangeFeedStartFrom.ContinuationToken() creates an internal " + - "ChangeFeedStartFromContinuationAndFeedRange type. The current implementation's " + - "FilterChangeFeedByStartFrom falls through to the default case (returns all entries) " + - "for unrecognized start types. Supporting this would require parsing opaque " + - "continuation tokens that contain internal Cosmos DB cursor state.")] - public async Task ChangeFeed_FromContinuationToken_ResumesCorrectly() - { - // IDEAL: Would resume from a continuation token obtained from a previous iterator - await Task.CompletedTask; - } - - /// - /// DIVERGENT BEHAVIOR: When an unrecognized ChangeFeedStartFrom type is used (e.g. - /// ContinuationAndFeedRange), the implementation falls back to returning all entries. - /// This sister test documents this fallback behavior. - /// - [Fact] - public async Task ChangeFeed_UnrecognizedStartType_FallsBackToAll() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - - // Beginning() is the default fallback path — verify it returns all - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact(Skip = "ChangeFeedStartFrom.ContinuationToken() creates an internal " + + "ChangeFeedStartFromContinuationAndFeedRange type. The current implementation's " + + "FilterChangeFeedByStartFrom falls through to the default case (returns all entries) " + + "for unrecognized start types. Supporting this would require parsing opaque " + + "continuation tokens that contain internal Cosmos DB cursor state.")] + public async Task ChangeFeed_FromContinuationToken_ResumesCorrectly() + { + // IDEAL: Would resume from a continuation token obtained from a previous iterator + await Task.CompletedTask; + } + + /// + /// DIVERGENT BEHAVIOR: When an unrecognized ChangeFeedStartFrom type is used (e.g. + /// ContinuationAndFeedRange), the implementation falls back to returning all entries. + /// This sister test documents this fallback behavior. + /// + [Fact] + public async Task ChangeFeed_UnrecognizedStartType_FallsBackToAll() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + + // Beginning() is the default fallback path — verify it returns all + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } } public class ChangeFeedGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_FromNow_IgnoresPriorChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_ViaCheckpoint_IncludesUpdatedVersion() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1", Value = 1 }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2", Value = 2 }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3", Value = 3 }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Incremental mode returns each version - results.Should().HaveCount(2); - results[0].Name.Should().Be("V2"); - results[1].Name.Should().Be("V3"); - } - - [Fact] - public async Task ChangeFeed_UpsertNewAndExisting_BothAppear() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_FromNow_IgnoresPriorChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_ViaCheckpoint_IncludesUpdatedVersion() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1", Value = 1 }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2", Value = 2 }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3", Value = 3 }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Incremental mode returns each version + results.Should().HaveCount(2); + results[0].Name.Should().Be("V2"); + results[1].Name.Should().Be("V3"); + } + + [Fact] + public async Task ChangeFeed_UpsertNewAndExisting_BothAppear() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } } @@ -2369,84 +2369,84 @@ await _container.UpsertItemAsync( public class ChangeFeedBug2CompositePkPipeTests { - [Fact] - public async Task ChangeFeed_CompositePK_WithPipeInValue_TombstoneHasCorrectFields() - { - var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); - - var json = """{"id":"1","tenantId":"tenant|pipe","userId":"u1","name":"Test"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var pk = new PartitionKeyBuilder().Add("tenant|pipe").Add("u1").Build(); - await container.CreateItemStreamAsync(stream, pk); - - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("1", pk); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var tombstone = results.Should().ContainSingle().Subject; - tombstone["_deleted"]!.Value().Should().BeTrue(); - tombstone["tenantId"]!.Value().Should().Be("tenant|pipe"); - tombstone["userId"]!.Value().Should().Be("u1"); - } - - [Fact] - public async Task ChangeFeed_CompositePK_WithPipeInMultipleValues_TombstoneCorrect() - { - var container = new InMemoryContainer("test", new[] { "/a", "/b" }); - - var json = """{"id":"1","a":"x|y","b":"m|n","name":"Test"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var pk = new PartitionKeyBuilder().Add("x|y").Add("m|n").Build(); - await container.CreateItemStreamAsync(stream, pk); - - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("1", pk); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var tombstone = results.Should().ContainSingle().Subject; - tombstone["a"]!.Value().Should().Be("x|y"); - tombstone["b"]!.Value().Should().Be("m|n"); - } - - [Fact] - public async Task ChangeFeed_StreamDelete_CompositePK_WithPipe_TombstoneCorrect() - { - var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); - - var json = """{"id":"1","tenantId":"a|b","userId":"c","name":"Test"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var pk = new PartitionKeyBuilder().Add("a|b").Add("c").Build(); - await container.CreateItemStreamAsync(stream, pk); - - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemStreamAsync("1", pk); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var tombstone = results.Should().ContainSingle().Subject; - tombstone["tenantId"]!.Value().Should().Be("a|b"); - tombstone["userId"]!.Value().Should().Be("c"); - } + [Fact] + public async Task ChangeFeed_CompositePK_WithPipeInValue_TombstoneHasCorrectFields() + { + var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); + + var json = """{"id":"1","tenantId":"tenant|pipe","userId":"u1","name":"Test"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var pk = new PartitionKeyBuilder().Add("tenant|pipe").Add("u1").Build(); + await container.CreateItemStreamAsync(stream, pk); + + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("1", pk); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var tombstone = results.Should().ContainSingle().Subject; + tombstone["_deleted"]!.Value().Should().BeTrue(); + tombstone["tenantId"]!.Value().Should().Be("tenant|pipe"); + tombstone["userId"]!.Value().Should().Be("u1"); + } + + [Fact] + public async Task ChangeFeed_CompositePK_WithPipeInMultipleValues_TombstoneCorrect() + { + var container = new InMemoryContainer("test", new[] { "/a", "/b" }); + + var json = """{"id":"1","a":"x|y","b":"m|n","name":"Test"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var pk = new PartitionKeyBuilder().Add("x|y").Add("m|n").Build(); + await container.CreateItemStreamAsync(stream, pk); + + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("1", pk); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var tombstone = results.Should().ContainSingle().Subject; + tombstone["a"]!.Value().Should().Be("x|y"); + tombstone["b"]!.Value().Should().Be("m|n"); + } + + [Fact] + public async Task ChangeFeed_StreamDelete_CompositePK_WithPipe_TombstoneCorrect() + { + var container = new InMemoryContainer("test", new[] { "/tenantId", "/userId" }); + + var json = """{"id":"1","tenantId":"a|b","userId":"c","name":"Test"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var pk = new PartitionKeyBuilder().Add("a|b").Add("c").Build(); + await container.CreateItemStreamAsync(stream, pk); + + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemStreamAsync("1", pk); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var tombstone = results.Should().ContainSingle().Subject; + tombstone["tenantId"]!.Value().Should().Be("a|b"); + tombstone["userId"]!.Value().Should().Be("c"); + } } @@ -2456,75 +2456,75 @@ public async Task ChangeFeed_StreamDelete_CompositePK_WithPipe_TombstoneCorrect( public class ChangeFeedResponseMetadataTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_Items_Have_Ts_Property() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var item = results.Should().ContainSingle().Subject; - item["_ts"].Should().NotBeNull(); - item["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task ChangeFeed_Items_Have_Etag_Property() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var item = results.Should().ContainSingle().Subject; - item["_etag"].Should().NotBeNull(); - item["_etag"]!.Value().Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ChangeFeed_Response_HasRequestCharge() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var response = await iterator.ReadNextAsync(); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task ChangeFeed_Response_StatusCode_OKForResults() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_Items_Have_Ts_Property() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var item = results.Should().ContainSingle().Subject; + item["_ts"].Should().NotBeNull(); + item["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task ChangeFeed_Items_Have_Etag_Property() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var item = results.Should().ContainSingle().Subject; + item["_etag"].Should().NotBeNull(); + item["_etag"]!.Value().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ChangeFeed_Response_HasRequestCharge() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var response = await iterator.ReadNextAsync(); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task ChangeFeed_Response_StatusCode_OKForResults() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } @@ -2534,108 +2534,108 @@ await _container.CreateItemAsync( public class ChangeFeedIncrementalModeDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_Incremental_RapidUpdates_ReturnsOnlyLatest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - - for (var i = 2; i <= 10; i++) - { - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("V10"); - } - - [Fact] - public async Task ChangeFeed_Incremental_CreateThenDelete_ReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_Incremental_MultipleItems_CorrectLatest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A-V1" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B-V1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A-V2" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Should().Contain(r => r.Id == "1" && r.Name == "A-V2"); - results.Should().Contain(r => r.Id == "2" && r.Name == "B-V1"); - } - - [Fact] - public async Task ChangeFeed_Incremental_OrderPreservedWithinPK() - { - for (var i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(5); - results.Select(r => r.Name).Should().BeEquivalentTo( - "Item0", "Item1", "Item2", "Item3", "Item4"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_Incremental_RapidUpdates_ReturnsOnlyLatest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + + for (var i = 2; i <= 10; i++) + { + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("V10"); + } + + [Fact] + public async Task ChangeFeed_Incremental_CreateThenDelete_ReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_Incremental_MultipleItems_CorrectLatest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A-V1" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B-V1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A-V2" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Should().Contain(r => r.Id == "1" && r.Name == "A-V2"); + results.Should().Contain(r => r.Id == "2" && r.Name == "B-V1"); + } + + [Fact] + public async Task ChangeFeed_Incremental_OrderPreservedWithinPK() + { + for (var i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(5); + results.Select(r => r.Name).Should().BeEquivalentTo( + "Item0", "Item1", "Item2", "Item3", "Item4"); + } } @@ -2645,131 +2645,131 @@ await _container.CreateItemAsync( public class ChangeFeedAllVersionsDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_AllVersions_IntermediateUpdatesAllPresent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - - for (var i = 2; i <= 6; i++) - { - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(6); - results[0].Name.Should().Be("V1"); - results[5].Name.Should().Be("V6"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_FullLifecycleVisible() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results[0]["name"]!.Value().Should().Be("Created"); - results[1]["name"]!.Value().Should().Be("Updated"); - results[2]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_AllVersions_InterleavedItemsCorrectOrder() - { - await _container.CreateItemAsync( - new TestDocument { Id = "A", PartitionKey = "pk1", Name = "A1" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "B", PartitionKey = "pk1", Name = "B1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "A", PartitionKey = "pk1", Name = "A2" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "B", PartitionKey = "pk1", Name = "B2" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(4); - results[0].Name.Should().Be("A1"); - results[1].Name.Should().Be("B1"); - results[2].Name.Should().Be("A2"); - results[3].Name.Should().Be("B2"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_CheckpointSkipsExactNumber() - { - for (var i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _container.GetChangeFeedIterator(3); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Name.Should().Be("Item3"); - results[1].Name.Should().Be("Item4"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_LargeCheckpointReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(999); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_AllVersions_IntermediateUpdatesAllPresent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + + for (var i = 2; i <= 6; i++) + { + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(6); + results[0].Name.Should().Be("V1"); + results[5].Name.Should().Be("V6"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_FullLifecycleVisible() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results[0]["name"]!.Value().Should().Be("Created"); + results[1]["name"]!.Value().Should().Be("Updated"); + results[2]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_AllVersions_InterleavedItemsCorrectOrder() + { + await _container.CreateItemAsync( + new TestDocument { Id = "A", PartitionKey = "pk1", Name = "A1" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "B", PartitionKey = "pk1", Name = "B1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "A", PartitionKey = "pk1", Name = "A2" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "B", PartitionKey = "pk1", Name = "B2" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(4); + results[0].Name.Should().Be("A1"); + results[1].Name.Should().Be("B1"); + results[2].Name.Should().Be("A2"); + results[3].Name.Should().Be("B2"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_CheckpointSkipsExactNumber() + { + for (var i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _container.GetChangeFeedIterator(3); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Name.Should().Be("Item3"); + results[1].Name.Should().Be("Item4"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_LargeCheckpointReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(999); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } } @@ -2779,117 +2779,117 @@ await _container.CreateItemAsync( public class ChangeFeedStreamDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeedStream_FromNow_OnlyItemsAfterCallVisible() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - var docs = (JArray)jObj["Documents"]!; - docs.Should().NotBeNull(); - } - } - - [Fact] - public async Task ChangeFeedStream_FromTime_FiltersCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - await Task.Delay(100); - var midpoint = DateTime.UtcNow; - await Task.Delay(100); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Time(midpoint), ChangeFeedMode.Incremental); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - var docs = (JArray)jObj["Documents"]!; - docs.Should().HaveCount(1); - docs[0]["name"]!.Value().Should().Be("New"); - } - } - - [Fact] - public async Task ChangeFeedStream_DocumentsEnvelope_Has_Count() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - - jObj["_count"].Should().NotBeNull(); - jObj["_count"]!.Value().Should().Be(2); - } - - [Fact] - public async Task ChangeFeedStream_WithFeedRange_ScopesCorrectly() - { - _container.FeedRangeCount = 4; - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, - new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var totalDocs = 0; - - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - - if (iterator.HasMoreResults) - { - using var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - totalDocs += ((JArray)jObj["Documents"]!).Count; - } - } - - totalDocs.Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeedStream_FromNow_OnlyItemsAfterCallVisible() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Now(), ChangeFeedMode.Incremental); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + var docs = (JArray)jObj["Documents"]!; + docs.Should().NotBeNull(); + } + } + + [Fact] + public async Task ChangeFeedStream_FromTime_FiltersCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + await Task.Delay(100); + var midpoint = DateTime.UtcNow; + await Task.Delay(100); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Time(midpoint), ChangeFeedMode.Incremental); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + var docs = (JArray)jObj["Documents"]!; + docs.Should().HaveCount(1); + docs[0]["name"]!.Value().Should().Be("New"); + } + } + + [Fact] + public async Task ChangeFeedStream_DocumentsEnvelope_Has_Count() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + + jObj["_count"].Should().NotBeNull(); + jObj["_count"]!.Value().Should().Be(2); + } + + [Fact] + public async Task ChangeFeedStream_WithFeedRange_ScopesCorrectly() + { + _container.FeedRangeCount = 4; + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, + new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var totalDocs = 0; + + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + + if (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + totalDocs += ((JArray)jObj["Documents"]!).Count; + } + } + + totalDocs.Should().Be(2); + } } @@ -2899,185 +2899,185 @@ await _container.CreateItemAsync( public class ChangeFeedProcessorDeliveryDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeedProcessor_HandlerFailure_SameItemsRedelivered() - { - var deliveries = new List>(); - var deliveryCount = 0; - var secondDelivery = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - var batch = changes.Select(c => c.Name).ToList(); - lock (deliveries) { deliveries.Add(batch); } - var count = Interlocked.Increment(ref deliveryCount); - if (count == 1) - throw new InvalidOperationException("Simulated failure"); - secondDelivery.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Redelivery" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondDelivery.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - deliveries.Should().HaveCountGreaterThanOrEqualTo(2); - deliveries[0].Should().Contain("Redelivery"); - deliveries[1].Should().Contain("Redelivery"); - } - - [Fact] - public async Task ChangeFeedProcessor_CheckpointNotAdvancedOnThrow() - { - var receivedCounts = new List(); - var callCount = 0; - var done = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - lock (receivedCounts) { receivedCounts.Add(changes.Count); } - var count = Interlocked.Increment(ref callCount); - if (count <= 2) - throw new InvalidOperationException("Fail"); - done.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.WhenAny(done.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - receivedCounts.Should().HaveCountGreaterThanOrEqualTo(3); - receivedCounts.Should().AllSatisfy(c => c.Should().Be(1)); - } - - [Fact] - public async Task ManualCheckpoint_PartialCheckpoint_RestRedelivered() - { - var deliveries = new List>(); - var callCount = 0; - var secondCall = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "test-processor", - async (context, changes, checkpointAsync, ct) => - { - var batch = changes.Select(c => c.Name).ToList(); - lock (deliveries) { deliveries.Add(batch); } - var count = Interlocked.Increment(ref callCount); - if (count == 1) - { - return; - } - await checkpointAsync(); - secondCall.TrySetResult(true); - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - await Task.WhenAny(secondCall.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - deliveries.Should().HaveCountGreaterThanOrEqualTo(2); - deliveries[0].Should().Contain("A"); - deliveries[1].Should().Contain("A"); - } - - [Fact] - public async Task ChangeFeedProcessor_DeletedItemsAsTombstones_ViaCheckpointIterator() - { - var receivedAll = new List(); - var handlerCalled = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - lock (receivedAll) { receivedAll.AddRange(changes); } - if (receivedAll.Any(c => c["_deleted"]?.Value() == true)) - handlerCalled.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - await Task.Delay(200); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); - await processor.StopAsync(); - - receivedAll.Should().Contain(j => j["_deleted"] != null && (bool)j["_deleted"]! == true); - } - - [Fact] - public async Task ChangeFeedProcessor_100Items_AllDelivered() - { - var received = new List(); - var allDone = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - lock (received) { received.AddRange(changes.Select(c => c.Name)); } - if (received.Count >= 100) - allDone.TrySetResult(true); - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - for (var i = 0; i < 100; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - await Task.WhenAny(allDone.Task, Task.Delay(TimeSpan.FromSeconds(15))); - await processor.StopAsync(); - - received.Should().HaveCount(100); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeedProcessor_HandlerFailure_SameItemsRedelivered() + { + var deliveries = new List>(); + var deliveryCount = 0; + var secondDelivery = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + var batch = changes.Select(c => c.Name).ToList(); + lock (deliveries) { deliveries.Add(batch); } + var count = Interlocked.Increment(ref deliveryCount); + if (count == 1) + throw new InvalidOperationException("Simulated failure"); + secondDelivery.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Redelivery" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondDelivery.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + deliveries.Should().HaveCountGreaterThanOrEqualTo(2); + deliveries[0].Should().Contain("Redelivery"); + deliveries[1].Should().Contain("Redelivery"); + } + + [Fact] + public async Task ChangeFeedProcessor_CheckpointNotAdvancedOnThrow() + { + var receivedCounts = new List(); + var callCount = 0; + var done = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + lock (receivedCounts) { receivedCounts.Add(changes.Count); } + var count = Interlocked.Increment(ref callCount); + if (count <= 2) + throw new InvalidOperationException("Fail"); + done.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.WhenAny(done.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + receivedCounts.Should().HaveCountGreaterThanOrEqualTo(3); + receivedCounts.Should().AllSatisfy(c => c.Should().Be(1)); + } + + [Fact] + public async Task ManualCheckpoint_PartialCheckpoint_RestRedelivered() + { + var deliveries = new List>(); + var callCount = 0; + var secondCall = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "test-processor", + async (context, changes, checkpointAsync, ct) => + { + var batch = changes.Select(c => c.Name).ToList(); + lock (deliveries) { deliveries.Add(batch); } + var count = Interlocked.Increment(ref callCount); + if (count == 1) + { + return; + } + await checkpointAsync(); + secondCall.TrySetResult(true); + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + await Task.WhenAny(secondCall.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + deliveries.Should().HaveCountGreaterThanOrEqualTo(2); + deliveries[0].Should().Contain("A"); + deliveries[1].Should().Contain("A"); + } + + [Fact] + public async Task ChangeFeedProcessor_DeletedItemsAsTombstones_ViaCheckpointIterator() + { + var receivedAll = new List(); + var handlerCalled = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + lock (receivedAll) { receivedAll.AddRange(changes); } + if (receivedAll.Any(c => c["_deleted"]?.Value() == true)) + handlerCalled.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + await Task.Delay(200); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + await Task.WhenAny(handlerCalled.Task, Task.Delay(TimeSpan.FromSeconds(5))); + await processor.StopAsync(); + + receivedAll.Should().Contain(j => j["_deleted"] != null && (bool)j["_deleted"]! == true); + } + + [Fact] + public async Task ChangeFeedProcessor_100Items_AllDelivered() + { + var received = new List(); + var allDone = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + lock (received) { received.AddRange(changes.Select(c => c.Name)); } + if (received.Count >= 100) + allDone.TrySetResult(true); + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + for (var i = 0; i < 100; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + await Task.WhenAny(allDone.Task, Task.Delay(TimeSpan.FromSeconds(15))); + await processor.StopAsync(); + + received.Should().HaveCount(100); + } } @@ -3087,96 +3087,96 @@ await _container.CreateItemAsync( public class ChangeFeedFeedRangeCombinationTests { - [Fact] - public async Task ChangeFeed_SingleRange_ReturnsAll() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().ContainSingle(); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(ranges[0]), ChangeFeedMode.Incremental); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ChangeFeed_MultiRange_NoOverlap_UnionEqualsAll() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item.Id); - } - } - - allIds.Should().HaveCount(20); - } - - [Fact] - public async Task ChangeFeed_FeedRange_WithTime_BothFiltersApplied() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - await Task.Delay(100); - var midpoint = DateTime.UtcNow; - await Task.Delay(100); - - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "New" }, - new PartitionKey("pk2")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(midpoint, range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - allResults.Should().ContainSingle().Which.Name.Should().Be("New"); - } + [Fact] + public async Task ChangeFeed_SingleRange_ReturnsAll() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().ContainSingle(); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(ranges[0]), ChangeFeedMode.Incremental); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ChangeFeed_MultiRange_NoOverlap_UnionEqualsAll() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item.Id); + } + } + + allIds.Should().HaveCount(20); + } + + [Fact] + public async Task ChangeFeed_FeedRange_WithTime_BothFiltersApplied() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + await Task.Delay(100); + var midpoint = DateTime.UtcNow; + await Task.Delay(100); + + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "New" }, + new PartitionKey("pk2")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(midpoint, range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + allResults.Should().ContainSingle().Which.Name.Should().Be("New"); + } } @@ -3186,127 +3186,127 @@ await container.CreateItemAsync( public class ChangeFeedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_EmptyStringPK_Preserved() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "", Name = "EmptyPK" }, - new PartitionKey("")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["partitionKey"]!.Value().Should().Be(""); - } - - [Fact] - public async Task ChangeFeed_SpecialCharacters_UnicodeEmoji_Preserved() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello 🌍 世界" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Hello 🌍 世界"); - } - - [Fact] - public async Task ChangeFeed_NullableFields_Preserved() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = null! }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var item = results.Should().ContainSingle().Subject; - item["name"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task ChangeFeed_NestedObjects_Preserved() - { - await _container.CreateItemAsync( - new TestDocument - { - Id = "1", - PartitionKey = "pk1", - Name = "Nested", - Nested = new NestedObject { Description = "deep" } - }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var item = results.Should().ContainSingle().Subject; - item["nested"]!["description"]!.Value().Should().Be("deep"); - } - - [Fact] - public async Task ChangeFeed_NumericPK_Preserved() - { - var container = new InMemoryContainer("test", "/numericKey"); - - var json = """{"id":"1","numericKey":42,"name":"NumPK"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - await container.CreateItemStreamAsync(stream, new PartitionKey(42)); - - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["numericKey"]!.Value().Should().Be(42); - } - - [Fact] - public async Task ChangeFeed_ReadNextAfterExhausted_ReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, - new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - while (iterator.HasMoreResults) - { - await iterator.ReadNextAsync(); - } - - iterator.HasMoreResults.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_EmptyStringPK_Preserved() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "", Name = "EmptyPK" }, + new PartitionKey("")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["partitionKey"]!.Value().Should().Be(""); + } + + [Fact] + public async Task ChangeFeed_SpecialCharacters_UnicodeEmoji_Preserved() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello 🌍 世界" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Hello 🌍 世界"); + } + + [Fact] + public async Task ChangeFeed_NullableFields_Preserved() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = null! }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var item = results.Should().ContainSingle().Subject; + item["name"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task ChangeFeed_NestedObjects_Preserved() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Nested", + Nested = new NestedObject { Description = "deep" } + }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var item = results.Should().ContainSingle().Subject; + item["nested"]!["description"]!.Value().Should().Be("deep"); + } + + [Fact] + public async Task ChangeFeed_NumericPK_Preserved() + { + var container = new InMemoryContainer("test", "/numericKey"); + + var json = """{"id":"1","numericKey":42,"name":"NumPK"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await container.CreateItemStreamAsync(stream, new PartitionKey(42)); + + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["numericKey"]!.Value().Should().Be(42); + } + + [Fact] + public async Task ChangeFeed_ReadNextAfterExhausted_ReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only" }, + new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + while (iterator.HasMoreResults) + { + await iterator.ReadNextAsync(); + } + + iterator.HasMoreResults.Should().BeFalse(); + } } @@ -3316,120 +3316,120 @@ await _container.CreateItemAsync( public class ChangeFeedTTLAndStatePersistenceTests { - [Fact] - public async Task ChangeFeed_TTL_ExpiredItem_StillInChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, - new PartitionKey("pk1")); - - await Task.Delay(1500); - - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Expiring"); - } - - [Fact] - public async Task ExportState_DoesNotIncludeChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var exported = container.ExportState(); - var jObj = JObject.Parse(exported); - - jObj["items"].Should().NotBeNull(); - jObj.Properties().Select(p => p.Name).Should().NotContain("changeFeed"); - } - - [Fact] - public async Task PITR_PreservesChangeFeed_CanReplayFromCheckpoint() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - - await Task.Delay(50); - var pitrPoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - - container.RestoreToPointInTime(pitrPoint); - - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("V1"); - } - - [Fact] - public async Task ChangeFeed_UniqueKeyViolation_NoChangeFeedEntry() - { - var containerProps = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(containerProps); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - - var act = async () => await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - - var newCheckpoint = container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().Be(checkpoint, "failed write should not produce a change feed entry"); - } - - [Fact] - public async Task ChangeFeed_ETagConflict_NoChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - - var act = async () => await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Conflict" }, - "1", - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" }); - - await act.Should().ThrowAsync(); - - var newCheckpoint = container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().Be(checkpoint, "failed replace should not produce a change feed entry"); - } + [Fact] + public async Task ChangeFeed_TTL_ExpiredItem_StillInChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, + new PartitionKey("pk1")); + + await Task.Delay(1500); + + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Expiring"); + } + + [Fact] + public async Task ExportState_DoesNotIncludeChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var exported = container.ExportState(); + var jObj = JObject.Parse(exported); + + jObj["items"].Should().NotBeNull(); + jObj.Properties().Select(p => p.Name).Should().NotContain("changeFeed"); + } + + [Fact] + public async Task PITR_PreservesChangeFeed_CanReplayFromCheckpoint() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + + await Task.Delay(50); + var pitrPoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + + container.RestoreToPointInTime(pitrPoint); + + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("V1"); + } + + [Fact] + public async Task ChangeFeed_UniqueKeyViolation_NoChangeFeedEntry() + { + var containerProps = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(containerProps); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + + var act = async () => await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + + var newCheckpoint = container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().Be(checkpoint, "failed write should not produce a change feed entry"); + } + + [Fact] + public async Task ChangeFeed_ETagConflict_NoChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + + var act = async () => await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Conflict" }, + "1", + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" }); + + await act.Should().ThrowAsync(); + + var newCheckpoint = container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().Be(checkpoint, "failed replace should not produce a change feed entry"); + } } @@ -3439,99 +3439,99 @@ await container.CreateItemAsync( public class ChangeFeedConditionalOperationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ChangeFeed_SuccessfulConditionalPatch_Recorded() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Patched") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Patched"); - } - - [Fact] - public async Task ChangeFeed_FailedConditionalPatch_NotRecorded() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var act = async () => await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Patched") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); - - await act.Should().ThrowAsync(); - - var newCheckpoint = _container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().Be(checkpoint, "failed conditional patch should not record in change feed"); - } - - [Fact] - public async Task ChangeFeed_SuccessfulETagReplace_Recorded() - { - var createResponse = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var etag = createResponse.ETag; - var checkpoint = _container.GetChangeFeedCheckpoint(); - - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - var iterator = _container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task ChangeFeed_FailedETagReplace_NotRecorded() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var act = async () => await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Conflict" }, - "1", - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"bad-etag\"" }); - - await act.Should().ThrowAsync(); - - var newCheckpoint = _container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().Be(checkpoint); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ChangeFeed_SuccessfulConditionalPatch_Recorded() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Patched") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Patched"); + } + + [Fact] + public async Task ChangeFeed_FailedConditionalPatch_NotRecorded() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var act = async () => await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Patched") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); + + await act.Should().ThrowAsync(); + + var newCheckpoint = _container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().Be(checkpoint, "failed conditional patch should not record in change feed"); + } + + [Fact] + public async Task ChangeFeed_SuccessfulETagReplace_Recorded() + { + var createResponse = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var etag = createResponse.ETag; + var checkpoint = _container.GetChangeFeedCheckpoint(); + + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + var iterator = _container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task ChangeFeed_FailedETagReplace_NotRecorded() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var act = async () => await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Conflict" }, + "1", + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"bad-etag\"" }); + + await act.Should().ThrowAsync(); + + var newCheckpoint = _container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().Be(checkpoint); + } } @@ -3541,84 +3541,84 @@ await _container.CreateItemAsync( public class ChangeFeedProcessorBuilderApiTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void ProcessorBuilder_WithPollInterval_Accepted() - { - var builder = _container.GetChangeFeedProcessorBuilder( - "processor", - (context, changes, token) => Task.CompletedTask); - - var chained = builder - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .WithPollInterval(TimeSpan.FromMilliseconds(100)); - - chained.Should().NotBeNull(); - } - - [Fact] - public void ProcessorBuilder_WithMaxItems_Accepted() - { - var builder = _container.GetChangeFeedProcessorBuilder( - "processor", - (context, changes, token) => Task.CompletedTask); - - var chained = builder - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .WithMaxItems(50); - - chained.Should().NotBeNull(); - } - - [Fact] - public void ProcessorBuilder_WithStartTime_Accepted() - { - var builder = _container.GetChangeFeedProcessorBuilder( - "processor", - (context, changes, token) => Task.CompletedTask); - - var chained = builder - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .WithStartTime(DateTime.UtcNow); - - chained.Should().NotBeNull(); - } - - [Fact] - public void ManualCheckpointBuilder_ChainingAccepted() - { - var builder = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( - "processor", - (context, changes, checkpointAsync, ct) => Task.CompletedTask); - - var chained = builder - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .WithPollInterval(TimeSpan.FromMilliseconds(100)) - .WithMaxItems(50); - - chained.Should().NotBeNull(); - } - - [Fact] - public void StreamProcessorBuilder_ChainingAccepted() - { - Container.ChangeFeedStreamHandler handler = - (context, changes, token) => Task.CompletedTask; - - var builder = _container.GetChangeFeedProcessorBuilder("processor", handler); - - var chained = builder - .WithInstanceName("instance") - .WithInMemoryLeaseContainer() - .WithPollInterval(TimeSpan.FromMilliseconds(100)); - - chained.Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public void ProcessorBuilder_WithPollInterval_Accepted() + { + var builder = _container.GetChangeFeedProcessorBuilder( + "processor", + (context, changes, token) => Task.CompletedTask); + + var chained = builder + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .WithPollInterval(TimeSpan.FromMilliseconds(100)); + + chained.Should().NotBeNull(); + } + + [Fact] + public void ProcessorBuilder_WithMaxItems_Accepted() + { + var builder = _container.GetChangeFeedProcessorBuilder( + "processor", + (context, changes, token) => Task.CompletedTask); + + var chained = builder + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .WithMaxItems(50); + + chained.Should().NotBeNull(); + } + + [Fact] + public void ProcessorBuilder_WithStartTime_Accepted() + { + var builder = _container.GetChangeFeedProcessorBuilder( + "processor", + (context, changes, token) => Task.CompletedTask); + + var chained = builder + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .WithStartTime(DateTime.UtcNow); + + chained.Should().NotBeNull(); + } + + [Fact] + public void ManualCheckpointBuilder_ChainingAccepted() + { + var builder = _container.GetChangeFeedProcessorBuilderWithManualCheckpoint( + "processor", + (context, changes, checkpointAsync, ct) => Task.CompletedTask); + + var chained = builder + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .WithPollInterval(TimeSpan.FromMilliseconds(100)) + .WithMaxItems(50); + + chained.Should().NotBeNull(); + } + + [Fact] + public void StreamProcessorBuilder_ChainingAccepted() + { + Container.ChangeFeedStreamHandler handler = + (context, changes, token) => Task.CompletedTask; + + var builder = _container.GetChangeFeedProcessorBuilder("processor", handler); + + var chained = builder + .WithInstanceName("instance") + .WithInMemoryLeaseContainer() + .WithPollInterval(TimeSpan.FromMilliseconds(100)); + + chained.Should().NotBeNull(); + } } @@ -3628,147 +3628,147 @@ public void StreamProcessorBuilder_ChainingAccepted() public class ChangeFeedDeepDiveSkippedAndSisterTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact(Skip = "ChangeFeedMode.AllVersionsAndDeletes is internal in the Cosmos SDK (v3.58). " + - "The emulator supports all-versions semantics via GetChangeFeedIterator(long checkpoint). " + - "See sister test ChangeFeed_AllVersionsAndDeletes_ViaCheckpoint_WorksCorrectly.")] - public async Task ChangeFeed_AllVersionsAndDeletes_ViaSDKEnum() - { - await Task.CompletedTask; - } - - /// - /// SISTER TEST: Documents that all-versions+deletes semantics are available via the - /// checkpoint-based iterator. - /// - [Fact] - public async Task ChangeFeed_AllVersionsAndDeletes_ViaCheckpoint_WorksCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var iterator = _container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results[0]["name"]!.Value().Should().Be("Created"); - results[1]["name"]!.Value().Should().Be("Updated"); - results[2]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_Processor_DeliversOnlyLatestVersion() - { - var receivedNames = new List(); - var v3Received = new TaskCompletionSource(); - - var processor = _container.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, token) => - { - lock (receivedNames) - { - receivedNames.AddRange(changes.Select(c => c.Name)); - if (receivedNames.Contains("V3")) - v3Received.TrySetResult(true); - } - return Task.CompletedTask; - }) - .WithInstanceName("instance1") - .WithInMemoryLeaseContainer() - .Build(); - - await processor.StartAsync(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, - new PartitionKey("pk1")); - - await Task.WhenAny(v3Received.Task, Task.Delay(TimeSpan.FromSeconds(10))); - await processor.StopAsync(); - - // Processor should deduplicate to latest version only - receivedNames.Should().Contain("V3"); - } - - [Fact] - public async Task ChangeFeed_TTL_Eviction_RecordsDeleteInChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, - new PartitionKey("pk1")); - - await Task.Delay(1500); - - // Trigger lazy eviction - try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } - - // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) - var iterator = container.GetChangeFeedIterator(0); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page.Resource); - } - - changes.Should().HaveCount(2); - } - - /// - /// TTL eviction now produces a change feed delete tombstone. - /// - [Fact] - public async Task ChangeFeed_TTL_ExpiredItem_ProducesDeleteTombstone() - { - var container = new InMemoryContainer("test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, - new PartitionKey("pk1")); - - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - await Task.Delay(1500); - - try - { - await container.ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // Expected — item is expired - } - - var newCheckpoint = container.GetChangeFeedCheckpoint(); - newCheckpoint.Should().BeGreaterThan(checkpointAfterCreate, - "TTL eviction should produce a change feed delete tombstone"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact(Skip = "ChangeFeedMode.AllVersionsAndDeletes is internal in the Cosmos SDK (v3.58). " + + "The emulator supports all-versions semantics via GetChangeFeedIterator(long checkpoint). " + + "See sister test ChangeFeed_AllVersionsAndDeletes_ViaCheckpoint_WorksCorrectly.")] + public async Task ChangeFeed_AllVersionsAndDeletes_ViaSDKEnum() + { + await Task.CompletedTask; + } + + /// + /// SISTER TEST: Documents that all-versions+deletes semantics are available via the + /// checkpoint-based iterator. + /// + [Fact] + public async Task ChangeFeed_AllVersionsAndDeletes_ViaCheckpoint_WorksCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var iterator = _container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results[0]["name"]!.Value().Should().Be("Created"); + results[1]["name"]!.Value().Should().Be("Updated"); + results[2]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_Processor_DeliversOnlyLatestVersion() + { + var receivedNames = new List(); + var v3Received = new TaskCompletionSource(); + + var processor = _container.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, token) => + { + lock (receivedNames) + { + receivedNames.AddRange(changes.Select(c => c.Name)); + if (receivedNames.Contains("V3")) + v3Received.TrySetResult(true); + } + return Task.CompletedTask; + }) + .WithInstanceName("instance1") + .WithInMemoryLeaseContainer() + .Build(); + + await processor.StartAsync(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V3" }, + new PartitionKey("pk1")); + + await Task.WhenAny(v3Received.Task, Task.Delay(TimeSpan.FromSeconds(10))); + await processor.StopAsync(); + + // Processor should deduplicate to latest version only + receivedNames.Should().Contain("V3"); + } + + [Fact] + public async Task ChangeFeed_TTL_Eviction_RecordsDeleteInChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, + new PartitionKey("pk1")); + + await Task.Delay(1500); + + // Trigger lazy eviction + try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } + + // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) + var iterator = container.GetChangeFeedIterator(0); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page.Resource); + } + + changes.Should().HaveCount(2); + } + + /// + /// TTL eviction now produces a change feed delete tombstone. + /// + [Fact] + public async Task ChangeFeed_TTL_ExpiredItem_ProducesDeleteTombstone() + { + var container = new InMemoryContainer("test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expiring" }, + new PartitionKey("pk1")); + + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + await Task.Delay(1500); + + try + { + await container.ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Expected — item is expired + } + + var newCheckpoint = container.GetChangeFeedCheckpoint(); + newCheckpoint.Should().BeGreaterThan(checkpointAfterCreate, + "TTL eviction should produce a change feed delete tombstone"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyDeepDiveTests.cs index bff359f..89c3066 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyDeepDiveTests.cs @@ -14,206 +14,206 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ComputedPropertyQueryClauseTests { - private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task Seed(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public async Task ComputedProperty_UsedWithLikeOperator() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await Seed(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lower LIKE 'ali%'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_OrderByDescending() - { - var container = CreateContainer(("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); - await Seed(container, - new { id = "1", pk = "p", name = "Al" }, - new { id = "2", pk = "p", name = "Alice" }, - new { id = "3", pk = "p", name = "Bob" }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_len FROM c ORDER BY c.cp_len DESC")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var ids = results.Select(r => r["id"]!.ToString()).ToList(); - ids.Should().Equal("2", "3", "1"); // 5, 3, 2 - } - - [Fact] - public async Task ComputedProperty_AggregateMin() - { - var container = CreateContainer(("cp_price", "SELECT VALUE c.price * 0.9 FROM c")); - await Seed(container, - new { id = "1", pk = "p", price = 100 }, - new { id = "2", pk = "p", price = 50 }, - new { id = "3", pk = "p", price = 200 }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE MIN(c.cp_price) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(45); // 50 * 0.9 - } - - [Fact] - public async Task ComputedProperty_AggregateMax() - { - var container = CreateContainer(("cp_price", "SELECT VALUE c.price * 0.9 FROM c")); - await Seed(container, - new { id = "1", pk = "p", price = 100 }, - new { id = "2", pk = "p", price = 50 }, - new { id = "3", pk = "p", price = 200 }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE MAX(c.cp_price) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(180); // 200 * 0.9 - } - - [Fact] - public async Task ComputedProperty_AggregateAvg() - { - var container = CreateContainer(("cp_discounted", "SELECT VALUE c.price * 0.5 FROM c")); - await Seed(container, - new { id = "1", pk = "p", price = 100 }, - new { id = "2", pk = "p", price = 200 }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE AVG(c.cp_discounted) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(75); // (50+100)/2 - } - - [Fact] - public async Task ComputedProperty_AggregateCountWithFilter() - { - var container = CreateContainer(("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); - await Seed(container, - new { id = "1", pk = "p", name = "Al" }, - new { id = "2", pk = "p", name = "Alice" }, - new { id = "3", pk = "p", name = "Bob" }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.cp_len > 2")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(2); // Alice(5), Bob(3) - } - - [Fact] - public async Task ComputedProperty_StartsWithFunction() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await Seed(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE STARTSWITH(c.cp_lower, 'ali')")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_ComplexNestedArithmetic() - { - var container = CreateContainer(("cp_final", "SELECT VALUE FLOOR(c.price * 0.8 + 10) FROM c")); - await Seed(container, new { id = "1", pk = "p", price = 100 }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_final FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(90); // FLOOR(100*0.8+10) = FLOOR(90) = 90 - } - - [Fact] - public async Task ComputedProperty_UsedInHavingClause() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.category) FROM c")); - await Seed(container, - new { id = "1", pk = "p", category = "Fruit" }, - new { id = "2", pk = "p", category = "Fruit" }, - new { id = "3", pk = "p", category = "Meat" }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_lower, COUNT(1) AS cnt FROM c GROUP BY c.cp_lower HAVING COUNT(1) > 1")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["cp_lower"]!.ToString().Should().Be("fruit"); - } - - [Fact] - public async Task ComputedProperty_UsedWithNotOperator() - { - var container = CreateContainer(("cp_active", "SELECT VALUE c.isActive FROM c")); - await Seed(container, - new { id = "1", pk = "p", isActive = true }, - new { id = "2", pk = "p", isActive = false }); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE NOT c.cp_active")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task ComputedProperty_NullCoalesceInOuterQuery() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await Seed(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p" }); // no name - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, (c.cp_lower ?? 'unknown') AS display FROM c ORDER BY c.id")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["display"]!.ToString().Should().Be("alice"); - results[1]["display"]!.ToString().Should().Be("unknown"); - } + private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task Seed(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public async Task ComputedProperty_UsedWithLikeOperator() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await Seed(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lower LIKE 'ali%'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_OrderByDescending() + { + var container = CreateContainer(("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); + await Seed(container, + new { id = "1", pk = "p", name = "Al" }, + new { id = "2", pk = "p", name = "Alice" }, + new { id = "3", pk = "p", name = "Bob" }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_len FROM c ORDER BY c.cp_len DESC")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var ids = results.Select(r => r["id"]!.ToString()).ToList(); + ids.Should().Equal("2", "3", "1"); // 5, 3, 2 + } + + [Fact] + public async Task ComputedProperty_AggregateMin() + { + var container = CreateContainer(("cp_price", "SELECT VALUE c.price * 0.9 FROM c")); + await Seed(container, + new { id = "1", pk = "p", price = 100 }, + new { id = "2", pk = "p", price = 50 }, + new { id = "3", pk = "p", price = 200 }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE MIN(c.cp_price) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(45); // 50 * 0.9 + } + + [Fact] + public async Task ComputedProperty_AggregateMax() + { + var container = CreateContainer(("cp_price", "SELECT VALUE c.price * 0.9 FROM c")); + await Seed(container, + new { id = "1", pk = "p", price = 100 }, + new { id = "2", pk = "p", price = 50 }, + new { id = "3", pk = "p", price = 200 }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE MAX(c.cp_price) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(180); // 200 * 0.9 + } + + [Fact] + public async Task ComputedProperty_AggregateAvg() + { + var container = CreateContainer(("cp_discounted", "SELECT VALUE c.price * 0.5 FROM c")); + await Seed(container, + new { id = "1", pk = "p", price = 100 }, + new { id = "2", pk = "p", price = 200 }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE AVG(c.cp_discounted) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(75); // (50+100)/2 + } + + [Fact] + public async Task ComputedProperty_AggregateCountWithFilter() + { + var container = CreateContainer(("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); + await Seed(container, + new { id = "1", pk = "p", name = "Al" }, + new { id = "2", pk = "p", name = "Alice" }, + new { id = "3", pk = "p", name = "Bob" }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.cp_len > 2")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(2); // Alice(5), Bob(3) + } + + [Fact] + public async Task ComputedProperty_StartsWithFunction() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await Seed(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE STARTSWITH(c.cp_lower, 'ali')")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_ComplexNestedArithmetic() + { + var container = CreateContainer(("cp_final", "SELECT VALUE FLOOR(c.price * 0.8 + 10) FROM c")); + await Seed(container, new { id = "1", pk = "p", price = 100 }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_final FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(90); // FLOOR(100*0.8+10) = FLOOR(90) = 90 + } + + [Fact] + public async Task ComputedProperty_UsedInHavingClause() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.category) FROM c")); + await Seed(container, + new { id = "1", pk = "p", category = "Fruit" }, + new { id = "2", pk = "p", category = "Fruit" }, + new { id = "3", pk = "p", category = "Meat" }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_lower, COUNT(1) AS cnt FROM c GROUP BY c.cp_lower HAVING COUNT(1) > 1")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["cp_lower"]!.ToString().Should().Be("fruit"); + } + + [Fact] + public async Task ComputedProperty_UsedWithNotOperator() + { + var container = CreateContainer(("cp_active", "SELECT VALUE c.isActive FROM c")); + await Seed(container, + new { id = "1", pk = "p", isActive = true }, + new { id = "2", pk = "p", isActive = false }); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE NOT c.cp_active")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task ComputedProperty_NullCoalesceInOuterQuery() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await Seed(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p" }); // no name + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, (c.cp_lower ?? 'unknown') AS display FROM c ORDER BY c.id")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["display"]!.ToString().Should().Be("alice"); + results[1]["display"]!.ToString().Should().Be("unknown"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -222,120 +222,120 @@ await Seed(container, public class ComputedPropertyExpressionTypeTests { - private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) - }; - return new InMemoryContainer(props); - } - - [Fact] - public async Task ComputedProperty_ReturnsObjectLiteral() - { - var container = CreateContainer(("cp_obj", "SELECT VALUE {\"lower\": LOWER(c.name), \"len\": LENGTH(c.name)} FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_obj FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var obj = results.Single()["cp_obj"]!; - obj["lower"]!.ToString().Should().Be("alice"); - ((int)obj["len"]!).Should().Be(5); - } - - [Fact] - public async Task ComputedProperty_ReturnsArrayLiteral() - { - var container = CreateContainer(("cp_arr", "SELECT VALUE [c.first, c.last] FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", first = "John", last = "Doe" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_arr FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var arr = (JArray)results.Single()["cp_arr"]!; - arr.Select(t => t.ToString()).Should().Equal("John", "Doe"); - } - - [Fact] - public async Task ComputedProperty_EndsWithFunction() - { - var container = CreateContainer(("cp_ends", "SELECT VALUE ENDSWITH(c.name, 'e') FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "p", name = "Bob" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE c.cp_ends = true")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_ReplaceFunction() - { - var container = CreateContainer(("cp_replaced", "SELECT VALUE REPLACE(c.name, 'A', 'X') FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_replaced FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("Xlice"); - } - - [Fact] - public async Task ComputedProperty_DeeplyNestedPath() - { - var container = CreateContainer(("cp_deep", "SELECT VALUE c.a.b.c.d FROM c")); - var item = JObject.Parse("{\"id\":\"1\",\"pk\":\"p\",\"a\":{\"b\":{\"c\":{\"d\":\"deepval\"}}}}"); - await container.CreateItemAsync(item, new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_deep FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("deepval"); - } - - [Fact] - public async Task ComputedProperty_TernaryExpression() - { - var container = CreateContainer(("cp_label", "SELECT VALUE (c.age > 18 ? 'adult' : 'minor') FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", age = 25 }), - new PartitionKey("p")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "p", age = 10 }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_label FROM c ORDER BY c.id")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results[0]["cp_label"]!.ToString().Should().Be("adult"); - results[1]["cp_label"]!.ToString().Should().Be("minor"); - } + private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) + }; + return new InMemoryContainer(props); + } + + [Fact] + public async Task ComputedProperty_ReturnsObjectLiteral() + { + var container = CreateContainer(("cp_obj", "SELECT VALUE {\"lower\": LOWER(c.name), \"len\": LENGTH(c.name)} FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_obj FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var obj = results.Single()["cp_obj"]!; + obj["lower"]!.ToString().Should().Be("alice"); + ((int)obj["len"]!).Should().Be(5); + } + + [Fact] + public async Task ComputedProperty_ReturnsArrayLiteral() + { + var container = CreateContainer(("cp_arr", "SELECT VALUE [c.first, c.last] FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", first = "John", last = "Doe" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_arr FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var arr = (JArray)results.Single()["cp_arr"]!; + arr.Select(t => t.ToString()).Should().Equal("John", "Doe"); + } + + [Fact] + public async Task ComputedProperty_EndsWithFunction() + { + var container = CreateContainer(("cp_ends", "SELECT VALUE ENDSWITH(c.name, 'e') FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", name = "Bob" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE c.cp_ends = true")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_ReplaceFunction() + { + var container = CreateContainer(("cp_replaced", "SELECT VALUE REPLACE(c.name, 'A', 'X') FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_replaced FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("Xlice"); + } + + [Fact] + public async Task ComputedProperty_DeeplyNestedPath() + { + var container = CreateContainer(("cp_deep", "SELECT VALUE c.a.b.c.d FROM c")); + var item = JObject.Parse("{\"id\":\"1\",\"pk\":\"p\",\"a\":{\"b\":{\"c\":{\"d\":\"deepval\"}}}}"); + await container.CreateItemAsync(item, new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_deep FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("deepval"); + } + + [Fact] + public async Task ComputedProperty_TernaryExpression() + { + var container = CreateContainer(("cp_label", "SELECT VALUE (c.age > 18 ? 'adult' : 'minor') FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", age = 25 }), + new PartitionKey("p")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", age = 10 }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_label FROM c ORDER BY c.id")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results[0]["cp_label"]!.ToString().Should().Be("adult"); + results[1]["cp_label"]!.ToString().Should().Be("minor"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -344,224 +344,224 @@ await container.CreateItemAsync( public class ComputedPropertyEdgeCaseTests { - private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) - }; - return new InMemoryContainer(props); - } - - [Fact] - public async Task ComputedProperty_EmptyStringInput() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task ComputedProperty_NegativeNumberArithmetic() - { - var container = CreateContainer(("cp_neg", "SELECT VALUE c.price * -1 FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", price = 42 }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_neg FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(-42); - } - - [Fact] - public async Task ComputedProperty_MixedDefinedUndefined() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "p" }), // no "name" property - new PartitionKey("p")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "p", name = "Bob" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c WHERE IS_DEFINED(c.cp_lower)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Items 1 and 3 have "name", so cp_lower should be defined for them - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(new[] { "1", "3" }); - } - - [Fact] - public async Task ComputedProperty_StatePersistence_ExportImport() - { - var container = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "test" }), - new PartitionKey("p")); - - var state = container.ExportState(); - var target = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); - target.ImportState(state); - - var iter = target.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_upper FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("TEST"); - } - - [Fact] - public async Task ComputedProperty_MultipleContainersIsolated() - { - var c1 = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - var c2 = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); - - await c1.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), new PartitionKey("p")); - await c2.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), new PartitionKey("p")); - - var iter1 = c1.GetItemQueryIterator(new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var r1 = new List(); - while (iter1.HasMoreResults) r1.AddRange(await iter1.ReadNextAsync()); - r1.Should().ContainSingle().Which.Should().Be("test"); - - var iter2 = c2.GetItemQueryIterator(new QueryDefinition("SELECT VALUE c.cp_upper FROM c")); - var r2 = new List(); - while (iter2.HasMoreResults) r2.AddRange(await iter2.ReadNextAsync()); - r2.Should().ContainSingle().Which.Should().Be("TEST"); - } - - [Fact] - public async Task ComputedProperty_ClearItemsThenQuery() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), - new PartitionKey("p")); - - container.ClearItems(); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ComputedProperty_ZeroMultiplication() - { - var container = CreateContainer(("cp_zero", "SELECT VALUE c.price * 0 FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", price = 42 }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_zero FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task ComputedProperty_UnicodeInput() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "ÜNÏCÖDÉ" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("ünïcödé"); - } - - [Fact] - public async Task ComputedProperty_EvaluationOrderIndependent() - { - // Same CPs defined in different order should produce the same results - var c1 = CreateContainer( - ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c"), - ("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); - var c2 = CreateContainer( - ("cp_len", "SELECT VALUE LENGTH(c.name) FROM c"), - ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - - var item = new { id = "1", pk = "p", name = "Alice" }; - await c1.CreateItemAsync(JObject.FromObject(item), new PartitionKey("p")); - await c2.CreateItemAsync(JObject.FromObject(item), new PartitionKey("p")); - - var q = new QueryDefinition("SELECT c.cp_lower, c.cp_len FROM c"); - - var iter1 = c1.GetItemQueryIterator(q); - var r1 = new List(); - while (iter1.HasMoreResults) r1.AddRange(await iter1.ReadNextAsync()); - - var iter2 = c2.GetItemQueryIterator(q); - var r2 = new List(); - while (iter2.HasMoreResults) r2.AddRange(await iter2.ReadNextAsync()); - - r1.Single()["cp_lower"]!.ToString().Should().Be(r2.Single()["cp_lower"]!.ToString()); - ((int)r1.Single()["cp_len"]!).Should().Be((int)r2.Single()["cp_len"]!); - } - - [Fact] - public async Task ComputedProperty_WhitespaceInQuery() - { - // Extra whitespace/newlines in query text — should still work - var container = CreateContainer(("cp_lower", " SELECT VALUE LOWER( c.name ) FROM c ")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("test"); - } - - [Fact] - public async Task ComputedProperty_IsNullOnNullInput() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - // Item with explicit null name - var item = JObject.Parse("{\"id\":\"1\",\"pk\":\"p\",\"name\":null}"); - await container.CreateItemAsync(item, new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE IS_NULL(c.cp_lower)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // LOWER(null) behavior: in Cosmos this is undefined, so IS_NULL would return false - // But the emulator may treat it as null - results.Should().HaveCountGreaterThanOrEqualTo(0); // document actual behavior - } + private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) + }; + return new InMemoryContainer(props); + } + + [Fact] + public async Task ComputedProperty_EmptyStringInput() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task ComputedProperty_NegativeNumberArithmetic() + { + var container = CreateContainer(("cp_neg", "SELECT VALUE c.price * -1 FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", price = 42 }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_neg FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(-42); + } + + [Fact] + public async Task ComputedProperty_MixedDefinedUndefined() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p" }), // no "name" property + new PartitionKey("p")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "p", name = "Bob" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c WHERE IS_DEFINED(c.cp_lower)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Items 1 and 3 have "name", so cp_lower should be defined for them + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(new[] { "1", "3" }); + } + + [Fact] + public async Task ComputedProperty_StatePersistence_ExportImport() + { + var container = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "test" }), + new PartitionKey("p")); + + var state = container.ExportState(); + var target = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); + target.ImportState(state); + + var iter = target.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_upper FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("TEST"); + } + + [Fact] + public async Task ComputedProperty_MultipleContainersIsolated() + { + var c1 = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + var c2 = CreateContainer(("cp_upper", "SELECT VALUE UPPER(c.name) FROM c")); + + await c1.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), new PartitionKey("p")); + await c2.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), new PartitionKey("p")); + + var iter1 = c1.GetItemQueryIterator(new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var r1 = new List(); + while (iter1.HasMoreResults) r1.AddRange(await iter1.ReadNextAsync()); + r1.Should().ContainSingle().Which.Should().Be("test"); + + var iter2 = c2.GetItemQueryIterator(new QueryDefinition("SELECT VALUE c.cp_upper FROM c")); + var r2 = new List(); + while (iter2.HasMoreResults) r2.AddRange(await iter2.ReadNextAsync()); + r2.Should().ContainSingle().Which.Should().Be("TEST"); + } + + [Fact] + public async Task ComputedProperty_ClearItemsThenQuery() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), + new PartitionKey("p")); + + container.ClearItems(); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ComputedProperty_ZeroMultiplication() + { + var container = CreateContainer(("cp_zero", "SELECT VALUE c.price * 0 FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", price = 42 }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_zero FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task ComputedProperty_UnicodeInput() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "ÜNÏCÖDÉ" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("ünïcödé"); + } + + [Fact] + public async Task ComputedProperty_EvaluationOrderIndependent() + { + // Same CPs defined in different order should produce the same results + var c1 = CreateContainer( + ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c"), + ("cp_len", "SELECT VALUE LENGTH(c.name) FROM c")); + var c2 = CreateContainer( + ("cp_len", "SELECT VALUE LENGTH(c.name) FROM c"), + ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + + var item = new { id = "1", pk = "p", name = "Alice" }; + await c1.CreateItemAsync(JObject.FromObject(item), new PartitionKey("p")); + await c2.CreateItemAsync(JObject.FromObject(item), new PartitionKey("p")); + + var q = new QueryDefinition("SELECT c.cp_lower, c.cp_len FROM c"); + + var iter1 = c1.GetItemQueryIterator(q); + var r1 = new List(); + while (iter1.HasMoreResults) r1.AddRange(await iter1.ReadNextAsync()); + + var iter2 = c2.GetItemQueryIterator(q); + var r2 = new List(); + while (iter2.HasMoreResults) r2.AddRange(await iter2.ReadNextAsync()); + + r1.Single()["cp_lower"]!.ToString().Should().Be(r2.Single()["cp_lower"]!.ToString()); + ((int)r1.Single()["cp_len"]!).Should().Be((int)r2.Single()["cp_len"]!); + } + + [Fact] + public async Task ComputedProperty_WhitespaceInQuery() + { + // Extra whitespace/newlines in query text — should still work + var container = CreateContainer(("cp_lower", " SELECT VALUE LOWER( c.name ) FROM c ")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Test" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("test"); + } + + [Fact] + public async Task ComputedProperty_IsNullOnNullInput() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + // Item with explicit null name + var item = JObject.Parse("{\"id\":\"1\",\"pk\":\"p\",\"name\":null}"); + await container.CreateItemAsync(item, new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE IS_NULL(c.cp_lower)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // LOWER(null) behavior: in Cosmos this is undefined, so IS_NULL would return false + // But the emulator may treat it as null + results.Should().HaveCountGreaterThanOrEqualTo(0); // document actual behavior + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -570,162 +570,162 @@ public async Task ComputedProperty_IsNullOnNullInput() public class ComputedPropertyIntegrationTests { - private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) - }; - return new InMemoryContainer(props); - } - - [Fact] - public async Task ComputedProperty_StreamQueryIterator() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var iterator = container.GetItemQueryStreamIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); - using var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - var docs = (JArray)jObj["Documents"]!; - docs.Should().HaveCount(1); - docs[0]!["cp_lower"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task ComputedProperty_PaginatedQueryWithContinuation() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - for (int i = 0; i < 10; i++) - { - await container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "p", name = $"Name{i}" }), - new PartitionKey("p")); - } - - var allResults = new List(); - string? continuation = null; - do - { - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c ORDER BY c.id"), - continuation, - new QueryRequestOptions { MaxItemCount = 3 }); - var resp = await iter.ReadNextAsync(); - allResults.AddRange(resp); - continuation = resp.ContinuationToken; - } while (continuation != null); - - allResults.Should().HaveCount(10); - allResults.All(r => r["cp_lower"] != null).Should().BeTrue(); - } - - [Fact] - public async Task ComputedProperty_FakeCosmosHandler() - { - var backingContainer = new InMemoryContainer( - new ContainerProperties("mycontainer", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }); - - await backingContainer.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - using var handler = new FakeCosmosHandler(backingContainer); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var container = client.GetContainer("fakeDb", "mycontainer"); - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["cp_lower"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task ComputedProperty_FakeCosmosHandler_SelectStarExcludesCPs() - { - var backingContainer = new InMemoryContainer( - new ContainerProperties("mycontainer", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }); - - await backingContainer.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - using var handler = new FakeCosmosHandler(backingContainer); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var container = client.GetContainer("fakeDb", "mycontainer"); - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - var item = results.Single(); - item["name"]!.ToString().Should().Be("Alice"); - // SELECT * should NOT include computed properties - item["cp_lower"].Should().BeNull(); - } - - [Fact] - public async Task ComputedProperty_ReplaceItemAsync_ReEvaluates() - { - var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - // Replace with different name - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Bob" }), - "1", new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("bob"); - } + private static InMemoryContainer CreateContainer(params (string Name, string Query)[] defs) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + defs.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) + }; + return new InMemoryContainer(props); + } + + [Fact] + public async Task ComputedProperty_StreamQueryIterator() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var iterator = container.GetItemQueryStreamIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); + using var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + var docs = (JArray)jObj["Documents"]!; + docs.Should().HaveCount(1); + docs[0]!["cp_lower"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task ComputedProperty_PaginatedQueryWithContinuation() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + for (int i = 0; i < 10; i++) + { + await container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "p", name = $"Name{i}" }), + new PartitionKey("p")); + } + + var allResults = new List(); + string? continuation = null; + do + { + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c ORDER BY c.id"), + continuation, + new QueryRequestOptions { MaxItemCount = 3 }); + var resp = await iter.ReadNextAsync(); + allResults.AddRange(resp); + continuation = resp.ContinuationToken; + } while (continuation != null); + + allResults.Should().HaveCount(10); + allResults.All(r => r["cp_lower"] != null).Should().BeTrue(); + } + + [Fact] + public async Task ComputedProperty_FakeCosmosHandler() + { + var backingContainer = new InMemoryContainer( + new ContainerProperties("mycontainer", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }); + + await backingContainer.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + using var handler = new FakeCosmosHandler(backingContainer); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var container = client.GetContainer("fakeDb", "mycontainer"); + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["cp_lower"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task ComputedProperty_FakeCosmosHandler_SelectStarExcludesCPs() + { + var backingContainer = new InMemoryContainer( + new ContainerProperties("mycontainer", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }); + + await backingContainer.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + using var handler = new FakeCosmosHandler(backingContainer); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var container = client.GetContainer("fakeDb", "mycontainer"); + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + var item = results.Single(); + item["name"]!.ToString().Should().Be("Alice"); + // SELECT * should NOT include computed properties + item["cp_lower"].Should().BeNull(); + } + + [Fact] + public async Task ComputedProperty_ReplaceItemAsync_ReEvaluates() + { + var container = CreateContainer(("cp_lower", "SELECT VALUE LOWER(c.name) FROM c")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + // Replace with different name + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Bob" }), + "1", new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("bob"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -734,283 +734,283 @@ await container.ReplaceItemAsync( public class ComputedPropertyValidationDeepTests { - [Fact] - public void ComputedProperty_DuplicateNames_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE c.a FROM c" }, - new() { Name = "cp_x", Query = "SELECT VALUE c.b FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_Exactly20_Allowed() - { - var defs = Enumerable.Range(1, 20) - .Select(i => new ComputedProperty { Name = $"cp_{i}", Query = $"SELECT VALUE c.f{i} FROM c" }) - .ToList(); - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection(defs) - }; - var container = new InMemoryContainer(props); // Should not throw - container.Should().NotBeNull(); - } - - [Fact] - public void ComputedProperty_QueryWithOrderBy_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE c.name FROM c ORDER BY c.name" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_QueryWithGroupBy_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE COUNT(1) FROM c GROUP BY c.pk" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_QueryWithDistinct_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT DISTINCT VALUE c.name FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_SelfReferencing_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE c.cp_x FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_NullQuery_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = null! } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_EmptyQuery_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_CrossCPReference_OrderIndependent() - { - // CP-B references CP-A, but CP-A is defined AFTER CP-B — should still be caught - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_upper_of_lower", Query = "SELECT VALUE UPPER(c.cp_lower) FROM c" }, - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_CrossCPReference_NoFalsePositive() - { - // CP named "first" should NOT block a query referencing "c.first_name" - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_first", Query = "SELECT VALUE c.first FROM c" }, - new() { Name = "cp_full", Query = "SELECT VALUE c.first_name FROM c" } - } - }; - // Should NOT throw — "c.first_name" does not reference CP "cp_first" - var container = new InMemoryContainer(props); - container.Should().NotBeNull(); - } - - [Fact] - public void ComputedProperty_ReservedName_Ts() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "_ts", Query = "SELECT VALUE c.name FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_ReservedName_Etag() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "_etag", Query = "SELECT VALUE c.name FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_QueryWithTop_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE TOP 1 c.name FROM c" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_QueryWithJoin_ThrowsBadRequest() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE t FROM c JOIN t IN c.tags" } - } - }; - var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ComputedProperty_ReplaceContainerWithInvalidCP_ThrowsBadRequest() - { - var container = new InMemoryContainer( - new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_x", Query = "SELECT VALUE c.name FROM c" } - } - }); - - // Replace with invalid CP (> 20 CPs) - var defs = Enumerable.Range(1, 21) - .Select(i => new ComputedProperty { Name = $"cp_{i}", Query = $"SELECT VALUE c.f{i} FROM c" }) - .ToList(); - var newProps = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection(defs) - }; - - var ex = await Assert.ThrowsAnyAsync(() => - container.ReplaceContainerAsync(newProps)); - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ComputedProperty_IsDefinedOnCP_CorrectSemantics() - { - var container = new InMemoryContainer( - new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "p" }), // no name - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lower) ORDER BY c.id")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Only item 1 has "name" defined, so only item 1's CP should be defined - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_IsDefinedOnCP_TrueWhenSourceExists() - { - var container = new InMemoryContainer( - new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lower)")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + [Fact] + public void ComputedProperty_DuplicateNames_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE c.a FROM c" }, + new() { Name = "cp_x", Query = "SELECT VALUE c.b FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_Exactly20_Allowed() + { + var defs = Enumerable.Range(1, 20) + .Select(i => new ComputedProperty { Name = $"cp_{i}", Query = $"SELECT VALUE c.f{i} FROM c" }) + .ToList(); + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection(defs) + }; + var container = new InMemoryContainer(props); // Should not throw + container.Should().NotBeNull(); + } + + [Fact] + public void ComputedProperty_QueryWithOrderBy_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE c.name FROM c ORDER BY c.name" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_QueryWithGroupBy_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE COUNT(1) FROM c GROUP BY c.pk" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_QueryWithDistinct_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT DISTINCT VALUE c.name FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_SelfReferencing_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE c.cp_x FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_NullQuery_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = null! } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_EmptyQuery_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_CrossCPReference_OrderIndependent() + { + // CP-B references CP-A, but CP-A is defined AFTER CP-B — should still be caught + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_upper_of_lower", Query = "SELECT VALUE UPPER(c.cp_lower) FROM c" }, + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_CrossCPReference_NoFalsePositive() + { + // CP named "first" should NOT block a query referencing "c.first_name" + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_first", Query = "SELECT VALUE c.first FROM c" }, + new() { Name = "cp_full", Query = "SELECT VALUE c.first_name FROM c" } + } + }; + // Should NOT throw — "c.first_name" does not reference CP "cp_first" + var container = new InMemoryContainer(props); + container.Should().NotBeNull(); + } + + [Fact] + public void ComputedProperty_ReservedName_Ts() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "_ts", Query = "SELECT VALUE c.name FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_ReservedName_Etag() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "_etag", Query = "SELECT VALUE c.name FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_QueryWithTop_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE TOP 1 c.name FROM c" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_QueryWithJoin_ThrowsBadRequest() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE t FROM c JOIN t IN c.tags" } + } + }; + var ex = Assert.ThrowsAny(() => new InMemoryContainer(props)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ComputedProperty_ReplaceContainerWithInvalidCP_ThrowsBadRequest() + { + var container = new InMemoryContainer( + new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_x", Query = "SELECT VALUE c.name FROM c" } + } + }); + + // Replace with invalid CP (> 20 CPs) + var defs = Enumerable.Range(1, 21) + .Select(i => new ComputedProperty { Name = $"cp_{i}", Query = $"SELECT VALUE c.f{i} FROM c" }) + .ToList(); + var newProps = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection(defs) + }; + + var ex = await Assert.ThrowsAnyAsync(() => + container.ReplaceContainerAsync(newProps)); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ComputedProperty_IsDefinedOnCP_CorrectSemantics() + { + var container = new InMemoryContainer( + new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p" }), // no name + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lower) ORDER BY c.id")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Only item 1 has "name" defined, so only item 1's CP should be defined + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_IsDefinedOnCP_TrueWhenSourceExists() + { + var container = new InMemoryContainer( + new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lower)")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyTests.cs index 1ab74b0..9b1d2e1 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ComputedPropertyTests.cs @@ -14,777 +14,777 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class ComputedPropertyTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SELECT projection - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_ProjectedInSelect() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "BOB" }); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results.Select(r => r["cp_lowerName"]?.ToString()).Should().BeEquivalentTo("alice", "bob"); - } - - [Fact] - public async Task ComputedProperty_NotIncludedInSelectStar() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT * FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc["cp_lowerName"].Should().BeNull("SELECT * must not include computed properties"); - doc["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task ComputedProperty_ExplicitSelectWithPersistedAndComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.name, c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc["name"]!.ToString().Should().Be("Alice"); - doc["cp_lowerName"]!.ToString().Should().Be("alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // WHERE clause - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UsedInWhereClause() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lowerName = 'alice'"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().HaveCount(1); - response.First()["id"]!.ToString().Should().Be("1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ORDER BY - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UsedInOrderBy() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Charlie" }, - new { id = "2", pk = "p", name = "Alice" }, - new { id = "3", pk = "p", name = "Bob" }); - - var query = new QueryDefinition( - "SELECT c.id FROM c ORDER BY c.cp_lowerName ASC"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Select(r => r["id"]!.ToString()).Should().ContainInOrder("2", "3", "1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // GROUP BY - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UsedInGroupBy() - { - var container = CreateContainerWithComputedProperties( - ("cp_category", "SELECT VALUE LOWER(c.category) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", category = "Books" }, - new { id = "2", pk = "p", category = "books" }, - new { id = "3", pk = "p", category = "Electronics" }); - - var query = new QueryDefinition( - "SELECT c.cp_category, COUNT(1) AS cnt FROM c GROUP BY c.cp_category"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - var booksGroup = results.First(r => r["cp_category"]!.ToString() == "books"); - booksGroup["cnt"]!.Value().Should().Be(2); - - var elecGroup = results.First(r => r["cp_category"]!.ToString() == "electronics"); - elecGroup["cnt"]!.Value().Should().Be(1); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ═══════════════════════════════════════════════════════════════════════════ - // Multiple computed properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_MultipleOnSameContainer() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c"), - ("cp_upperName", "SELECT VALUE UPPER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_lowerName, c.cp_upperName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc["cp_lowerName"]!.ToString().Should().Be("alice"); - doc["cp_upperName"]!.ToString().Should().Be("ALICE"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ReplaceContainerAsync updates - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UpdatedViaReplaceContainer() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Verify original works - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iter1 = container.GetItemQueryIterator(query); - var res1 = await iter1.ReadNextAsync(); - res1.First()["cp_lowerName"]!.ToString().Should().Be("alice"); - - // Replace with a different computed property - var readResp = await container.ReadContainerAsync(); - var updatedProps = readResp.Resource; - updatedProps.ComputedProperties = new Collection - { - new ComputedProperty - { - Name = "cp_upperName", - Query = "SELECT VALUE UPPER(c.name) FROM c" - } - }; - await container.ReplaceContainerAsync(updatedProps); - - // Old computed property no longer exists - var query2 = new QueryDefinition("SELECT c.cp_upperName FROM c"); - var iter2 = container.GetItemQueryIterator(query2); - var res2 = await iter2.ReadNextAsync(); - res2.First()["cp_upperName"]!.ToString().Should().Be("ALICE"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Arithmetic expression - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_ArithmeticExpression() - { - var container = CreateContainerWithComputedProperties( - ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", price = 100.0 }); - - var query = new QueryDefinition("SELECT c.cp_discountedPrice FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc["cp_discountedPrice"]!.Value().Should().Be(80.0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // String concatenation - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_ConcatExpression() - { - var container = CreateContainerWithComputedProperties( - ("cp_fullName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", first = "Jane", last = "Doe" }); - - var query = new QueryDefinition("SELECT c.cp_fullName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_fullName"]!.ToString().Should().Be("Jane Doe"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Partition-scoped queries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_WithPartitionKeyFilter() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p1", name = "Alice" }, - new { id = "2", pk = "p2", name = "Bob" }); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var options = new QueryRequestOptions { PartitionKey = new PartitionKey("p1") }; - var iterator = container.GetItemQueryIterator(query, requestOptions: options); - var response = await iterator.ReadNextAsync(); - - response.Should().HaveCount(1); - response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // No regression for containers without computed properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_NoComputedProperties_ZeroOverhead() - { - var container = new InMemoryContainer("test", "/pk"); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.name FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().HaveCount(1); - response.First()["name"]!.ToString().Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Container read returns computed properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_StoredAndReturnedOnContainerRead() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - var response = await container.ReadContainerAsync(); - var cps = response.Resource.ComputedProperties; - - cps.Should().HaveCount(1); - cps[0].Name.Should().Be("cp_lowerName"); - cps[0].Query.Should().Be("SELECT VALUE LOWER(c.name) FROM c"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Document read does not include computed properties - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_DoesNotPersistOnDocument() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Direct point read — computed property should NOT be on the document - var readResponse = await container.ReadItemAsync("1", new PartitionKey("p")); - readResponse.Resource["cp_lowerName"].Should().BeNull( - "computed properties are virtual and not persisted on documents"); - readResponse.Resource["name"]!.ToString().Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 2: Query clause combinations - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UsedWithDistinct() - { - var container = CreateContainerWithComputedProperties( - ("cp_category", "SELECT VALUE LOWER(c.category) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", category = "Books" }, - new { id = "2", pk = "p", category = "books" }, - new { id = "3", pk = "p", category = "Electronics" }); - - var query = new QueryDefinition("SELECT DISTINCT c.cp_category FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ComputedProperty_UsedWithTop() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Charlie" }, - new { id = "2", pk = "p", name = "Alice" }, - new { id = "3", pk = "p", name = "Bob" }); - - var query = new QueryDefinition( - "SELECT TOP 2 c.cp_lowerName FROM c ORDER BY c.cp_lowerName"); - var results = new List(); - var iterator = container.GetItemQueryIterator(query); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); - results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); - } - - [Fact] - public async Task ComputedProperty_UsedWithOffsetLimit() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }, - new { id = "3", pk = "p", name = "Charlie" }); - - var query = new QueryDefinition( - "SELECT c.cp_lowerName FROM c ORDER BY c.cp_lowerName OFFSET 1 LIMIT 1"); - var results = new List(); - var iterator = container.GetItemQueryIterator(query); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle() - .Which["cp_lowerName"]!.ToString().Should().Be("bob"); - } - - [Fact] - public async Task ComputedProperty_UsedWithValueSelect() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var query = new QueryDefinition("SELECT VALUE c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Select(r => r.ToString()).Should().BeEquivalentTo("alice", "bob"); - } - - [Fact] - public async Task ComputedProperty_UsedInAggregateFunction() - { - var container = CreateContainerWithComputedProperties( - ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", price = 100.0 }, - new { id = "2", pk = "p", price = 200.0 }); - - var query = new QueryDefinition("SELECT VALUE SUM(c.cp_discountedPrice) FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Value().Should().Be(240.0); - } - - [Fact] - public async Task ComputedProperty_UsedWithAlias() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_lowerName AS lowered FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["lowered"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task ComputedProperty_UsedInWhereWithComparison() - { - var container = CreateContainerWithComputedProperties( - ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", price = 100.0 }, - new { id = "2", pk = "p", price = 200.0 }); - - var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_discountedPrice < 100"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_UsedInWhereWithBooleanLogic() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }, - new { id = "3", pk = "p", name = "Charlie" }); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.cp_lowerName = 'alice' OR c.cp_lowerName = 'bob'"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().HaveCount(2); - } - - [Fact] - public async Task ComputedProperty_UsedInExpressionInSelect() - { - var container = CreateContainerWithComputedProperties( - ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", price = 100.0 }); - - var query = new QueryDefinition( - "SELECT c.price - c.cp_discountedPrice AS savings FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["savings"]!.Value().Should().BeApproximately(20.0, 0.01); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 3: Expression types - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_NestedPropertyAccess() - { - var container = CreateContainerWithComputedProperties( - ("cp_city", "SELECT VALUE c.address.city FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", address = new { city = "London" } }); - - var query = new QueryDefinition("SELECT c.cp_city FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_city"]!.ToString().Should().Be("London"); - } - - [Fact] - public async Task ComputedProperty_MathFunctions() - { - var container = CreateContainerWithComputedProperties( - ("cp_rounded", "SELECT VALUE ROUND(c.price) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", price = 19.99 }); - - var query = new QueryDefinition("SELECT c.cp_rounded FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_rounded"]!.Value().Should().Be(20.0); - } - - [Fact] - public async Task ComputedProperty_StringLengthFunction() - { - var container = CreateContainerWithComputedProperties( - ("cp_nameLen", "SELECT VALUE LENGTH(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_nameLen FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_nameLen"]!.Value().Should().Be(5); - } - - [Fact] - public async Task ComputedProperty_BooleanExpression() - { - var container = CreateContainerWithComputedProperties( - ("cp_hasAli", "SELECT VALUE CONTAINS(c.name, 'ali', true) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var query = new QueryDefinition("SELECT c.id, c.cp_hasAli FROM c ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results[0]["cp_hasAli"]!.Value().Should().BeTrue(); - results[1]["cp_hasAli"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task ComputedProperty_TypeCheckFunction() - { - var container = CreateContainerWithComputedProperties( - ("cp_isStr", "SELECT VALUE IS_STRING(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_isStr FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_isStr"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ComputedProperty_ArrayFunction() - { - var container = CreateContainerWithComputedProperties( - ("cp_tagCount", "SELECT VALUE ARRAY_LENGTH(c.tags) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", tags = new[] { "a", "b", "c" } }); - - var query = new QueryDefinition("SELECT c.cp_tagCount FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_tagCount"]!.Value().Should().Be(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 4: Edge cases & lifecycle - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_EmptyComputedPropertiesCollection() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection() - }; - var container = new InMemoryContainer(props); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.name FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task ComputedProperty_EvaluatesPerItem() - { - var container = CreateContainerWithComputedProperties( - ("cp_discounted", "SELECT VALUE c.price * 0.9 FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", price = 100.0 }, - new { id = "2", pk = "p", price = 200.0 }, - new { id = "3", pk = "p", price = 300.0 }); - - var query = new QueryDefinition( - "SELECT c.id, c.cp_discounted FROM c ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results[0]["cp_discounted"]!.Value().Should().Be(90.0); - results[1]["cp_discounted"]!.Value().Should().Be(180.0); - results[2]["cp_discounted"]!.Value().Should().Be(270.0); - } - - [Fact] - public async Task ComputedProperty_ReEvaluatesAfterDocumentUpdate() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Verify initial value - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iter1 = container.GetItemQueryIterator(query); - (await iter1.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("alice"); - - // Update the document - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Bob" }), - new PartitionKey("p")); - - // CP should reflect the update - var iter2 = container.GetItemQueryIterator(query); - (await iter2.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("bob"); - } - - [Fact] - public async Task ComputedProperty_CrossPartitionQuery() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p1", name = "Alice" }, - new { id = "2", pk = "p2", name = "Bob" }); - - // Cross-partition (no partition key filter) - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c ORDER BY c.cp_lowerName"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); - results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); - } - - [Fact] - public async Task ComputedProperty_OldCPRemovedAfterReplace() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Replace with completely different CP - var readResp = await container.ReadContainerAsync(); - var props = readResp.Resource; - props.ComputedProperties = new Collection - { - new() { Name = "cp_upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } - }; - await container.ReplaceContainerAsync(props); - - // Old CP should return null - var query = new QueryDefinition("SELECT c.cp_lowerName, c.cp_upperName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - // Old CP should not evaluate to "alice" anymore — it is no longer a computed property - var oldVal = doc["cp_lowerName"]; - (oldVal is null || oldVal.Type == JTokenType.Null).Should().BeTrue( - "old CP should no longer be evaluated after replace — property should be absent or null"); - - doc["cp_upperName"]!.ToString().Should().Be("ALICE"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 5: Divergent behaviour - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_UndefinedPropagation_RealCosmos() - { - // When the source property is missing (undefined), LOWER(undefined) → undefined, - // meaning the computed property should be ABSENT from the result document entirely. - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", age = 30 }); // no "name" field - - var query = new QueryDefinition("SELECT c.id, c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - // Property should be absent (undefined), not present as null - doc["cp_lowerName"].Should().BeNull("property should be absent from the result entirely"); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SELECT projection + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_ProjectedInSelect() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "BOB" }); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results.Select(r => r["cp_lowerName"]?.ToString()).Should().BeEquivalentTo("alice", "bob"); + } + + [Fact] + public async Task ComputedProperty_NotIncludedInSelectStar() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT * FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc["cp_lowerName"].Should().BeNull("SELECT * must not include computed properties"); + doc["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task ComputedProperty_ExplicitSelectWithPersistedAndComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.name, c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc["name"]!.ToString().Should().Be("Alice"); + doc["cp_lowerName"]!.ToString().Should().Be("alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WHERE clause + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UsedInWhereClause() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lowerName = 'alice'"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().HaveCount(1); + response.First()["id"]!.ToString().Should().Be("1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ORDER BY + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UsedInOrderBy() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Charlie" }, + new { id = "2", pk = "p", name = "Alice" }, + new { id = "3", pk = "p", name = "Bob" }); + + var query = new QueryDefinition( + "SELECT c.id FROM c ORDER BY c.cp_lowerName ASC"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Select(r => r["id"]!.ToString()).Should().ContainInOrder("2", "3", "1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // GROUP BY + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UsedInGroupBy() + { + var container = CreateContainerWithComputedProperties( + ("cp_category", "SELECT VALUE LOWER(c.category) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", category = "Books" }, + new { id = "2", pk = "p", category = "books" }, + new { id = "3", pk = "p", category = "Electronics" }); + + var query = new QueryDefinition( + "SELECT c.cp_category, COUNT(1) AS cnt FROM c GROUP BY c.cp_category"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + var booksGroup = results.First(r => r["cp_category"]!.ToString() == "books"); + booksGroup["cnt"]!.Value().Should().Be(2); + + var elecGroup = results.First(r => r["cp_category"]!.ToString() == "electronics"); + elecGroup["cnt"]!.Value().Should().Be(1); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════════ + // Multiple computed properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_MultipleOnSameContainer() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c"), + ("cp_upperName", "SELECT VALUE UPPER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_lowerName, c.cp_upperName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc["cp_lowerName"]!.ToString().Should().Be("alice"); + doc["cp_upperName"]!.ToString().Should().Be("ALICE"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ReplaceContainerAsync updates + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UpdatedViaReplaceContainer() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Verify original works + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iter1 = container.GetItemQueryIterator(query); + var res1 = await iter1.ReadNextAsync(); + res1.First()["cp_lowerName"]!.ToString().Should().Be("alice"); + + // Replace with a different computed property + var readResp = await container.ReadContainerAsync(); + var updatedProps = readResp.Resource; + updatedProps.ComputedProperties = new Collection + { + new ComputedProperty + { + Name = "cp_upperName", + Query = "SELECT VALUE UPPER(c.name) FROM c" + } + }; + await container.ReplaceContainerAsync(updatedProps); + + // Old computed property no longer exists + var query2 = new QueryDefinition("SELECT c.cp_upperName FROM c"); + var iter2 = container.GetItemQueryIterator(query2); + var res2 = await iter2.ReadNextAsync(); + res2.First()["cp_upperName"]!.ToString().Should().Be("ALICE"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Arithmetic expression + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_ArithmeticExpression() + { + var container = CreateContainerWithComputedProperties( + ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", price = 100.0 }); + + var query = new QueryDefinition("SELECT c.cp_discountedPrice FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc["cp_discountedPrice"]!.Value().Should().Be(80.0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // String concatenation + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_ConcatExpression() + { + var container = CreateContainerWithComputedProperties( + ("cp_fullName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", first = "Jane", last = "Doe" }); + + var query = new QueryDefinition("SELECT c.cp_fullName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_fullName"]!.ToString().Should().Be("Jane Doe"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Partition-scoped queries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_WithPartitionKeyFilter() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p1", name = "Alice" }, + new { id = "2", pk = "p2", name = "Bob" }); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var options = new QueryRequestOptions { PartitionKey = new PartitionKey("p1") }; + var iterator = container.GetItemQueryIterator(query, requestOptions: options); + var response = await iterator.ReadNextAsync(); + + response.Should().HaveCount(1); + response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // No regression for containers without computed properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_NoComputedProperties_ZeroOverhead() + { + var container = new InMemoryContainer("test", "/pk"); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.name FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().HaveCount(1); + response.First()["name"]!.ToString().Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Container read returns computed properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_StoredAndReturnedOnContainerRead() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + var response = await container.ReadContainerAsync(); + var cps = response.Resource.ComputedProperties; + + cps.Should().HaveCount(1); + cps[0].Name.Should().Be("cp_lowerName"); + cps[0].Query.Should().Be("SELECT VALUE LOWER(c.name) FROM c"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Document read does not include computed properties + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_DoesNotPersistOnDocument() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Direct point read — computed property should NOT be on the document + var readResponse = await container.ReadItemAsync("1", new PartitionKey("p")); + readResponse.Resource["cp_lowerName"].Should().BeNull( + "computed properties are virtual and not persisted on documents"); + readResponse.Resource["name"]!.ToString().Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 2: Query clause combinations + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UsedWithDistinct() + { + var container = CreateContainerWithComputedProperties( + ("cp_category", "SELECT VALUE LOWER(c.category) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", category = "Books" }, + new { id = "2", pk = "p", category = "books" }, + new { id = "3", pk = "p", category = "Electronics" }); + + var query = new QueryDefinition("SELECT DISTINCT c.cp_category FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ComputedProperty_UsedWithTop() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Charlie" }, + new { id = "2", pk = "p", name = "Alice" }, + new { id = "3", pk = "p", name = "Bob" }); + + var query = new QueryDefinition( + "SELECT TOP 2 c.cp_lowerName FROM c ORDER BY c.cp_lowerName"); + var results = new List(); + var iterator = container.GetItemQueryIterator(query); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); + results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); + } + + [Fact] + public async Task ComputedProperty_UsedWithOffsetLimit() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }, + new { id = "3", pk = "p", name = "Charlie" }); + + var query = new QueryDefinition( + "SELECT c.cp_lowerName FROM c ORDER BY c.cp_lowerName OFFSET 1 LIMIT 1"); + var results = new List(); + var iterator = container.GetItemQueryIterator(query); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle() + .Which["cp_lowerName"]!.ToString().Should().Be("bob"); + } + + [Fact] + public async Task ComputedProperty_UsedWithValueSelect() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var query = new QueryDefinition("SELECT VALUE c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Select(r => r.ToString()).Should().BeEquivalentTo("alice", "bob"); + } + + [Fact] + public async Task ComputedProperty_UsedInAggregateFunction() + { + var container = CreateContainerWithComputedProperties( + ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", price = 100.0 }, + new { id = "2", pk = "p", price = 200.0 }); + + var query = new QueryDefinition("SELECT VALUE SUM(c.cp_discountedPrice) FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Value().Should().Be(240.0); + } + + [Fact] + public async Task ComputedProperty_UsedWithAlias() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_lowerName AS lowered FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["lowered"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task ComputedProperty_UsedInWhereWithComparison() + { + var container = CreateContainerWithComputedProperties( + ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", price = 100.0 }, + new { id = "2", pk = "p", price = 200.0 }); + + var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_discountedPrice < 100"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_UsedInWhereWithBooleanLogic() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }, + new { id = "3", pk = "p", name = "Charlie" }); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.cp_lowerName = 'alice' OR c.cp_lowerName = 'bob'"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().HaveCount(2); + } + + [Fact] + public async Task ComputedProperty_UsedInExpressionInSelect() + { + var container = CreateContainerWithComputedProperties( + ("cp_discountedPrice", "SELECT VALUE c.price * 0.8 FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", price = 100.0 }); + + var query = new QueryDefinition( + "SELECT c.price - c.cp_discountedPrice AS savings FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["savings"]!.Value().Should().BeApproximately(20.0, 0.01); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 3: Expression types + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_NestedPropertyAccess() + { + var container = CreateContainerWithComputedProperties( + ("cp_city", "SELECT VALUE c.address.city FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", address = new { city = "London" } }); + + var query = new QueryDefinition("SELECT c.cp_city FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_city"]!.ToString().Should().Be("London"); + } + + [Fact] + public async Task ComputedProperty_MathFunctions() + { + var container = CreateContainerWithComputedProperties( + ("cp_rounded", "SELECT VALUE ROUND(c.price) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", price = 19.99 }); + + var query = new QueryDefinition("SELECT c.cp_rounded FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_rounded"]!.Value().Should().Be(20.0); + } + + [Fact] + public async Task ComputedProperty_StringLengthFunction() + { + var container = CreateContainerWithComputedProperties( + ("cp_nameLen", "SELECT VALUE LENGTH(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_nameLen FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_nameLen"]!.Value().Should().Be(5); + } + + [Fact] + public async Task ComputedProperty_BooleanExpression() + { + var container = CreateContainerWithComputedProperties( + ("cp_hasAli", "SELECT VALUE CONTAINS(c.name, 'ali', true) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var query = new QueryDefinition("SELECT c.id, c.cp_hasAli FROM c ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results[0]["cp_hasAli"]!.Value().Should().BeTrue(); + results[1]["cp_hasAli"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task ComputedProperty_TypeCheckFunction() + { + var container = CreateContainerWithComputedProperties( + ("cp_isStr", "SELECT VALUE IS_STRING(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_isStr FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_isStr"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ComputedProperty_ArrayFunction() + { + var container = CreateContainerWithComputedProperties( + ("cp_tagCount", "SELECT VALUE ARRAY_LENGTH(c.tags) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", tags = new[] { "a", "b", "c" } }); + + var query = new QueryDefinition("SELECT c.cp_tagCount FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_tagCount"]!.Value().Should().Be(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 4: Edge cases & lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_EmptyComputedPropertiesCollection() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection() + }; + var container = new InMemoryContainer(props); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.name FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task ComputedProperty_EvaluatesPerItem() + { + var container = CreateContainerWithComputedProperties( + ("cp_discounted", "SELECT VALUE c.price * 0.9 FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", price = 100.0 }, + new { id = "2", pk = "p", price = 200.0 }, + new { id = "3", pk = "p", price = 300.0 }); + + var query = new QueryDefinition( + "SELECT c.id, c.cp_discounted FROM c ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results[0]["cp_discounted"]!.Value().Should().Be(90.0); + results[1]["cp_discounted"]!.Value().Should().Be(180.0); + results[2]["cp_discounted"]!.Value().Should().Be(270.0); + } + + [Fact] + public async Task ComputedProperty_ReEvaluatesAfterDocumentUpdate() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Verify initial value + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iter1 = container.GetItemQueryIterator(query); + (await iter1.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("alice"); + + // Update the document + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Bob" }), + new PartitionKey("p")); + + // CP should reflect the update + var iter2 = container.GetItemQueryIterator(query); + (await iter2.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("bob"); + } + + [Fact] + public async Task ComputedProperty_CrossPartitionQuery() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p1", name = "Alice" }, + new { id = "2", pk = "p2", name = "Bob" }); + + // Cross-partition (no partition key filter) + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c ORDER BY c.cp_lowerName"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); + results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); + } + + [Fact] + public async Task ComputedProperty_OldCPRemovedAfterReplace() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Replace with completely different CP + var readResp = await container.ReadContainerAsync(); + var props = readResp.Resource; + props.ComputedProperties = new Collection + { + new() { Name = "cp_upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } + }; + await container.ReplaceContainerAsync(props); + + // Old CP should return null + var query = new QueryDefinition("SELECT c.cp_lowerName, c.cp_upperName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + // Old CP should not evaluate to "alice" anymore — it is no longer a computed property + var oldVal = doc["cp_lowerName"]; + (oldVal is null || oldVal.Type == JTokenType.Null).Should().BeTrue( + "old CP should no longer be evaluated after replace — property should be absent or null"); + + doc["cp_upperName"]!.ToString().Should().Be("ALICE"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 5: Divergent behaviour + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_UndefinedPropagation_RealCosmos() + { + // When the source property is missing (undefined), LOWER(undefined) → undefined, + // meaning the computed property should be ABSENT from the result document entirely. + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", age = 30 }); // no "name" field + + var query = new QueryDefinition("SELECT c.id, c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + // Property should be absent (undefined), not present as null + doc["cp_lowerName"].Should().BeNull("property should be absent from the result entirely"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -793,79 +793,79 @@ public async Task ComputedProperty_UndefinedPropagation_RealCosmos() public class ComputedPropertyImplementationBugTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public async Task ComputedProperty_NotIncludedInSelectAliasStar_RealCosmos() - { - // EXPECTED REAL COSMOS: SELECT c.* should exclude computed properties, - // identical to SELECT * behaviour. - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.* FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc.ContainsKey("cp_lowerName").Should().BeFalse("SELECT c.* should exclude CPs like SELECT *"); - } - - [Fact] - public async Task ComputedProperty_ConcatWithUndefinedArg_RealCosmos() - { - // CONCAT with any undefined arg → entire result undefined - var container = CreateContainerWithComputedProperties( - ("cp_fullName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", first = "Jane" }); - - var query = new QueryDefinition("SELECT c.id, c.cp_fullName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var doc = response.First(); - - doc["cp_fullName"].Should().BeNull("CONCAT with undefined arg should not produce a value"); - } - - [Fact] - public async Task ComputedProperty_PatchComputedPropertyPath_RealCosmos() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Real Cosmos: this should throw 400 Bad Request - var act = () => container.PatchItemAsync( - "1", new PartitionKey("p"), - new[] { PatchOperation.Set("/cp_lowerName", "hacked") }); - await act.Should().ThrowAsync(); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public async Task ComputedProperty_NotIncludedInSelectAliasStar_RealCosmos() + { + // EXPECTED REAL COSMOS: SELECT c.* should exclude computed properties, + // identical to SELECT * behaviour. + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.* FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc.ContainsKey("cp_lowerName").Should().BeFalse("SELECT c.* should exclude CPs like SELECT *"); + } + + [Fact] + public async Task ComputedProperty_ConcatWithUndefinedArg_RealCosmos() + { + // CONCAT with any undefined arg → entire result undefined + var container = CreateContainerWithComputedProperties( + ("cp_fullName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", first = "Jane" }); + + var query = new QueryDefinition("SELECT c.id, c.cp_fullName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var doc = response.First(); + + doc["cp_fullName"].Should().BeNull("CONCAT with undefined arg should not produce a value"); + } + + [Fact] + public async Task ComputedProperty_PatchComputedPropertyPath_RealCosmos() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Real Cosmos: this should throw 400 Bad Request + var act = () => container.PatchItemAsync( + "1", new PartitionKey("p"), + new[] { PatchOperation.Set("/cp_lowerName", "hacked") }); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -874,169 +874,169 @@ public async Task ComputedProperty_PatchComputedPropertyPath_RealCosmos() public class ComputedPropertyUnfinishedPriorPlanTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public async Task ComputedProperty_SubstringWithIndexOf() - { - var container = CreateContainerWithComputedProperties( - ("cp_prefix", "SELECT VALUE SUBSTRING(c.categoryName, 0, INDEX_OF(c.categoryName, ',')) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", categoryName = "Bikes, Touring Bikes" }); - - var query = new QueryDefinition("SELECT c.cp_prefix FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_prefix"]!.ToString().Should().Be("Bikes"); - } - - [Fact] - public async Task ComputedProperty_ConditionalIIF() - { - var container = CreateContainerWithComputedProperties( - ("cp_ageGroup", "SELECT VALUE IIF(c.age >= 18, 'adult', 'minor') FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", age = 25 }, - new { id = "2", pk = "p", age = 10 }); - - var query = new QueryDefinition("SELECT c.id, c.cp_ageGroup FROM c ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results[0]["cp_ageGroup"]!.ToString().Should().Be("adult"); - results[1]["cp_ageGroup"]!.ToString().Should().Be("minor"); - } - - [Fact] - public async Task ComputedProperty_CoalesceExpression() - { - var container = CreateContainerWithComputedProperties( - ("cp_displayName", "SELECT VALUE c.nickname ?? c.name FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob", nickname = "Bobby" }); - - var query = new QueryDefinition("SELECT c.id, c.cp_displayName FROM c ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results[0]["cp_displayName"]!.ToString().Should().Be("Alice"); - results[1]["cp_displayName"]!.ToString().Should().Be("Bobby"); - } - - [Fact] - public async Task ComputedProperty_DifferentFromAlias() - { - // CP query using "root" instead of "c" as the FROM alias - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(root.name) FROM root")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task ComputedProperty_ReadItemStreamAsync_ExcludesComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var response = await container.ReadItemStreamAsync("1", new PartitionKey("p")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - - doc.ContainsKey("cp_lowerName").Should().BeFalse( - "point read (stream) should not include computed properties"); - } - - [Fact] - public async Task ComputedProperty_NameCollisionWithPersistedProperty() - { - var container = CreateContainerWithComputedProperties( - ("cp_name", "SELECT VALUE LOWER(c.name) FROM c")); - - // Document has a persisted field with the same name as the CP - var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice", cp_name = "Persisted" }); - await container.CreateItemAsync(jObj, new PartitionKey("p")); - - // In queries, the computed value should override the persisted value - var query = new QueryDefinition("SELECT c.cp_name FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_name"]!.ToString().Should().Be("alice", - "computed definition should take precedence over persisted value in queries"); - } - - [Fact] - public async Task ComputedProperty_ReplaceContainerStreamAsync_InvalidatesCache() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Verify initial CP works - var q1 = container.GetItemQueryIterator(new QueryDefinition("SELECT c.cp_lowerName FROM c")); - (await q1.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("alice"); - - // Replace via stream variant - var readResp = await container.ReadContainerAsync(); - var props = readResp.Resource; - props.ComputedProperties = new Collection - { - new() { Name = "cp_upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } - }; - - using var stream = new MemoryStream(); - using (var writer = new StreamWriter(stream, leaveOpen: true)) - { - await writer.WriteAsync(JsonConvert.SerializeObject(props)); - } - stream.Position = 0; - await container.ReplaceContainerStreamAsync(props); - - // New CP should work after stream replace - var q2 = container.GetItemQueryIterator(new QueryDefinition("SELECT c.cp_upperName FROM c")); - (await q2.ReadNextAsync()).First()["cp_upperName"]!.ToString().Should().Be("ALICE"); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public async Task ComputedProperty_SubstringWithIndexOf() + { + var container = CreateContainerWithComputedProperties( + ("cp_prefix", "SELECT VALUE SUBSTRING(c.categoryName, 0, INDEX_OF(c.categoryName, ',')) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", categoryName = "Bikes, Touring Bikes" }); + + var query = new QueryDefinition("SELECT c.cp_prefix FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_prefix"]!.ToString().Should().Be("Bikes"); + } + + [Fact] + public async Task ComputedProperty_ConditionalIIF() + { + var container = CreateContainerWithComputedProperties( + ("cp_ageGroup", "SELECT VALUE IIF(c.age >= 18, 'adult', 'minor') FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", age = 25 }, + new { id = "2", pk = "p", age = 10 }); + + var query = new QueryDefinition("SELECT c.id, c.cp_ageGroup FROM c ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results[0]["cp_ageGroup"]!.ToString().Should().Be("adult"); + results[1]["cp_ageGroup"]!.ToString().Should().Be("minor"); + } + + [Fact] + public async Task ComputedProperty_CoalesceExpression() + { + var container = CreateContainerWithComputedProperties( + ("cp_displayName", "SELECT VALUE c.nickname ?? c.name FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob", nickname = "Bobby" }); + + var query = new QueryDefinition("SELECT c.id, c.cp_displayName FROM c ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results[0]["cp_displayName"]!.ToString().Should().Be("Alice"); + results[1]["cp_displayName"]!.ToString().Should().Be("Bobby"); + } + + [Fact] + public async Task ComputedProperty_DifferentFromAlias() + { + // CP query using "root" instead of "c" as the FROM alias + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(root.name) FROM root")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task ComputedProperty_ReadItemStreamAsync_ExcludesComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var response = await container.ReadItemStreamAsync("1", new PartitionKey("p")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + + doc.ContainsKey("cp_lowerName").Should().BeFalse( + "point read (stream) should not include computed properties"); + } + + [Fact] + public async Task ComputedProperty_NameCollisionWithPersistedProperty() + { + var container = CreateContainerWithComputedProperties( + ("cp_name", "SELECT VALUE LOWER(c.name) FROM c")); + + // Document has a persisted field with the same name as the CP + var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice", cp_name = "Persisted" }); + await container.CreateItemAsync(jObj, new PartitionKey("p")); + + // In queries, the computed value should override the persisted value + var query = new QueryDefinition("SELECT c.cp_name FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_name"]!.ToString().Should().Be("alice", + "computed definition should take precedence over persisted value in queries"); + } + + [Fact] + public async Task ComputedProperty_ReplaceContainerStreamAsync_InvalidatesCache() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Verify initial CP works + var q1 = container.GetItemQueryIterator(new QueryDefinition("SELECT c.cp_lowerName FROM c")); + (await q1.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("alice"); + + // Replace via stream variant + var readResp = await container.ReadContainerAsync(); + var props = readResp.Resource; + props.ComputedProperties = new Collection + { + new() { Name = "cp_upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } + }; + + using var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, leaveOpen: true)) + { + await writer.WriteAsync(JsonConvert.SerializeObject(props)); + } + stream.Position = 0; + await container.ReplaceContainerStreamAsync(props); + + // New CP should work after stream replace + var q2 = container.GetItemQueryIterator(new QueryDefinition("SELECT c.cp_upperName FROM c")); + (await q2.ReadNextAsync()).First()["cp_upperName"]!.ToString().Should().Be("ALICE"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1045,221 +1045,221 @@ public async Task ComputedProperty_ReplaceContainerStreamAsync_InvalidatesCache( public class ComputedPropertyCrudLifecycleTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public async Task ComputedProperty_ReadManyItemsAsync_ExcludesComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var response = await container.ReadManyItemsAsync( - new List<(string, PartitionKey)> - { - ("1", new PartitionKey("p")), - ("2", new PartitionKey("p")) - }); - - foreach (var doc in response) - { - doc.ContainsKey("cp_lowerName").Should().BeFalse( - "ReadManyItemsAsync should not include computed properties"); - } - } - - [Fact] - public async Task ComputedProperty_ReadManyItemsStreamAsync_ExcludesComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("p")) }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().NotContain("cp_lowerName", - "ReadManyItemsStreamAsync should not include computed properties"); - } - - [Fact] - public async Task ComputedProperty_ChangeFeedDoesNotIncludeComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - items.AddRange(response); - } - - items.Should().NotBeEmpty(); - foreach (var doc in items) - { - doc.ContainsKey("cp_lowerName").Should().BeFalse( - "change feed should not include computed properties"); - } - } - - [Fact] - public async Task ComputedProperty_TransactionalBatchReadItem_ExcludesComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var batch = container.CreateTransactionalBatch(new PartitionKey("p")); - batch.ReadItem("1"); - var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = response.GetOperationResultAtIndex(0); - result.Resource.ContainsKey("cp_lowerName").Should().BeFalse( - "batch read should not include computed properties"); - } - - [Fact] - public async Task ComputedProperty_DeleteItemThenQuery_NoCPLeakage() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Verify CP before delete - var q1 = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_lowerName FROM c")); - (await q1.ReadNextAsync()).Should().ContainSingle() - .Which["cp_lowerName"]!.ToString().Should().Be("alice"); - - // Delete the item - await container.DeleteItemAsync("1", new PartitionKey("p")); - - // Query should return no results - var q2 = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_lowerName FROM c")); - var response = await q2.ReadNextAsync(); - response.Should().BeEmpty(); - } - - [Fact] - public async Task ComputedProperty_UpsertDoesNotPersistComputed() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - // Upsert with a field named same as CP - var updated = JObject.FromObject(new { id = "1", pk = "p", name = "Bob", cp_lowerName = "overridden" }); - await container.UpsertItemAsync(updated, new PartitionKey("p")); - - // Point read should show persisted value (not computed) - var readResp = await container.ReadItemAsync("1", new PartitionKey("p")); - readResp.Resource["cp_lowerName"]!.ToString().Should().Be("overridden", - "point read returns persisted data, not computed"); - - // Query should use computed value - var q = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.cp_lowerName FROM c")); - (await q.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("bob", - "query should use computed definition, not persisted value"); - } - - [Fact] - public async Task ComputedProperty_CreateContainerAsync_ViaDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var containerProps = new ContainerProperties("testcontainer", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }; - - var containerResponse = await db.CreateContainerAsync(containerProps); - var container = containerResponse.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task ComputedProperty_CreateContainerIfNotExistsAsync_ViaDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var containerProps = new ContainerProperties("testcontainer", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }; - - var containerResponse = await db.CreateContainerIfNotExistsAsync(containerProps); - var container = containerResponse.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), - new PartitionKey("p")); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public async Task ComputedProperty_ReadManyItemsAsync_ExcludesComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var response = await container.ReadManyItemsAsync( + new List<(string, PartitionKey)> + { + ("1", new PartitionKey("p")), + ("2", new PartitionKey("p")) + }); + + foreach (var doc in response) + { + doc.ContainsKey("cp_lowerName").Should().BeFalse( + "ReadManyItemsAsync should not include computed properties"); + } + } + + [Fact] + public async Task ComputedProperty_ReadManyItemsStreamAsync_ExcludesComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("p")) }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().NotContain("cp_lowerName", + "ReadManyItemsStreamAsync should not include computed properties"); + } + + [Fact] + public async Task ComputedProperty_ChangeFeedDoesNotIncludeComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + items.AddRange(response); + } + + items.Should().NotBeEmpty(); + foreach (var doc in items) + { + doc.ContainsKey("cp_lowerName").Should().BeFalse( + "change feed should not include computed properties"); + } + } + + [Fact] + public async Task ComputedProperty_TransactionalBatchReadItem_ExcludesComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var batch = container.CreateTransactionalBatch(new PartitionKey("p")); + batch.ReadItem("1"); + var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = response.GetOperationResultAtIndex(0); + result.Resource.ContainsKey("cp_lowerName").Should().BeFalse( + "batch read should not include computed properties"); + } + + [Fact] + public async Task ComputedProperty_DeleteItemThenQuery_NoCPLeakage() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Verify CP before delete + var q1 = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_lowerName FROM c")); + (await q1.ReadNextAsync()).Should().ContainSingle() + .Which["cp_lowerName"]!.ToString().Should().Be("alice"); + + // Delete the item + await container.DeleteItemAsync("1", new PartitionKey("p")); + + // Query should return no results + var q2 = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_lowerName FROM c")); + var response = await q2.ReadNextAsync(); + response.Should().BeEmpty(); + } + + [Fact] + public async Task ComputedProperty_UpsertDoesNotPersistComputed() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + // Upsert with a field named same as CP + var updated = JObject.FromObject(new { id = "1", pk = "p", name = "Bob", cp_lowerName = "overridden" }); + await container.UpsertItemAsync(updated, new PartitionKey("p")); + + // Point read should show persisted value (not computed) + var readResp = await container.ReadItemAsync("1", new PartitionKey("p")); + readResp.Resource["cp_lowerName"]!.ToString().Should().Be("overridden", + "point read returns persisted data, not computed"); + + // Query should use computed value + var q = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.cp_lowerName FROM c")); + (await q.ReadNextAsync()).First()["cp_lowerName"]!.ToString().Should().Be("bob", + "query should use computed definition, not persisted value"); + } + + [Fact] + public async Task ComputedProperty_CreateContainerAsync_ViaDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var containerProps = new ContainerProperties("testcontainer", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }; + + var containerResponse = await db.CreateContainerAsync(containerProps); + var container = containerResponse.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task ComputedProperty_CreateContainerIfNotExistsAsync_ViaDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var containerProps = new ContainerProperties("testcontainer", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }; + + var containerResponse = await db.CreateContainerIfNotExistsAsync(containerProps); + var container = containerResponse.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }), + new PartitionKey("p")); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_lowerName"]!.ToString().Should().Be("alice"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1268,262 +1268,262 @@ await container.CreateItemAsync( public class ComputedPropertyExpressionTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public async Task ComputedProperty_UsingSystemProperty_Ts() - { - var container = CreateContainerWithComputedProperties( - ("cp_timestamp", "SELECT VALUE c._ts FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_timestamp FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var ts = response.First()["cp_timestamp"]; - - ts.Should().NotBeNull(); - ts!.Value().Should().BeGreaterThan(0, "_ts system property should be accessible in CP"); - } - - [Fact] - public async Task ComputedProperty_TimestampToDateTime() - { - var container = CreateContainerWithComputedProperties( - ("cp_datetime", "SELECT VALUE TimestampToDateTime(c._ts * 1000) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); - - var query = new QueryDefinition("SELECT c.cp_datetime FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var dt = response.First()["cp_datetime"]!.ToString(); - - dt.Should().Contain("T", "should be a datetime string in ISO format"); - dt.Should().EndWith("Z", "should be UTC"); - } - - [Fact] - public async Task ComputedProperty_StringSplit() - { - var container = CreateContainerWithComputedProperties( - ("cp_skuParts", "SELECT VALUE StringSplit(c.sku, '-') FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", sku = "BK-T79U-50" }); - - var query = new QueryDefinition("SELECT c.cp_skuParts FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var parts = response.First()["cp_skuParts"] as JArray; - - parts.Should().NotBeNull(); - parts!.Select(t => t.ToString()).Should().BeEquivalentTo(new[] { "BK", "T79U", "50" }); - } - - [Fact] - public async Task ComputedProperty_ParameterizedQueryOnCP() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }); - - var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lowerName = @name") - .WithParameter("@name", "alice"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_InOperatorWithCP() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", name = "Bob" }, - new { id = "3", pk = "p", name = "Charlie" }); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.cp_lowerName IN ('alice', 'bob') ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results.Should().HaveCount(2); - results[0]["id"]!.ToString().Should().Be("1"); - results[1]["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task ComputedProperty_BetweenOperatorWithCP() - { - var container = CreateContainerWithComputedProperties( - ("cp_discounted", "SELECT VALUE c.price * 0.8 FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", price = 50.0 }, // 40 - new { id = "2", pk = "p", price = 100.0 }, // 80 - new { id = "3", pk = "p", price = 200.0 }); // 160 - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.cp_discounted BETWEEN 50 AND 100"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task ComputedProperty_ArrayContainsOnCPResult_RealCosmos() - { - var container = CreateContainerWithComputedProperties( - ("cp_tags", "SELECT VALUE c.tags FROM c")); - - var item1 = JObject.FromObject(new { id = "1", pk = "p" }); - item1["tags"] = new JArray("important", "urgent"); - await container.CreateItemAsync(item1, new PartitionKey("p")); - - var item2 = JObject.FromObject(new { id = "2", pk = "p" }); - item2["tags"] = new JArray("low"); - await container.CreateItemAsync(item2, new PartitionKey("p")); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE ARRAY_CONTAINS(c.cp_tags, 'important')"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ComputedProperty_IsDefinedOnCP() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Alice" }, - new { id = "2", pk = "p", age = 30 }); // no name - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lowerName) ORDER BY c.id"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - // Both items have the CP defined (emulator returns null for undefined source, - // which IS_DEFINED treats as defined) - response.Count.Should().BeGreaterThanOrEqualTo(1); - } - - [Fact] - public async Task ComputedProperty_ToStringConversion() - { - var container = CreateContainerWithComputedProperties( - ("cp_ageStr", "SELECT VALUE ToString(c.age) FROM c")); - - await SeedItems(container, new { id = "1", pk = "p", age = 25 }); - - var query = new QueryDefinition("SELECT c.cp_ageStr FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.First()["cp_ageStr"]!.ToString().Should().Be("25"); - } - - [Fact] - public async Task ComputedProperty_NullExplicitInput() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - // Explicit null value for name (not missing/undefined) - var jObj = JObject.FromObject(new { id = "1", pk = "p" }); - jObj["name"] = JValue.CreateNull(); - await container.CreateItemAsync(jObj, new PartitionKey("p")); - - var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - // Cosmos DB: LOWER(null) → undefined → computed property not set - response.First()["cp_lowerName"].Should().BeNull(); - } - - [Fact] - public async Task ComputedProperty_MultipleCPsUsedTogether() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c"), - ("cp_discounted", "SELECT VALUE c.price * 0.9 FROM c")); - - await SeedItems(container, - new { id = "1", pk = "p", name = "Charlie", price = 200.0 }, - new { id = "2", pk = "p", name = "Alice", price = 50.0 }, - new { id = "3", pk = "p", name = "Bob", price = 100.0 }); - - var query = new QueryDefinition( - "SELECT c.cp_lowerName, c.cp_discounted FROM c WHERE c.cp_discounted < 100 ORDER BY c.cp_lowerName"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results.Should().HaveCount(2); - results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); - results[0]["cp_discounted"]!.Value().Should().Be(45.0); - results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); - results[1]["cp_discounted"]!.Value().Should().Be(90.0); - } - - [Fact] - public async Task ComputedProperty_CPWithJoinQuery() - { - var container = CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); - - var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }); - jObj["tags"] = new JArray( - JObject.FromObject(new { name = "tag1" }), - JObject.FromObject(new { name = "tag2" })); - await container.CreateItemAsync(jObj, new PartitionKey("p")); - - var query = new QueryDefinition( - "SELECT c.cp_lowerName, t.name AS tagName FROM c JOIN t IN c.tags"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - var results = response.ToList(); - - results.Should().HaveCount(2); - results.All(r => r["cp_lowerName"]!.ToString() == "alice").Should().BeTrue(); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public async Task ComputedProperty_UsingSystemProperty_Ts() + { + var container = CreateContainerWithComputedProperties( + ("cp_timestamp", "SELECT VALUE c._ts FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_timestamp FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var ts = response.First()["cp_timestamp"]; + + ts.Should().NotBeNull(); + ts!.Value().Should().BeGreaterThan(0, "_ts system property should be accessible in CP"); + } + + [Fact] + public async Task ComputedProperty_TimestampToDateTime() + { + var container = CreateContainerWithComputedProperties( + ("cp_datetime", "SELECT VALUE TimestampToDateTime(c._ts * 1000) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", name = "Alice" }); + + var query = new QueryDefinition("SELECT c.cp_datetime FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var dt = response.First()["cp_datetime"]!.ToString(); + + dt.Should().Contain("T", "should be a datetime string in ISO format"); + dt.Should().EndWith("Z", "should be UTC"); + } + + [Fact] + public async Task ComputedProperty_StringSplit() + { + var container = CreateContainerWithComputedProperties( + ("cp_skuParts", "SELECT VALUE StringSplit(c.sku, '-') FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", sku = "BK-T79U-50" }); + + var query = new QueryDefinition("SELECT c.cp_skuParts FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var parts = response.First()["cp_skuParts"] as JArray; + + parts.Should().NotBeNull(); + parts!.Select(t => t.ToString()).Should().BeEquivalentTo(new[] { "BK", "T79U", "50" }); + } + + [Fact] + public async Task ComputedProperty_ParameterizedQueryOnCP() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }); + + var query = new QueryDefinition("SELECT c.id FROM c WHERE c.cp_lowerName = @name") + .WithParameter("@name", "alice"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_InOperatorWithCP() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", name = "Bob" }, + new { id = "3", pk = "p", name = "Charlie" }); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.cp_lowerName IN ('alice', 'bob') ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results.Should().HaveCount(2); + results[0]["id"]!.ToString().Should().Be("1"); + results[1]["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task ComputedProperty_BetweenOperatorWithCP() + { + var container = CreateContainerWithComputedProperties( + ("cp_discounted", "SELECT VALUE c.price * 0.8 FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", price = 50.0 }, // 40 + new { id = "2", pk = "p", price = 100.0 }, // 80 + new { id = "3", pk = "p", price = 200.0 }); // 160 + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.cp_discounted BETWEEN 50 AND 100"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task ComputedProperty_ArrayContainsOnCPResult_RealCosmos() + { + var container = CreateContainerWithComputedProperties( + ("cp_tags", "SELECT VALUE c.tags FROM c")); + + var item1 = JObject.FromObject(new { id = "1", pk = "p" }); + item1["tags"] = new JArray("important", "urgent"); + await container.CreateItemAsync(item1, new PartitionKey("p")); + + var item2 = JObject.FromObject(new { id = "2", pk = "p" }); + item2["tags"] = new JArray("low"); + await container.CreateItemAsync(item2, new PartitionKey("p")); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE ARRAY_CONTAINS(c.cp_tags, 'important')"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ComputedProperty_IsDefinedOnCP() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Alice" }, + new { id = "2", pk = "p", age = 30 }); // no name + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE IS_DEFINED(c.cp_lowerName) ORDER BY c.id"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + // Both items have the CP defined (emulator returns null for undefined source, + // which IS_DEFINED treats as defined) + response.Count.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task ComputedProperty_ToStringConversion() + { + var container = CreateContainerWithComputedProperties( + ("cp_ageStr", "SELECT VALUE ToString(c.age) FROM c")); + + await SeedItems(container, new { id = "1", pk = "p", age = 25 }); + + var query = new QueryDefinition("SELECT c.cp_ageStr FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.First()["cp_ageStr"]!.ToString().Should().Be("25"); + } + + [Fact] + public async Task ComputedProperty_NullExplicitInput() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + // Explicit null value for name (not missing/undefined) + var jObj = JObject.FromObject(new { id = "1", pk = "p" }); + jObj["name"] = JValue.CreateNull(); + await container.CreateItemAsync(jObj, new PartitionKey("p")); + + var query = new QueryDefinition("SELECT c.cp_lowerName FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + // Cosmos DB: LOWER(null) → undefined → computed property not set + response.First()["cp_lowerName"].Should().BeNull(); + } + + [Fact] + public async Task ComputedProperty_MultipleCPsUsedTogether() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c"), + ("cp_discounted", "SELECT VALUE c.price * 0.9 FROM c")); + + await SeedItems(container, + new { id = "1", pk = "p", name = "Charlie", price = 200.0 }, + new { id = "2", pk = "p", name = "Alice", price = 50.0 }, + new { id = "3", pk = "p", name = "Bob", price = 100.0 }); + + var query = new QueryDefinition( + "SELECT c.cp_lowerName, c.cp_discounted FROM c WHERE c.cp_discounted < 100 ORDER BY c.cp_lowerName"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results.Should().HaveCount(2); + results[0]["cp_lowerName"]!.ToString().Should().Be("alice"); + results[0]["cp_discounted"]!.Value().Should().Be(45.0); + results[1]["cp_lowerName"]!.ToString().Should().Be("bob"); + results[1]["cp_discounted"]!.Value().Should().Be(90.0); + } + + [Fact] + public async Task ComputedProperty_CPWithJoinQuery() + { + var container = CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT VALUE LOWER(c.name) FROM c")); + + var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }); + jObj["tags"] = new JArray( + JObject.FromObject(new { name = "tag1" }), + JObject.FromObject(new { name = "tag2" })); + await container.CreateItemAsync(jObj, new PartitionKey("p")); + + var query = new QueryDefinition( + "SELECT c.cp_lowerName, t.name AS tagName FROM c JOIN t IN c.tags"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + var results = response.ToList(); + + results.Should().HaveCount(2); + results.All(r => r["cp_lowerName"]!.ToString() == "alice").Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1532,75 +1532,75 @@ public async Task ComputedProperty_CPWithJoinQuery() public class ComputedPropertyValidationDivergentTests { - private static InMemoryContainer CreateContainerWithComputedProperties( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - definitions.Select(d => new ComputedProperty - { - Name = d.Name, - Query = d.Query - }).ToList()) - }; - return new InMemoryContainer(props); - } - - private static async Task SeedItems(InMemoryContainer container, params object[] items) - { - foreach (var item in items) - { - var jObj = JObject.FromObject(item); - await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); - } - } - - [Fact] - public void ComputedProperty_MaxLimit_RealCosmos() - { - var definitions = Enumerable.Range(1, 21) - .Select(i => ($"cp_{i}", $"SELECT VALUE c.field{i} FROM c")) - .ToArray(); - - var act = () => CreateContainerWithComputedProperties(definitions); - - act.Should().Throw(); - } - - [Fact] - public void ComputedProperty_ReservedNameValidation_RealCosmos() - { - var act = () => CreateContainerWithComputedProperties( - ("id", "SELECT VALUE LOWER(c.name) FROM c")); - - act.Should().Throw(); - } - - [Fact] - public void ComputedProperty_QueryMustBeSelectValue_RealCosmos() - { - var act = () => CreateContainerWithComputedProperties( - ("cp_lowerName", "SELECT LOWER(c.name) FROM c")); - act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_QueryCannotContainWhere_RealCosmos() - { - var act = () => CreateContainerWithComputedProperties( - ("cp_name", "SELECT VALUE c.name FROM c WHERE c.age > 18")); - act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public void ComputedProperty_CannotReferenceOtherCP_RealCosmos() - { - var act = () => CreateContainerWithComputedProperties( - ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c"), - ("cp_upper_of_lower", "SELECT VALUE UPPER(c.cp_lower) FROM c")); - act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private static InMemoryContainer CreateContainerWithComputedProperties( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + definitions.Select(d => new ComputedProperty + { + Name = d.Name, + Query = d.Query + }).ToList()) + }; + return new InMemoryContainer(props); + } + + private static async Task SeedItems(InMemoryContainer container, params object[] items) + { + foreach (var item in items) + { + var jObj = JObject.FromObject(item); + await container.CreateItemAsync(jObj, new PartitionKey(jObj["pk"]!.ToString())); + } + } + + [Fact] + public void ComputedProperty_MaxLimit_RealCosmos() + { + var definitions = Enumerable.Range(1, 21) + .Select(i => ($"cp_{i}", $"SELECT VALUE c.field{i} FROM c")) + .ToArray(); + + var act = () => CreateContainerWithComputedProperties(definitions); + + act.Should().Throw(); + } + + [Fact] + public void ComputedProperty_ReservedNameValidation_RealCosmos() + { + var act = () => CreateContainerWithComputedProperties( + ("id", "SELECT VALUE LOWER(c.name) FROM c")); + + act.Should().Throw(); + } + + [Fact] + public void ComputedProperty_QueryMustBeSelectValue_RealCosmos() + { + var act = () => CreateContainerWithComputedProperties( + ("cp_lowerName", "SELECT LOWER(c.name) FROM c")); + act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_QueryCannotContainWhere_RealCosmos() + { + var act = () => CreateContainerWithComputedProperties( + ("cp_name", "SELECT VALUE c.name FROM c WHERE c.age > 18")); + act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public void ComputedProperty_CannotReferenceOtherCP_RealCosmos() + { + var act = () => CreateContainerWithComputedProperties( + ("cp_lower", "SELECT VALUE LOWER(c.name) FROM c"), + ("cp_upper_of_lower", "SELECT VALUE UPPER(c.cp_lower) FROM c")); + act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1609,33 +1609,33 @@ public void ComputedProperty_CannotReferenceOtherCP_RealCosmos() public class ComputedPropertyLinqDivergentTests { - [Fact(Skip = "In real Cosmos DB, LINQ queries are translated to SQL and computed properties " + - "are available. In the emulator, LINQ queries bypass the SQL engine entirely " + - "and read directly from the in-memory store, so computed properties are not " + - "available in LINQ queries.")] - public void ComputedProperty_LinqQuery_RealCosmos() { } - - [Fact] - public async Task ComputedProperty_LinqQuery_DoesNotIncludeComputed() - { - // DIVERGENT: LINQ queries bypass the SQL engine — CPs are not available - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }; - var container = new InMemoryContainer(props); - - var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }); - await container.CreateItemAsync(jObj, new PartitionKey("p")); - - var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); - var results = queryable.ToList(); - - results.Should().ContainSingle(); - results[0].ContainsKey("cp_lowerName").Should().BeFalse( - "LINQ queries bypass SQL engine and do not evaluate computed properties"); - } + [Fact(Skip = "In real Cosmos DB, LINQ queries are translated to SQL and computed properties " + + "are available. In the emulator, LINQ queries bypass the SQL engine entirely " + + "and read directly from the in-memory store, so computed properties are not " + + "available in LINQ queries.")] + public void ComputedProperty_LinqQuery_RealCosmos() { } + + [Fact] + public async Task ComputedProperty_LinqQuery_DoesNotIncludeComputed() + { + // DIVERGENT: LINQ queries bypass the SQL engine — CPs are not available + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lowerName", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }; + var container = new InMemoryContainer(props); + + var jObj = JObject.FromObject(new { id = "1", pk = "p", name = "Alice" }); + await container.CreateItemAsync(jObj, new PartitionKey("p")); + + var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); + var results = queryable.ToList(); + + results.Should().ContainSingle(); + results[0].ContainsKey("cp_lowerName").Should().BeFalse( + "LINQ queries bypass SQL engine and do not evaluate computed properties"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyDeepDiveTests.cs index 3082d6e..c67efdb 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyDeepDiveTests.cs @@ -15,36 +15,36 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ConcurrentDeleteWithETagTests { - [Fact] - public async Task ConcurrentDeletes_WithETag_AtLeastOneSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - try - { - var response = await container.DeleteItemAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - return (int)response.StatusCode; - } - catch (CosmosException ex) { return (int)ex.StatusCode; } - }); - - var results = await Task.WhenAll(tasks); - - var deleted = results.Count(r => r == 204); - var notFound = results.Count(r => r == 404); - var preconditionFailed = results.Count(r => r == 412); - - deleted.Should().BeGreaterThanOrEqualTo(1); - (deleted + notFound + preconditionFailed).Should().Be(50); - } + [Fact] + public async Task ConcurrentDeletes_WithETag_AtLeastOneSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + try + { + var response = await container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + return (int)response.StatusCode; + } + catch (CosmosException ex) { return (int)ex.StatusCode; } + }); + + var results = await Task.WhenAll(tasks); + + var deleted = results.Count(r => r == 204); + var notFound = results.Count(r => r == 404); + var preconditionFailed = results.Count(r => r == 412); + + deleted.Should().BeGreaterThanOrEqualTo(1); + (deleted + notFound + preconditionFailed).Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -53,65 +53,65 @@ public async Task ConcurrentDeletes_WithETag_AtLeastOneSucceeds() public class ConcurrentDeleteAllByPKTests { - [Fact] - public async Task ConcurrentDeleteAllByPartitionKey_WhileWriting() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("pk1")); - - var writeTasks = Enumerable.Range(100, 10).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1"))); - - await Task.WhenAll(new[] { deleteTask }.Concat(writeTasks.Select(t => (Task)t))); - - // After completion: container state is consistent, no corruption - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Some items may survive (written after delete), but no corruption - results.Should().OnlyContain(r => r["id"] != null); - } - - [Fact] - public async Task ConcurrentDeleteAllByPartitionKey_MultiplePartitions() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 20; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"a{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = $"b{i}", PartitionKey = "pk2", Name = $"N{i}" }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = $"c{i}", PartitionKey = "pk3", Name = $"N{i}" }, - new PartitionKey("pk3")); - } - - await Task.WhenAll( - container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")), - container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk2"))); - - // pk3 items should survive intact - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk3") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(20); - } + [Fact] + public async Task ConcurrentDeleteAllByPartitionKey_WhileWriting() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + var deleteTask = container.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("pk1")); + + var writeTasks = Enumerable.Range(100, 10).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1"))); + + await Task.WhenAll(new[] { deleteTask }.Concat(writeTasks.Select(t => (Task)t))); + + // After completion: container state is consistent, no corruption + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Some items may survive (written after delete), but no corruption + results.Should().OnlyContain(r => r["id"] != null); + } + + [Fact] + public async Task ConcurrentDeleteAllByPartitionKey_MultiplePartitions() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 20; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"a{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = $"b{i}", PartitionKey = "pk2", Name = $"N{i}" }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = $"c{i}", PartitionKey = "pk3", Name = $"N{i}" }, + new PartitionKey("pk3")); + } + + await Task.WhenAll( + container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")), + container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk2"))); + + // pk3 items should survive intact + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk3") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(20); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -120,26 +120,26 @@ await Task.WhenAll( public class ConcurrentReplaceNonExistentTests { - [Fact] - public async Task ConcurrentReplace_NonExistentItem_AllGet404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.ReplaceItemAsync( - new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = $"V{i}" }, - "nonexistent", new PartitionKey("pk1")); - return 200; - } - catch (CosmosException ex) { return (int)ex.StatusCode; } - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r == 404); - } + [Fact] + public async Task ConcurrentReplace_NonExistentItem_AllGet404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.ReplaceItemAsync( + new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = $"V{i}" }, + "nonexistent", new PartitionKey("pk1")); + return 200; + } + catch (CosmosException ex) { return (int)ex.StatusCode; } + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r == 404); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -148,45 +148,45 @@ await container.ReplaceItemAsync( public class ConcurrentPatchAfterDeleteTests { - [Fact] - public async Task ConcurrentPatch_AfterDelete_Gets404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, - new PartitionKey("pk1")); - - var deleteTask = Task.Run(async () => - { - await Task.Delay(5); - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - }); - - var patchTasks = Enumerable.Range(0, 50).Select(async _ => - { - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - return 200; - } - catch (CosmosException ex) { return (int)ex.StatusCode; } - }); - - await Task.WhenAll(new[] { deleteTask }.Concat(patchTasks.Select(t => (Task)t))); - - // After delete, any remaining patches should get 404 - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - Assert.Fail("Should have thrown NotFound"); - } - catch (CosmosException ex) - { - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } + [Fact] + public async Task ConcurrentPatch_AfterDelete_Gets404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, + new PartitionKey("pk1")); + + var deleteTask = Task.Run(async () => + { + await Task.Delay(5); + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + }); + + var patchTasks = Enumerable.Range(0, 50).Select(async _ => + { + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + return 200; + } + catch (CosmosException ex) { return (int)ex.StatusCode; } + }); + + await Task.WhenAll(new[] { deleteTask }.Concat(patchTasks.Select(t => (Task)t))); + + // After delete, any remaining patches should get 404 + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + Assert.Fail("Should have thrown NotFound"); + } + catch (CosmosException ex) + { + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -195,37 +195,37 @@ await container.CreateItemAsync( public class ConcurrentClearItemsTests { - [Fact] - public async Task ConcurrentClearItems_DuringOperations() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - var readTasks = Enumerable.Range(0, 20).Select(async i => - { - try - { - await container.ReadItemAsync($"item{i}", new PartitionKey("pk1")); - return true; - } - catch { return false; } - }); - - var clearTask = Task.Run(() => container.ClearItems()); - - await Task.WhenAll(new[] { clearTask }.Concat(readTasks.Select(t => (Task)t))); - - // After clear, container should be empty - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().BeEmpty(); - } + [Fact] + public async Task ConcurrentClearItems_DuringOperations() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + var readTasks = Enumerable.Range(0, 20).Select(async i => + { + try + { + await container.ReadItemAsync($"item{i}", new PartitionKey("pk1")); + return true; + } + catch { return false; } + }); + + var clearTask = Task.Run(() => container.ClearItems()); + + await Task.WhenAll(new[] { clearTask }.Concat(readTasks.Select(t => (Task)t))); + + // After clear, container should be empty + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -234,36 +234,36 @@ await container.CreateItemAsync( public class ConcurrentImportStateTests { - [Fact] - public async Task ConcurrentImportState_DuringReads() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - var state = container.ExportState(); - container.ClearItems(); - - var readTasks = Enumerable.Range(0, 20).Select(async _ => - { - try - { - await container.ReadItemAsync("item0", new PartitionKey("pk1")); - return true; - } - catch { return false; } - }); - - var importTask = Task.Run(() => container.ImportState(state)); - - await Task.WhenAll(new[] { importTask }.Concat(readTasks.Select(t => (Task)t))); - - // After import: state should be consistent - var final = await container.ReadItemAsync("item0", new PartitionKey("pk1")); - final.Resource.Should().NotBeNull(); - } + [Fact] + public async Task ConcurrentImportState_DuringReads() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + var state = container.ExportState(); + container.ClearItems(); + + var readTasks = Enumerable.Range(0, 20).Select(async _ => + { + try + { + await container.ReadItemAsync("item0", new PartitionKey("pk1")); + return true; + } + catch { return false; } + }); + + var importTask = Task.Run(() => container.ImportState(state)); + + await Task.WhenAll(new[] { importTask }.Concat(readTasks.Select(t => (Task)t))); + + // After import: state should be consistent + var final = await container.ReadItemAsync("item0", new PartitionKey("pk1")); + final.Resource.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -272,31 +272,31 @@ await container.CreateItemAsync( public class ConcurrentMultiContainerTests { - [Fact] - public async Task ConcurrentOperations_MultipleContainers_SameDatabase() - { - var containers = Enumerable.Range(0, 5) - .Select(i => new InMemoryContainer($"container{i}", "/partitionKey")) - .ToList(); - - var tasks = containers.SelectMany((c, ci) => - Enumerable.Range(0, 10).Select(i => - c.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"C{ci}_N{i}" }, - new PartitionKey("pk1")))); - - await Task.WhenAll(tasks); - - // Each container should have exactly 10 items - foreach (var c in containers) - { - var iter = c.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(10); - } - } + [Fact] + public async Task ConcurrentOperations_MultipleContainers_SameDatabase() + { + var containers = Enumerable.Range(0, 5) + .Select(i => new InMemoryContainer($"container{i}", "/partitionKey")) + .ToList(); + + var tasks = containers.SelectMany((c, ci) => + Enumerable.Range(0, 10).Select(i => + c.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"C{ci}_N{i}" }, + new PartitionKey("pk1")))); + + await Task.WhenAll(tasks); + + // Each container should have exactly 10 items + foreach (var c in containers) + { + var iter = c.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(10); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -305,19 +305,19 @@ public async Task ConcurrentOperations_MultipleContainers_SameDatabase() public class ConcurrentPartitionKeyNoneTests { - [Fact] - public async Task ConcurrentOperations_PartitionKeyNone() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"item{i}" }), - PartitionKey.None)); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } + [Fact] + public async Task ConcurrentOperations_PartitionKeyNone() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"item{i}" }), + PartitionKey.None)); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -326,51 +326,51 @@ public async Task ConcurrentOperations_PartitionKeyNone() public class ConcurrentPITRTests { - [Fact] - public async Task ConcurrentPITR_DuringWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - // Add more items after the restore point - for (int i = 10; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - // Restore while concurrent writes happen - var writeTask = Task.Run(async () => - { - for (int i = 20; i < 30; i++) - { - try - { - await container.UpsertItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - } - catch { /* ignore during restore */ } - } - }); - - container.RestoreToPointInTime(restorePoint); - await writeTask; - - // Container should have items from restore point or written after restore - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // At minimum the 10 restored items should exist - results.Count.Should().BeGreaterThanOrEqualTo(10); - } + [Fact] + public async Task ConcurrentPITR_DuringWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + // Add more items after the restore point + for (int i = 10; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + // Restore while concurrent writes happen + var writeTask = Task.Run(async () => + { + for (int i = 20; i < 30; i++) + { + try + { + await container.UpsertItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + } + catch { /* ignore during restore */ } + } + }); + + container.RestoreToPointInTime(restorePoint); + await writeTask; + + // Container should have items from restore point or written after restore + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // At minimum the 10 restored items should exist + results.Count.Should().BeGreaterThanOrEqualTo(10); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -379,38 +379,38 @@ await container.UpsertItemAsync( public class ConcurrentFeedRangeQueryTests { - [Fact] - public async Task ConcurrentFeedRangeQuery_DuringWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = $"pk{i % 5}", Name = $"N{i}" }, - new PartitionKey($"pk{i % 5}")); - - var ranges = await container.GetFeedRangesAsync(); - - var writeTasks = Enumerable.Range(100, 20).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = $"pk{i % 5}", Name = $"N{i}" }, - new PartitionKey($"pk{i % 5}"))); - - var queryTasks = ranges.Select(async range => - { - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results.Count; - }); - - await Task.WhenAll(writeTasks.Select(t => (Task)t).Concat(queryTasks.Select(t => (Task)t))); - - // Queries should complete without error - var counts = await Task.WhenAll(queryTasks); - counts.Should().OnlyContain(c => c > 0); - } + [Fact] + public async Task ConcurrentFeedRangeQuery_DuringWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = $"pk{i % 5}", Name = $"N{i}" }, + new PartitionKey($"pk{i % 5}")); + + var ranges = await container.GetFeedRangesAsync(); + + var writeTasks = Enumerable.Range(100, 20).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = $"pk{i % 5}", Name = $"N{i}" }, + new PartitionKey($"pk{i % 5}"))); + + var queryTasks = ranges.Select(async range => + { + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results.Count; + }); + + await Task.WhenAll(writeTasks.Select(t => (Task)t).Concat(queryTasks.Select(t => (Task)t))); + + // Queries should complete without error + var counts = await Task.WhenAll(queryTasks); + counts.Should().OnlyContain(c => c > 0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -419,41 +419,41 @@ await container.CreateItemAsync( public class ConcurrentTriggerExecutionTests { - [Fact] - public async Task ConcurrentTriggerExecution_PreAndPost() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - container.RegisterTrigger("preTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(item => - { - item["triggered"] = true; - return item; - })); - - var postCount = 0; - container.RegisterTrigger("postTrigger", TriggerType.Post, TriggerOperation.Create, - (Action)(item => - { - Interlocked.Increment(ref postCount); - })); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"item{i}", partitionKey = "pk1" }), - new PartitionKey("pk1"), - new ItemRequestOptions - { - PreTriggers = new List { "preTrigger" }, - PostTriggers = new List { "postTrigger" } - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - // All post triggers should have fired - postCount.Should().Be(50); - } + [Fact] + public async Task ConcurrentTriggerExecution_PreAndPost() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + container.RegisterTrigger("preTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(item => + { + item["triggered"] = true; + return item; + })); + + var postCount = 0; + container.RegisterTrigger("postTrigger", TriggerType.Post, TriggerOperation.Create, + (Action)(item => + { + Interlocked.Increment(ref postCount); + })); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"item{i}", partitionKey = "pk1" }), + new PartitionKey("pk1"), + new ItemRequestOptions + { + PreTriggers = new List { "preTrigger" }, + PostTriggers = new List { "postTrigger" } + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + // All post triggers should have fired + postCount.Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -462,31 +462,31 @@ public async Task ConcurrentTriggerExecution_PreAndPost() public class ConcurrentDeleteStreamETagTests { - [Fact] - public async Task ConcurrentDelete_WithETag_StreamVariant() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - var response = await container.DeleteItemStreamAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - return (int)response.StatusCode; - }); - - var results = await Task.WhenAll(tasks); - var deleted = results.Count(r => r == 204); - var notFound = results.Count(r => r == 404); - var preconditionFailed = results.Count(r => r == 412); - - deleted.Should().BeGreaterThanOrEqualTo(1); - (deleted + notFound + preconditionFailed).Should().Be(50); - } + [Fact] + public async Task ConcurrentDelete_WithETag_StreamVariant() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + var response = await container.DeleteItemStreamAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + return (int)response.StatusCode; + }); + + var results = await Task.WhenAll(tasks); + var deleted = results.Count(r => r == 204); + var notFound = results.Count(r => r == 404); + var preconditionFailed = results.Count(r => r == 412); + + deleted.Should().BeGreaterThanOrEqualTo(1); + (deleted + notFound + preconditionFailed).Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -495,23 +495,23 @@ public async Task ConcurrentDelete_WithETag_StreamVariant() public class ConcurrentUpsertCreateTests { - [Fact] - public async Task ConcurrentUpsertCreate_SameId_CorrectStatusCodes() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - var codes = results.Select(r => (int)r.StatusCode).ToList(); - - // At least one 201 (Created), rest should be 200 (OK) - codes.Should().Contain(201); - codes.Should().OnlyContain(c => c == 201 || c == 200); - } + [Fact] + public async Task ConcurrentUpsertCreate_SameId_CorrectStatusCodes() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + var codes = results.Select(r => (int)r.StatusCode).ToList(); + + // At least one 201 (Created), rest should be 200 (OK) + codes.Should().Contain(201); + codes.Should().OnlyContain(c => c == 201 || c == 200); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -520,33 +520,33 @@ public async Task ConcurrentUpsertCreate_SameId_CorrectStatusCodes() public class ConcurrentReplaceStreamETagTests { - [Fact] - public async Task ConcurrentReplace_WithETag_StreamVariant() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await container.ReplaceItemStreamAsync( - stream, "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - return (int)response.StatusCode; - }); - - var results = await Task.WhenAll(tasks); - var succeeded = results.Count(r => r == 200); - var preconditionFailed = results.Count(r => r == 412); - - succeeded.Should().BeGreaterThanOrEqualTo(1); - (succeeded + preconditionFailed).Should().Be(50); - } + [Fact] + public async Task ConcurrentReplace_WithETag_StreamVariant() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await container.ReplaceItemStreamAsync( + stream, "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + return (int)response.StatusCode; + }); + + var results = await Task.WhenAll(tasks); + var succeeded = results.Count(r => r == 200); + var preconditionFailed = results.Count(r => r == 412); + + succeeded.Should().BeGreaterThanOrEqualTo(1); + (succeeded + preconditionFailed).Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -555,31 +555,31 @@ public async Task ConcurrentReplace_WithETag_StreamVariant() public class ConcurrentPatchAtomicTests { - [Fact] - public async Task ConcurrentPatch_MultipleOperations_Atomic() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Start", Value = 0, IsActive = false }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] - { - PatchOperation.Set("/name", $"V{i}"), - PatchOperation.Increment("/value", 1), - PatchOperation.Set("/isActive", true) - })); - - await Task.WhenAll(tasks); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - // All 50 increments should be applied (patches are serialized) - final.Resource.Value.Should().Be(50); - final.Resource.IsActive.Should().BeTrue(); - final.Resource.Name.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task ConcurrentPatch_MultipleOperations_Atomic() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Start", Value = 0, IsActive = false }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] + { + PatchOperation.Set("/name", $"V{i}"), + PatchOperation.Increment("/value", 1), + PatchOperation.Set("/isActive", true) + })); + + await Task.WhenAll(tasks); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + // All 50 increments should be applied (patches are serialized) + final.Resource.Value.Should().Be(50); + final.Resource.IsActive.Should().BeTrue(); + final.Resource.Name.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -588,47 +588,47 @@ await container.CreateItemAsync( public class ConcurrentComputedPropertyQueryTests { - [Fact] - public async Task ConcurrentComputedPropertyQuery() - { - var container = new InMemoryContainer( - new ContainerProperties("test", "/partitionKey") - { - ComputedProperties = new Collection - { - new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } - } - }); - - // Concurrent writes - var writeTasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"Name{i}" }, - new PartitionKey("pk1"))); - - // Concurrent CP queries - var queryTasks = Enumerable.Range(0, 10).Select(async _ => - { - await Task.Delay(10); - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - }); - - await Task.WhenAll(writeTasks.Select(t => (Task)t).Concat(queryTasks.Select(t => (Task)t))); - - // Final query should show all computed properties correctly - var finalIter = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); - var finalResults = new List(); - while (finalIter.HasMoreResults) finalResults.AddRange(await finalIter.ReadNextAsync()); - - finalResults.Should().HaveCount(50); - finalResults.Should().OnlyContain(r => - r["cp_lower"]!.ToString() == r["cp_lower"]!.ToString().ToLowerInvariant()); - } + [Fact] + public async Task ConcurrentComputedPropertyQuery() + { + var container = new InMemoryContainer( + new ContainerProperties("test", "/partitionKey") + { + ComputedProperties = new Collection + { + new() { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" } + } + }); + + // Concurrent writes + var writeTasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"Name{i}" }, + new PartitionKey("pk1"))); + + // Concurrent CP queries + var queryTasks = Enumerable.Range(0, 10).Select(async _ => + { + await Task.Delay(10); + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + }); + + await Task.WhenAll(writeTasks.Select(t => (Task)t).Concat(queryTasks.Select(t => (Task)t))); + + // Final query should show all computed properties correctly + var finalIter = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.cp_lower FROM c")); + var finalResults = new List(); + while (finalIter.HasMoreResults) finalResults.AddRange(await finalIter.ReadNextAsync()); + + finalResults.Should().HaveCount(50); + finalResults.Should().OnlyContain(r => + r["cp_lower"]!.ToString() == r["cp_lower"]!.ToString().ToLowerInvariant()); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -637,34 +637,34 @@ public async Task ConcurrentComputedPropertyQuery() public class ConcurrentChangeFeedCheckpointTests { - [Fact] - public async Task ConcurrentChangeFeed_Checkpoint_Consistency() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Concurrent writes - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1"))); - - await Task.WhenAll(tasks); - - // Read change feed — all 50 creates should be captured - var changes = new List(); - var changeFeed = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - - while (changeFeed.HasMoreResults) - { - var response = await changeFeed.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(response); - } - - changes.Should().HaveCount(50); - } + [Fact] + public async Task ConcurrentChangeFeed_Checkpoint_Consistency() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Concurrent writes + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1"))); + + await Task.WhenAll(tasks); + + // Read change feed — all 50 creates should be captured + var changes = new List(); + var changeFeed = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + + while (changeFeed.HasMoreResults) + { + var response = await changeFeed.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(response); + } + + changes.Should().HaveCount(50); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -673,36 +673,36 @@ public async Task ConcurrentChangeFeed_Checkpoint_Consistency() public class ConcurrentBatchConflictTests { - [Fact] - public async Task ConcurrentBatch_SameItems_ConflictDetected() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var batch1Task = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "conflict1", PartitionKey = "pk1", Name = "B1" }); - batch.CreateItem(new TestDocument { Id = "unique1", PartitionKey = "pk1", Name = "B1" }); - return await batch.ExecuteAsync(); - }); - - var batch2Task = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "conflict1", PartitionKey = "pk1", Name = "B2" }); - batch.CreateItem(new TestDocument { Id = "unique2", PartitionKey = "pk1", Name = "B2" }); - return await batch.ExecuteAsync(); - }); - - var results = await Task.WhenAll(batch1Task, batch2Task); - - // One batch should succeed, the other should fail on the conflicting ID - var successCount = results.Count(r => r.IsSuccessStatusCode); - var failCount = results.Count(r => !r.IsSuccessStatusCode); - - // At least one should succeed and items should be in valid state - successCount.Should().BeGreaterThanOrEqualTo(1); - } + [Fact] + public async Task ConcurrentBatch_SameItems_ConflictDetected() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var batch1Task = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "conflict1", PartitionKey = "pk1", Name = "B1" }); + batch.CreateItem(new TestDocument { Id = "unique1", PartitionKey = "pk1", Name = "B1" }); + return await batch.ExecuteAsync(); + }); + + var batch2Task = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "conflict1", PartitionKey = "pk1", Name = "B2" }); + batch.CreateItem(new TestDocument { Id = "unique2", PartitionKey = "pk1", Name = "B2" }); + return await batch.ExecuteAsync(); + }); + + var results = await Task.WhenAll(batch1Task, batch2Task); + + // One batch should succeed, the other should fail on the conflicting ID + var successCount = results.Count(r => r.IsSuccessStatusCode); + var failCount = results.Count(r => !r.IsSuccessStatusCode); + + // At least one should succeed and items should be in valid state + successCount.Should().BeGreaterThanOrEqualTo(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -711,41 +711,41 @@ public async Task ConcurrentBatch_SameItems_ConflictDetected() public class ConcurrentDeleteAndPatchTests { - [Fact] - public async Task ConcurrentDeleteAndPatch_SameItem_SafeOutcome() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, - new PartitionKey("pk1")); - - var deleteTasks = Enumerable.Range(0, 25).Select(async _ => - { - try - { - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - return "delete"; - } - catch (CosmosException) { return "delete-fail"; } - }); - - var patchTasks = Enumerable.Range(0, 25).Select(async _ => - { - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - return "patch"; - } - catch (CosmosException) { return "patch-fail"; } - }); - - var results = await Task.WhenAll(deleteTasks.Concat(patchTasks)); - - // No unhandled exceptions — all outcomes are expected - results.Should().OnlyContain(r => - r == "delete" || r == "delete-fail" || r == "patch" || r == "patch-fail"); - } + [Fact] + public async Task ConcurrentDeleteAndPatch_SameItem_SafeOutcome() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, + new PartitionKey("pk1")); + + var deleteTasks = Enumerable.Range(0, 25).Select(async _ => + { + try + { + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + return "delete"; + } + catch (CosmosException) { return "delete-fail"; } + }); + + var patchTasks = Enumerable.Range(0, 25).Select(async _ => + { + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + return "patch"; + } + catch (CosmosException) { return "patch-fail"; } + }); + + var results = await Task.WhenAll(deleteTasks.Concat(patchTasks)); + + // No unhandled exceptions — all outcomes are expected + results.Should().OnlyContain(r => + r == "delete" || r == "delete-fail" || r == "patch" || r == "patch-fail"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -754,41 +754,41 @@ await container.CreateItemAsync( public class ConcurrentETagTOCTOUTests { - [Fact] - public async Task ETagTOCTOU_OnlyOneWriteSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - var response = await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - return (int)response.StatusCode; - } - catch (CosmosException ex) { return (int)ex.StatusCode; } - }); - - var results = await Task.WhenAll(tasks); - - // CheckIfMatch is now inside _itemLocks — exactly 1 should succeed - var succeeded = results.Count(r => r == 200); - var preconditionFailed = results.Count(r => r == 412); - - succeeded.Should().Be(1); - preconditionFailed.Should().Be(49); - - // Item should be in a valid state - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Should().NotBeNull(); - } + [Fact] + public async Task ETagTOCTOU_OnlyOneWriteSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + var response = await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + return (int)response.StatusCode; + } + catch (CosmosException ex) { return (int)ex.StatusCode; } + }); + + var results = await Task.WhenAll(tasks); + + // CheckIfMatch is now inside _itemLocks — exactly 1 should succeed + var succeeded = results.Count(r => r == 200); + var preconditionFailed = results.Count(r => r == 412); + + succeeded.Should().Be(1); + preconditionFailed.Should().Be(49); + + // Item should be in a valid state + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -797,37 +797,37 @@ public async Task ETagTOCTOU_OnlyOneWriteSucceeds() public class ConcurrentBatchRestoreTests { - [Fact(Skip = "RestoreSnapshot clears dictionaries then re-populates, creating a brief window " + - "where concurrent reads see empty/partial state. Real Cosmos batches are " + - "isolated at the partition level.")] - public void RestoreSnapshot_ConcurrentReadsAlwaysSeeConsistentState_RealCosmos() { } - - [Fact] - public async Task RestoreSnapshot_EmulatorBehaviour_ConcurrentReadsMaySeePartialState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 10 items - for (int i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, - new PartitionKey("pk1")); - - // Execute a batch that will fail (trigger rollback via RestoreSnapshot) - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); - batch.CreateItem(new TestDocument { Id = "item0", PartitionKey = "pk1", Name = "Dup" }); // conflict - - var batchResult = await batch.ExecuteAsync(); - batchResult.IsSuccessStatusCode.Should().BeFalse(); - - // After rollback: original items should be intact - for (int i = 0; i < 10; i++) - { - var item = await container.ReadItemAsync($"item{i}", new PartitionKey("pk1")); - item.Resource.Should().NotBeNull(); - } - } + [Fact(Skip = "RestoreSnapshot clears dictionaries then re-populates, creating a brief window " + + "where concurrent reads see empty/partial state. Real Cosmos batches are " + + "isolated at the partition level.")] + public void RestoreSnapshot_ConcurrentReadsAlwaysSeeConsistentState_RealCosmos() { } + + [Fact] + public async Task RestoreSnapshot_EmulatorBehaviour_ConcurrentReadsMaySeePartialState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 10 items + for (int i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item{i}", PartitionKey = "pk1", Name = $"N{i}" }, + new PartitionKey("pk1")); + + // Execute a batch that will fail (trigger rollback via RestoreSnapshot) + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); + batch.CreateItem(new TestDocument { Id = "item0", PartitionKey = "pk1", Name = "Dup" }); // conflict + + var batchResult = await batch.ExecuteAsync(); + batchResult.IsSuccessStatusCode.Should().BeFalse(); + + // After rollback: original items should be intact + for (int i = 0; i < 10; i++) + { + var item = await container.ReadItemAsync($"item{i}", new PartitionKey("pk1")); + item.Resource.Should().NotBeNull(); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -836,30 +836,30 @@ await container.CreateItemAsync( public class ConcurrentPatchReplaceLockTests { - [Fact] - public async Task PatchAndReplace_SameItem_WritesAreSerialized() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, - new PartitionKey("pk1")); - - var patchTasks = Enumerable.Range(0, 25).Select(_ => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) })); - - var replaceTasks = Enumerable.Range(0, 25).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}", Value = 1000 }, - "1", new PartitionKey("pk1"))); - - await Task.WhenAll(patchTasks.Select(t => (Task)t).Concat(replaceTasks.Select(t => (Task)t))); - - // Patch and Replace now use the same _itemLocks — writes are serialized per item. - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Should().NotBeNull(); - final.Resource.Id.Should().Be("1"); - } + [Fact] + public async Task PatchAndReplace_SameItem_WritesAreSerialized() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, + new PartitionKey("pk1")); + + var patchTasks = Enumerable.Range(0, 25).Select(_ => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) })); + + var replaceTasks = Enumerable.Range(0, 25).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}", Value = 1000 }, + "1", new PartitionKey("pk1"))); + + await Task.WhenAll(patchTasks.Select(t => (Task)t).Concat(replaceTasks.Select(t => (Task)t))); + + // Patch and Replace now use the same _itemLocks — writes are serialized per item. + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Should().NotBeNull(); + final.Resource.Id.Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -870,105 +870,105 @@ await container.CreateItemAsync( public class ConcurrentPatchAfterUpsertTests { - [Fact] - public async Task ConcurrentPatch_WithUpsert_FinalValueIsConsistent() - { - // Same structural pattern as ConcurrentPatch_AfterDelete_Gets404: - // 1. Create item with Value=0 - // 2. Fire concurrent patches (increment by 1) alongside an upsert (Value=1000) - // 3. AFTER all settle, do one more patch (+1) and read - // 4. Post-condition: Value must be > 1000 if upsert ran and a patch came after, - // OR >= 1 if all patches ran before the upsert. Either way, the item - // must exist and its value must be an integer (not corrupted). - // - // Without the lock on upsert, a patch could read Value=0, then upsert - // sets 1000, then patch writes back 1 — losing the upsert. A subsequent - // patch+read would see a small value when it should see >= 1000. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, - new PartitionKey("pk1")); - - var upsertTask = Task.Run(async () => - { - await Task.Delay(5); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted", Value = 1000 }, - new PartitionKey("pk1")); - }); - - var patchTasks = Enumerable.Range(0, 50).Select(async _ => - { - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - } - catch (CosmosException) { } - }); - - await Task.WhenAll(new[] { upsertTask }.Concat(patchTasks)); - - // Post-condition: upsert set Value=1000, and the item lock ensures - // no patch can overwrite it with a stale read-modify-write value. - // The item must exist, be valid, and have Value >= 0. - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Should().NotBeNull(); - final.Resource.Id.Should().Be("1"); - } - - [Fact] - public async Task ConcurrentUpsert_AfterDelete_ItemMustNotBeResurrected() - { - // Same pattern as ConcurrentPatch_AfterDelete_Gets404 but with upsert. - // Without the lock on upsert, a concurrent upsert could check existence, - // then delete removes the item, then upsert writes to _items/_etags/_timestamps - // in a non-atomic sequence — potentially leaving partial state. - // - // With the lock, upsert and delete serialize. After this sequence: - // delete first -> upsert re-creates -> final read sees it. OR - // upsert first -> delete removes -> final read gets 404. - // Either outcome is valid, but the item must be in a clean state. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, - new PartitionKey("pk1")); - - // Delete, then immediately upsert + more deletes racing - var tasks = new List(); - tasks.Add(container.DeleteItemAsync("1", new PartitionKey("pk1"))); - tasks.AddRange(Enumerable.Range(0, 10).Select(i => Task.Run(async () => - { - try - { - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Upsert{i}", Value = i }, - new PartitionKey("pk1")); - } - catch (CosmosException) { } - }))); - tasks.AddRange(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => - { - try { await container.DeleteItemAsync("1", new PartitionKey("pk1")); } - catch (CosmosException) { } - }))); - - await Task.WhenAll(tasks); - - // Post-condition: the item is either fully present (with valid etag, - // timestamp, and enriched system properties) or fully absent. - // No partial state allowed. - try - { - var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); - result.Resource.Id.Should().Be("1"); - result.ETag.Should().NotBeNullOrEmpty(); - } - catch (CosmosException ex) - { - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } + [Fact] + public async Task ConcurrentPatch_WithUpsert_FinalValueIsConsistent() + { + // Same structural pattern as ConcurrentPatch_AfterDelete_Gets404: + // 1. Create item with Value=0 + // 2. Fire concurrent patches (increment by 1) alongside an upsert (Value=1000) + // 3. AFTER all settle, do one more patch (+1) and read + // 4. Post-condition: Value must be > 1000 if upsert ran and a patch came after, + // OR >= 1 if all patches ran before the upsert. Either way, the item + // must exist and its value must be an integer (not corrupted). + // + // Without the lock on upsert, a patch could read Value=0, then upsert + // sets 1000, then patch writes back 1 — losing the upsert. A subsequent + // patch+read would see a small value when it should see >= 1000. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, + new PartitionKey("pk1")); + + var upsertTask = Task.Run(async () => + { + await Task.Delay(5); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted", Value = 1000 }, + new PartitionKey("pk1")); + }); + + var patchTasks = Enumerable.Range(0, 50).Select(async _ => + { + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + } + catch (CosmosException) { } + }); + + await Task.WhenAll(new[] { upsertTask }.Concat(patchTasks)); + + // Post-condition: upsert set Value=1000, and the item lock ensures + // no patch can overwrite it with a stale read-modify-write value. + // The item must exist, be valid, and have Value >= 0. + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Should().NotBeNull(); + final.Resource.Id.Should().Be("1"); + } + + [Fact] + public async Task ConcurrentUpsert_AfterDelete_ItemMustNotBeResurrected() + { + // Same pattern as ConcurrentPatch_AfterDelete_Gets404 but with upsert. + // Without the lock on upsert, a concurrent upsert could check existence, + // then delete removes the item, then upsert writes to _items/_etags/_timestamps + // in a non-atomic sequence — potentially leaving partial state. + // + // With the lock, upsert and delete serialize. After this sequence: + // delete first -> upsert re-creates -> final read sees it. OR + // upsert first -> delete removes -> final read gets 404. + // Either outcome is valid, but the item must be in a clean state. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, + new PartitionKey("pk1")); + + // Delete, then immediately upsert + more deletes racing + var tasks = new List(); + tasks.Add(container.DeleteItemAsync("1", new PartitionKey("pk1"))); + tasks.AddRange(Enumerable.Range(0, 10).Select(i => Task.Run(async () => + { + try + { + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Upsert{i}", Value = i }, + new PartitionKey("pk1")); + } + catch (CosmosException) { } + }))); + tasks.AddRange(Enumerable.Range(0, 10).Select(_ => Task.Run(async () => + { + try { await container.DeleteItemAsync("1", new PartitionKey("pk1")); } + catch (CosmosException) { } + }))); + + await Task.WhenAll(tasks); + + // Post-condition: the item is either fully present (with valid etag, + // timestamp, and enriched system properties) or fully absent. + // No partial state allowed. + try + { + var result = await container.ReadItemAsync("1", new PartitionKey("pk1")); + result.Resource.Id.Should().Be("1"); + result.ETag.Should().NotBeNullOrEmpty(); + } + catch (CosmosException ex) + { + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -977,60 +977,60 @@ await container.UpsertItemAsync( public class ConcurrentPatchDuringCreateTests { - [Fact] - public async Task ConcurrentCreate_WithPatch_PatchSeesCompleteItem() - { - // Without the create lock, there's a window between _items.TryAdd - // and setting _etags/_timestamps/enrichment where a concurrent patch - // could read the un-enriched item (no _ts, no _etag, no _rid). - // With the lock, patches wait until create fully completes. - // - // We prove this by having patches immediately after create, then - // verifying system properties are always present. - var container = new InMemoryContainer("test", "/partitionKey"); - - var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var createTask = Task.Run(async () => - { - await barrier.Task; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created", Value = 100 }, - new PartitionKey("pk1")); - }); - - var patchResults = new System.Collections.Concurrent.ConcurrentBag<(int status, string json)>(); - var patchTasks = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => - { - await barrier.Task; - try - { - var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - return 200; - } - catch (CosmosException ex) { return (int)ex.StatusCode; } - })); - - barrier.SetResult(); - var results = await Task.WhenAll(patchTasks); - await createTask; - - // Every patch either succeeded (200) or got 404 (item didn't exist yet). - results.Should().OnlyContain(r => r == 200 || r == 404); - - // Post-condition: item must have valid system properties - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Should().NotBeNull(); - final.ETag.Should().NotBeNullOrEmpty(); - - // Read raw to verify system properties are present - var rawResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - using var reader = new StreamReader(rawResponse.Content); - var rawJson = JObject.Parse(await reader.ReadToEndAsync()); - rawJson["_etag"].Should().NotBeNull(); - rawJson["_ts"].Should().NotBeNull(); - } + [Fact] + public async Task ConcurrentCreate_WithPatch_PatchSeesCompleteItem() + { + // Without the create lock, there's a window between _items.TryAdd + // and setting _etags/_timestamps/enrichment where a concurrent patch + // could read the un-enriched item (no _ts, no _etag, no _rid). + // With the lock, patches wait until create fully completes. + // + // We prove this by having patches immediately after create, then + // verifying system properties are always present. + var container = new InMemoryContainer("test", "/partitionKey"); + + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var createTask = Task.Run(async () => + { + await barrier.Task; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created", Value = 100 }, + new PartitionKey("pk1")); + }); + + var patchResults = new System.Collections.Concurrent.ConcurrentBag<(int status, string json)>(); + var patchTasks = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => + { + await barrier.Task; + try + { + var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + return 200; + } + catch (CosmosException ex) { return (int)ex.StatusCode; } + })); + + barrier.SetResult(); + var results = await Task.WhenAll(patchTasks); + await createTask; + + // Every patch either succeeded (200) or got 404 (item didn't exist yet). + results.Should().OnlyContain(r => r == 200 || r == 404); + + // Post-condition: item must have valid system properties + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Should().NotBeNull(); + final.ETag.Should().NotBeNullOrEmpty(); + + // Read raw to verify system properties are present + var rawResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + using var reader = new StreamReader(rawResponse.Content); + var rawJson = JObject.Parse(await reader.ReadToEndAsync()); + rawJson["_etag"].Should().NotBeNull(); + rawJson["_ts"].Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1039,103 +1039,103 @@ await container.CreateItemAsync( public class ConcurrentCreateUpsertStreamTests { - [Fact] - public async Task ConcurrentUpsertStream_AfterDelete_NoPartialState() - { - // Stream variant of the upsert+delete race. - // Without the lock, UpsertItemStreamAsync could interleave with - // DeleteItemStreamAsync, leaving _items set but _etags/_timestamps missing. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = new List>(); - - // Mix of stream upserts and stream deletes - tasks.AddRange(Enumerable.Range(0, 20).Select(i => Task.Run(async () => - { - var json = JsonConvert.SerializeObject( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"StreamUpsert{i}", Value = i * 100 }); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); - return (int)response.StatusCode; - }))); - - tasks.AddRange(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => - { - var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - return (int)response.StatusCode; - }))); - - var results = await Task.WhenAll(tasks); - - // All operations should have returned valid status codes - results.Should().OnlyContain(r => r == 200 || r == 201 || r == 204 || r == 404); - - // Post-condition: item is either fully present or fully absent. - var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - if (readResponse.StatusCode == HttpStatusCode.OK) - { - // If present, system properties must be intact - using var reader = new StreamReader(readResponse.Content); - var body = JObject.Parse(await reader.ReadToEndAsync()); - body["_etag"].Should().NotBeNull("item exists but has no _etag — partial state from unlocked write"); - body["_ts"].Should().NotBeNull("item exists but has no _ts — partial state from unlocked write"); - body["id"]!.ToString().Should().Be("1"); - } - else - { - ((int)readResponse.StatusCode).Should().Be(404); - } - } - - [Fact] - public async Task ConcurrentCreateStream_WithDeleteStream_NoResurrection() - { - // Create + Delete race via stream API. - // Pattern mirrors ConcurrentPatch_AfterDelete_Gets404: - // After all operations settle, the post-condition must hold. - var container = new InMemoryContainer("test", "/partitionKey"); - - var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - // 20 create attempts for the same ID + 20 delete attempts - var createTasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () => - { - await barrier.Task; - var json = JsonConvert.SerializeObject( - new TestDocument { Id = "race1", PartitionKey = "pk1", Name = $"Created{i}" }); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var response = await container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); - return (int)response.StatusCode; - })); - - var deleteTasks = Enumerable.Range(0, 20).Select(_ => Task.Run(async () => - { - await barrier.Task; - var response = await container.DeleteItemStreamAsync("race1", new PartitionKey("pk1")); - return (int)response.StatusCode; - })); - - barrier.SetResult(); - var allResults = await Task.WhenAll(createTasks.Concat(deleteTasks)); - - // Valid status codes only - allResults.Should().OnlyContain(r => r == 201 || r == 204 || r == 404 || r == 409); - - // Post-condition: item is either fully present or fully absent - var readResponse = await container.ReadItemStreamAsync("race1", new PartitionKey("pk1")); - if (readResponse.StatusCode == HttpStatusCode.OK) - { - using var reader = new StreamReader(readResponse.Content); - var body = JObject.Parse(await reader.ReadToEndAsync()); - body["_etag"].Should().NotBeNull("item exists but has no _etag — partial state"); - body["_ts"].Should().NotBeNull("item exists but has no _ts — partial state"); - } - else - { - ((int)readResponse.StatusCode).Should().Be(404); - } - } + [Fact] + public async Task ConcurrentUpsertStream_AfterDelete_NoPartialState() + { + // Stream variant of the upsert+delete race. + // Without the lock, UpsertItemStreamAsync could interleave with + // DeleteItemStreamAsync, leaving _items set but _etags/_timestamps missing. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = new List>(); + + // Mix of stream upserts and stream deletes + tasks.AddRange(Enumerable.Range(0, 20).Select(i => Task.Run(async () => + { + var json = JsonConvert.SerializeObject( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"StreamUpsert{i}", Value = i * 100 }); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); + return (int)response.StatusCode; + }))); + + tasks.AddRange(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => + { + var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + return (int)response.StatusCode; + }))); + + var results = await Task.WhenAll(tasks); + + // All operations should have returned valid status codes + results.Should().OnlyContain(r => r == 200 || r == 201 || r == 204 || r == 404); + + // Post-condition: item is either fully present or fully absent. + var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + if (readResponse.StatusCode == HttpStatusCode.OK) + { + // If present, system properties must be intact + using var reader = new StreamReader(readResponse.Content); + var body = JObject.Parse(await reader.ReadToEndAsync()); + body["_etag"].Should().NotBeNull("item exists but has no _etag — partial state from unlocked write"); + body["_ts"].Should().NotBeNull("item exists but has no _ts — partial state from unlocked write"); + body["id"]!.ToString().Should().Be("1"); + } + else + { + ((int)readResponse.StatusCode).Should().Be(404); + } + } + + [Fact] + public async Task ConcurrentCreateStream_WithDeleteStream_NoResurrection() + { + // Create + Delete race via stream API. + // Pattern mirrors ConcurrentPatch_AfterDelete_Gets404: + // After all operations settle, the post-condition must hold. + var container = new InMemoryContainer("test", "/partitionKey"); + + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // 20 create attempts for the same ID + 20 delete attempts + var createTasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () => + { + await barrier.Task; + var json = JsonConvert.SerializeObject( + new TestDocument { Id = "race1", PartitionKey = "pk1", Name = $"Created{i}" }); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var response = await container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); + return (int)response.StatusCode; + })); + + var deleteTasks = Enumerable.Range(0, 20).Select(_ => Task.Run(async () => + { + await barrier.Task; + var response = await container.DeleteItemStreamAsync("race1", new PartitionKey("pk1")); + return (int)response.StatusCode; + })); + + barrier.SetResult(); + var allResults = await Task.WhenAll(createTasks.Concat(deleteTasks)); + + // Valid status codes only + allResults.Should().OnlyContain(r => r == 201 || r == 204 || r == 404 || r == 409); + + // Post-condition: item is either fully present or fully absent + var readResponse = await container.ReadItemStreamAsync("race1", new PartitionKey("pk1")); + if (readResponse.StatusCode == HttpStatusCode.OK) + { + using var reader = new StreamReader(readResponse.Content); + var body = JObject.Parse(await reader.ReadToEndAsync()); + body["_etag"].Should().NotBeNull("item exists but has no _etag — partial state"); + body["_ts"].Should().NotBeNull("item exists but has no _ts — partial state"); + } + else + { + ((int)readResponse.StatusCode).Should().Be(404); + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyTests.cs index 3459149..c60c57b 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ConcurrencyTests.cs @@ -1,9 +1,9 @@ -using AwesomeAssertions; -using Microsoft.Azure.Cosmos; -using Newtonsoft.Json.Linq; using System.Collections.ObjectModel; using System.Net; using System.Text; +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -11,203 +11,203 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ConcurrencyGapTests3 { - [Fact] - public async Task ConcurrentBatchOperations_Isolation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items for batch operations - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, - new PartitionKey("pk1")); - - var batchTasks = Enumerable.Range(0, 5).Select(async batchIndex => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = $"batch-{batchIndex}", PartitionKey = "pk1", Name = $"Batch{batchIndex}" }); - using var response = await batch.ExecuteAsync(); - return response.IsSuccessStatusCode; - }); - - var results = await Task.WhenAll(batchTasks); - results.Should().OnlyContain(success => success); - } - - [Fact] - public async Task ConcurrentChangeFeedRead_ThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 10).Select(async _ => - { - var checkpoint = container.GetChangeFeedCheckpoint() - 10; - if (checkpoint < 0) checkpoint = 0; - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - }); - - await Task.WhenAll(tasks); - } + [Fact] + public async Task ConcurrentBatchOperations_Isolation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items for batch operations + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, + new PartitionKey("pk1")); + + var batchTasks = Enumerable.Range(0, 5).Select(async batchIndex => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = $"batch-{batchIndex}", PartitionKey = "pk1", Name = $"Batch{batchIndex}" }); + using var response = await batch.ExecuteAsync(); + return response.IsSuccessStatusCode; + }); + + var results = await Task.WhenAll(batchTasks); + results.Should().OnlyContain(success => success); + } + + [Fact] + public async Task ConcurrentChangeFeedRead_ThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 10).Select(async _ => + { + var checkpoint = container.GetChangeFeedCheckpoint() - 10; + if (checkpoint < 0) checkpoint = 0; + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + }); + + await Task.WhenAll(tasks); + } } public class ConcurrencyGapTests { - [Fact] - public async Task ConcurrentCreates_DifferentIds_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task ConcurrentCreates_SameId_ExactlyOneSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var successes = 0; - var failures = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.CreateItemAsync( - new TestDocument { Id = "same", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - Interlocked.Increment(ref failures); - } - }); - - await Task.WhenAll(tasks); - - successes.Should().Be(1); - failures.Should().Be(49); - } - - [Fact] - public async Task ConcurrentReads_SameItem_AllReturnSameData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(_ => - container.ReadItemAsync("1", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - results.Should().OnlyContain(r => r.Resource.Name == "Alice"); - } - - [Fact] - public async Task ConcurrentUpserts_SameItem_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } + [Fact] + public async Task ConcurrentCreates_DifferentIds_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task ConcurrentCreates_SameId_ExactlyOneSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var successes = 0; + var failures = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.CreateItemAsync( + new TestDocument { Id = "same", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + Interlocked.Increment(ref failures); + } + }); + + await Task.WhenAll(tasks); + + successes.Should().Be(1); + failures.Should().Be(49); + } + + [Fact] + public async Task ConcurrentReads_SameItem_AllReturnSameData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(_ => + container.ReadItemAsync("1", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => r.Resource.Name == "Alice"); + } + + [Fact] + public async Task ConcurrentUpserts_SameItem_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } } public class ConcurrencyGapTests2 { - [Fact] - public async Task ConcurrentCreateAndRead_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create items - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, - new PartitionKey("pk1")); - } - - // Concurrent writes and reads - var writeTasks = Enumerable.Range(50, 50) - .Select(i => container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var readTasks = Enumerable.Range(0, 50) - .Select(i => container.ReadItemAsync($"pre-{i}", new PartitionKey("pk1"))); - - var allTasks = writeTasks.Cast().Concat(readTasks.Cast()); - await Task.WhenAll(allTasks); - - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task ConcurrentQueryAndWrite_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var writeTasks = Enumerable.Range(50, 50) - .Select(i => container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1"))); - - var queryTasks = Enumerable.Range(0, 10).Select(async _ => - { - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - }); - - await Task.WhenAll(writeTasks.Cast().Concat(queryTasks)); - } + [Fact] + public async Task ConcurrentCreateAndRead_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create items + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"pre-{i}", PartitionKey = "pk1", Name = $"Pre{i}" }, + new PartitionKey("pk1")); + } + + // Concurrent writes and reads + var writeTasks = Enumerable.Range(50, 50) + .Select(i => container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var readTasks = Enumerable.Range(0, 50) + .Select(i => container.ReadItemAsync($"pre-{i}", new PartitionKey("pk1"))); + + var allTasks = writeTasks.Cast().Concat(readTasks.Cast()); + await Task.WhenAll(allTasks); + + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task ConcurrentQueryAndWrite_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var writeTasks = Enumerable.Range(50, 50) + .Select(i => container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1"))); + + var queryTasks = Enumerable.Range(0, 10).Select(async _ => + { + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + }); + + await Task.WhenAll(writeTasks.Cast().Concat(queryTasks)); + } } @@ -217,112 +217,112 @@ await container.CreateItemAsync( public class ConcurrentDeleteTests { - [Fact] - public async Task ConcurrentDeletes_SameItem_AllCompleteWithoutCrash() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - try - { - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - return "deleted"; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return "notfound"; - } - }); - - var results = await Task.WhenAll(tasks); - results.Should().Contain("deleted"); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task ConcurrentDeleteAndRead_ReadsEitherSucceedOrGetNotFound() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var deleteTasks = Enumerable.Range(0, 50).Select(async i => - { - try { await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); } - catch (CosmosException) { } - }); - - var readTasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - return r.StatusCode; - } - catch (CosmosException ex) { return ex.StatusCode; } - }); - - await Task.WhenAll(deleteTasks.Cast().Concat(readTasks.Select(async t => { await t; }))); - } - - [Fact] - public async Task ConcurrentDeleteAndCreate_SameId_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "x", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(async i => - { - try - { - if (i % 2 == 0) - await container.DeleteItemAsync("x", new PartitionKey("pk1")); - else - await container.CreateItemAsync( - new TestDocument { Id = "x", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1")); - } - catch (CosmosException) { } - }); - - await Task.WhenAll(tasks); - // Final state: item either exists or doesn't, but no corruption - container.ItemCount.Should().BeLessThanOrEqualTo(1); - } - - [Fact] - public async Task ConcurrentDeleteAndUpsert_SameItem_StateIsConsistent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(async i => - { - try - { - if (i % 2 == 0) - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - else - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1")); - } - catch (CosmosException) { } - }); - - await Task.WhenAll(tasks); - container.ItemCount.Should().BeLessThanOrEqualTo(1); - } + [Fact] + public async Task ConcurrentDeletes_SameItem_AllCompleteWithoutCrash() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + try + { + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + return "deleted"; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return "notfound"; + } + }); + + var results = await Task.WhenAll(tasks); + results.Should().Contain("deleted"); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task ConcurrentDeleteAndRead_ReadsEitherSucceedOrGetNotFound() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var deleteTasks = Enumerable.Range(0, 50).Select(async i => + { + try { await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); } + catch (CosmosException) { } + }); + + var readTasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + return r.StatusCode; + } + catch (CosmosException ex) { return ex.StatusCode; } + }); + + await Task.WhenAll(deleteTasks.Cast().Concat(readTasks.Select(async t => { await t; }))); + } + + [Fact] + public async Task ConcurrentDeleteAndCreate_SameId_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "x", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(async i => + { + try + { + if (i % 2 == 0) + await container.DeleteItemAsync("x", new PartitionKey("pk1")); + else + await container.CreateItemAsync( + new TestDocument { Id = "x", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1")); + } + catch (CosmosException) { } + }); + + await Task.WhenAll(tasks); + // Final state: item either exists or doesn't, but no corruption + container.ItemCount.Should().BeLessThanOrEqualTo(1); + } + + [Fact] + public async Task ConcurrentDeleteAndUpsert_SameItem_StateIsConsistent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(async i => + { + try + { + if (i % 2 == 0) + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + else + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1")); + } + catch (CosmosException) { } + }); + + await Task.WhenAll(tasks); + container.ItemCount.Should().BeLessThanOrEqualTo(1); + } } @@ -332,116 +332,116 @@ await container.UpsertItemAsync( public class ConcurrentPatchTests { - [Fact] - public async Task ConcurrentPatches_SameItem_AllSucceed_LastWriteWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Patched"); - } - - [Fact] - public async Task ConcurrentPatches_SameItem_WithETag_ExactlyOneSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; - - var successes = 0; - var preconditionFailed = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") }, - new PatchItemRequestOptions { IfMatchEtag = etag }); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - Interlocked.Increment(ref preconditionFailed); - } - }); - - await Task.WhenAll(tasks); - successes.Should().Be(1); - preconditionFailed.Should().Be(49); - } - - [Fact] - public async Task ConcurrentPatchAndReplace_SameItem_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var patchTasks = Enumerable.Range(0, 25).Select(i => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var replaceTasks = Enumerable.Range(0, 25).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}" }, - "1", new PartitionKey("pk1"))); - - await Task.WhenAll(patchTasks.Cast().Concat(replaceTasks)); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ConcurrentPatch_DifferentItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.PatchItemAsync($"{i}", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task ConcurrentPatch_IncrementOperation_ValueIsAtMost100() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Counter", Value = 0 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(_ => - container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) })); - - await Task.WhenAll(tasks); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - // Patches are serialized per-item via SemaphoreSlim, so all 100 increments are applied. - final.Resource.Value.Should().Be(100, "patches are serialized per-item via SemaphoreSlim"); - } + [Fact] + public async Task ConcurrentPatches_SameItem_AllSucceed_LastWriteWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Patched"); + } + + [Fact] + public async Task ConcurrentPatches_SameItem_WithETag_ExactlyOneSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; + + var successes = 0; + var preconditionFailed = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") }, + new PatchItemRequestOptions { IfMatchEtag = etag }); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + Interlocked.Increment(ref preconditionFailed); + } + }); + + await Task.WhenAll(tasks); + successes.Should().Be(1); + preconditionFailed.Should().Be(49); + } + + [Fact] + public async Task ConcurrentPatchAndReplace_SameItem_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var patchTasks = Enumerable.Range(0, 25).Select(i => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var replaceTasks = Enumerable.Range(0, 25).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}" }, + "1", new PartitionKey("pk1"))); + + await Task.WhenAll(patchTasks.Cast().Concat(replaceTasks)); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ConcurrentPatch_DifferentItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.PatchItemAsync($"{i}", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task ConcurrentPatch_IncrementOperation_ValueIsAtMost100() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Counter", Value = 0 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(_ => + container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) })); + + await Task.WhenAll(tasks); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + // Patches are serialized per-item via SemaphoreSlim, so all 100 increments are applied. + final.Resource.Value.Should().Be(100, "patches are serialized per-item via SemaphoreSlim"); + } } @@ -451,77 +451,77 @@ await container.CreateItemAsync( public class ConcurrentReplaceTests { - [Fact] - public async Task ConcurrentReplaces_SameItem_AllSucceed_LastWriteWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, - "1", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Version"); - } - - [Fact] - public async Task ConcurrentReplaces_SameItem_WithETag_ExactlyOneSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; - - var successes = 0; - var preconditionFailed = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - Interlocked.Increment(ref preconditionFailed); - } - }); - - await Task.WhenAll(tasks); - successes.Should().Be(1); - preconditionFailed.Should().Be(49); - } - - [Fact] - public async Task ConcurrentReplaces_DifferentItems_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, - $"{i}", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } + [Fact] + public async Task ConcurrentReplaces_SameItem_AllSucceed_LastWriteWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, + "1", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Version"); + } + + [Fact] + public async Task ConcurrentReplaces_SameItem_WithETag_ExactlyOneSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; + + var successes = 0; + var preconditionFailed = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Version{i}" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + Interlocked.Increment(ref preconditionFailed); + } + }); + + await Task.WhenAll(tasks); + successes.Should().Be(1); + preconditionFailed.Should().Be(49); + } + + [Fact] + public async Task ConcurrentReplaces_DifferentItems_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Replaced{i}" }, + $"{i}", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } } @@ -531,71 +531,71 @@ await container.CreateItemAsync( public class ConcurrentUpsertTests { - [Fact] - public async Task ConcurrentUpserts_ItemDoesntExist_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = "same", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => - r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task ConcurrentUpserts_WithETag_ExactlyOneSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; - - var successes = 0; - var preconditionFailed = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - Interlocked.Increment(ref preconditionFailed); - } - }); - - await Task.WhenAll(tasks); - successes.Should().Be(1); - preconditionFailed.Should().Be(49); - } - - [Fact] - public async Task ConcurrentUpserts_DifferentPartitionKeys_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk{i}", Name = $"Item{i}" }, - new PartitionKey($"pk{i}"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - container.ItemCount.Should().Be(100); - } + [Fact] + public async Task ConcurrentUpserts_ItemDoesntExist_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = "same", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => + r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task ConcurrentUpserts_WithETag_ExactlyOneSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; + + var successes = 0; + var preconditionFailed = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + Interlocked.Increment(ref preconditionFailed); + } + }); + + await Task.WhenAll(tasks); + successes.Should().Be(1); + preconditionFailed.Should().Be(49); + } + + [Fact] + public async Task ConcurrentUpserts_DifferentPartitionKeys_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk{i}", Name = $"Item{i}" }, + new PartitionKey($"pk{i}"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + container.ItemCount.Should().Be(100); + } } @@ -605,63 +605,63 @@ public async Task ConcurrentUpserts_DifferentPartitionKeys_AllSucceed() public class ConcurrentBatchTests { - [Fact] - public async Task TransactionalBatch_Rollback_RestoresTimestamps() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Read original _ts - var before = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = (long)before.Resource["_ts"]!; - - // Wait a moment so _ts would change - await Task.Delay(1100); - - // Batch that modifies item 1 then fails on a duplicate create - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Modified" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); // will conflict - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - - // After rollback, _ts should be restored to original value - var after = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = (long)after.Resource["_ts"]!; - tsAfter.Should().Be(tsBefore); - } - - [Fact] - public async Task ConcurrentBatches_SamePartition_DifferentItems_BothSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var batch1Task = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "a1", PartitionKey = "pk1", Name = "A1" }); - batch.CreateItem(new TestDocument { Id = "a2", PartitionKey = "pk1", Name = "A2" }); - return await batch.ExecuteAsync(); - }); - - var batch2Task = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "B1" }); - batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "B2" }); - return await batch.ExecuteAsync(); - }); - - using var r1 = await batch1Task; - using var r2 = await batch2Task; - - r1.IsSuccessStatusCode.Should().BeTrue(); - r2.IsSuccessStatusCode.Should().BeTrue(); - container.ItemCount.Should().Be(4); - } + [Fact] + public async Task TransactionalBatch_Rollback_RestoresTimestamps() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Read original _ts + var before = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = (long)before.Resource["_ts"]!; + + // Wait a moment so _ts would change + await Task.Delay(1100); + + // Batch that modifies item 1 then fails on a duplicate create + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Modified" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); // will conflict + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + + // After rollback, _ts should be restored to original value + var after = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = (long)after.Resource["_ts"]!; + tsAfter.Should().Be(tsBefore); + } + + [Fact] + public async Task ConcurrentBatches_SamePartition_DifferentItems_BothSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var batch1Task = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "a1", PartitionKey = "pk1", Name = "A1" }); + batch.CreateItem(new TestDocument { Id = "a2", PartitionKey = "pk1", Name = "A2" }); + return await batch.ExecuteAsync(); + }); + + var batch2Task = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "B1" }); + batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "B2" }); + return await batch.ExecuteAsync(); + }); + + using var r1 = await batch1Task; + using var r2 = await batch2Task; + + r1.IsSuccessStatusCode.Should().BeTrue(); + r2.IsSuccessStatusCode.Should().BeTrue(); + container.ItemCount.Should().Be(4); + } } @@ -671,43 +671,43 @@ public async Task ConcurrentBatches_SamePartition_DifferentItems_BothSucceed() public class ConcurrentChangeFeedTests { - [Fact] - public async Task ConcurrentChangeFeedRead_WhileWriting_NoMissedEntries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - const int writeCount = 100; - - var writeTasks = Enumerable.Range(0, writeCount).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var readTasks = Enumerable.Range(0, 5).Select(async _ => - { - await Task.Delay(10); - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results.Count; - }); - - await Task.WhenAll(writeTasks); - var readResults = await Task.WhenAll(readTasks); - - // After writes complete, final read should see all items - var finalIterator = container.GetChangeFeedIterator(0); - var finalResults = new List(); - while (finalIterator.HasMoreResults) - { - var page = await finalIterator.ReadNextAsync(); - finalResults.AddRange(page); - } - finalResults.Should().HaveCount(writeCount); - } + [Fact] + public async Task ConcurrentChangeFeedRead_WhileWriting_NoMissedEntries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + const int writeCount = 100; + + var writeTasks = Enumerable.Range(0, writeCount).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var readTasks = Enumerable.Range(0, 5).Select(async _ => + { + await Task.Delay(10); + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results.Count; + }); + + await Task.WhenAll(writeTasks); + var readResults = await Task.WhenAll(readTasks); + + // After writes complete, final read should see all items + var finalIterator = container.GetChangeFeedIterator(0); + var finalResults = new List(); + while (finalIterator.HasMoreResults) + { + var page = await finalIterator.ReadNextAsync(); + finalResults.AddRange(page); + } + finalResults.Should().HaveCount(writeCount); + } } @@ -717,63 +717,63 @@ public async Task ConcurrentChangeFeedRead_WhileWriting_NoMissedEntries() public class ConcurrentCrossPartitionTests { - [Fact] - public async Task ConcurrentOperations_DifferentPartitions_FullyIndependent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 10).Select(async pk => - { - var pkStr = $"pk{pk}"; - for (var i = 0; i < 10; i++) - { - var id = $"{pk}-{i}"; - await container.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = pkStr, Name = $"Item{id}" }, - new PartitionKey(pkStr)); - } - - // Read back all items in this partition - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", pkStr)); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(10); - }); - - await Task.WhenAll(tasks); - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task CrossPartitionQuery_DuringConcurrentWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"pre-{i}", PartitionKey = $"pk{i % 5}", Name = $"Pre{i}" }, - new PartitionKey($"pk{i % 5}")); - - var writeTasks = Enumerable.Range(50, 50).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk{i % 5}", Name = $"New{i}" }, - new PartitionKey($"pk{i % 5}"))); - - var queryTasks = Enumerable.Range(0, 5).Select(async _ => - { - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - results.Should().NotBeEmpty(); - }); - - await Task.WhenAll(writeTasks.Cast().Concat(queryTasks)); - container.ItemCount.Should().Be(100); - } + [Fact] + public async Task ConcurrentOperations_DifferentPartitions_FullyIndependent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 10).Select(async pk => + { + var pkStr = $"pk{pk}"; + for (var i = 0; i < 10; i++) + { + var id = $"{pk}-{i}"; + await container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pkStr, Name = $"Item{id}" }, + new PartitionKey(pkStr)); + } + + // Read back all items in this partition + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", pkStr)); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(10); + }); + + await Task.WhenAll(tasks); + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task CrossPartitionQuery_DuringConcurrentWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"pre-{i}", PartitionKey = $"pk{i % 5}", Name = $"Pre{i}" }, + new PartitionKey($"pk{i % 5}")); + + var writeTasks = Enumerable.Range(50, 50).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk{i % 5}", Name = $"New{i}" }, + new PartitionKey($"pk{i % 5}"))); + + var queryTasks = Enumerable.Range(0, 5).Select(async _ => + { + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + results.Should().NotBeEmpty(); + }); + + await Task.WhenAll(writeTasks.Cast().Concat(queryTasks)); + container.ItemCount.Should().Be(100); + } } @@ -783,40 +783,40 @@ await container.CreateItemAsync( public class ConcurrentUniqueKeyTests { - [Fact] - public async Task ConcurrentCreates_UniqueKeyViolation_ExactlyOneSucceeds() - { - var containerProps = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(containerProps); - - var successes = 0; - var conflicts = 0; - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.CreateItemAsync( - new TestDocument { Id = $"id{i}", PartitionKey = "pk1", Name = "SameName" }, - new PartitionKey("pk1")); - Interlocked.Increment(ref successes); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - Interlocked.Increment(ref conflicts); - } - }); - - await Task.WhenAll(tasks); - successes.Should().Be(1); - conflicts.Should().Be(49); - } + [Fact] + public async Task ConcurrentCreates_UniqueKeyViolation_ExactlyOneSucceeds() + { + var containerProps = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(containerProps); + + var successes = 0; + var conflicts = 0; + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.CreateItemAsync( + new TestDocument { Id = $"id{i}", PartitionKey = "pk1", Name = "SameName" }, + new PartitionKey("pk1")); + Interlocked.Increment(ref successes); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + Interlocked.Increment(ref conflicts); + } + }); + + await Task.WhenAll(tasks); + successes.Should().Be(1); + conflicts.Should().Be(49); + } } @@ -826,61 +826,61 @@ await container.CreateItemAsync( public class ConcurrentStreamTests { - [Fact] - public async Task ConcurrentCreateItemStream_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 100).Select(async i => - { - var json = $"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\",\"name\":\"Item{i}\"}}"; - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - using var response = await container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); - return response.StatusCode; - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => s == HttpStatusCode.Created); - container.ItemCount.Should().Be(100); - } - - [Fact] - public async Task ConcurrentUpsertItemStream_SameItem_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"V{i}\"}}"; - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - using var response = await container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); - return response.StatusCode; - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(s => - s == HttpStatusCode.Created || s == HttpStatusCode.OK); - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task ConcurrentDeleteItemStream_SameItem_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - using var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - return response.StatusCode; - }); - - var results = await Task.WhenAll(tasks); - results.Should().Contain(HttpStatusCode.NoContent); - container.ItemCount.Should().Be(0); - } + [Fact] + public async Task ConcurrentCreateItemStream_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 100).Select(async i => + { + var json = $"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\",\"name\":\"Item{i}\"}}"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + using var response = await container.CreateItemStreamAsync(stream, new PartitionKey("pk1")); + return response.StatusCode; + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => s == HttpStatusCode.Created); + container.ItemCount.Should().Be(100); + } + + [Fact] + public async Task ConcurrentUpsertItemStream_SameItem_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"V{i}\"}}"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + using var response = await container.UpsertItemStreamAsync(stream, new PartitionKey("pk1")); + return response.StatusCode; + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(s => + s == HttpStatusCode.Created || s == HttpStatusCode.OK); + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task ConcurrentDeleteItemStream_SameItem_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + using var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + return response.StatusCode; + }); + + var results = await Task.WhenAll(tasks); + results.Should().Contain(HttpStatusCode.NoContent); + container.ItemCount.Should().Be(0); + } } @@ -890,31 +890,31 @@ await container.CreateItemAsync( public class ConcurrentReadManyTests { - [Fact] - public async Task ConcurrentReadMany_WhileWriting_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var writeTasks = Enumerable.Range(50, 50).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1"))); - - var readTasks = Enumerable.Range(0, 10).Select(async _ => - { - var ids = Enumerable.Range(0, 20) - .Select(i => (i.ToString(), new PartitionKey("pk1"))) - .ToList(); - var response = await container.ReadManyItemsAsync(ids); - response.Resource.Should().NotBeEmpty(); - }); - - await Task.WhenAll(writeTasks.Cast().Concat(readTasks)); - } + [Fact] + public async Task ConcurrentReadMany_WhileWriting_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var writeTasks = Enumerable.Range(50, 50).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1"))); + + var readTasks = Enumerable.Range(0, 10).Select(async _ => + { + var ids = Enumerable.Range(0, 20) + .Select(i => (i.ToString(), new PartitionKey("pk1"))) + .ToList(); + var response = await container.ReadManyItemsAsync(ids); + response.Resource.Should().NotBeEmpty(); + }); + + await Task.WhenAll(writeTasks.Cast().Concat(readTasks)); + } } @@ -924,30 +924,30 @@ await container.CreateItemAsync( public class ConcurrentContainerDatabaseTests { - [Fact] - public async Task ConcurrentContainerCreation_SameDatabase() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("db1"); - - var tasks = Enumerable.Range(0, 50).Select(i => - db.CreateContainerIfNotExistsAsync($"container{i}", "/pk")); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task ConcurrentDatabaseCreation() - { - var client = new InMemoryCosmosClient(); - - var tasks = Enumerable.Range(0, 50).Select(i => - client.CreateDatabaseIfNotExistsAsync($"db{i}")); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - } + [Fact] + public async Task ConcurrentContainerCreation_SameDatabase() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("db1"); + + var tasks = Enumerable.Range(0, 50).Select(i => + db.CreateContainerIfNotExistsAsync($"container{i}", "/pk")); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task ConcurrentDatabaseCreation() + { + var client = new InMemoryCosmosClient(); + + var tasks = Enumerable.Range(0, 50).Select(i => + client.CreateDatabaseIfNotExistsAsync($"db{i}")); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + } } @@ -957,69 +957,70 @@ public async Task ConcurrentDatabaseCreation() public class ConcurrencyStressTests { - [Fact] - public async Task HighContention_MixedOperations_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey"); - const int itemPool = 50; - - // Pre-create items - for (var i = 0; i < itemPool; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, - new PartitionKey("pk1")); - - var rng = new Random(42); // deterministic seed for reproducibility - var tasks = Enumerable.Range(0, 200).Select(async t => - { - var id = $"{t % itemPool}"; - var op = rng.Next(5); - try - { - switch (op) - { - case 0: // read - await container.ReadItemAsync(id, new PartitionKey("pk1")); - break; - case 1: // upsert - await container.UpsertItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Upserted{t}" }, - new PartitionKey("pk1")); - break; - case 2: // replace - await container.ReplaceItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Replaced{t}" }, - id, new PartitionKey("pk1")); - break; - case 3: // patch - await container.PatchItemAsync(id, new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{t}") }); - break; - case 4: // delete + recreate - await container.DeleteItemAsync(id, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Recreated{t}" }, - new PartitionKey("pk1")); - break; - } - } - catch (CosmosException ex) when (ex.StatusCode is HttpStatusCode.NotFound - or HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) { } - }); - - await Task.WhenAll(tasks); - - // Verify no corruption: all remaining items are readable - for (var i = 0; i < itemPool; i++) - { - try - { - var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - r.Resource.Id.Should().Be($"{i}"); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } - } - } + [Fact] + public async Task HighContention_MixedOperations_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey"); + const int itemPool = 50; + + // Pre-create items + for (var i = 0; i < itemPool; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = 0 }, + new PartitionKey("pk1")); + + var rng = new Random(42); // deterministic seed for reproducibility + var tasks = Enumerable.Range(0, 200).Select(async t => + { + var id = $"{t % itemPool}"; + var op = rng.Next(5); + try + { + switch (op) + { + case 0: // read + await container.ReadItemAsync(id, new PartitionKey("pk1")); + break; + case 1: // upsert + await container.UpsertItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Upserted{t}" }, + new PartitionKey("pk1")); + break; + case 2: // replace + await container.ReplaceItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Replaced{t}" }, + id, new PartitionKey("pk1")); + break; + case 3: // patch + await container.PatchItemAsync(id, new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{t}") }); + break; + case 4: // delete + recreate + await container.DeleteItemAsync(id, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Recreated{t}" }, + new PartitionKey("pk1")); + break; + } + } + catch (CosmosException ex) when (ex.StatusCode is HttpStatusCode.NotFound + or HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) + { } + }); + + await Task.WhenAll(tasks); + + // Verify no corruption: all remaining items are readable + for (var i = 0; i < itemPool; i++) + { + try + { + var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + r.Resource.Id.Should().Be($"{i}"); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1028,141 +1029,141 @@ await container.CreateItemAsync( public class ConcurrentStreamExtendedTests { - [Fact] - public async Task ConcurrentPatchItemStream_SameItem_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Patched"); - } - - [Fact] - public async Task ConcurrentReplaceItemStream_SameItem_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - { - var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Replaced{i}\"}}"; - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - return container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1")); - }); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().StartWith("Replaced"); - } - - [Fact] - public async Task ConcurrentReadItemStream_SameItem_AllReturn200() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 100).Select(_ => - container.ReadItemStreamAsync("1", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } + [Fact] + public async Task ConcurrentPatchItemStream_SameItem_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Patched"); + } + + [Fact] + public async Task ConcurrentReplaceItemStream_SameItem_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + { + var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Replaced{i}\"}}"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1")); + }); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().StartWith("Replaced"); + } + + [Fact] + public async Task ConcurrentReadItemStream_SameItem_AllReturn200() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 100).Select(_ => + container.ReadItemStreamAsync("1", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } } public class ConcurrentETagExtendedTests { - [Fact] - public async Task ConcurrentETag_Wildcard_IfMatch_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(i => - container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); - } - - [Fact] - public async Task ConcurrentDelete_Then_Create_VerifyETagFreshness() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create item, read ETag - var create1 = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var etag1 = create1.ETag; - - // Delete - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - // Recreate same ID - var create2 = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }, - new PartitionKey("pk1")); - var etag2 = create2.ETag; - - // New ETag should differ - etag2.Should().NotBe(etag1, "recreated item should get a fresh ETag"); - } - - [Fact] - public async Task ConcurrentETag_IfNoneMatch_Star_OnCreate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // 50 threads try to upsert with IfNoneMatch="*" (create-if-not-exists) - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - var response = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - return (int)response.StatusCode; - } - catch (CosmosException ex) - { - return (int)ex.StatusCode; - } - }); - - var results = await Task.WhenAll(tasks); - - // At least one should succeed (201 Created), rest should be expected status codes - var created = results.Count(r => r == 201); - var ok = results.Count(r => r == 200); - var preconditionFailed = results.Count(r => r == (int)HttpStatusCode.PreconditionFailed); - var notModified = results.Count(r => r == (int)HttpStatusCode.NotModified); - - created.Should().BeGreaterThanOrEqualTo(1); - (created + ok + preconditionFailed + notModified).Should().Be(50, - "all results should be expected status codes, no unexpected errors"); - } + [Fact] + public async Task ConcurrentETag_Wildcard_IfMatch_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(i => + container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Replaced{i}" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.OK); + } + + [Fact] + public async Task ConcurrentDelete_Then_Create_VerifyETagFreshness() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create item, read ETag + var create1 = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag1 = create1.ETag; + + // Delete + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + // Recreate same ID + var create2 = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }, + new PartitionKey("pk1")); + var etag2 = create2.ETag; + + // New ETag should differ + etag2.Should().NotBe(etag1, "recreated item should get a fresh ETag"); + } + + [Fact] + public async Task ConcurrentETag_IfNoneMatch_Star_OnCreate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // 50 threads try to upsert with IfNoneMatch="*" (create-if-not-exists) + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + var response = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + return (int)response.StatusCode; + } + catch (CosmosException ex) + { + return (int)ex.StatusCode; + } + }); + + var results = await Task.WhenAll(tasks); + + // At least one should succeed (201 Created), rest should be expected status codes + var created = results.Count(r => r == 201); + var ok = results.Count(r => r == 200); + var preconditionFailed = results.Count(r => r == (int)HttpStatusCode.PreconditionFailed); + var notModified = results.Count(r => r == (int)HttpStatusCode.NotModified); + + created.Should().BeGreaterThanOrEqualTo(1); + (created + ok + preconditionFailed + notModified).Should().Be(50, + "all results should be expected status codes, no unexpected errors"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1171,419 +1172,419 @@ public async Task ConcurrentETag_IfNoneMatch_Star_OnCreate() public class ConcurrentDeleteSerializationTests { - [Fact] - public async Task ConcurrentDeletes_SameItem_ExactlyOneSucceeds_RestGet404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(async _ => - { - try - { - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - return "deleted"; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return "notfound"; - } - }); - - var results = await Task.WhenAll(tasks); - var deletedCount = results.Count(r => r == "deleted"); - var notFoundCount = results.Count(r => r == "notfound"); - - // At minimum, at least one must succeed and the item must be gone - deletedCount.Should().BeGreaterThanOrEqualTo(1); - (deletedCount + notFoundCount).Should().Be(50); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task ConcurrentDeleteItemStream_SameItem_AtLeastOneReturns204() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(0, 50).Select(_ => - container.DeleteItemStreamAsync("1", new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - var successCount = results.Count(r => r.StatusCode == HttpStatusCode.NoContent); - var notFoundCount = results.Count(r => r.StatusCode == HttpStatusCode.NotFound); - - successCount.Should().BeGreaterThanOrEqualTo(1); - (successCount + notFoundCount).Should().Be(50); - container.ItemCount.Should().Be(0); - } + [Fact] + public async Task ConcurrentDeletes_SameItem_ExactlyOneSucceeds_RestGet404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(async _ => + { + try + { + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + return "deleted"; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return "notfound"; + } + }); + + var results = await Task.WhenAll(tasks); + var deletedCount = results.Count(r => r == "deleted"); + var notFoundCount = results.Count(r => r == "notfound"); + + // At minimum, at least one must succeed and the item must be gone + deletedCount.Should().BeGreaterThanOrEqualTo(1); + (deletedCount + notFoundCount).Should().Be(50); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task ConcurrentDeleteItemStream_SameItem_AtLeastOneReturns204() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(0, 50).Select(_ => + container.DeleteItemStreamAsync("1", new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r.StatusCode == HttpStatusCode.NoContent); + var notFoundCount = results.Count(r => r.StatusCode == HttpStatusCode.NotFound); + + successCount.Should().BeGreaterThanOrEqualTo(1); + (successCount + notFoundCount).Should().Be(50); + container.ItemCount.Should().Be(0); + } } public class ConcurrentChangeFeedExtendedTests { - [Fact] - public async Task ConcurrentChangeFeed_DeleteTombstones_WhileDeleting() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 20 items - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Read checkpoint BEFORE deletes - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - // Concurrently delete all - var deleteTasks = Enumerable.Range(0, 20).Select(async i => - { - try - { - await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); - } - catch (CosmosException) { } - }); - - await Task.WhenAll(deleteTasks); - - // Use the checkpoint-based change feed to see entries after creates - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - items.AddRange(response); - } - - // The LatestVersion feed shows current state — all items are deleted, - // so the feed may be empty or contain only the latest creates before deletes. - // The key test is: no crash, no corruption under concurrent deletes + feed reads. - // We just verify the container is empty and the feed read completed without error. - container.ItemCount.Should().Be(0, "all items should be deleted"); - } - - [Fact] - public async Task ConcurrentChangeFeedProcessors_SameContainer_BothSeeAllChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var received1 = new List(); - var received2 = new List(); - - // Create items - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Read change feed from beginning with two independent iterators - var iter1 = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - while (iter1.HasMoreResults) - { - var response = await iter1.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - received1.AddRange(response.Select(j => j["id"]!.ToString())); - } - - var iter2 = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - while (iter2.HasMoreResults) - { - var response = await iter2.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - received2.AddRange(response.Select(j => j["id"]!.ToString())); - } - - received1.Should().HaveCount(10); - received2.Should().HaveCount(10); - } - - [Fact] - public async Task ConcurrentChangeFeedRead_TrulyInterleaved() - { - var container = new InMemoryContainer("test", "/partitionKey"); - const int writeCount = 100; - - // Start writes and reads in a single WhenAll for true interleaving - var writeTasks = Enumerable.Range(0, writeCount).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var readTasks = Enumerable.Range(0, 5).Select(async _ => - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(response); - } - return results.Count; - }); - - var allTasks = writeTasks.Cast().Concat(readTasks).ToArray(); - await Task.WhenAll(allTasks); - - // After all complete, final read should see everything - var finalIterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - var finalResults = new List(); - while (finalIterator.HasMoreResults) - { - var response = await finalIterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - finalResults.AddRange(response); - } - finalResults.Should().HaveCount(writeCount); - } + [Fact] + public async Task ConcurrentChangeFeed_DeleteTombstones_WhileDeleting() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 20 items + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Read checkpoint BEFORE deletes + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + // Concurrently delete all + var deleteTasks = Enumerable.Range(0, 20).Select(async i => + { + try + { + await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); + } + catch (CosmosException) { } + }); + + await Task.WhenAll(deleteTasks); + + // Use the checkpoint-based change feed to see entries after creates + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + items.AddRange(response); + } + + // The LatestVersion feed shows current state — all items are deleted, + // so the feed may be empty or contain only the latest creates before deletes. + // The key test is: no crash, no corruption under concurrent deletes + feed reads. + // We just verify the container is empty and the feed read completed without error. + container.ItemCount.Should().Be(0, "all items should be deleted"); + } + + [Fact] + public async Task ConcurrentChangeFeedProcessors_SameContainer_BothSeeAllChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var received1 = new List(); + var received2 = new List(); + + // Create items + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Read change feed from beginning with two independent iterators + var iter1 = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + while (iter1.HasMoreResults) + { + var response = await iter1.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + received1.AddRange(response.Select(j => j["id"]!.ToString())); + } + + var iter2 = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + while (iter2.HasMoreResults) + { + var response = await iter2.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + received2.AddRange(response.Select(j => j["id"]!.ToString())); + } + + received1.Should().HaveCount(10); + received2.Should().HaveCount(10); + } + + [Fact] + public async Task ConcurrentChangeFeedRead_TrulyInterleaved() + { + var container = new InMemoryContainer("test", "/partitionKey"); + const int writeCount = 100; + + // Start writes and reads in a single WhenAll for true interleaving + var writeTasks = Enumerable.Range(0, writeCount).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var readTasks = Enumerable.Range(0, 5).Select(async _ => + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(response); + } + return results.Count; + }); + + var allTasks = writeTasks.Cast().Concat(readTasks).ToArray(); + await Task.WhenAll(allTasks); + + // After all complete, final read should see everything + var finalIterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + var finalResults = new List(); + while (finalIterator.HasMoreResults) + { + var response = await finalIterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + finalResults.AddRange(response); + } + finalResults.Should().HaveCount(writeCount); + } } public class ConcurrentUniqueKeyExtendedTests { - [Fact] - public async Task ConcurrentUpserts_UniqueKeyViolation_Handled() - { - var props = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(props); - - // Pre-create an item - await container.CreateItemAsync( - new TestDocument { Id = "seed", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - // 50 threads upsert with different IDs but same /name value - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.UpsertItemAsync( - new TestDocument { Id = $"new{i}", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - return "success"; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - return "conflict"; - } - }); - - var results = await Task.WhenAll(tasks); - results.Should().Contain("conflict", "unique key violations should be caught"); - } + [Fact] + public async Task ConcurrentUpserts_UniqueKeyViolation_Handled() + { + var props = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(props); + + // Pre-create an item + await container.CreateItemAsync( + new TestDocument { Id = "seed", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + // 50 threads upsert with different IDs but same /name value + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.UpsertItemAsync( + new TestDocument { Id = $"new{i}", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + return "success"; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + return "conflict"; + } + }); + + var results = await Task.WhenAll(tasks); + results.Should().Contain("conflict", "unique key violations should be caught"); + } } public class ConcurrentPatchExtendedTests { - [Fact] - public async Task ConcurrentPatch_WithFilterPredicate_UnderContention() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, - new PartitionKey("pk1")); - - // 50 threads patch with filter predicate — only succeeds if name is still "Original" - var tasks = Enumerable.Range(0, 50).Select(async i => - { - try - { - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", $"Patched{i}") }, - new PatchItemRequestOptions - { - FilterPredicate = "FROM c WHERE c.name = 'Original'" - }); - return "success"; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) - { - return "precondition_failed"; - } - }); - - var results = await Task.WhenAll(tasks); - var successCount = results.Count(r => r == "success"); - - // At least one should succeed (the first one to change the name) - successCount.Should().BeGreaterThanOrEqualTo(1); - } + [Fact] + public async Task ConcurrentPatch_WithFilterPredicate_UnderContention() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 0 }, + new PartitionKey("pk1")); + + // 50 threads patch with filter predicate — only succeeds if name is still "Original" + var tasks = Enumerable.Range(0, 50).Select(async i => + { + try + { + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", $"Patched{i}") }, + new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.name = 'Original'" + }); + return "success"; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed) + { + return "precondition_failed"; + } + }); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r == "success"); + + // At least one should succeed (the first one to change the name) + successCount.Should().BeGreaterThanOrEqualTo(1); + } } public class ConcurrentHierarchicalPkTests { - [Fact] - public async Task ConcurrentOperations_HierarchicalPartitionKey_AllSucceed() - { - var container = new InMemoryContainer("test", - new List { "/tenantId", "/userId" }); - - var tasks = Enumerable.Range(0, 50).Select(async i => - { - var tenantId = $"tenant{i % 5}"; - var userId = $"user{i % 10}"; - var pk = new PartitionKeyBuilder() - .Add(tenantId) - .Add(userId) - .Build(); - - var jObj = JObject.FromObject(new - { - id = $"item{i}", - tenantId, - userId, - name = $"Item{i}" - }); - - await container.UpsertItemAsync(jObj, pk); - }); - - await Task.WhenAll(tasks); - container.ItemCount.Should().Be(50); - } + [Fact] + public async Task ConcurrentOperations_HierarchicalPartitionKey_AllSucceed() + { + var container = new InMemoryContainer("test", + new List { "/tenantId", "/userId" }); + + var tasks = Enumerable.Range(0, 50).Select(async i => + { + var tenantId = $"tenant{i % 5}"; + var userId = $"user{i % 10}"; + var pk = new PartitionKeyBuilder() + .Add(tenantId) + .Add(userId) + .Build(); + + var jObj = JObject.FromObject(new + { + id = $"item{i}", + tenantId, + userId, + name = $"Item{i}" + }); + + await container.UpsertItemAsync(jObj, pk); + }); + + await Task.WhenAll(tasks); + container.ItemCount.Should().Be(50); + } } public class ConcurrentTTLTests { - [Fact] - public async Task ConcurrentTTL_ExpirationDuringOperations() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.DefaultTimeToLive = 1; // 1 second - - // Create items - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Wait for TTL expiration - await Task.Delay(1500); - - // Trigger lazy eviction via query - var query = new QueryDefinition("SELECT * FROM c"); - var iterator = container.GetItemQueryIterator(query); - var items = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - items.AddRange(response); - } - - // After TTL expiration + query trigger, items should be gone - items.Should().BeEmpty("all items should have expired after TTL"); - } + [Fact] + public async Task ConcurrentTTL_ExpirationDuringOperations() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.DefaultTimeToLive = 1; // 1 second + + // Create items + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Wait for TTL expiration + await Task.Delay(1500); + + // Trigger lazy eviction via query + var query = new QueryDefinition("SELECT * FROM c"); + var iterator = container.GetItemQueryIterator(query); + var items = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + items.AddRange(response); + } + + // After TTL expiration + query trigger, items should be gone + items.Should().BeEmpty("all items should have expired after TTL"); + } } public class ConcurrentStatePersistenceTests { - [Fact] - public async Task StatePersistence_ExportDuringConcurrentWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create some items - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - // Start writes and export concurrently - var writeTasks = Enumerable.Range(20, 30).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - var exportTask = Task.Run(() => container.ExportState()); - - await Task.WhenAll(writeTasks.Cast().Append(exportTask)); - - var exportedState = await exportTask; - exportedState.Should().NotBeNullOrEmpty("exported state should be valid JSON"); - - // State should be valid JSON - var parsed = JObject.Parse(exportedState); - parsed["items"].Should().NotBeNull(); - } + [Fact] + public async Task StatePersistence_ExportDuringConcurrentWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create some items + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + // Start writes and export concurrently + var writeTasks = Enumerable.Range(20, 30).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + var exportTask = Task.Run(() => container.ExportState()); + + await Task.WhenAll(writeTasks.Cast().Append(exportTask)); + + var exportedState = await exportTask; + exportedState.Should().NotBeNullOrEmpty("exported state should be valid JSON"); + + // State should be valid JSON + var parsed = JObject.Parse(exportedState); + parsed["items"].Should().NotBeNull(); + } } public class ConcurrentContainerLifecycleTests { - [Fact] - public async Task ConcurrentContainerDeletion_WhileWriting() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var containerResponse = await db.CreateContainerAsync("testcontainer", "/partitionKey"); - var container = containerResponse.Container; - - // Write some items - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", name = $"Item{i}" }), - new PartitionKey("pk1")); - - // Delete container - await container.DeleteContainerAsync(); - - // After deletion, re-creating should succeed (returns Created, not a no-op) - var resp = await db.CreateContainerIfNotExistsAsync("testcontainer", "/partitionKey"); - resp.StatusCode.Should().Be(HttpStatusCode.Created); - - // New container should be empty - var newContainer = resp.Container; - var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); - var iterator = newContainer.GetItemQueryIterator(query); - var count = (await iterator.ReadNextAsync()).First(); - count.Should().Be(0, "re-created container should be empty"); - } + [Fact] + public async Task ConcurrentContainerDeletion_WhileWriting() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var containerResponse = await db.CreateContainerAsync("testcontainer", "/partitionKey"); + var container = containerResponse.Container; + + // Write some items + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", name = $"Item{i}" }), + new PartitionKey("pk1")); + + // Delete container + await container.DeleteContainerAsync(); + + // After deletion, re-creating should succeed (returns Created, not a no-op) + var resp = await db.CreateContainerIfNotExistsAsync("testcontainer", "/partitionKey"); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + // New container should be empty + var newContainer = resp.Container; + var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); + var iterator = newContainer.GetItemQueryIterator(query); + var count = (await iterator.ReadNextAsync()).First(); + count.Should().Be(0, "re-created container should be empty"); + } } public class ConcurrentBulkExtendedTests { - [Fact] - public async Task ConcurrentBulkOperations_AllowBulkExecution() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.AllowBulkExecution = true; - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("test", "/partitionKey")).Container; - - // Fire-and-forget bulk pattern - var tasks = Enumerable.Range(0, 100).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", name = $"Item{i}" }), - new PartitionKey("pk1"))); - - await Task.WhenAll(tasks); - - // All items should exist - var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - response.First().Should().Be(100); - } + [Fact] + public async Task ConcurrentBulkOperations_AllowBulkExecution() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.AllowBulkExecution = true; + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("test", "/partitionKey")).Container; + + // Fire-and-forget bulk pattern + var tasks = Enumerable.Range(0, 100).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "pk1", name = $"Item{i}" }), + new PartitionKey("pk1"))); + + await Task.WhenAll(tasks); + + // All items should exist + var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + response.First().Should().Be(100); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1592,82 +1593,82 @@ public async Task ConcurrentBulkOperations_AllowBulkExecution() public class ConcurrentBatchIsolationTests { - [Fact(Skip = "Emulator batch execution is not globally isolated from non-batch operations. " + - "RestoreSnapshot does Clear()+rewrite which is not atomic. Concurrent direct CRUD " + - "can see partial state during batch execution. Would need a global per-partition " + - "lock to fix.")] - public void ConcurrentBatch_AndDirectCrud_NoCorruption_RealCosmos() { } - - [Fact] - public async Task ConcurrentBatch_AndDirectCrud_EmulatorBehaviour() - { - // DIVERGENT: Batch execution is not globally isolated — concurrent reads may see - // intermediate batch state. The batch itself always completes atomically (all-or-nothing). - var container = new InMemoryContainer("test", "/partitionKey"); - - // Pre-create item for the batch - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Run batch and direct reads concurrently - var batchTask = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "BatchModified" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "BatchCreated" }); - return await batch.ExecuteAsync(); - }); - - var readTasks = Enumerable.Range(0, 20).Select(async _ => - { - try - { - var r = await container.ReadItemAsync("1", new PartitionKey("pk1")); - return r.Resource.Name; - } - catch (CosmosException) { return "error"; } - }); - - var allTasks = readTasks.Cast().Concat(new[] { (Task)batchTask }); - await Task.WhenAll(allTasks); - - var batchResult = await batchTask; - batchResult.IsSuccessStatusCode.Should().BeTrue(); - - // After batch completes, state should be consistent - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().Be("BatchModified"); - } - - [Fact(Skip = "RestoreSnapshot clears dictionaries then re-populates, creating a brief window " + - "where concurrent readers may see empty state. Would need copy-on-write or atomic " + - "swap to fix.")] - public void ConcurrentBatch_RollbackPreservesState_FromConcurrentReaders_RealCosmos() { } - - [Fact] - public async Task ConcurrentBatch_RollbackPreservesState_EmulatorBehaviour() - { - // DIVERGENT: During rollback, concurrent readers may briefly see empty state. - // After rollback completes, state is correctly restored. - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Batch that will fail (duplicate create causes rollback) - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Modified" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - - // After rollback, original state should be restored - var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); - final.Resource.Name.Should().Be("Original"); - } + [Fact(Skip = "Emulator batch execution is not globally isolated from non-batch operations. " + + "RestoreSnapshot does Clear()+rewrite which is not atomic. Concurrent direct CRUD " + + "can see partial state during batch execution. Would need a global per-partition " + + "lock to fix.")] + public void ConcurrentBatch_AndDirectCrud_NoCorruption_RealCosmos() { } + + [Fact] + public async Task ConcurrentBatch_AndDirectCrud_EmulatorBehaviour() + { + // DIVERGENT: Batch execution is not globally isolated — concurrent reads may see + // intermediate batch state. The batch itself always completes atomically (all-or-nothing). + var container = new InMemoryContainer("test", "/partitionKey"); + + // Pre-create item for the batch + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Run batch and direct reads concurrently + var batchTask = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "BatchModified" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "BatchCreated" }); + return await batch.ExecuteAsync(); + }); + + var readTasks = Enumerable.Range(0, 20).Select(async _ => + { + try + { + var r = await container.ReadItemAsync("1", new PartitionKey("pk1")); + return r.Resource.Name; + } + catch (CosmosException) { return "error"; } + }); + + var allTasks = readTasks.Cast().Concat(new[] { (Task)batchTask }); + await Task.WhenAll(allTasks); + + var batchResult = await batchTask; + batchResult.IsSuccessStatusCode.Should().BeTrue(); + + // After batch completes, state should be consistent + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().Be("BatchModified"); + } + + [Fact(Skip = "RestoreSnapshot clears dictionaries then re-populates, creating a brief window " + + "where concurrent readers may see empty state. Would need copy-on-write or atomic " + + "swap to fix.")] + public void ConcurrentBatch_RollbackPreservesState_FromConcurrentReaders_RealCosmos() { } + + [Fact] + public async Task ConcurrentBatch_RollbackPreservesState_EmulatorBehaviour() + { + // DIVERGENT: During rollback, concurrent readers may briefly see empty state. + // After rollback completes, state is correctly restored. + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Batch that will fail (duplicate create causes rollback) + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Modified" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + + // After rollback, original state should be restored + var final = await container.ReadItemAsync("1", new PartitionKey("pk1")); + final.Resource.Name.Should().Be("Original"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1676,71 +1677,72 @@ await container.CreateItemAsync( public class ConcurrencyStressWithUniqueKeysTests { - [Fact] - public async Task HighContention_WithUniqueKeys_NoViolationsInFinalState() - { - var props = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(props); - const int itemPool = 50; - - // Pre-create items with unique names - for (var i = 0; i < itemPool; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Unique{i}", Value = 0 }, - new PartitionKey("pk1")); - - // 200 threads doing random CRUD — only upsert the SAME id (so unique key is preserved) - var tasks = Enumerable.Range(0, 200).Select(async t => - { - var id = $"{t % itemPool}"; - var op = t % 4; - try - { - switch (op) - { - case 0: - await container.ReadItemAsync(id, new PartitionKey("pk1")); - break; - case 1: - await container.UpsertItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Unique{t % itemPool}", Value = t }, - new PartitionKey("pk1")); - break; - case 2: - await container.ReplaceItemAsync( - new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Unique{t % itemPool}", Value = t }, - id, new PartitionKey("pk1")); - break; - case 3: - await container.PatchItemAsync(id, new PartitionKey("pk1"), - new[] { PatchOperation.Set("/value", t) }); - break; - } - } - catch (CosmosException ex) when (ex.StatusCode is HttpStatusCode.NotFound - or HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) { } - }); - - await Task.WhenAll(tasks); - - // Verify no corruption: all remaining items are readable and have unique names - var names = new HashSet(); - for (var i = 0; i < itemPool; i++) - { - try - { - var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); - r.Resource.Id.Should().Be($"{i}"); - names.Add(r.Resource.Name).Should().BeTrue( - $"each item should have a unique name, but '{r.Resource.Name}' was duplicated"); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } - } - } + [Fact] + public async Task HighContention_WithUniqueKeys_NoViolationsInFinalState() + { + var props = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(props); + const int itemPool = 50; + + // Pre-create items with unique names + for (var i = 0; i < itemPool; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Unique{i}", Value = 0 }, + new PartitionKey("pk1")); + + // 200 threads doing random CRUD — only upsert the SAME id (so unique key is preserved) + var tasks = Enumerable.Range(0, 200).Select(async t => + { + var id = $"{t % itemPool}"; + var op = t % 4; + try + { + switch (op) + { + case 0: + await container.ReadItemAsync(id, new PartitionKey("pk1")); + break; + case 1: + await container.UpsertItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Unique{t % itemPool}", Value = t }, + new PartitionKey("pk1")); + break; + case 2: + await container.ReplaceItemAsync( + new TestDocument { Id = id, PartitionKey = "pk1", Name = $"Unique{t % itemPool}", Value = t }, + id, new PartitionKey("pk1")); + break; + case 3: + await container.PatchItemAsync(id, new PartitionKey("pk1"), + new[] { PatchOperation.Set("/value", t) }); + break; + } + } + catch (CosmosException ex) when (ex.StatusCode is HttpStatusCode.NotFound + or HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) + { } + }); + + await Task.WhenAll(tasks); + + // Verify no corruption: all remaining items are readable and have unique names + var names = new HashSet(); + for (var i = 0; i < itemPool; i++) + { + try + { + var r = await container.ReadItemAsync($"{i}", new PartitionKey("pk1")); + r.Resource.Id.Should().Be($"{i}"); + names.Add(r.Resource.Name).Should().BeTrue( + $"each item should have a unique name, but '{r.Resource.Name}' was duplicated"); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { } + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementDeepDiveTests.cs index f168272..d060e26 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementDeepDiveTests.cs @@ -14,359 +14,359 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ContainerStreamValidationTests { - // T01: BUG-1 — ReplaceContainerStreamAsync should reject partition key path changes - [Fact] - public async Task ReplaceContainerStreamAsync_RejectsPartitionKeyPathChange() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/differentPk"); - using var response = await container.ReplaceContainerStreamAsync(newProps); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T02: BUG-1 — ReplaceContainerStreamAsync should reject invalid computed properties - [Fact] - public async Task ReplaceContainerStreamAsync_RejectsInvalidComputedProperties() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/pk"); - // Add 21 computed properties (max is 20) - for (var i = 0; i < 21; i++) - { - newProps.ComputedProperties.Add(new ComputedProperty - { - Name = $"cp{i}", - Query = $"SELECT VALUE c.field{i} FROM c" - }); - } - - using var response = await container.ReplaceContainerStreamAsync(newProps); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T03: BUG-2 — ReplaceContainerStreamAsync should preserve UniqueKeyPolicy - [Fact] - public async Task ReplaceContainerStreamAsync_PreservesUniqueKeyPolicy() - { - var originalProps = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(originalProps); - container.ExplicitlyCreated = true; - - // Replace with props that omit UniqueKeyPolicy - var replaceProps = new ContainerProperties("test", "/pk"); - using var response = await container.ReplaceContainerStreamAsync(replaceProps); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Verify UniqueKeyPolicy was preserved - var readResponse = await container.ReadContainerAsync(); - readResponse.Resource.UniqueKeyPolicy.UniqueKeys.Should().HaveCount(1); - readResponse.Resource.UniqueKeyPolicy.UniqueKeys[0].Paths.Should().Contain("/email"); - - // Verify unique key is actually enforced - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", email = "x@test.com" }), - new PartitionKey("a")); - - var act = () => container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", email = "x@test.com" }), - new PartitionKey("a")); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - // T04: BUG-3 — ReadContainerStreamAsync should return 404 for non-existent containers - [Fact] - public async Task ReadContainerStreamAsync_NonExistentContainer_Returns404() - { - var db = new InMemoryDatabase("testdb"); - var container = (InMemoryContainer)db.GetContainer("nonexistent"); - - using var response = await container.ReadContainerStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + // T01: BUG-1 — ReplaceContainerStreamAsync should reject partition key path changes + [Fact] + public async Task ReplaceContainerStreamAsync_RejectsPartitionKeyPathChange() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/differentPk"); + using var response = await container.ReplaceContainerStreamAsync(newProps); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T02: BUG-1 — ReplaceContainerStreamAsync should reject invalid computed properties + [Fact] + public async Task ReplaceContainerStreamAsync_RejectsInvalidComputedProperties() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/pk"); + // Add 21 computed properties (max is 20) + for (var i = 0; i < 21; i++) + { + newProps.ComputedProperties.Add(new ComputedProperty + { + Name = $"cp{i}", + Query = $"SELECT VALUE c.field{i} FROM c" + }); + } + + using var response = await container.ReplaceContainerStreamAsync(newProps); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T03: BUG-2 — ReplaceContainerStreamAsync should preserve UniqueKeyPolicy + [Fact] + public async Task ReplaceContainerStreamAsync_PreservesUniqueKeyPolicy() + { + var originalProps = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(originalProps); + container.ExplicitlyCreated = true; + + // Replace with props that omit UniqueKeyPolicy + var replaceProps = new ContainerProperties("test", "/pk"); + using var response = await container.ReplaceContainerStreamAsync(replaceProps); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify UniqueKeyPolicy was preserved + var readResponse = await container.ReadContainerAsync(); + readResponse.Resource.UniqueKeyPolicy.UniqueKeys.Should().HaveCount(1); + readResponse.Resource.UniqueKeyPolicy.UniqueKeys[0].Paths.Should().Contain("/email"); + + // Verify unique key is actually enforced + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", email = "x@test.com" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", email = "x@test.com" }), + new PartitionKey("a")); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // T04: BUG-3 — ReadContainerStreamAsync should return 404 for non-existent containers + [Fact] + public async Task ReadContainerStreamAsync_NonExistentContainer_Returns404() + { + var db = new InMemoryDatabase("testdb"); + var container = (InMemoryContainer)db.GetContainer("nonexistent"); + + using var response = await container.ReadContainerStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } public class DeleteContainerCleanupTests { - // T05: BUG-4 — DeleteContainerAsync should clear stored procedure properties - [Fact] - public async Task DeleteContainer_ClearsStoredProcedureProperties() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "sproc1", - Body = "function() { return true; }" - }); - - // Verify sproc exists - var sproc = await container.Scripts.ReadStoredProcedureAsync("sproc1"); - sproc.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete container - await container.DeleteContainerAsync(); - - // Verify sproc properties are cleared - var act = () => container.Scripts.ReadStoredProcedureAsync("sproc1"); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // T06: BUG-4 — DeleteContainerAsync should clear trigger properties - [Fact] - public async Task DeleteContainer_ClearsTriggerProperties() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trigger1", - Body = "function() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - // Verify trigger exists - var trigger = await container.Scripts.ReadTriggerAsync("trigger1"); - trigger.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete container - await container.DeleteContainerAsync(); - - // Verify trigger properties are cleared - var act = () => container.Scripts.ReadTriggerAsync("trigger1"); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // T34: BUG-4 — DeleteContainerStreamAsync should clear stored procedure properties - [Fact] - public async Task DeleteContainer_StreamVariant_ClearsStoredProcedureProperties() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "sproc1", - Body = "function() { return true; }" - }); - - // Delete container via stream - using var response = await container.DeleteContainerStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify sproc properties are cleared - var act = () => container.Scripts.ReadStoredProcedureAsync("sproc1"); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // T35: BUG-4 — DeleteContainerStreamAsync should clear trigger properties - [Fact] - public async Task DeleteContainer_StreamVariant_ClearsTriggerProperties() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trigger1", - Body = "function() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - // Delete container via stream - using var response = await container.DeleteContainerStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify trigger properties are cleared - var act = () => container.Scripts.ReadTriggerAsync("trigger1"); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + // T05: BUG-4 — DeleteContainerAsync should clear stored procedure properties + [Fact] + public async Task DeleteContainer_ClearsStoredProcedureProperties() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "sproc1", + Body = "function() { return true; }" + }); + + // Verify sproc exists + var sproc = await container.Scripts.ReadStoredProcedureAsync("sproc1"); + sproc.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete container + await container.DeleteContainerAsync(); + + // Verify sproc properties are cleared + var act = () => container.Scripts.ReadStoredProcedureAsync("sproc1"); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // T06: BUG-4 — DeleteContainerAsync should clear trigger properties + [Fact] + public async Task DeleteContainer_ClearsTriggerProperties() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trigger1", + Body = "function() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + // Verify trigger exists + var trigger = await container.Scripts.ReadTriggerAsync("trigger1"); + trigger.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete container + await container.DeleteContainerAsync(); + + // Verify trigger properties are cleared + var act = () => container.Scripts.ReadTriggerAsync("trigger1"); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // T34: BUG-4 — DeleteContainerStreamAsync should clear stored procedure properties + [Fact] + public async Task DeleteContainer_StreamVariant_ClearsStoredProcedureProperties() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "sproc1", + Body = "function() { return true; }" + }); + + // Delete container via stream + using var response = await container.DeleteContainerStreamAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify sproc properties are cleared + var act = () => container.Scripts.ReadStoredProcedureAsync("sproc1"); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // T35: BUG-4 — DeleteContainerStreamAsync should clear trigger properties + [Fact] + public async Task DeleteContainer_StreamVariant_ClearsTriggerProperties() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trigger1", + Body = "function() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + // Delete container via stream + using var response = await container.DeleteContainerStreamAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify trigger properties are cleared + var act = () => container.Scripts.ReadTriggerAsync("trigger1"); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } } public class ContainerExplicitCreationLifecycleTests { - // T07: BUG-5 — After DeleteContainerAsync, lazy resolve should show container not found - [Fact] - public async Task DeleteContainer_ThenLazyResolve_ReadContainerThrowsNotFound() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("mycontainer", "/pk"); - - // Verify container exists - var container = db.GetContainer("mycontainer"); - var readResponse = await container.ReadContainerAsync(); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete the container - await container.DeleteContainerAsync(); - - // Get a new lazy reference - var newRef = db.GetContainer("mycontainer"); - - // ReadContainerAsync should throw NotFound - var act = () => newRef.ReadContainerAsync(); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // T08: BUG-5 — Database delete should clear _explicitlyCreatedContainers - [Fact] - public async Task DatabaseDelete_ClearsExplicitlyCreatedContainers() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseAsync("testdb"); - var db = dbResponse.Database; - - await db.CreateContainerAsync("mycontainer", "/pk"); - - // Verify container exists - var container = db.GetContainer("mycontainer"); - var readResponse = await container.ReadContainerAsync(); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete the database - await db.DeleteAsync(); - - // Re-create the database and get a lazy container reference - var newDbResponse = await client.CreateDatabaseAsync("testdb"); - var newDb = newDbResponse.Database; - var newContainer = newDb.GetContainer("mycontainer"); - - // ReadContainerAsync should throw NotFound - var act = () => newContainer.ReadContainerAsync(); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + // T07: BUG-5 — After DeleteContainerAsync, lazy resolve should show container not found + [Fact] + public async Task DeleteContainer_ThenLazyResolve_ReadContainerThrowsNotFound() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("mycontainer", "/pk"); + + // Verify container exists + var container = db.GetContainer("mycontainer"); + var readResponse = await container.ReadContainerAsync(); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete the container + await container.DeleteContainerAsync(); + + // Get a new lazy reference + var newRef = db.GetContainer("mycontainer"); + + // ReadContainerAsync should throw NotFound + var act = () => newRef.ReadContainerAsync(); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // T08: BUG-5 — Database delete should clear _explicitlyCreatedContainers + [Fact] + public async Task DatabaseDelete_ClearsExplicitlyCreatedContainers() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseAsync("testdb"); + var db = dbResponse.Database; + + await db.CreateContainerAsync("mycontainer", "/pk"); + + // Verify container exists + var container = db.GetContainer("mycontainer"); + var readResponse = await container.ReadContainerAsync(); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete the database + await db.DeleteAsync(); + + // Re-create the database and get a lazy container reference + var newDbResponse = await client.CreateDatabaseAsync("testdb"); + var newDb = newDbResponse.Database; + var newContainer = newDb.GetContainer("mycontainer"); + + // ReadContainerAsync should throw NotFound + var act = () => newContainer.ReadContainerAsync(); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } } public class ThroughputPersistenceDeepDiveTests { - // Issue #26: CreateContainerAsync throughput parameter should be persisted - [Fact] - public async Task CreateContainer_WithThroughput_PersistsValue() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - - var containerProps = new ContainerProperties("items", "/pk"); - await db.CreateContainerAsync(containerProps, throughput: 2000); - - var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); - actualThroughput.Should().Be(2000); - } - - // Issue #26: CreateContainerIfNotExistsAsync throughput parameter should be persisted - [Fact] - public async Task CreateContainerIfNotExists_WithThroughput_PersistsValue() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - - await db.CreateContainerIfNotExistsAsync("items", "/pk", throughput: 1500); - - var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); - actualThroughput.Should().Be(1500); - } - - // Issue #26: CreateContainerAsync with ThroughputProperties should persist - [Fact] - public async Task CreateContainer_WithThroughputProperties_PersistsValue() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - - var containerProps = new ContainerProperties("items", "/pk"); - await db.CreateContainerAsync(containerProps, ThroughputProperties.CreateManualThroughput(3000)); - - var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); - actualThroughput.Should().Be(3000); - } - - // T09: BUG-6 — ReplaceThroughputAsync(ThroughputProperties) should persist the value - [Fact] - public async Task ReplaceThroughput_ThroughputProperties_PersistsValue() - { - var container = new InMemoryContainer("test", "/pk"); - - // Default throughput should be 400 - var initialThroughput = await container.ReadThroughputAsync(); - initialThroughput.Should().Be(400); - - // Replace with ThroughputProperties - await container.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(800)); - - // Throughput should now be 800 - var newThroughput = await container.ReadThroughputAsync(); - newThroughput.Should().Be(800); - } + // Issue #26: CreateContainerAsync throughput parameter should be persisted + [Fact] + public async Task CreateContainer_WithThroughput_PersistsValue() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("items", "/pk"); + await db.CreateContainerAsync(containerProps, throughput: 2000); + + var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); + actualThroughput.Should().Be(2000); + } + + // Issue #26: CreateContainerIfNotExistsAsync throughput parameter should be persisted + [Fact] + public async Task CreateContainerIfNotExists_WithThroughput_PersistsValue() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + await db.CreateContainerIfNotExistsAsync("items", "/pk", throughput: 1500); + + var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); + actualThroughput.Should().Be(1500); + } + + // Issue #26: CreateContainerAsync with ThroughputProperties should persist + [Fact] + public async Task CreateContainer_WithThroughputProperties_PersistsValue() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("items", "/pk"); + await db.CreateContainerAsync(containerProps, ThroughputProperties.CreateManualThroughput(3000)); + + var actualThroughput = await client.GetContainer("testdb", "items").ReadThroughputAsync(); + actualThroughput.Should().Be(3000); + } + + // T09: BUG-6 — ReplaceThroughputAsync(ThroughputProperties) should persist the value + [Fact] + public async Task ReplaceThroughput_ThroughputProperties_PersistsValue() + { + var container = new InMemoryContainer("test", "/pk"); + + // Default throughput should be 400 + var initialThroughput = await container.ReadThroughputAsync(); + initialThroughput.Should().Be(400); + + // Replace with ThroughputProperties + await container.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(800)); + + // Throughput should now be 800 + var newThroughput = await container.ReadThroughputAsync(); + newThroughput.Should().Be(800); + } } public class RegistrationValidationTests { - // T10: BUG-7 — RegisterUdf should validate null name - [Fact] - public void RegisterUdf_NullName_ThrowsArgumentNull() - { - var container = new InMemoryContainer("test", "/pk"); + // T10: BUG-7 — RegisterUdf should validate null name + [Fact] + public void RegisterUdf_NullName_ThrowsArgumentNull() + { + var container = new InMemoryContainer("test", "/pk"); - var act = () => container.RegisterUdf(null!, _ => null); + var act = () => container.RegisterUdf(null!, _ => null); - act.Should().Throw(); - } + act.Should().Throw(); + } - // T11: BUG-8 — RegisterTrigger should validate null triggerId (pre-trigger overload) - [Fact] - public void RegisterTrigger_NullTriggerId_ThrowsArgumentNull() - { - var container = new InMemoryContainer("test", "/pk"); + // T11: BUG-8 — RegisterTrigger should validate null triggerId (pre-trigger overload) + [Fact] + public void RegisterTrigger_NullTriggerId_ThrowsArgumentNull() + { + var container = new InMemoryContainer("test", "/pk"); - var act = () => container.RegisterTrigger(null!, TriggerType.Pre, TriggerOperation.All, - (Func)(doc => doc)); + var act = () => container.RegisterTrigger(null!, TriggerType.Pre, TriggerOperation.All, + (Func)(doc => doc)); - act.Should().Throw(); - } + act.Should().Throw(); + } - // T11b: BUG-8 — RegisterTrigger should validate null triggerId (post-trigger overload) - [Fact] - public void RegisterTrigger_PostHandler_NullTriggerId_ThrowsArgumentNull() - { - var container = new InMemoryContainer("test", "/pk"); + // T11b: BUG-8 — RegisterTrigger should validate null triggerId (post-trigger overload) + [Fact] + public void RegisterTrigger_PostHandler_NullTriggerId_ThrowsArgumentNull() + { + var container = new InMemoryContainer("test", "/pk"); - var act = () => container.RegisterTrigger(null!, TriggerType.Post, TriggerOperation.All, - (Action)(_ => { })); + var act = () => container.RegisterTrigger(null!, TriggerType.Post, TriggerOperation.All, + (Action)(_ => { })); - act.Should().Throw(); - } + act.Should().Throw(); + } - // T12: BUG-8 — DeregisterTrigger should validate null triggerId - [Fact] - public void DeregisterTrigger_NullTriggerId_ThrowsArgumentNull() - { - var container = new InMemoryContainer("test", "/pk"); + // T12: BUG-8 — DeregisterTrigger should validate null triggerId + [Fact] + public void DeregisterTrigger_NullTriggerId_ThrowsArgumentNull() + { + var container = new InMemoryContainer("test", "/pk"); - var act = () => container.DeregisterTrigger(null!); + var act = () => container.DeregisterTrigger(null!); - act.Should().Throw(); - } + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -375,449 +375,449 @@ public void DeregisterTrigger_NullTriggerId_ThrowsArgumentNull() public class ContainerNameValidationTests { - // T13: GAP-9 — Name too long - [Fact] - public async Task CreateContainerAsync_NameTooLong_ThrowsBadRequest() - { - var db = new InMemoryDatabase("testdb"); - var longName = new string('x', 256); - - var act = () => db.CreateContainerAsync(longName, "/pk"); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T14: GAP-9/10 — Name with slash - [Fact] - public async Task CreateContainerAsync_NameWithSlash_ThrowsBadRequest() - { - var db = new InMemoryDatabase("testdb"); - - var act = () => db.CreateContainerAsync("my/container", "/pk"); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T15: GAP-10 — Name with backslash - [Fact] - public async Task CreateContainerAsync_NameWithBackslash_ThrowsBadRequest() - { - var db = new InMemoryDatabase("testdb"); - - var act = () => db.CreateContainerAsync("my\\container", "/pk"); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T16: GAP-10 — Name with hash - [Fact] - public async Task CreateContainerAsync_NameWithHash_ThrowsBadRequest() - { - var db = new InMemoryDatabase("testdb"); - - var act = () => db.CreateContainerAsync("my#container", "/pk"); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T17: GAP-10 — Name with question mark - [Fact] - public async Task CreateContainerAsync_NameWithQuestionMark_ThrowsBadRequest() - { - var db = new InMemoryDatabase("testdb"); - - var act = () => db.CreateContainerAsync("my?container", "/pk"); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + // T13: GAP-9 — Name too long + [Fact] + public async Task CreateContainerAsync_NameTooLong_ThrowsBadRequest() + { + var db = new InMemoryDatabase("testdb"); + var longName = new string('x', 256); + + var act = () => db.CreateContainerAsync(longName, "/pk"); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T14: GAP-9/10 — Name with slash + [Fact] + public async Task CreateContainerAsync_NameWithSlash_ThrowsBadRequest() + { + var db = new InMemoryDatabase("testdb"); + + var act = () => db.CreateContainerAsync("my/container", "/pk"); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T15: GAP-10 — Name with backslash + [Fact] + public async Task CreateContainerAsync_NameWithBackslash_ThrowsBadRequest() + { + var db = new InMemoryDatabase("testdb"); + + var act = () => db.CreateContainerAsync("my\\container", "/pk"); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T16: GAP-10 — Name with hash + [Fact] + public async Task CreateContainerAsync_NameWithHash_ThrowsBadRequest() + { + var db = new InMemoryDatabase("testdb"); + + var act = () => db.CreateContainerAsync("my#container", "/pk"); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T17: GAP-10 — Name with question mark + [Fact] + public async Task CreateContainerAsync_NameWithQuestionMark_ThrowsBadRequest() + { + var db = new InMemoryDatabase("testdb"); + + var act = () => db.CreateContainerAsync("my?container", "/pk"); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } public class ContainerCreationEdgeCaseTests3 { - // T18: GAP-11 — CreateContainerIfNotExists with different PK returns existing - [Fact] - public async Task CreateContainerIfNotExists_DifferentPkPath_ReturnsExistingContainer() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("container1", "/a"); - - // Create again with different PK path - should return existing - var response = await db.CreateContainerIfNotExistsAsync("container1", "/b"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - // The container should still have the original PK path - var readResponse = await response.Container.ReadContainerAsync(); - readResponse.Resource.PartitionKeyPath.Should().Be("/a"); - } + // T18: GAP-11 — CreateContainerIfNotExists with different PK returns existing + [Fact] + public async Task CreateContainerIfNotExists_DifferentPkPath_ReturnsExistingContainer() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("container1", "/a"); + + // Create again with different PK path - should return existing + var response = await db.CreateContainerIfNotExistsAsync("container1", "/b"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + // The container should still have the original PK path + var readResponse = await response.Container.ReadContainerAsync(); + readResponse.Resource.PartitionKeyPath.Should().Be("/a"); + } } public class DeleteAllByPartitionKeyDeepDiveTests { - // T19: GAP-12 — DeleteAllByPK with PartitionKey.None - [Fact] - public async Task DeleteAllByPK_PartitionKeyNone_RemovesMatchingItems() - { - var container = new InMemoryContainer("test", "/pk"); - - // Create items with no partition key value (PartitionKey.None-like behavior) - await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); - await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.None); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "other" }), - new PartitionKey("other")); - - using var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.None); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Items with PK.None should be gone - var act1 = () => container.ReadItemAsync("1", PartitionKey.None); - (await act1.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - - // Items with other PK should still exist - var remaining = await container.ReadItemAsync("3", new PartitionKey("other")); - remaining.StatusCode.Should().Be(HttpStatusCode.OK); - } + // T19: GAP-12 — DeleteAllByPK with PartitionKey.None + [Fact] + public async Task DeleteAllByPK_PartitionKeyNone_RemovesMatchingItems() + { + var container = new InMemoryContainer("test", "/pk"); + + // Create items with no partition key value (PartitionKey.None-like behavior) + await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); + await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.None); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "other" }), + new PartitionKey("other")); + + using var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.None); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Items with PK.None should be gone + var act1 = () => container.ReadItemAsync("1", PartitionKey.None); + (await act1.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Items with other PK should still exist + var remaining = await container.ReadItemAsync("3", new PartitionKey("other")); + remaining.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class ContainerQueryFilterTests { - // T20: GAP-13 — GetContainerQueryIterator with WHERE filter - [Fact] - public async Task GetContainerQueryIterator_WhereFilter_ReturnsMatchingContainer() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("container-1", "/pk"); - await db.CreateContainerAsync("container-2", "/pk"); - await db.CreateContainerAsync("container-3", "/pk"); - - var iterator = db.GetContainerQueryIterator( - "SELECT * FROM c WHERE c.id = 'container-2'"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Id.Should().Be("container-2"); - } - - // T21: GAP-14 — GetContainerQueryIterator with continuation token paging - [Fact] - public async Task GetContainerQueryIterator_ContinuationToken_CorrectPaging() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("c1", "/pk"); - await db.CreateContainerAsync("c2", "/pk"); - await db.CreateContainerAsync("c3", "/pk"); - - var allResults = new List(); - string? continuationToken = null; - - // Page through with MaxItemCount=1 - do - { - var iterator = db.GetContainerQueryIterator( - queryText: null, - continuationToken: continuationToken, - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - continuationToken = page.ContinuationToken; - } while (continuationToken != null); - - allResults.Should().HaveCount(3); - } - - // T22: GAP-15 — GetContainerQueryIterator returns properties without metadata - [Fact] - public async Task GetContainerQueryIterator_Properties_ConstructedFromIdAndPk() - { - var db = new InMemoryDatabase("testdb"); - var props = new ContainerProperties("mycontainer", "/pk") - { - DefaultTimeToLive = 3600 - }; - await db.CreateContainerAsync(props); - - var iterator = db.GetContainerQueryIterator(); - var page = await iterator.ReadNextAsync(); - var returned = page.First(); - - returned.Id.Should().Be("mycontainer"); - returned.PartitionKeyPath.Should().Be("/pk"); - } - - // Issue #7: GetContainerQueryIterator with SELECT VALUE query should project container IDs - [Fact] - public async Task GetContainerQueryIterator_SelectValueId_ReturnsContainerIds() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync(new ContainerProperties("container-a", "/pk")); - await db.CreateContainerAsync(new ContainerProperties("container-b", "/pk")); - - var iterator = db.GetContainerQueryIterator("SELECT VALUE(c.id) FROM c"); - var page = await iterator.ReadNextAsync(); - var ids = page.ToList(); - - ids.Should().Contain("container-a"); - ids.Should().Contain("container-b"); - } - - // Issue #7: Alternative SELECT VALUE syntax without parentheses - [Fact] - public async Task GetContainerQueryIterator_SelectValueIdNoParen_ReturnsContainerIds() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync(new ContainerProperties("my-container", "/pk")); - - var iterator = db.GetContainerQueryIterator("SELECT VALUE c.id FROM c"); - var page = await iterator.ReadNextAsync(); - var ids = page.ToList(); - - ids.Should().Contain("my-container"); - } + // T20: GAP-13 — GetContainerQueryIterator with WHERE filter + [Fact] + public async Task GetContainerQueryIterator_WhereFilter_ReturnsMatchingContainer() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("container-1", "/pk"); + await db.CreateContainerAsync("container-2", "/pk"); + await db.CreateContainerAsync("container-3", "/pk"); + + var iterator = db.GetContainerQueryIterator( + "SELECT * FROM c WHERE c.id = 'container-2'"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Id.Should().Be("container-2"); + } + + // T21: GAP-14 — GetContainerQueryIterator with continuation token paging + [Fact] + public async Task GetContainerQueryIterator_ContinuationToken_CorrectPaging() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("c1", "/pk"); + await db.CreateContainerAsync("c2", "/pk"); + await db.CreateContainerAsync("c3", "/pk"); + + var allResults = new List(); + string? continuationToken = null; + + // Page through with MaxItemCount=1 + do + { + var iterator = db.GetContainerQueryIterator( + queryText: null, + continuationToken: continuationToken, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + continuationToken = page.ContinuationToken; + } while (continuationToken != null); + + allResults.Should().HaveCount(3); + } + + // T22: GAP-15 — GetContainerQueryIterator returns properties without metadata + [Fact] + public async Task GetContainerQueryIterator_Properties_ConstructedFromIdAndPk() + { + var db = new InMemoryDatabase("testdb"); + var props = new ContainerProperties("mycontainer", "/pk") + { + DefaultTimeToLive = 3600 + }; + await db.CreateContainerAsync(props); + + var iterator = db.GetContainerQueryIterator(); + var page = await iterator.ReadNextAsync(); + var returned = page.First(); + + returned.Id.Should().Be("mycontainer"); + returned.PartitionKeyPath.Should().Be("/pk"); + } + + // Issue #7: GetContainerQueryIterator with SELECT VALUE query should project container IDs + [Fact] + public async Task GetContainerQueryIterator_SelectValueId_ReturnsContainerIds() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync(new ContainerProperties("container-a", "/pk")); + await db.CreateContainerAsync(new ContainerProperties("container-b", "/pk")); + + var iterator = db.GetContainerQueryIterator("SELECT VALUE(c.id) FROM c"); + var page = await iterator.ReadNextAsync(); + var ids = page.ToList(); + + ids.Should().Contain("container-a"); + ids.Should().Contain("container-b"); + } + + // Issue #7: Alternative SELECT VALUE syntax without parentheses + [Fact] + public async Task GetContainerQueryIterator_SelectValueIdNoParen_ReturnsContainerIds() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync(new ContainerProperties("my-container", "/pk")); + + var iterator = db.GetContainerQueryIterator("SELECT VALUE c.id FROM c"); + var page = await iterator.ReadNextAsync(); + var ids = page.ToList(); + + ids.Should().Contain("my-container"); + } } public class ContainerCancellationTokenDeepDiveTests { - // T23: GAP-16 — DeleteContainerAsync with cancelled token - [Fact] - public async Task DeleteContainerAsync_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => container.DeleteContainerAsync(cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - // T24: GAP-16 — ReplaceContainerAsync with cancelled token - [Fact] - public async Task ReplaceContainerAsync_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => container.ReplaceContainerAsync( - new ContainerProperties("test", "/pk"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } + // T23: GAP-16 — DeleteContainerAsync with cancelled token + [Fact] + public async Task DeleteContainerAsync_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => container.DeleteContainerAsync(cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + // T24: GAP-16 — ReplaceContainerAsync with cancelled token + [Fact] + public async Task ReplaceContainerAsync_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => container.ReplaceContainerAsync( + new ContainerProperties("test", "/pk"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } } public class ReplaceContainerEdgeCaseTests { - // T25: GAP-17 — ReplaceContainerAsync with null properties - [Fact] - public async Task ReplaceContainerAsync_NullProperties_Throws() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; + // T25: GAP-17 — ReplaceContainerAsync with null properties + [Fact] + public async Task ReplaceContainerAsync_NullProperties_Throws() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; - var act = () => container.ReplaceContainerAsync(null!); + var act = () => container.ReplaceContainerAsync(null!); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } } public class DeleteContainerIdempotencyDeepDiveTests { - // T26: GAP-18 — Delete twice, database registration removed after first - [Fact] - public async Task DeleteContainerAsync_Twice_OnlyRemovesFromDatabaseOnce() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("mycontainer", "/pk"); - - var container = db.GetContainer("mycontainer"); - - // First delete - var response1 = await container.DeleteContainerAsync(); - response1.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Second delete - should still return NoContent (idempotent) - var response2 = await container.DeleteContainerAsync(); - response2.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Container should not appear in iterator - var iterator = db.GetContainerQueryIterator(); - var page = await iterator.ReadNextAsync(); - page.Should().NotContain(p => p.Id == "mycontainer"); - } + // T26: GAP-18 — Delete twice, database registration removed after first + [Fact] + public async Task DeleteContainerAsync_Twice_OnlyRemovesFromDatabaseOnce() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("mycontainer", "/pk"); + + var container = db.GetContainer("mycontainer"); + + // First delete + var response1 = await container.DeleteContainerAsync(); + response1.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Second delete - should still return NoContent (idempotent) + var response2 = await container.DeleteContainerAsync(); + response2.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Container should not appear in iterator + var iterator = db.GetContainerQueryIterator(); + var page = await iterator.ReadNextAsync(); + page.Should().NotContain(p => p.Id == "mycontainer"); + } } public class DatabaseDeletionContainerDeepDiveTests { - // T27/S01 — Database delete cascade behavior - [Fact] - public async Task DatabaseDeleteAsync_ShouldCascadeContainerCleanup() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("c1", "/pk"); - var container = (InMemoryContainer)db.GetContainer("c1"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await db.DeleteAsync(); - - // Items should be gone after database delete (cascade) - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + // T27/S01 — Database delete cascade behavior + [Fact] + public async Task DatabaseDeleteAsync_ShouldCascadeContainerCleanup() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("c1", "/pk"); + var container = (InMemoryContainer)db.GetContainer("c1"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await db.DeleteAsync(); + + // Items should be gone after database delete (cascade) + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } } public class ContainerCreationResponseTests { - // T28: GAP-20 — CreateContainerStreamAsync response body - [Fact] - public async Task CreateContainerStreamAsync_ResponseBody_ContainsContainerProperties() - { - var db = new InMemoryDatabase("testdb"); - var props = new ContainerProperties("myCont", "/pk"); - - using var response = await db.CreateContainerStreamAsync(props); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - // Stream response should be non-null (may or may not have a body depending on implementation) - response.Should().NotBeNull(); - } + // T28: GAP-20 — CreateContainerStreamAsync response body + [Fact] + public async Task CreateContainerStreamAsync_ResponseBody_ContainsContainerProperties() + { + var db = new InMemoryDatabase("testdb"); + var props = new ContainerProperties("myCont", "/pk"); + + using var response = await db.CreateContainerStreamAsync(props); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + // Stream response should be non-null (may or may not have a body depending on implementation) + response.Should().NotBeNull(); + } } public class ComputedPropertyValidationTests { - // T29: GAP-21 — Too many computed properties via Replace - [Fact] - public async Task ReplaceContainerAsync_ComputedProperties_TooMany_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/pk"); - for (var i = 0; i < 21; i++) - { - newProps.ComputedProperties.Add(new ComputedProperty - { - Name = $"cp{i}", - Query = $"SELECT VALUE c.field{i} FROM c" - }); - } - - var act = () => container.ReplaceContainerAsync(newProps); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T30: GAP-21 — Reserved computed property name via Replace - [Fact] - public async Task ReplaceContainerAsync_ComputedProperties_ReservedName_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/pk"); - newProps.ComputedProperties.Add(new ComputedProperty - { - Name = "id", - Query = "SELECT VALUE c.someField FROM c" - }); - - var act = () => container.ReplaceContainerAsync(newProps); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // T31: GAP-21 — Missing SELECT VALUE in computed property via Replace - [Fact] - public async Task ReplaceContainerAsync_ComputedProperties_MissingSelectValue_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/pk"); - newProps.ComputedProperties.Add(new ComputedProperty - { - Name = "cp1", - Query = "SELECT c.someField FROM c" - }); - - var act = () => container.ReplaceContainerAsync(newProps); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + // T29: GAP-21 — Too many computed properties via Replace + [Fact] + public async Task ReplaceContainerAsync_ComputedProperties_TooMany_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/pk"); + for (var i = 0; i < 21; i++) + { + newProps.ComputedProperties.Add(new ComputedProperty + { + Name = $"cp{i}", + Query = $"SELECT VALUE c.field{i} FROM c" + }); + } + + var act = () => container.ReplaceContainerAsync(newProps); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T30: GAP-21 — Reserved computed property name via Replace + [Fact] + public async Task ReplaceContainerAsync_ComputedProperties_ReservedName_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/pk"); + newProps.ComputedProperties.Add(new ComputedProperty + { + Name = "id", + Query = "SELECT VALUE c.someField FROM c" + }); + + var act = () => container.ReplaceContainerAsync(newProps); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // T31: GAP-21 — Missing SELECT VALUE in computed property via Replace + [Fact] + public async Task ReplaceContainerAsync_ComputedProperties_MissingSelectValue_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/pk"); + newProps.ComputedProperties.Add(new ComputedProperty + { + Name = "cp1", + Query = "SELECT c.someField FROM c" + }); + + var act = () => container.ReplaceContainerAsync(newProps); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } public class FeedRangeStabilityTests { - // T32: GAP-22 — Feed ranges stable across item operations - [Fact] - public async Task GetFeedRangesAsync_StableAcrossItemOperations() - { - var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 3 }; - - var ranges1 = await container.GetFeedRangesAsync(); - ranges1.Should().HaveCount(3); - - // Add items - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), - new PartitionKey("b")); - - var ranges2 = await container.GetFeedRangesAsync(); - ranges2.Should().HaveCount(3); - - // Delete items - await container.DeleteItemAsync("1", new PartitionKey("a")); - - var ranges3 = await container.GetFeedRangesAsync(); - ranges3.Should().HaveCount(3); - - // Feed ranges should be consistent - for (var i = 0; i < 3; i++) - { - ranges1[i].ToString().Should().Be(ranges2[i].ToString()); - ranges2[i].ToString().Should().Be(ranges3[i].ToString()); - } - } + // T32: GAP-22 — Feed ranges stable across item operations + [Fact] + public async Task GetFeedRangesAsync_StableAcrossItemOperations() + { + var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 3 }; + + var ranges1 = await container.GetFeedRangesAsync(); + ranges1.Should().HaveCount(3); + + // Add items + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), + new PartitionKey("b")); + + var ranges2 = await container.GetFeedRangesAsync(); + ranges2.Should().HaveCount(3); + + // Delete items + await container.DeleteItemAsync("1", new PartitionKey("a")); + + var ranges3 = await container.GetFeedRangesAsync(); + ranges3.Should().HaveCount(3); + + // Feed ranges should be consistent + for (var i = 0; i < 3; i++) + { + ranges1[i].ToString().Should().Be(ranges2[i].ToString()); + ranges2[i].ToString().Should().Be(ranges3[i].ToString()); + } + } } public class ContainerTtlValidationTests { - // T33: GAP-23 — DefaultTimeToLive = 0 should throw - [Fact] - public async Task DefaultTimeToLive_SetToZero_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.ExplicitlyCreated = true; - - var newProps = new ContainerProperties("test", "/pk") - { - DefaultTimeToLive = 0 - }; - - var act = () => container.ReplaceContainerAsync(newProps); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + // T33: GAP-23 — DefaultTimeToLive = 0 should throw + [Fact] + public async Task DefaultTimeToLive_SetToZero_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.ExplicitlyCreated = true; + + var newProps = new ContainerProperties("test", "/pk") + { + DefaultTimeToLive = 0 + }; + + var act = () => container.ReplaceContainerAsync(newProps); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -826,33 +826,33 @@ public async Task DefaultTimeToLive_SetToZero_ThrowsBadRequest() public class DeletedContainerBehaviorTests { - // S02 — After DeleteContainerAsync, held reference returns 404 - [Fact] - public async Task ReadContainerAsync_ShouldReturn404ForDeletedContainer() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("c1", "/pk"); - var container = db.GetContainer("c1"); - - await container.DeleteContainerAsync(); - - var act = () => container.ReadContainerAsync(); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // SISTER: documents actual divergent behavior - [Fact] - public async Task DeleteContainerAsync_ThenReadContainer_Returns404() - { - var db = new InMemoryDatabase("testdb"); - await db.CreateContainerAsync("c1", "/pk"); - var container = db.GetContainer("c1"); - - await container.DeleteContainerAsync(); - - var act = () => container.ReadContainerAsync(); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + // S02 — After DeleteContainerAsync, held reference returns 404 + [Fact] + public async Task ReadContainerAsync_ShouldReturn404ForDeletedContainer() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("c1", "/pk"); + var container = db.GetContainer("c1"); + + await container.DeleteContainerAsync(); + + var act = () => container.ReadContainerAsync(); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // SISTER: documents actual divergent behavior + [Fact] + public async Task DeleteContainerAsync_ThenReadContainer_Returns404() + { + var db = new InMemoryDatabase("testdb"); + await db.CreateContainerAsync("c1", "/pk"); + var container = db.GetContainer("c1"); + + await container.DeleteContainerAsync(); + + var act = () => container.ReadContainerAsync(); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs index 5f57b18..30d404f 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs @@ -1,213 +1,213 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class ContainerManagementTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void Id_ReturnsContainerName() - { - _container.Id.Should().Be("test-container"); - } - - [Fact] - public async Task ReadContainerAsync_ReturnsContainerProperties() - { - var response = await _container.ReadContainerAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("test-container"); - response.Resource.PartitionKeyPath.Should().Be("/partitionKey"); - } - - [Fact] - public async Task ReadContainerStreamAsync_ReturnsOk() - { - using var response = await _container.ReadContainerStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteContainerAsync_ReturnsNoContent() - { - var response = await _container.DeleteContainerAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteContainerStreamAsync_ReturnsNoContent() - { - using var response = await _container.DeleteContainerStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task GetFeedRangesAsync_ReturnsNonEmptyList() - { - var feedRanges = await _container.GetFeedRangesAsync(); - - feedRanges.Should().NotBeEmpty(); - } - - [Fact] - public async Task DeleteAllItemsByPartitionKeyStreamAsync_RemovesItemsInPartition() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, - new PartitionKey("pk2")); - - using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - - var remaining = await _container.ReadItemAsync("3", new PartitionKey("pk2")); - remaining.Resource.Name.Should().Be("Charlie"); - } - - [Fact] - public async Task DeleteAllItemsByPartitionKeyStreamAsync_EmptyPartition_ReturnsOk() - { - using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("nonexistent")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public void Id_ReturnsContainerName() + { + _container.Id.Should().Be("test-container"); + } + + [Fact] + public async Task ReadContainerAsync_ReturnsContainerProperties() + { + var response = await _container.ReadContainerAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("test-container"); + response.Resource.PartitionKeyPath.Should().Be("/partitionKey"); + } + + [Fact] + public async Task ReadContainerStreamAsync_ReturnsOk() + { + using var response = await _container.ReadContainerStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteContainerAsync_ReturnsNoContent() + { + var response = await _container.DeleteContainerAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteContainerStreamAsync_ReturnsNoContent() + { + using var response = await _container.DeleteContainerStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task GetFeedRangesAsync_ReturnsNonEmptyList() + { + var feedRanges = await _container.GetFeedRangesAsync(); + + feedRanges.Should().NotBeEmpty(); + } + + [Fact] + public async Task DeleteAllItemsByPartitionKeyStreamAsync_RemovesItemsInPartition() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, + new PartitionKey("pk2")); + + using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var remaining = await _container.ReadItemAsync("3", new PartitionKey("pk2")); + remaining.Resource.Name.Should().Be("Charlie"); + } + + [Fact] + public async Task DeleteAllItemsByPartitionKeyStreamAsync_EmptyPartition_ReturnsOk() + { + using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("nonexistent")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class ContainerManagementEdgeCaseTests { - [Fact] - public async Task ReplaceContainerStreamAsync_ReturnsOk() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - var properties = new ContainerProperties("test-container", "/partitionKey"); - - var response = await container.ReplaceContainerStreamAsync(properties); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceContainerAsync_UpdatesIndexingPolicy() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - var properties = new ContainerProperties("test-container", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - - var response = await container.ReplaceContainerAsync(properties); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceContainerAsync_UpdatesDefaultTimeToLive() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - var properties = new ContainerProperties("test-container", "/partitionKey") - { - DefaultTimeToLive = 3600 - }; - - var response = await container.ReplaceContainerAsync(properties); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateContainerAsync_WithNullId_Throws() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseAsync("test-db"); - - var act = () => db.Database.CreateContainerAsync(null!, "/pk"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateContainerAsync_WithNullPartitionKeyPath_Throws() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseAsync("test-db"); - - var act = () => db.Database.CreateContainerAsync("c", null!); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteContainerAsync_ThenReadContainer_Returns404() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await container.DeleteContainerAsync(); - - // After delete, ReadContainerAsync returns 404 (matches real Cosmos DB) - var act = () => container.ReadContainerAsync(); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public void GetContainer_ReturnsProxyRef_DoesNotValidateExistence() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); - - // GetContainer returns a proxy reference without validating existence - var container = db.GetContainer("nonexistent"); - container.Should().NotBeNull(); - container.Id.Should().Be("nonexistent"); - } - - [Fact] - public async Task CreateContainerAsync_WithUniqueKeyPolicy_SetsProperties() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseAsync("test-db"); - - var properties = new ContainerProperties("unique-container", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - - var response = await db.Database.CreateContainerAsync(properties); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task ReplaceContainerStreamAsync_ReturnsOk() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + var properties = new ContainerProperties("test-container", "/partitionKey"); + + var response = await container.ReplaceContainerStreamAsync(properties); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceContainerAsync_UpdatesIndexingPolicy() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + var properties = new ContainerProperties("test-container", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + + var response = await container.ReplaceContainerAsync(properties); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceContainerAsync_UpdatesDefaultTimeToLive() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + var properties = new ContainerProperties("test-container", "/partitionKey") + { + DefaultTimeToLive = 3600 + }; + + var response = await container.ReplaceContainerAsync(properties); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateContainerAsync_WithNullId_Throws() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseAsync("test-db"); + + var act = () => db.Database.CreateContainerAsync(null!, "/pk"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateContainerAsync_WithNullPartitionKeyPath_Throws() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseAsync("test-db"); + + var act = () => db.Database.CreateContainerAsync("c", null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteContainerAsync_ThenReadContainer_Returns404() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await container.DeleteContainerAsync(); + + // After delete, ReadContainerAsync returns 404 (matches real Cosmos DB) + var act = () => container.ReadContainerAsync(); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public void GetContainer_ReturnsProxyRef_DoesNotValidateExistence() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); + + // GetContainer returns a proxy reference without validating existence + var container = db.GetContainer("nonexistent"); + container.Should().NotBeNull(); + container.Id.Should().Be("nonexistent"); + } + + [Fact] + public async Task CreateContainerAsync_WithUniqueKeyPolicy_SetsProperties() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseAsync("test-db"); + + var properties = new ContainerProperties("unique-container", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + + var response = await db.Database.CreateContainerAsync(properties); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } @@ -217,122 +217,122 @@ public async Task CreateContainerAsync_WithUniqueKeyPolicy_SetsProperties() /// public class ContainerStreamReplacePersistenceTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ReplaceContainerStream_PersistsPropertyChanges() - { - var newProperties = new ContainerProperties("test-container", "/partitionKey") - { - DefaultTimeToLive = 600 - }; - await _container.ReplaceContainerStreamAsync(newProperties); - - var readResponse = await _container.ReadContainerAsync(); - readResponse.Resource.DefaultTimeToLive.Should().Be(600); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ReplaceContainerStream_PersistsPropertyChanges() + { + var newProperties = new ContainerProperties("test-container", "/partitionKey") + { + DefaultTimeToLive = 600 + }; + await _container.ReplaceContainerStreamAsync(newProperties); + + var readResponse = await _container.ReadContainerAsync(); + readResponse.Resource.DefaultTimeToLive.Should().Be(600); + } } public class ContainerManagementGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task DeleteContainer_ClearsAllItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public async Task DeleteContainer_ClearsAllItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - await _container.DeleteContainerAsync(); + await _container.DeleteContainerAsync(); - _container.ItemCount.Should().Be(0); - } + _container.ItemCount.Should().Be(0); + } - [Fact] - public async Task ReadThroughput_ReturnsSyntheticValue() - { - var throughput = await _container.ReadThroughputAsync(); + [Fact] + public async Task ReadThroughput_ReturnsSyntheticValue() + { + var throughput = await _container.ReadThroughputAsync(); - throughput.Should().Be(400); - } + throughput.Should().Be(400); + } - [Fact] - public async Task ReplaceThroughput_AcceptsWithoutError() - { - var act = () => _container.ReplaceThroughputAsync(1000); + [Fact] + public async Task ReplaceThroughput_AcceptsWithoutError() + { + var act = () => _container.ReplaceThroughputAsync(1000); - await act.Should().NotThrowAsync(); - } + await act.Should().NotThrowAsync(); + } - [Fact] - public async Task ReplaceContainer_AcceptsProperties() - { - var properties = new ContainerProperties("test-container", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy { Automatic = true } - }; + [Fact] + public async Task ReplaceContainer_AcceptsProperties() + { + var properties = new ContainerProperties("test-container", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy { Automatic = true } + }; - var response = await _container.ReplaceContainerAsync(properties); + var response = await _container.ReplaceContainerAsync(properties); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class ContainerManagementGapTests4 { - [Fact] - public async Task DeleteContainer_StreamVariant_Returns204() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await container.DeleteContainerStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + [Fact] + public async Task DeleteContainer_StreamVariant_Returns204() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await container.DeleteContainerStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } public class ContainerThroughputTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Container_ReadThroughputAsync_ReturnsValue() - { - var result = await _container.ReadThroughputAsync(); - result.Should().NotBeNull(); - } - - [Fact] - public async Task Container_ReadThroughputAsync_WithRequestOptions_ReturnsResponse() - { - var response = await _container.ReadThroughputAsync(new RequestOptions()); - response.Should().NotBeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Container_ReplaceThroughputAsync_Int_Succeeds() - { - var response = await _container.ReplaceThroughputAsync(800); - response.Should().NotBeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Container_ReplaceThroughputAsync_ThroughputProperties_Succeeds() - { - var tp = ThroughputProperties.CreateManualThroughput(1000); - var response = await _container.ReplaceThroughputAsync(tp); - response.Should().NotBeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Container_ReadThroughputAsync_ReturnsValue() + { + var result = await _container.ReadThroughputAsync(); + result.Should().NotBeNull(); + } + + [Fact] + public async Task Container_ReadThroughputAsync_WithRequestOptions_ReturnsResponse() + { + var response = await _container.ReadThroughputAsync(new RequestOptions()); + response.Should().NotBeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Container_ReplaceThroughputAsync_Int_Succeeds() + { + var response = await _container.ReplaceThroughputAsync(800); + response.Should().NotBeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Container_ReplaceThroughputAsync_ThroughputProperties_Succeeds() + { + var tp = ThroughputProperties.CreateManualThroughput(1000); + var response = await _container.ReplaceThroughputAsync(tp); + response.Should().NotBeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } @@ -343,263 +343,263 @@ public async Task Container_ReplaceThroughputAsync_ThroughputProperties_Succeeds /// public class ContainerConflictsPropertyTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public void Conflicts_Property_ReturnsNonNull() - { - _container.Conflicts.Should().NotBeNull(); - } + [Fact] + public void Conflicts_Property_ReturnsNonNull() + { + _container.Conflicts.Should().NotBeNull(); + } } public class ContainerManagementGapTests2 { - [Fact] - public async Task ReadContainer_ReturnsContainerProperties() - { - var container = new InMemoryContainer("my-container", "/partitionKey"); + [Fact] + public async Task ReadContainer_ReturnsContainerProperties() + { + var container = new InMemoryContainer("my-container", "/partitionKey"); - var response = await container.ReadContainerAsync(); + var response = await container.ReadContainerAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("my-container"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("my-container"); + } } public class ContainerDatabaseBacklinkTests { - [Fact] - public void Container_Database_Property_ReturnsNonNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.Database.Should().NotBeNull(); - } - - [Fact] - public async Task Container_CreatedViaDatabase_Database_ReturnsParent() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseAsync("test-db"); - var containerResponse = await dbResponse.Database.CreateContainerAsync("test-container", "/partitionKey"); - - // The container should have a Database property - containerResponse.Container.Should().NotBeNull(); - } + [Fact] + public void Container_Database_Property_ReturnsNonNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.Database.Should().NotBeNull(); + } + + [Fact] + public async Task Container_CreatedViaDatabase_Database_ReturnsParent() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseAsync("test-db"); + var containerResponse = await dbResponse.Database.CreateContainerAsync("test-container", "/partitionKey"); + + // The container should have a Database property + containerResponse.Container.Should().NotBeNull(); + } } // ─── Unique Key Policy Enforcement ────────────────────────────────────── public class UniqueKeyPolicyTests { - [Fact] - public async Task CreateItem_ViolatesUniqueKey_ThrowsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - 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")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateItem_SameUniqueKey_DifferentPartition_Succeeds() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), - new PartitionKey("a")); - - // Same email but different partition — should succeed (unique keys are per-partition) - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", email = "alice@test.com" }), - new PartitionKey("b")); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task CreateItem_CompositeUniqueKey_ViolatesBoth_ThrowsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/firstName", "/lastName" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", firstName = "Alice", lastName = "Smith" }), - new PartitionKey("a")); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", firstName = "Alice", lastName = "Smith" }), - new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateItem_CompositeUniqueKey_OnlyOneDiffers_Succeeds() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/firstName", "/lastName" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", firstName = "Alice", lastName = "Smith" }), - new PartitionKey("a")); - - // Different lastName — should succeed - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", firstName = "Alice", lastName = "Jones" }), - new PartitionKey("a")); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task UpsertItem_ViolatesUniqueKey_OfDifferentItem_ThrowsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), - new PartitionKey("a")); - - // Upsert a DIFFERENT item with same email — should conflict - 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); - } - - [Fact] - public async Task UpsertItem_SameItem_UpdatesWithoutConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), - new PartitionKey("a")); - - // Upsert same item (id=1) with same email — should succeed (updating self) - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", name = "updated" }), - new PartitionKey("a")); - - await act.Should().NotThrowAsync(); - } + [Fact] + public async Task CreateItem_ViolatesUniqueKey_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + 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")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_SameUniqueKey_DifferentPartition_Succeeds() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + // Same email but different partition — should succeed (unique keys are per-partition) + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", email = "alice@test.com" }), + new PartitionKey("b")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateItem_CompositeUniqueKey_ViolatesBoth_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/firstName", "/lastName" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "Alice", lastName = "Smith" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "Alice", lastName = "Smith" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_CompositeUniqueKey_OnlyOneDiffers_Succeeds() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/firstName", "/lastName" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "Alice", lastName = "Smith" }), + new PartitionKey("a")); + + // Different lastName — should succeed + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "Alice", lastName = "Jones" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpsertItem_ViolatesUniqueKey_OfDifferentItem_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + // Upsert a DIFFERENT item with same email — should conflict + 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); + } + + [Fact] + public async Task UpsertItem_SameItem_UpdatesWithoutConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + // Upsert same item (id=1) with same email — should succeed (updating self) + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", name = "updated" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } } // ─── ConflictResolutionPolicy Stored But Not Enforced ─────────────────── public class ConflictResolutionPolicyTests { - /// - /// In real Cosmos DB, the conflict resolution policy with a custom stored procedure - /// resolves write conflicts in multi-region setups by invoking the specified sproc. - /// The emulator stores the policy but operates with implicit strong consistency and - /// single-region semantics, so conflict resolution never actually triggers. - /// - [Fact(Skip = "ConflictResolutionPolicy is stored on ContainerProperties and returned " + - "on reads, but it is never enforced. The emulator operates in single-region mode " + - "with implicit strong consistency, so write–write conflicts that would trigger the " + - "policy in a multi-region setup cannot occur. The stored policy is purely decorative.")] - public async Task ConflictResolution_CustomSproc_ShouldResolveConflicts() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - await db.CreateContainerAsync(new ContainerProperties("ctr1", "/pk") - { - ConflictResolutionPolicy = new ConflictResolutionPolicy - { - Mode = ConflictResolutionMode.Custom, - ResolutionProcedure = "dbs/testdb/colls/ctr1/sprocs/resolveConflict" - } - }); - - // In a multi-region real Cosmos account, concurrent writes from different regions - // would trigger the custom stored procedure. This cannot be simulated. - Assert.Fail("Cannot simulate multi-region write conflicts in single-region emulator."); - } - - /// - /// Sister test: the policy is stored and echoed back, but has no runtime effect. - /// - [Fact] - public async Task ConflictResolution_EmulatorBehavior_PolicyStoredButNotEnforced() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: ConflictResolutionPolicy determines how write conflicts are - // resolved in multi-region write configurations. LastWriterWins uses _ts - // comparison. Custom mode invokes a stored procedure. - // In-Memory Emulator: The policy is accepted by ContainerProperties and returned - // on ReadContainerAsync / ReplaceContainerAsync. However, since the emulator is - // single-region and strongly consistent, no write–write conflicts can occur, - // and the policy is never triggered. It's stored for API compatibility only. - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var containerResponse = await db.CreateContainerAsync(new ContainerProperties("ctr1", "/pk") - { - ConflictResolutionPolicy = new ConflictResolutionPolicy - { - Mode = ConflictResolutionMode.LastWriterWins - } - }); - - var readBack = await containerResponse.Container.ReadContainerAsync(); - readBack.Resource.ConflictResolutionPolicy.Mode.Should().Be( - ConflictResolutionMode.LastWriterWins, - "the policy is stored and returned but never actually enforced"); - } + /// + /// In real Cosmos DB, the conflict resolution policy with a custom stored procedure + /// resolves write conflicts in multi-region setups by invoking the specified sproc. + /// The emulator stores the policy but operates with implicit strong consistency and + /// single-region semantics, so conflict resolution never actually triggers. + /// + [Fact(Skip = "ConflictResolutionPolicy is stored on ContainerProperties and returned " + + "on reads, but it is never enforced. The emulator operates in single-region mode " + + "with implicit strong consistency, so write–write conflicts that would trigger the " + + "policy in a multi-region setup cannot occur. The stored policy is purely decorative.")] + public async Task ConflictResolution_CustomSproc_ShouldResolveConflicts() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + await db.CreateContainerAsync(new ContainerProperties("ctr1", "/pk") + { + ConflictResolutionPolicy = new ConflictResolutionPolicy + { + Mode = ConflictResolutionMode.Custom, + ResolutionProcedure = "dbs/testdb/colls/ctr1/sprocs/resolveConflict" + } + }); + + // In a multi-region real Cosmos account, concurrent writes from different regions + // would trigger the custom stored procedure. This cannot be simulated. + Assert.Fail("Cannot simulate multi-region write conflicts in single-region emulator."); + } + + /// + /// Sister test: the policy is stored and echoed back, but has no runtime effect. + /// + [Fact] + public async Task ConflictResolution_EmulatorBehavior_PolicyStoredButNotEnforced() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: ConflictResolutionPolicy determines how write conflicts are + // resolved in multi-region write configurations. LastWriterWins uses _ts + // comparison. Custom mode invokes a stored procedure. + // In-Memory Emulator: The policy is accepted by ContainerProperties and returned + // on ReadContainerAsync / ReplaceContainerAsync. However, since the emulator is + // single-region and strongly consistent, no write–write conflicts can occur, + // and the policy is never triggered. It's stored for API compatibility only. + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var containerResponse = await db.CreateContainerAsync(new ContainerProperties("ctr1", "/pk") + { + ConflictResolutionPolicy = new ConflictResolutionPolicy + { + Mode = ConflictResolutionMode.LastWriterWins + } + }); + + var readBack = await containerResponse.Container.ReadContainerAsync(); + readBack.Resource.ConflictResolutionPolicy.Mode.Should().Be( + ConflictResolutionMode.LastWriterWins, + "the policy is stored and returned but never actually enforced"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -608,48 +608,48 @@ public async Task ConflictResolution_EmulatorBehavior_PolicyStoredButNotEnforced public class DeleteContainerChangeFeedTests { - [Fact] - public async Task DeleteContainerAsync_ClearsChangeFeed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task DeleteContainerAsync_ClearsChangeFeed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - container.GetChangeFeedCheckpoint().Should().BeGreaterThan(0, "change feed should have entries before delete"); + container.GetChangeFeedCheckpoint().Should().BeGreaterThan(0, "change feed should have entries before delete"); - await container.DeleteContainerAsync(); + await container.DeleteContainerAsync(); - container.GetChangeFeedCheckpoint().Should().Be(0, "change feed should be empty after DeleteContainerAsync"); - } + container.GetChangeFeedCheckpoint().Should().Be(0, "change feed should be empty after DeleteContainerAsync"); + } - [Fact] - public async Task DeleteContainerStreamAsync_ClearsChangeFeed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task DeleteContainerStreamAsync_ClearsChangeFeed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - container.GetChangeFeedCheckpoint().Should().BeGreaterThan(0); + container.GetChangeFeedCheckpoint().Should().BeGreaterThan(0); - await container.DeleteContainerStreamAsync(); + await container.DeleteContainerStreamAsync(); - container.GetChangeFeedCheckpoint().Should().Be(0, "change feed should be empty after DeleteContainerStreamAsync"); - } + container.GetChangeFeedCheckpoint().Should().Be(0, "change feed should be empty after DeleteContainerStreamAsync"); + } - [Fact] - public async Task DeleteContainer_ThenAddItems_ChangeFeedOnlyHasNewEntries() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "old", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task DeleteContainer_ThenAddItems_ChangeFeedOnlyHasNewEntries() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "old", pk = "a" }), new PartitionKey("a")); - await container.DeleteContainerAsync(); + await container.DeleteContainerAsync(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "new", pk = "b" }), new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "new", pk = "b" }), new PartitionKey("b")); - container.GetChangeFeedCheckpoint().Should().Be(1, "only the post-delete item should be in the change feed"); - } + container.GetChangeFeedCheckpoint().Should().Be(1, "only the post-delete item should be in the change feed"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -658,111 +658,111 @@ await container.CreateItemAsync( public class DatabaseContainerCreationPropertyTests { - [Fact] - public async Task CreateContainerAsync_WithContainerProperties_PreservesUniqueKeyPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var properties = new ContainerProperties("ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - - var response = await db.CreateContainerAsync(properties); - var container = (InMemoryContainer)response.Container; - - // Create first item - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - // Attempt duplicate email in same partition — should conflict - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateContainerAsync_WithContainerProperties_PreservesDefaultTtl() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var properties = new ContainerProperties("ctr", "/pk") - { - DefaultTimeToLive = 3600 - }; - - var response = await db.CreateContainerAsync(properties); - var container = (InMemoryContainer)response.Container; - - container.DefaultTimeToLive.Should().Be(3600); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_WithContainerProperties_PreservesUniqueKeyPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var properties = new ContainerProperties("ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - - var response = await db.CreateContainerIfNotExistsAsync(properties); - var container = (InMemoryContainer)response.Container; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateContainerStreamAsync_WithContainerProperties_PreservesUniqueKeyPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - - var properties = new ContainerProperties("ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - - await db.CreateContainerStreamAsync(properties); - var container = (InMemoryContainer)db.GetContainer("ctr"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), - new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + [Fact] + public async Task CreateContainerAsync_WithContainerProperties_PreservesUniqueKeyPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var properties = new ContainerProperties("ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + + var response = await db.CreateContainerAsync(properties); + var container = (InMemoryContainer)response.Container; + + // Create first item + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + // Attempt duplicate email in same partition — should conflict + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateContainerAsync_WithContainerProperties_PreservesDefaultTtl() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var properties = new ContainerProperties("ctr", "/pk") + { + DefaultTimeToLive = 3600 + }; + + var response = await db.CreateContainerAsync(properties); + var container = (InMemoryContainer)response.Container; + + container.DefaultTimeToLive.Should().Be(3600); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_WithContainerProperties_PreservesUniqueKeyPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var properties = new ContainerProperties("ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + + var response = await db.CreateContainerIfNotExistsAsync(properties); + var container = (InMemoryContainer)response.Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateContainerStreamAsync_WithContainerProperties_PreservesUniqueKeyPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + + var properties = new ContainerProperties("ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + + await db.CreateContainerStreamAsync(properties); + var container = (InMemoryContainer)db.GetContainer("ctr"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -771,91 +771,91 @@ await container.CreateItemAsync( public class ContainerLifecycleTests { - [Fact] - public async Task DeleteContainerAsync_RemovesFromDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - - var ctr1 = db.GetContainer("ctr1"); - await ctr1.DeleteContainerAsync(); - - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Should().ContainSingle(c => c.Id == "ctr2"); - containers.Should().NotContain(c => c.Id == "ctr1"); - } - - [Fact] - public async Task DeleteContainerStreamAsync_RemovesFromDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - - var ctr1 = db.GetContainer("ctr1"); - await ctr1.DeleteContainerStreamAsync(); - - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Should().ContainSingle(c => c.Id == "ctr2"); - } - - [Fact] - public async Task DeleteContainer_ThenRecreate_SameId_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var resp1 = await db.CreateContainerAsync("ctr", "/pk"); - var ctr1 = (InMemoryContainer)resp1.Container; - await ctr1.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - await ctr1.DeleteContainerAsync(); - - // Recreate with same id - var resp2 = await db.CreateContainerAsync("ctr", "/pk"); - var ctr2 = (InMemoryContainer)resp2.Container; - - ctr2.ItemCount.Should().Be(0, "recreated container should be empty"); - resp2.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteContainer_ClearsAllItems_AndChangeFeed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - - container.ItemCount.Should().Be(2); - container.GetChangeFeedCheckpoint().Should().Be(2); - - await container.DeleteContainerAsync(); - - container.ItemCount.Should().Be(0); - container.GetChangeFeedCheckpoint().Should().Be(0); - } + [Fact] + public async Task DeleteContainerAsync_RemovesFromDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + + var ctr1 = db.GetContainer("ctr1"); + await ctr1.DeleteContainerAsync(); + + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Should().ContainSingle(c => c.Id == "ctr2"); + containers.Should().NotContain(c => c.Id == "ctr1"); + } + + [Fact] + public async Task DeleteContainerStreamAsync_RemovesFromDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + + var ctr1 = db.GetContainer("ctr1"); + await ctr1.DeleteContainerStreamAsync(); + + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Should().ContainSingle(c => c.Id == "ctr2"); + } + + [Fact] + public async Task DeleteContainer_ThenRecreate_SameId_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var resp1 = await db.CreateContainerAsync("ctr", "/pk"); + var ctr1 = (InMemoryContainer)resp1.Container; + await ctr1.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + await ctr1.DeleteContainerAsync(); + + // Recreate with same id + var resp2 = await db.CreateContainerAsync("ctr", "/pk"); + var ctr2 = (InMemoryContainer)resp2.Container; + + ctr2.ItemCount.Should().Be(0, "recreated container should be empty"); + resp2.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteContainer_ClearsAllItems_AndChangeFeed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + + container.ItemCount.Should().Be(2); + container.GetChangeFeedCheckpoint().Should().Be(2); + + await container.DeleteContainerAsync(); + + container.ItemCount.Should().Be(0); + container.GetChangeFeedCheckpoint().Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -864,83 +864,83 @@ await container.CreateItemAsync( public class ContainerQueryingTests { - [Fact] - public async Task GetContainerQueryIterator_ReturnsAllContainers() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - await db.CreateContainerAsync("ctr3", "/pk"); - - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr2", "ctr3"]); - } - - [Fact] - public async Task GetContainerQueryIterator_EmptyDatabase_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Should().BeEmpty(); - } - - [Fact] - public async Task GetContainerQueryStreamIterator_ReturnsContainers() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - - var iterator = db.GetContainerQueryStreamIterator(); - var response = await iterator.ReadNextAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - } - - [Fact] - public async Task GetContainerQueryIterator_AfterDelete_ExcludesDeleted() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - await db.CreateContainerAsync("ctr3", "/pk"); - - var ctr2 = db.GetContainer("ctr2"); - await ctr2.DeleteContainerAsync(); - - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr3"]); - } + [Fact] + public async Task GetContainerQueryIterator_ReturnsAllContainers() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + await db.CreateContainerAsync("ctr3", "/pk"); + + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr2", "ctr3"]); + } + + [Fact] + public async Task GetContainerQueryIterator_EmptyDatabase_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Should().BeEmpty(); + } + + [Fact] + public async Task GetContainerQueryStreamIterator_ReturnsContainers() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + + var iterator = db.GetContainerQueryStreamIterator(); + var response = await iterator.ReadNextAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + } + + [Fact] + public async Task GetContainerQueryIterator_AfterDelete_ExcludesDeleted() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + await db.CreateContainerAsync("ctr3", "/pk"); + + var ctr2 = db.GetContainer("ctr2"); + await ctr2.DeleteContainerAsync(); + + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr3"]); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -949,66 +949,66 @@ public async Task GetContainerQueryIterator_AfterDelete_ExcludesDeleted() public class ReplaceContainerPropertyTests { - [Fact] - public async Task ReplaceContainerAsync_UpdatesDefaultTtl_ReadReflectsChange() - { - var container = new InMemoryContainer("test", "/pk"); - - var props = new ContainerProperties("test", "/pk") { DefaultTimeToLive = 1800 }; - await container.ReplaceContainerAsync(props); - - var readBack = await container.ReadContainerAsync(); - readBack.Resource.DefaultTimeToLive.Should().Be(1800); - } - - [Fact] - public async Task ReplaceContainerAsync_UpdatesIndexingPolicy_ReadReflectsChange() - { - var container = new InMemoryContainer("test", "/pk"); - - var props = new ContainerProperties("test", "/pk") - { - IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None } - }; - await container.ReplaceContainerAsync(props); - - var readBack = await container.ReadContainerAsync(); - readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); - readBack.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public async Task ReplaceContainerStreamAsync_PersistsMultiplePropertyChanges() - { - var container = new InMemoryContainer("test", "/pk"); - - var props = new ContainerProperties("test", "/pk") - { - DefaultTimeToLive = 900, - IndexingPolicy = new IndexingPolicy { Automatic = false } - }; - await container.ReplaceContainerStreamAsync(props); - - var readBack = await container.ReadContainerAsync(); - readBack.Resource.DefaultTimeToLive.Should().Be(900); - readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); - } - - [Fact] - public async Task ReplaceContainerAsync_CannotChangePartitionKeyPath() - { - // Real Cosmos DB behaviour: - // Attempting to change the partition key path via ReplaceContainerAsync returns - // 400 BadRequest with message "Partition key paths for a container cannot be changed." - // The in-memory emulator should ideally throw a similar error. - var container = new InMemoryContainer("test", "/pk"); - - var props = new ContainerProperties("test", "/differentPk"); - var act = () => container.ReplaceContainerAsync(props); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task ReplaceContainerAsync_UpdatesDefaultTtl_ReadReflectsChange() + { + var container = new InMemoryContainer("test", "/pk"); + + var props = new ContainerProperties("test", "/pk") { DefaultTimeToLive = 1800 }; + await container.ReplaceContainerAsync(props); + + var readBack = await container.ReadContainerAsync(); + readBack.Resource.DefaultTimeToLive.Should().Be(1800); + } + + [Fact] + public async Task ReplaceContainerAsync_UpdatesIndexingPolicy_ReadReflectsChange() + { + var container = new InMemoryContainer("test", "/pk"); + + var props = new ContainerProperties("test", "/pk") + { + IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None } + }; + await container.ReplaceContainerAsync(props); + + var readBack = await container.ReadContainerAsync(); + readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); + readBack.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public async Task ReplaceContainerStreamAsync_PersistsMultiplePropertyChanges() + { + var container = new InMemoryContainer("test", "/pk"); + + var props = new ContainerProperties("test", "/pk") + { + DefaultTimeToLive = 900, + IndexingPolicy = new IndexingPolicy { Automatic = false } + }; + await container.ReplaceContainerStreamAsync(props); + + var readBack = await container.ReadContainerAsync(); + readBack.Resource.DefaultTimeToLive.Should().Be(900); + readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); + } + + [Fact] + public async Task ReplaceContainerAsync_CannotChangePartitionKeyPath() + { + // Real Cosmos DB behaviour: + // Attempting to change the partition key path via ReplaceContainerAsync returns + // 400 BadRequest with message "Partition key paths for a container cannot be changed." + // The in-memory emulator should ideally throw a similar error. + var container = new InMemoryContainer("test", "/pk"); + + var props = new ContainerProperties("test", "/differentPk"); + var act = () => container.ReplaceContainerAsync(props); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1017,46 +1017,46 @@ public async Task ReplaceContainerAsync_CannotChangePartitionKeyPath() public class ContainerCreationEdgeCaseTests2 { - [Fact] - public async Task CreateContainerAsync_DuplicateId_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.CreateContainerAsync("ctr", "/pk"); - - var act = () => db.CreateContainerAsync("ctr", "/pk"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_DuplicateId_ReturnsOk() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var resp1 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - resp1.StatusCode.Should().Be(HttpStatusCode.Created); - - var resp2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - resp2.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateContainerStreamAsync_DuplicateId_ReturnsConflict() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var props = new ContainerProperties("ctr", "/pk"); - var resp1 = await db.CreateContainerStreamAsync(props); - resp1.StatusCode.Should().Be(HttpStatusCode.Created); - - var resp2 = await db.CreateContainerStreamAsync(props); - resp2.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + [Fact] + public async Task CreateContainerAsync_DuplicateId_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.CreateContainerAsync("ctr", "/pk"); + + var act = () => db.CreateContainerAsync("ctr", "/pk"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_DuplicateId_ReturnsOk() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var resp1 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + resp1.StatusCode.Should().Be(HttpStatusCode.Created); + + var resp2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + resp2.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateContainerStreamAsync_DuplicateId_ReturnsConflict() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var props = new ContainerProperties("ctr", "/pk"); + var resp1 = await db.CreateContainerStreamAsync(props); + resp1.StatusCode.Should().Be(HttpStatusCode.Created); + + var resp2 = await db.CreateContainerStreamAsync(props); + resp2.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1065,50 +1065,50 @@ public async Task CreateContainerStreamAsync_DuplicateId_ReturnsConflict() public class ContainerThroughputEdgeCaseTests { - [Fact] - public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughputProperties() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughputProperties() + { + var container = new InMemoryContainer("test", "/pk"); - var response = await container.ReadThroughputAsync(new RequestOptions()); + var response = await container.ReadThroughputAsync(new RequestOptions()); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } - [Fact] - public async Task ReplaceThroughputAsync_WithAutoscaleThroughputProperties_Accepted() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task ReplaceThroughputAsync_WithAutoscaleThroughputProperties_Accepted() + { + var container = new InMemoryContainer("test", "/pk"); - var tp = ThroughputProperties.CreateAutoscaleThroughput(4000); - var response = await container.ReplaceThroughputAsync(tp); + var tp = ThroughputProperties.CreateAutoscaleThroughput(4000); + var response = await container.ReplaceThroughputAsync(tp); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } - [Fact] - public async Task Database_ReadThroughputAsync_ReturnsSynthetic400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task Database_ReadThroughputAsync_ReturnsSynthetic400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var throughput = await db.ReadThroughputAsync(); + var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(400); - } + throughput.Should().Be(400); + } - [Fact] - public async Task Database_ReplaceThroughputAsync_AcceptsWithoutError() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task Database_ReplaceThroughputAsync_AcceptsWithoutError() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var act = () => db.ReplaceThroughputAsync(1000); + var act = () => db.ReplaceThroughputAsync(1000); - await act.Should().NotThrowAsync(); - } + await act.Should().NotThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1117,91 +1117,91 @@ public async Task Database_ReplaceThroughputAsync_AcceptsWithoutError() public class DeleteAllByPartitionKeyEdgeCaseTests { - [Fact] - public async Task DeleteAllByPK_RecordsChangeFeedTombstones() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - - var checkpointBefore = container.GetChangeFeedCheckpoint(); - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - - // Expect 2 additional tombstone entries - container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore + 2); - } - - /// - /// Sister test documenting the emulator's actual behavior for DeleteAllByPK change feed. - /// - [Fact] - public async Task DeleteAllByPK_ChangeFeedTombstones_NowRecorded() - { - // FIXED: DeleteAllItemsByPartitionKeyStreamAsync now records change feed tombstones - // for each deleted item, matching real Cosmos DB behavior. - var container = new InMemoryContainer("test", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - - var checkpointBefore = container.GetChangeFeedCheckpoint(); - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - - container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore + 2, - "tombstones are now recorded for each deleted item"); - container.ItemCount.Should().Be(0, "items are still removed"); - } - - [Fact] - public async Task DeleteAllByPK_WithCompositePartitionKey_RemovesCorrectItems() - { - var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), - new PartitionKeyBuilder().Add("t1").Add("us").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenant = "t1", region = "eu" }), - new PartitionKeyBuilder().Add("t1").Add("eu").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", tenant = "t2", region = "us" }), - new PartitionKeyBuilder().Add("t2").Add("us").Build()); - - await container.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKeyBuilder().Add("t1").Add("us").Build()); - - container.ItemCount.Should().Be(2); - - // Item 1 should be gone - var act = () => container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("us").Build()); - await act.Should().ThrowAsync(); - - // Items 2 and 3 should remain - var item2 = await container.ReadItemAsync("2", - new PartitionKeyBuilder().Add("t1").Add("eu").Build()); - item2.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteAllByPK_MultipleTimes_Idempotent() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var resp1 = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - resp1.StatusCode.Should().Be(HttpStatusCode.OK); - - var resp2 = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - resp2.StatusCode.Should().Be(HttpStatusCode.OK, "second delete on empty partition should still return OK"); - } + [Fact] + public async Task DeleteAllByPK_RecordsChangeFeedTombstones() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + + var checkpointBefore = container.GetChangeFeedCheckpoint(); + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + + // Expect 2 additional tombstone entries + container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore + 2); + } + + /// + /// Sister test documenting the emulator's actual behavior for DeleteAllByPK change feed. + /// + [Fact] + public async Task DeleteAllByPK_ChangeFeedTombstones_NowRecorded() + { + // FIXED: DeleteAllItemsByPartitionKeyStreamAsync now records change feed tombstones + // for each deleted item, matching real Cosmos DB behavior. + var container = new InMemoryContainer("test", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + + var checkpointBefore = container.GetChangeFeedCheckpoint(); + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + + container.GetChangeFeedCheckpoint().Should().Be(checkpointBefore + 2, + "tombstones are now recorded for each deleted item"); + container.ItemCount.Should().Be(0, "items are still removed"); + } + + [Fact] + public async Task DeleteAllByPK_WithCompositePartitionKey_RemovesCorrectItems() + { + var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenant = "t1", region = "eu" }), + new PartitionKeyBuilder().Add("t1").Add("eu").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", tenant = "t2", region = "us" }), + new PartitionKeyBuilder().Add("t2").Add("us").Build()); + + await container.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + + container.ItemCount.Should().Be(2); + + // Item 1 should be gone + var act = () => container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + await act.Should().ThrowAsync(); + + // Items 2 and 3 should remain + var item2 = await container.ReadItemAsync("2", + new PartitionKeyBuilder().Add("t1").Add("eu").Build()); + item2.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteAllByPK_MultipleTimes_Idempotent() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var resp1 = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + resp1.StatusCode.Should().Be(HttpStatusCode.OK); + + var resp2 = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + resp2.StatusCode.Should().Be(HttpStatusCode.OK, "second delete on empty partition should still return OK"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1210,66 +1210,66 @@ await container.CreateItemAsync( public class ContainerPropertiesMetadataTests { - [Fact] - public async Task ReadContainerAsync_ReturnsPartitionKeyPath_Composite() - { - var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); - - var response = await container.ReadContainerAsync(); - - response.Resource.PartitionKeyPaths.Should().NotBeNull(); - response.Resource.PartitionKeyPaths.Should().HaveCount(2); - } - - [Fact] - public async Task ReadContainerStreamAsync_ReturnsJsonWithContainerProperties() - { - var container = new InMemoryContainer("test", "/pk"); - - using var response = await container.ReadContainerStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var obj = JObject.Parse(json); - obj["id"]?.ToString().Should().Be("test"); - } - - [Fact] - public void Container_Scripts_Property_ReturnsNonNull() - { - var container = new InMemoryContainer("test", "/pk"); - container.Scripts.Should().NotBeNull(); - } - - [Fact] - public void Container_Database_Property_ReturnsNonNull() - { - // ── Divergent behavior note ── - // Real Cosmos DB: The Database property returns the actual parent Database instance - // that created or manages this container. - // In-Memory Emulator: Returns a fresh NSubstitute mock on each access. There is no - // back-reference to the parent InMemoryDatabase. This is sufficient for most - // test scenarios but code that navigates container.Database.Id will get a null. - var container = new InMemoryContainer("test", "/pk"); - container.Database.Should().NotBeNull(); - } + [Fact] + public async Task ReadContainerAsync_ReturnsPartitionKeyPath_Composite() + { + var container = new InMemoryContainer("test", new[] { "/tenant", "/region" }); + + var response = await container.ReadContainerAsync(); + + response.Resource.PartitionKeyPaths.Should().NotBeNull(); + response.Resource.PartitionKeyPaths.Should().HaveCount(2); + } + + [Fact] + public async Task ReadContainerStreamAsync_ReturnsJsonWithContainerProperties() + { + var container = new InMemoryContainer("test", "/pk"); + + using var response = await container.ReadContainerStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var obj = JObject.Parse(json); + obj["id"]?.ToString().Should().Be("test"); + } + + [Fact] + public void Container_Scripts_Property_ReturnsNonNull() + { + var container = new InMemoryContainer("test", "/pk"); + container.Scripts.Should().NotBeNull(); + } + + [Fact] + public void Container_Database_Property_ReturnsNonNull() + { + // ── Divergent behavior note ── + // Real Cosmos DB: The Database property returns the actual parent Database instance + // that created or manages this container. + // In-Memory Emulator: Returns a fresh NSubstitute mock on each access. There is no + // back-reference to the parent InMemoryDatabase. This is sufficient for most + // test scenarios but code that navigates container.Database.Id will get a null. + var container = new InMemoryContainer("test", "/pk"); + container.Database.Should().NotBeNull(); + } } public class DefineContainerBuilderTests2 { - [Fact] - public async Task DefineContainer_ReturnsContainerBuilder() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task DefineContainer_ReturnsContainerBuilder() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var builder = db.DefineContainer("ctr", "/pk"); + var builder = db.DefineContainer("ctr", "/pk"); - builder.Should().NotBeNull(); - } + builder.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1278,107 +1278,107 @@ public async Task DefineContainer_ReturnsContainerBuilder() public class ThroughputPersistenceTests { - [Fact] - public async Task Container_ReplaceThroughput_ThenRead_ReturnsPersisted() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.ReplaceThroughputAsync(800); - - var throughput = await container.ReadThroughputAsync(); - throughput.Should().Be(800); - } - - [Fact] - public async Task Container_ReplaceThroughput_ThenReadWithRequestOptions_ReturnsPersisted() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.ReplaceThroughputAsync(1200); - - var response = await container.ReadThroughputAsync(new RequestOptions()); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } - - [Fact] - public async Task Database_ReplaceThroughput_ThenRead_ReturnsPersisted() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.ReplaceThroughputAsync(1000); - - var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(1000); - } - - [Fact] - public async Task Container_DefaultThroughput_Is400() - { - var container = new InMemoryContainer("test", "/pk"); - - var throughput = await container.ReadThroughputAsync(); - throughput.Should().Be(400); - } - - [Fact] - public async Task Database_DefaultThroughput_Is400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(400); - } + [Fact] + public async Task Container_ReplaceThroughput_ThenRead_ReturnsPersisted() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.ReplaceThroughputAsync(800); + + var throughput = await container.ReadThroughputAsync(); + throughput.Should().Be(800); + } + + [Fact] + public async Task Container_ReplaceThroughput_ThenReadWithRequestOptions_ReturnsPersisted() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.ReplaceThroughputAsync(1200); + + var response = await container.ReadThroughputAsync(new RequestOptions()); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } + + [Fact] + public async Task Database_ReplaceThroughput_ThenRead_ReturnsPersisted() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.ReplaceThroughputAsync(1000); + + var throughput = await db.ReadThroughputAsync(); + throughput.Should().Be(1000); + } + + [Fact] + public async Task Container_DefaultThroughput_Is400() + { + var container = new InMemoryContainer("test", "/pk"); + + var throughput = await container.ReadThroughputAsync(); + throughput.Should().Be(400); + } + + [Fact] + public async Task Database_DefaultThroughput_Is400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var throughput = await db.ReadThroughputAsync(); + throughput.Should().Be(400); + } } public class ReplaceContainerIdValidationTests { - [Fact] - public async Task ReplaceContainerAsync_MismatchedId_ThrowsBadRequest() - { - var container = new InMemoryContainer("my-container", "/pk"); + [Fact] + public async Task ReplaceContainerAsync_MismatchedId_ThrowsBadRequest() + { + var container = new InMemoryContainer("my-container", "/pk"); - var props = new ContainerProperties("wrong-id", "/pk"); - var act = () => container.ReplaceContainerAsync(props); + var props = new ContainerProperties("wrong-id", "/pk"); + var act = () => container.ReplaceContainerAsync(props); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task ReplaceContainerStreamAsync_MismatchedId_ReturnsBadRequest() - { - var container = new InMemoryContainer("my-container", "/pk"); + [Fact] + public async Task ReplaceContainerStreamAsync_MismatchedId_ReturnsBadRequest() + { + var container = new InMemoryContainer("my-container", "/pk"); - var props = new ContainerProperties("wrong-id", "/pk"); - var response = await container.ReplaceContainerStreamAsync(props); + var props = new ContainerProperties("wrong-id", "/pk"); + var response = await container.ReplaceContainerStreamAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task ReplaceContainerAsync_MatchingId_Succeeds() - { - var container = new InMemoryContainer("my-container", "/pk"); + [Fact] + public async Task ReplaceContainerAsync_MatchingId_Succeeds() + { + var container = new InMemoryContainer("my-container", "/pk"); - var props = new ContainerProperties("my-container", "/pk") { DefaultTimeToLive = 600 }; - var response = await container.ReplaceContainerAsync(props); + var props = new ContainerProperties("my-container", "/pk") { DefaultTimeToLive = 600 }; + var response = await container.ReplaceContainerAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task ReplaceContainerStreamAsync_MatchingId_Succeeds() - { - var container = new InMemoryContainer("my-container", "/pk"); + [Fact] + public async Task ReplaceContainerStreamAsync_MatchingId_Succeeds() + { + var container = new InMemoryContainer("my-container", "/pk"); - var props = new ContainerProperties("my-container", "/pk") { DefaultTimeToLive = 600 }; - var response = await container.ReplaceContainerStreamAsync(props); + var props = new ContainerProperties("my-container", "/pk") { DefaultTimeToLive = 600 }; + var response = await container.ReplaceContainerStreamAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1387,56 +1387,56 @@ public async Task ReplaceContainerStreamAsync_MatchingId_Succeeds() public class ContainerConstructorDefaultsTests { - [Fact] - public void DefaultConstructor_SetsDefaults() - { - var container = new InMemoryContainer(); + [Fact] + public void DefaultConstructor_SetsDefaults() + { + var container = new InMemoryContainer(); - container.Id.Should().Be("in-memory-container"); - } + container.Id.Should().Be("in-memory-container"); + } - [Fact] - public async Task DefaultConstructor_PartitionKeyDefaultsToId() - { - var container = new InMemoryContainer(); + [Fact] + public async Task DefaultConstructor_PartitionKeyDefaultsToId() + { + var container = new InMemoryContainer(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1" }), new PartitionKey("1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1" }), new PartitionKey("1")); - var item = await container.ReadItemAsync("1", new PartitionKey("1")); - item.StatusCode.Should().Be(HttpStatusCode.OK); - } + var item = await container.ReadItemAsync("1", new PartitionKey("1")); + item.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task ReadContainerAsync_ResponseContainer_ReturnsSelf() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task ReadContainerAsync_ResponseContainer_ReturnsSelf() + { + var container = new InMemoryContainer("test", "/pk"); - var response = await container.ReadContainerAsync(); + var response = await container.ReadContainerAsync(); - response.Container.Should().BeSameAs(container); - } + response.Container.Should().BeSameAs(container); + } - [Fact] - public async Task DeleteContainerAsync_ResponseContainer_ReturnsSelf() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task DeleteContainerAsync_ResponseContainer_ReturnsSelf() + { + var container = new InMemoryContainer("test", "/pk"); - var response = await container.DeleteContainerAsync(); + var response = await container.DeleteContainerAsync(); - response.Container.Should().BeSameAs(container); - } + response.Container.Should().BeSameAs(container); + } - [Fact] - public async Task ReplaceContainerAsync_ResponseContainer_ReturnsSelf() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task ReplaceContainerAsync_ResponseContainer_ReturnsSelf() + { + var container = new InMemoryContainer("test", "/pk"); - var props = new ContainerProperties("test", "/pk"); - var response = await container.ReplaceContainerAsync(props); + var props = new ContainerProperties("test", "/pk"); + var response = await container.ReplaceContainerAsync(props); - response.Container.Should().BeSameAs(container); - } + response.Container.Should().BeSameAs(container); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1445,82 +1445,82 @@ public async Task ReplaceContainerAsync_ResponseContainer_ReturnsSelf() public class ReplaceContainerPreservationTests { - [Fact] - public async Task ReplaceContainerAsync_OnlyTtlChange_IndexingPolicyResetToDefault() - { - var container = new InMemoryContainer("test", "/pk"); - var initialPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None }; - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { IndexingPolicy = initialPolicy }); - - // Replace with new ContainerProperties that has default IndexingPolicy (Automatic=true) - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 300 }); - - var readBack = await container.ReadContainerAsync(); - // ContainerProperties constructor always creates a default IndexingPolicy (Automatic=true) - // so the Replace overwrites the previous Automatic=false with Automatic=true - readBack.Resource.IndexingPolicy.Automatic.Should().BeTrue(); - } - - [Fact] - public async Task ReplaceContainerAsync_MultipleSequentialReplaces_LastWins() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 100 }); - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 200 }); - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 300 }); - - var readBack = await container.ReadContainerAsync(); - readBack.Resource.DefaultTimeToLive.Should().Be(300); - } - - [Fact] - public async Task ReplaceContainerAsync_ShouldPreserveUniqueKeyPolicy_WhenNotIncludedInReplacement() - { - var properties = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - // Replace with TTL only — no UniqueKeyPolicy - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 900 }); - - // Should still enforce uniqueness - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), new PartitionKey("a")); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReplaceContainerAsync_InvalidatesComputedPropertyCache() - { - var props = new ContainerProperties("test", "/pk"); - props.ComputedProperties = new System.Collections.ObjectModel.Collection - { - new() { Name = "cp1", Query = "SELECT VALUE c.name FROM c" } - }; - var container = new InMemoryContainer(props); - - // Seed an item to populate the cache - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - - // Replace — should invalidate computed property cache - await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk")); - - // Verify the container still works - var readBack = await container.ReadContainerAsync(); - readBack.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task ReplaceContainerAsync_OnlyTtlChange_IndexingPolicyResetToDefault() + { + var container = new InMemoryContainer("test", "/pk"); + var initialPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None }; + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { IndexingPolicy = initialPolicy }); + + // Replace with new ContainerProperties that has default IndexingPolicy (Automatic=true) + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 300 }); + + var readBack = await container.ReadContainerAsync(); + // ContainerProperties constructor always creates a default IndexingPolicy (Automatic=true) + // so the Replace overwrites the previous Automatic=false with Automatic=true + readBack.Resource.IndexingPolicy.Automatic.Should().BeTrue(); + } + + [Fact] + public async Task ReplaceContainerAsync_MultipleSequentialReplaces_LastWins() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 100 }); + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 200 }); + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 300 }); + + var readBack = await container.ReadContainerAsync(); + readBack.Resource.DefaultTimeToLive.Should().Be(300); + } + + [Fact] + public async Task ReplaceContainerAsync_ShouldPreserveUniqueKeyPolicy_WhenNotIncludedInReplacement() + { + var properties = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + // Replace with TTL only — no UniqueKeyPolicy + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk") { DefaultTimeToLive = 900 }); + + // Should still enforce uniqueness + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "dup@test.com" }), new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "dup@test.com" }), new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceContainerAsync_InvalidatesComputedPropertyCache() + { + var props = new ContainerProperties("test", "/pk"); + props.ComputedProperties = new System.Collections.ObjectModel.Collection + { + new() { Name = "cp1", Query = "SELECT VALUE c.name FROM c" } + }; + var container = new InMemoryContainer(props); + + // Seed an item to populate the cache + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + + // Replace — should invalidate computed property cache + await container.ReplaceContainerAsync(new ContainerProperties("test", "/pk")); + + // Verify the container still works + var readBack = await container.ReadContainerAsync(); + readBack.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1529,86 +1529,86 @@ await container.CreateItemAsync( public class ContainerCreationValidationTests { - [Fact] - public async Task CreateContainerAsync_WithEmptyStringId_Throws() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var act = () => db.CreateContainerAsync("", "/pk"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateContainerAsync_WithThroughputProperties_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var props = new ContainerProperties("ctr", "/pk"); - var response = await db.CreateContainerAsync(props, ThroughputProperties.CreateManualThroughput(1000)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerStreamAsync_WithThroughputProperties_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var props = new ContainerProperties("ctr", "/pk"); - var response = await db.CreateContainerStreamAsync(props, ThroughputProperties.CreateManualThroughput(1000)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_ExistingContainerWithData_PreservesData() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var resp1 = await db.CreateContainerAsync("ctr", "/pk"); - var container = (InMemoryContainer)resp1.Container; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - // CreateIfNotExists on same id — data should survive - var resp2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - var existing = (InMemoryContainer)resp2.Container; - - existing.ItemCount.Should().Be(1); - } - - [Fact] - public async Task CreateContainerStreamAsync_PreservesIndexingPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var props = new ContainerProperties("ctr", "/pk") - { - IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None } - }; - await db.CreateContainerStreamAsync(props); - - var container = db.GetContainer("ctr"); - var readBack = await container.ReadContainerAsync(); - readBack.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var props = new ContainerProperties("ctr", "/pk"); - var response = await db.CreateContainerIfNotExistsAsync(props, ThroughputProperties.CreateManualThroughput(1000)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task CreateContainerAsync_WithEmptyStringId_Throws() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var act = () => db.CreateContainerAsync("", "/pk"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateContainerAsync_WithThroughputProperties_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var props = new ContainerProperties("ctr", "/pk"); + var response = await db.CreateContainerAsync(props, ThroughputProperties.CreateManualThroughput(1000)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerStreamAsync_WithThroughputProperties_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var props = new ContainerProperties("ctr", "/pk"); + var response = await db.CreateContainerStreamAsync(props, ThroughputProperties.CreateManualThroughput(1000)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_ExistingContainerWithData_PreservesData() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var resp1 = await db.CreateContainerAsync("ctr", "/pk"); + var container = (InMemoryContainer)resp1.Container; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + // CreateIfNotExists on same id — data should survive + var resp2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + var existing = (InMemoryContainer)resp2.Container; + + existing.ItemCount.Should().Be(1); + } + + [Fact] + public async Task CreateContainerStreamAsync_PreservesIndexingPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var props = new ContainerProperties("ctr", "/pk") + { + IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None } + }; + await db.CreateContainerStreamAsync(props); + + var container = db.GetContainer("ctr"); + var readBack = await container.ReadContainerAsync(); + readBack.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var props = new ContainerProperties("ctr", "/pk"); + var response = await db.CreateContainerIfNotExistsAsync(props, ThroughputProperties.CreateManualThroughput(1000)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1617,77 +1617,77 @@ public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_Succe public class DeleteContainerRegistrationTests { - [Fact] - public async Task DeleteContainerAsync_ShouldClearRegisteredTriggersAndUDFs() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterUdf("upper", args => ((string)args[0]).ToUpperInvariant()); - - await container.DeleteContainerAsync(); - - // Re-add an item and verify UDF no longer works (throws because it's unregistered) - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "alice" }), new PartitionKey("a")); - - var act = () => - { - var iterator = container.GetItemQueryIterator("SELECT VALUE udf.upper(c.name) FROM c"); - return iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } + [Fact] + public async Task DeleteContainerAsync_ShouldClearRegisteredTriggersAndUDFs() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterUdf("upper", args => ((string)args[0]).ToUpperInvariant()); + + await container.DeleteContainerAsync(); + + // Re-add an item and verify UDF no longer works (throws because it's unregistered) + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "alice" }), new PartitionKey("a")); + + var act = () => + { + var iterator = container.GetItemQueryIterator("SELECT VALUE udf.upper(c.name) FROM c"); + return iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } } public class DeleteAllByPartitionKeyExtendedTests { - [Fact] - public async Task DeleteAllByPK_ThenCreateInSamePartition_Succeeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - - // Create new item in the same partition - var response = await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - container.ItemCount.Should().Be(1); - } + [Fact] + public async Task DeleteAllByPK_ThenCreateInSamePartition_Succeeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + + // Create new item in the same partition + var response = await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + container.ItemCount.Should().Be(1); + } } public class DeleteContainerIdempotencyTests { - [Fact] - public async Task DeleteContainerAsync_CalledTwice_SecondCallStillSucceeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var resp1 = await container.DeleteContainerAsync(); - resp1.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var resp2 = await container.DeleteContainerAsync(); - resp2.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteContainerStreamAsync_CalledTwice_SecondCallStillSucceeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var resp1 = await container.DeleteContainerStreamAsync(); - resp1.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var resp2 = await container.DeleteContainerStreamAsync(); - resp2.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + [Fact] + public async Task DeleteContainerAsync_CalledTwice_SecondCallStillSucceeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var resp1 = await container.DeleteContainerAsync(); + resp1.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var resp2 = await container.DeleteContainerAsync(); + resp2.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteContainerStreamAsync_CalledTwice_SecondCallStillSucceeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var resp1 = await container.DeleteContainerStreamAsync(); + resp1.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var resp2 = await container.DeleteContainerStreamAsync(); + resp2.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1696,35 +1696,35 @@ await container.CreateItemAsync( public class FeedRangeConfigurationTests { - [Fact] - public async Task GetFeedRangesAsync_FeedRangeCount5_Returns5Ranges() - { - var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 5 }; + [Fact] + public async Task GetFeedRangesAsync_FeedRangeCount5_Returns5Ranges() + { + var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 5 }; - var ranges = await container.GetFeedRangesAsync(); + var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(5); - } + ranges.Should().HaveCount(5); + } - [Fact] - public async Task GetFeedRangesAsync_FeedRangeCount0_Returns1Range() - { - var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 0 }; + [Fact] + public async Task GetFeedRangesAsync_FeedRangeCount0_Returns1Range() + { + var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 0 }; - var ranges = await container.GetFeedRangesAsync(); + var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - } + ranges.Should().HaveCount(1); + } - [Fact] - public async Task GetFeedRangesAsync_NegativeFeedRangeCount_Returns1Range() - { - var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = -5 }; + [Fact] + public async Task GetFeedRangesAsync_NegativeFeedRangeCount_Returns1Range() + { + var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = -5 }; - var ranges = await container.GetFeedRangesAsync(); + var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - } + ranges.Should().HaveCount(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1733,37 +1733,37 @@ public async Task GetFeedRangesAsync_NegativeFeedRangeCount_Returns1Range() public class ContainerQueryIteratorExtendedTests { - [Fact] - public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsAllContainers() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - - var iterator = db.GetContainerQueryIterator(new QueryDefinition("SELECT * FROM c")); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr2"]); - } - - [Fact] - public async Task GetContainerQueryStreamIterator_WithQueryDefinition_ReturnsOk() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - - var iterator = db.GetContainerQueryStreamIterator(new QueryDefinition("SELECT * FROM c")); - var response = await iterator.ReadNextAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsAllContainers() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + + var iterator = db.GetContainerQueryIterator(new QueryDefinition("SELECT * FROM c")); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Select(c => c.Id).Should().BeEquivalentTo(["ctr1", "ctr2"]); + } + + [Fact] + public async Task GetContainerQueryStreamIterator_WithQueryDefinition_ReturnsOk() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + + var iterator = db.GetContainerQueryStreamIterator(new QueryDefinition("SELECT * FROM c")); + var response = await iterator.ReadNextAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1772,48 +1772,48 @@ public async Task GetContainerQueryStreamIterator_WithQueryDefinition_ReturnsOk( public class DatabaseDeletionContainerTests { - [Fact] - public async Task Database_DeleteAsync_ClearsAllContainersAndData() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - var resp1 = await db.CreateContainerAsync("ctr1", "/pk"); - var ctr1 = (InMemoryContainer)resp1.Container; - await ctr1.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var resp2 = await db.CreateContainerAsync("ctr2", "/pk"); - var ctr2 = (InMemoryContainer)resp2.Container; - await ctr2.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - - await db.DeleteAsync(); - - // Containers list should be empty - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - containers.Should().BeEmpty(); - } - - [Fact] - public async Task Database_DeleteAsync_RemovesDatabaseFromClient() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - - var db = client.GetDatabase("test-db"); - await db.DeleteAsync(); - - // Re-create should succeed (database no longer exists) - var response = await client.CreateDatabaseAsync("test-db"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task Database_DeleteAsync_ClearsAllContainersAndData() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + var resp1 = await db.CreateContainerAsync("ctr1", "/pk"); + var ctr1 = (InMemoryContainer)resp1.Container; + await ctr1.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var resp2 = await db.CreateContainerAsync("ctr2", "/pk"); + var ctr2 = (InMemoryContainer)resp2.Container; + await ctr2.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + + await db.DeleteAsync(); + + // Containers list should be empty + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + containers.Should().BeEmpty(); + } + + [Fact] + public async Task Database_DeleteAsync_RemovesDatabaseFromClient() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + + var db = client.GetDatabase("test-db"); + await db.DeleteAsync(); + + // Re-create should succeed (database no longer exists) + var response = await client.CreateDatabaseAsync("test-db"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1822,31 +1822,31 @@ public async Task Database_DeleteAsync_RemovesDatabaseFromClient() public class ContainerClearItemsTests { - [Fact] - public async Task ClearItems_RemovesAllItemsAndChangeFeed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - - container.ClearItems(); - - container.ItemCount.Should().Be(0); - container.GetChangeFeedCheckpoint().Should().Be(0); - } - - [Fact] - public void ClearItems_OnEmptyContainer_NoOp() - { - var container = new InMemoryContainer("test", "/pk"); - - var act = () => container.ClearItems(); - - act.Should().NotThrow(); - container.ItemCount.Should().Be(0); - } + [Fact] + public async Task ClearItems_RemovesAllItemsAndChangeFeed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + + container.ClearItems(); + + container.ItemCount.Should().Be(0); + container.GetChangeFeedCheckpoint().Should().Be(0); + } + + [Fact] + public void ClearItems_OnEmptyContainer_NoOp() + { + var container = new InMemoryContainer("test", "/pk"); + + var act = () => container.ClearItems(); + + act.Should().NotThrow(); + container.ItemCount.Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1855,31 +1855,31 @@ public void ClearItems_OnEmptyContainer_NoOp() public class ContainerSpecialCharacterTests { - [Fact] - public async Task Container_WithSpecialCharactersInId_WorksCorrectly() - { - var container = new InMemoryContainer("test-container_v2.1 (prod)", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var item = await container.ReadItemAsync("1", new PartitionKey("a")); - item.StatusCode.Should().Be(HttpStatusCode.OK); - container.Id.Should().Be("test-container_v2.1 (prod)"); - } - - [Fact] - public async Task Container_WithUnicodeId_WorksCorrectly() - { - var container = new InMemoryContainer("コンテナ-テスト", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var item = await container.ReadItemAsync("1", new PartitionKey("a")); - item.StatusCode.Should().Be(HttpStatusCode.OK); - container.Id.Should().Be("コンテナ-テスト"); - } + [Fact] + public async Task Container_WithSpecialCharactersInId_WorksCorrectly() + { + var container = new InMemoryContainer("test-container_v2.1 (prod)", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var item = await container.ReadItemAsync("1", new PartitionKey("a")); + item.StatusCode.Should().Be(HttpStatusCode.OK); + container.Id.Should().Be("test-container_v2.1 (prod)"); + } + + [Fact] + public async Task Container_WithUnicodeId_WorksCorrectly() + { + var container = new InMemoryContainer("コンテナ-テスト", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var item = await container.ReadItemAsync("1", new PartitionKey("a")); + item.StatusCode.Should().Be(HttpStatusCode.OK); + container.Id.Should().Be("コンテナ-テスト"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1888,16 +1888,16 @@ await container.CreateItemAsync( public class ContainerCancellationTokenTests { - [Fact] - public async Task ReadContainerAsync_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/pk"); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => container.ReadContainerAsync(cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task ReadContainerAsync_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/pk"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => container.ReadContainerAsync(cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1906,22 +1906,22 @@ public async Task ReadContainerAsync_WithCancelledToken_ThrowsOperationCancelled public class ReadContainerStreamRoundTripTests { - [Fact] - public async Task ReadContainerStreamAsync_JsonCanBeDeserializedToContainerProperties() - { - var container = new InMemoryContainer("my-container", "/myPk"); - - using var response = await container.ReadContainerStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var deserialized = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - - deserialized.Should().NotBeNull(); - deserialized.Id.Should().Be("my-container"); - deserialized.PartitionKeyPath.Should().Be("/myPk"); - } + [Fact] + public async Task ReadContainerStreamAsync_JsonCanBeDeserializedToContainerProperties() + { + var container = new InMemoryContainer("my-container", "/myPk"); + + using var response = await container.ReadContainerStreamAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var deserialized = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + deserialized.Should().NotBeNull(); + deserialized.Id.Should().Be("my-container"); + deserialized.PartitionKeyPath.Should().Be("/myPk"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1930,19 +1930,19 @@ public async Task ReadContainerStreamAsync_JsonCanBeDeserializedToContainerPrope public class ContainerDatabaseIdTests { - [Fact] - public void Container_Database_Id_ShouldReturnParentDatabaseId() - { - var container = new InMemoryContainer("test", "/pk"); - container.Database.Id.Should().NotBeNull(); - } - - [Fact] - public void Container_Database_SameInstanceOnEachAccess() - { - var container = new InMemoryContainer("test", "/pk"); - var db1 = container.Database; - var db2 = container.Database; - db1.Should().BeSameAs(db2, "Database property should return the same instance"); - } + [Fact] + public void Container_Database_Id_ShouldReturnParentDatabaseId() + { + var container = new InMemoryContainer("test", "/pk"); + container.Database.Id.Should().NotBeNull(); + } + + [Fact] + public void Container_Database_SameInstanceOnEachAccess() + { + var container = new InMemoryContainer("test", "/pk"); + var db1 = container.Database; + var db2 = container.Database; + db1.Should().BeSameAs(db2, "Database property should return the same instance"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientAndDatabaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientAndDatabaseTests.cs index c4c6060..b6ee72d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientAndDatabaseTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientAndDatabaseTests.cs @@ -1,10 +1,10 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; -using Xunit; -using System.Text; using Microsoft.Azure.Cosmos.Fluent; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -14,2000 +14,2000 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class CosmosClientAndDatabaseTests { - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 1: Foundation Fixes - // ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 1: Foundation Fixes + // ═══════════════════════════════════════════════════════════════════════ - [Fact] - public async Task Client_Property_ReturnsParentCosmosClient() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; + [Fact] + public async Task Client_Property_ReturnsParentCosmosClient() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; - database.Client.Should().NotBeNull(); - database.Client.Should().BeSameAs(client); - } + database.Client.Should().NotBeNull(); + database.Client.Should().BeSameAs(client); + } - [Fact] - public void Client_Property_ViaGetDatabase_ReturnsParentCosmosClient() - { - var client = new InMemoryCosmosClient(); - var database = client.GetDatabase("test-db"); + [Fact] + public void Client_Property_ViaGetDatabase_ReturnsParentCosmosClient() + { + var client = new InMemoryCosmosClient(); + var database = client.GetDatabase("test-db"); - database.Client.Should().BeSameAs(client); - } - - [Fact] - public void Endpoint_ReturnsNonNullUri() - { - var client = new InMemoryCosmosClient(); - - var endpoint = client.Endpoint; - - endpoint.Should().NotBeNull(); - endpoint.Should().BeOfType(); - } - - [Fact] - public void Dispose_CalledMultipleTimes_DoesNotThrow() - { - var client = new InMemoryCosmosClient(); - - client.Dispose(); - var act = () => client.Dispose(); - - act.Should().NotThrow(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 2: CRUD Correctness — Duplicate / Delete Bugs - // ═══════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CreateDatabaseAsync_DuplicateId_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - - var act = () => client.CreateDatabaseAsync("test-db"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateContainerAsync_DuplicateId_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - await database.CreateContainerAsync("container1", "/pk"); - - var act = () => database.CreateContainerAsync("container1", "/pk"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateContainerAsync_WithContainerProperties_DuplicateId_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - await database.CreateContainerAsync(new ContainerProperties("container1", "/pk")); - - var act = () => database.CreateContainerAsync(new ContainerProperties("container1", "/pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task DeleteAsync_ReturnsNoContent() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var database = client.GetDatabase("test-db"); - - var response = await database.DeleteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteAsync_RemovesDatabaseFromClient() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var database = client.GetDatabase("test-db"); - - await database.DeleteAsync(); - - // After deletion, creating the same database should succeed (201, not 200) - var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteAsync_RemovesAllContainersInDatabase() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - var container = db.GetContainer("container1"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await db.DeleteAsync(); - - // Re-create database and container — should be empty - await client.CreateDatabaseAsync("test-db"); - var newDb = client.GetDatabase("test-db"); - await newDb.CreateContainerAsync("container1", "/pk"); - var newContainer = newDb.GetContainer("container1"); - - var act = () => newContainer.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 3: ThroughputProperties Overloads - // ═══════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CreateDatabaseAsync_WithThroughputProperties_CreatesDatabase() - { - var client = new InMemoryCosmosClient(); - - var response = await client.CreateDatabaseAsync( - "test-db", - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("test-db"); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_WithThroughputProperties_CreatesDatabase() - { - var client = new InMemoryCosmosClient(); - - var response = await client.CreateDatabaseIfNotExistsAsync( - "test-db", - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("test-db"); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_WithThroughputProperties_ExistingDb_Returns200() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); - - var response = await client.CreateDatabaseIfNotExistsAsync( - "test-db", - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateContainerAsync_WithThroughputProperties_CreatesContainer() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - var response = await database.CreateContainerAsync( - new ContainerProperties("container1", "/pk"), - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("container1"); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_CreatesContainer() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - var response = await database.CreateContainerIfNotExistsAsync( - new ContainerProperties("container1", "/pk"), - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_ExistingContainer_Returns200() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - await database.CreateContainerAsync("container1", "/pk"); - - var response = await database.CreateContainerIfNotExistsAsync( - new ContainerProperties("container1", "/pk"), - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 4: Stream APIs - // ═══════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task CreateDatabaseStreamAsync_ReturnsCreatedResponse() - { - var client = new InMemoryCosmosClient(); - - using var response = await client.CreateDatabaseStreamAsync( - new DatabaseProperties("test-db")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_DuplicateId_ReturnsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - - using var response = await client.CreateDatabaseStreamAsync( - new DatabaseProperties("test-db")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task CreateContainerStreamAsync_ReturnsCreatedResponse() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - using var response = await database.CreateContainerStreamAsync( - new ContainerProperties("container1", "/pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task CreateContainerStreamAsync_DuplicateId_ReturnsConflict() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - await database.CreateContainerAsync("container1", "/pk"); - - using var response = await database.CreateContainerStreamAsync( - new ContainerProperties("container1", "/pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task CreateContainerStreamAsync_WithThroughputProperties_ReturnsCreated() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; - - using var response = await database.CreateContainerStreamAsync( - new ContainerProperties("container1", "/pk"), - ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReadStreamAsync_ReturnsOkResponse() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var database = client.GetDatabase("test-db"); - - using var response = await database.ReadStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task DeleteStreamAsync_ReturnsNoContent() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var database = client.GetDatabase("test-db"); - - using var response = await database.DeleteStreamAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteStreamAsync_RemovesDatabaseFromClient() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var database = client.GetDatabase("test-db"); - - await database.DeleteStreamAsync(); - - var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 5: Query Iterators - // ═══════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task GetDatabaseQueryIterator_WithNullQuery_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - await client.CreateDatabaseAsync("db3"); - - var iterator = client.GetDatabaseQueryIterator(); - - var databases = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - databases.AddRange(response); - } - - databases.Should().HaveCount(3); - databases.Select(d => d.Id).Should().BeEquivalentTo(new[] { "db1", "db2", "db3" }); - } - - [Fact] - public async Task GetDatabaseQueryIterator_SelectAll_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - var iterator = client.GetDatabaseQueryIterator("SELECT * FROM c"); - - var databases = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - databases.AddRange(response); - } - - databases.Should().HaveCount(2); - } - - [Fact] - public async Task GetDatabaseQueryIterator_WithQueryDefinition_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - var queryDef = new QueryDefinition("SELECT * FROM c"); - var iterator = client.GetDatabaseQueryIterator(queryDef); - - var databases = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - databases.AddRange(response); - } - - databases.Should().HaveCount(2); - } - - [Fact] - public async Task GetDatabaseQueryIterator_EmptyAccount_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - - var iterator = client.GetDatabaseQueryIterator(); - - var databases = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - databases.AddRange(response); - } - - databases.Should().BeEmpty(); - } - - [Fact] - public async Task GetContainerQueryIterator_NullQuery_ReturnsAllContainers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - await db.CreateContainerAsync("container2", "/pk"); - await db.CreateContainerAsync("container3", "/pk"); - - var iterator = db.GetContainerQueryIterator(); - - var containers = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - containers.AddRange(response); - } - - containers.Should().HaveCount(3); - containers.Select(c => c.Id).Should().BeEquivalentTo( - new[] { "container1", "container2", "container3" }); - } - - [Fact] - public async Task GetContainerQueryIterator_SelectAll_ReturnsAllContainers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - - var iterator = db.GetContainerQueryIterator("SELECT * FROM c"); - - var containers = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - containers.AddRange(response); - } - - containers.Should().HaveCount(1); - containers[0].Id.Should().Be("container1"); - } - - [Fact] - public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsContainers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - - var queryDef = new QueryDefinition("SELECT * FROM c"); - var iterator = db.GetContainerQueryIterator(queryDef); - - var containers = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - containers.AddRange(response); - } - - containers.Should().HaveCount(1); - } - - [Fact] - public async Task GetContainerQueryIterator_EmptyDatabase_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - - var iterator = db.GetContainerQueryIterator(); - - var containers = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - containers.AddRange(response); - } - - containers.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 6: Additional ReadAsync Edge Cases - // ═══════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ReadAsync_ReturnsCorrectDatabaseProperties() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("my-db"); - var database = client.GetDatabase("my-db"); - - var response = await database.ReadAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Database.Should().NotBeNull(); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("my-db"); - } - - // ═══════════════════════════════════════════════════════════════════════ - // PHASE 7: Skipped Tests & Divergent Behavior Documentation - // ═══════════════════════════════════════════════════════════════════════ - - // ── A1.2 ClientOptions ────────────────────────────────────────────────── - - [Fact] - public void ClientOptions_ReturnsNonNull() - { - var client = new InMemoryCosmosClient(); - var options = client.ClientOptions; - options.Should().NotBeNull(); - } - - // ── A1.3 ResponseFactory ──────────────────────────────────────────────── - - [Fact] - public void ResponseFactory_ReturnsNonNull() - { - var client = new InMemoryCosmosClient(); - var factory = client.ResponseFactory; - factory.Should().NotBeNull(); - } - - // ── A5 ReadAccountAsync ───────────────────────────────────────────────── - - [Fact] - public async Task ReadAccountAsync_ReturnsAccountProperties() - { - var client = new InMemoryCosmosClient(); - var accountProperties = await client.ReadAccountAsync(); - accountProperties.Should().NotBeNull(); - accountProperties.Id.Should().NotBeNullOrEmpty(); - } - - // ── A9 GetDatabaseQueryStreamIterator ──────────────────────────────────── - - [Fact] - public async Task GetDatabaseQueryStreamIterator_WithNullQuery_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - var iterator = client.GetDatabaseQueryStreamIterator(); - var responses = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - responses.Add(response); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - responses.Should().NotBeEmpty(); - } - - // ── A10 CreateAndInitializeAsync ───────────────────────────────────────── - - /// - /// CreateAndInitializeAsync is a static factory method on CosmosClient that cannot be - /// overridden in InMemoryCosmosClient. However, it can still be used in tests by passing - /// a with an HttpClientFactory that points at a - /// . The real SDK method executes normally but all HTTP - /// traffic is served by the in-memory handler — same pattern as RealToFeedIteratorTests. - /// - [Fact] - public async Task CreateAndInitializeAsync_WorksWithFakeCosmosHandler() - { - // Arrange: set up in-memory container and FakeCosmosHandler - var inMemoryContainer = new InMemoryContainer("myContainer", "/partitionKey"); - await inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Seeded", Value = 42 }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(inMemoryContainer); - - var containers = new List<(string, string)> { ("fakeDb", "myContainer") }; - - // Act: call the REAL static factory method — FakeCosmosHandler serves all HTTP traffic - using var client = await CosmosClient.CreateAndInitializeAsync( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - containers, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - // Assert: client was created and can read pre-seeded data - client.Should().NotBeNull(); - - var container = client.GetContainer("fakeDb", "myContainer"); - var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Seeded"); - } - - [Fact] - public async Task CreateAndInitializeAsync_ConnectionString_WorksWithFakeCosmosHandler() - { - var inMemoryContainer = new InMemoryContainer("orders", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - var containers = new List<(string, string)> { ("shopDb", "orders") }; - - using var client = await CosmosClient.CreateAndInitializeAsync( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - containers, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - client.Should().NotBeNull(); - - // Verify we can write and read through the CreateAndInitializeAsync-created client - var container = client.GetContainer("shopDb", "orders"); - await container.CreateItemAsync( - new TestDocument { Id = "o1", PartitionKey = "pk1", Name = "Order1", Value = 100 }, - new PartitionKey("pk1")); - - var response = await container.ReadItemAsync("o1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Order1"); - } - - [Fact] - public async Task CreateAndInitializeAsync_MultiContainer_WorksWithRouter() - { - var usersContainer = new InMemoryContainer("users", "/partitionKey"); - var ordersContainer = new InMemoryContainer("orders", "/partitionKey"); - - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["users"] = new FakeCosmosHandler(usersContainer), - ["orders"] = new FakeCosmosHandler(ordersContainer) - }); - - var containers = new List<(string, string)> { ("myDb", "users"), ("myDb", "orders") }; - - using var client = await CosmosClient.CreateAndInitializeAsync( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - containers, - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - // Write to each container through the real SDK client - await client.GetContainer("myDb", "users").CreateItemAsync( - new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Alice", Value = 1 }, - new PartitionKey("pk1")); - await client.GetContainer("myDb", "orders").CreateItemAsync( - new TestDocument { Id = "o1", PartitionKey = "pk1", Name = "Order1", Value = 99 }, - new PartitionKey("pk1")); - - // Verify each container has only its own data - var userResp = await client.GetContainer("myDb", "users") - .ReadItemAsync("u1", new PartitionKey("pk1")); - userResp.Resource.Name.Should().Be("Alice"); - - var orderResp = await client.GetContainer("myDb", "orders") - .ReadItemAsync("o1", new PartitionKey("pk1")); - orderResp.Resource.Name.Should().Be("Order1"); - } - - // ── A6 GetDatabase proxy semantics ─────────────────────────────────────── - - /// - /// DIVERGENT BEHAVIOR: Real CosmosClient.GetDatabase returns a proxy that does NOT - /// create the database. Subsequent operations (ReadAsync) would fail with 404 if - /// the database doesn't exist. InMemoryCosmosClient auto-creates the database on - /// GetDatabase for test convenience. This means you can't test "database not found" - /// scenarios through this path. - /// - [Fact] - public void DivergentBehavior_GetDatabase_AutoCreatesDatabase() - { - var client = new InMemoryCosmosClient(); - - // In real SDK, this would just be a proxy — no database created. - // In emulator, this actually creates the database. - var database = client.GetDatabase("auto-created"); - - database.Should().NotBeNull(); - database.Id.Should().Be("auto-created"); - } - - // ── B12 GetContainerQueryStreamIterator ────────────────────────────────── - - [Fact] - public async Task GetContainerQueryStreamIterator_ReturnsAllContainers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - var iterator = db.GetContainerQueryStreamIterator(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - // ── B13 Throughput Management ──────────────────────────────────────────── - - [Fact] - public async Task ReadThroughputAsync_Returns400() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - - var throughput = await db.ReadThroughputAsync(); - - throughput.Should().Be(400); - } - - [Fact] - public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughputResponse() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - var response = await db.ReadThroughputAsync(new RequestOptions()); - response.Should().NotBeNull(); - } - - [Fact] - public async Task ReplaceThroughputAsync_Int_DoesNotThrow() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - var response = await db.ReplaceThroughputAsync(400); - response.Should().NotBeNull(); - } - - [Fact] - public async Task ReplaceThroughputAsync_ThroughputProperties_DoesNotThrow() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - var response = await db.ReplaceThroughputAsync( - ThroughputProperties.CreateManualThroughput(400)); - response.Should().NotBeNull(); - } - - // ── B14 DefineContainer ────────────────────────────────────────────────── - - [Fact] - public async Task DefineContainer_CreatesContainerViaFluentBuilder() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - var containerResponse = await db.DefineContainer("container1", "/pk") - .WithIndexingPolicy() - .WithAutomaticIndexing(true) - .Attach() - .CreateAsync(); - containerResponse.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ── B15 User Management ────────────────────────────────────────────────── - - [Fact] - public async Task CreateUserAsync_CreatesUser() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - - var response = await db.CreateUserAsync("user1"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("user1"); - } - - [Fact] - public async Task CreateUserAsync_Duplicate_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - - var act = () => db.CreateUserAsync("user1"); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task UpsertUserAsync_CreatesNewUser() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - - var response = await db.UpsertUserAsync("user1"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("user1"); - } - - [Fact] - public async Task UpsertUserAsync_UpdatesExistingUser() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - - var response = await db.UpsertUserAsync("user1"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("user1"); - } - - [Fact] - public void GetUser_ReturnsUserProxy() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); - - var user = db.GetUser("user1"); - - user.Should().NotBeNull(); - user.Id.Should().Be("user1"); - } - - [Fact] - public async Task GetUserQueryIterator_ReturnsUsers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - await db.CreateUserAsync("user2"); - - var iterator = db.GetUserQueryIterator(); - var users = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - users.AddRange(response); - } - - users.Should().HaveCount(2); - users.Select(u => u.Id).Should().BeEquivalentTo(["user1", "user2"]); - } - - [Fact] - public async Task GetUserQueryIterator_QueryDefinition_ReturnsUsers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - - var iterator = db.GetUserQueryIterator( - new QueryDefinition("SELECT * FROM u")); - var users = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - users.AddRange(response); - } - - users.Should().HaveCount(1); - } - - [Fact] - public async Task User_ReadAsync_ReturnsUserProperties() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - - var response = await user.ReadAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("user1"); - } - - [Fact] - public async Task User_ReplaceAsync_UpdatesUser() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - - var response = await user.ReplaceAsync(new UserProperties("user1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("user1"); - } - - [Fact] - public async Task User_DeleteAsync_RemovesUser() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - - var response = await user.DeleteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify user is gone from query results - var iterator = db.GetUserQueryIterator(); - var users = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - users.AddRange(page); - } - users.Should().BeEmpty(); - } - - // ── B15b Permission Management ─────────────────────────────────────────── - - [Fact] - public async Task Permission_CreateAsync_CreatesPermission() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - - var permProps = new PermissionProperties("perm1", PermissionMode.All, container); - var response = await user.CreatePermissionAsync(permProps); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("perm1"); - response.Resource.PermissionMode.Should().Be(PermissionMode.All); - } - - [Fact] - public async Task Permission_CreateAsync_Duplicate_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - var act = () => user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task Permission_UpsertAsync_CreatesNew() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - - var response = await user.UpsertPermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Permission_UpsertAsync_UpdatesExisting() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); - - var response = await user.UpsertPermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.PermissionMode.Should().Be(PermissionMode.All); - } - - [Fact] - public async Task Permission_ReadAsync_ReturnsPermission() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - var perm = user.GetPermission("perm1"); - var response = await perm.ReadAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("perm1"); - response.Resource.PermissionMode.Should().Be(PermissionMode.All); - } - - [Fact] - public async Task Permission_ReplaceAsync_UpdatesPermission() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); - - var perm = user.GetPermission("perm1"); - var response = await perm.ReplaceAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.PermissionMode.Should().Be(PermissionMode.All); - } - - [Fact] - public async Task Permission_DeleteAsync_RemovesPermission() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - var perm = user.GetPermission("perm1"); - var response = await perm.DeleteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify permission is gone - var iterator = user.GetPermissionQueryIterator(); - var perms = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - perms.AddRange(page); - } - perms.Should().BeEmpty(); - } - - [Fact] - public async Task Permission_GetQueryIterator_ReturnsAll() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - await user.CreatePermissionAsync(new PermissionProperties("perm2", PermissionMode.Read, container)); - - var iterator = user.GetPermissionQueryIterator(); - var perms = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - perms.AddRange(page); - } - - perms.Should().HaveCount(2); - perms.Select(p => p.Id).Should().BeEquivalentTo(["perm1", "perm2"]); - } - - [Fact] - public async Task Permission_Token_IsSyntheticString() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - var perm = user.GetPermission("perm1"); - var response = await perm.ReadAsync(); - - response.Resource.Token.Should().NotBeNullOrEmpty(); - response.Resource.Token.Should().Contain("perm1"); - } - - /// - /// DIVERGENT BEHAVIOR: Real Cosmos DB permission tokens are cryptographic resource tokens - /// with a specific format, signed by the service, and time-limited. The in-memory emulator - /// returns synthetic tokens in the format "type=resource&ver=1&sig=stub_{permissionId}" - /// that are non-functional placeholders. Token expiry (tokenExpiryInSeconds) is accepted - /// but not enforced. No actual authorization is performed — all operations succeed regardless - /// of permission settings. - /// - [Fact] - public async Task DivergentBehavior_PermissionTokens_AreSyntheticNotCryptographic() - { - // In real Cosmos DB, permission tokens are cryptographic and time-limited. - // The emulator returns synthetic placeholder tokens. - // Token expiry is accepted but not enforced. - // No authorization is performed — all operations succeed regardless. - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var container = new InMemoryContainer("my-container", "/pk"); - await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); - - var response = await user.GetPermission("perm1").ReadAsync(); - - // Token is synthetic, not a real resource token - response.Resource.Token.Should().StartWith("type=resource&ver=1&sig=stub_"); - } - - // ── B16 Client Encryption Keys ─────────────────────────────────────────── - - [Fact(Skip = "SKIP REASON: Client encryption key management requires Azure Key Vault integration and deep SDK internals (MDE/Always Encrypted). Not meaningful for in-memory emulator.")] - public void GetClientEncryptionKey_ReturnsKey() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); - var key = db.GetClientEncryptionKey("key1"); - key.Should().NotBeNull(); - } - - [Fact(Skip = "SKIP REASON: Same as above - encryption key infrastructure not emulated.")] - public async Task CreateClientEncryptionKeyAsync_CreatesKey() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); - await db.CreateClientEncryptionKeyAsync(null); - } - - /// - /// DIVERGENT BEHAVIOR: Client encryption key management (GetClientEncryptionKey, - /// CreateClientEncryptionKeyAsync, GetClientEncryptionKeyQueryIterator) requires - /// Azure Key Vault integration. All methods throw NotImplementedException. - /// - [Fact] - public void DivergentBehavior_ClientEncryptionKeys_ThrowNotImplemented() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); - - ((Func)(() => db.GetClientEncryptionKey("key1"))).Should().Throw(); - } + database.Client.Should().BeSameAs(client); + } + + [Fact] + public void Endpoint_ReturnsNonNullUri() + { + var client = new InMemoryCosmosClient(); + + var endpoint = client.Endpoint; + + endpoint.Should().NotBeNull(); + endpoint.Should().BeOfType(); + } + + [Fact] + public void Dispose_CalledMultipleTimes_DoesNotThrow() + { + var client = new InMemoryCosmosClient(); + + client.Dispose(); + var act = () => client.Dispose(); + + act.Should().NotThrow(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 2: CRUD Correctness — Duplicate / Delete Bugs + // ═══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CreateDatabaseAsync_DuplicateId_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + + var act = () => client.CreateDatabaseAsync("test-db"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateContainerAsync_DuplicateId_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + await database.CreateContainerAsync("container1", "/pk"); + + var act = () => database.CreateContainerAsync("container1", "/pk"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateContainerAsync_WithContainerProperties_DuplicateId_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + await database.CreateContainerAsync(new ContainerProperties("container1", "/pk")); + + var act = () => database.CreateContainerAsync(new ContainerProperties("container1", "/pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DeleteAsync_ReturnsNoContent() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var database = client.GetDatabase("test-db"); + + var response = await database.DeleteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteAsync_RemovesDatabaseFromClient() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var database = client.GetDatabase("test-db"); + + await database.DeleteAsync(); + + // After deletion, creating the same database should succeed (201, not 200) + var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteAsync_RemovesAllContainersInDatabase() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + var container = db.GetContainer("container1"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await db.DeleteAsync(); + + // Re-create database and container — should be empty + await client.CreateDatabaseAsync("test-db"); + var newDb = client.GetDatabase("test-db"); + await newDb.CreateContainerAsync("container1", "/pk"); + var newContainer = newDb.GetContainer("container1"); + + var act = () => newContainer.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 3: ThroughputProperties Overloads + // ═══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CreateDatabaseAsync_WithThroughputProperties_CreatesDatabase() + { + var client = new InMemoryCosmosClient(); + + var response = await client.CreateDatabaseAsync( + "test-db", + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("test-db"); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_WithThroughputProperties_CreatesDatabase() + { + var client = new InMemoryCosmosClient(); + + var response = await client.CreateDatabaseIfNotExistsAsync( + "test-db", + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("test-db"); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_WithThroughputProperties_ExistingDb_Returns200() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); + + var response = await client.CreateDatabaseIfNotExistsAsync( + "test-db", + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateContainerAsync_WithThroughputProperties_CreatesContainer() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + var response = await database.CreateContainerAsync( + new ContainerProperties("container1", "/pk"), + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("container1"); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_CreatesContainer() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + var response = await database.CreateContainerIfNotExistsAsync( + new ContainerProperties("container1", "/pk"), + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_WithThroughputProperties_ExistingContainer_Returns200() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + await database.CreateContainerAsync("container1", "/pk"); + + var response = await database.CreateContainerIfNotExistsAsync( + new ContainerProperties("container1", "/pk"), + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 4: Stream APIs + // ═══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CreateDatabaseStreamAsync_ReturnsCreatedResponse() + { + var client = new InMemoryCosmosClient(); + + using var response = await client.CreateDatabaseStreamAsync( + new DatabaseProperties("test-db")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_DuplicateId_ReturnsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + + using var response = await client.CreateDatabaseStreamAsync( + new DatabaseProperties("test-db")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task CreateContainerStreamAsync_ReturnsCreatedResponse() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + using var response = await database.CreateContainerStreamAsync( + new ContainerProperties("container1", "/pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task CreateContainerStreamAsync_DuplicateId_ReturnsConflict() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + await database.CreateContainerAsync("container1", "/pk"); + + using var response = await database.CreateContainerStreamAsync( + new ContainerProperties("container1", "/pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task CreateContainerStreamAsync_WithThroughputProperties_ReturnsCreated() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; + + using var response = await database.CreateContainerStreamAsync( + new ContainerProperties("container1", "/pk"), + ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReadStreamAsync_ReturnsOkResponse() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var database = client.GetDatabase("test-db"); + + using var response = await database.ReadStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task DeleteStreamAsync_ReturnsNoContent() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var database = client.GetDatabase("test-db"); + + using var response = await database.DeleteStreamAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteStreamAsync_RemovesDatabaseFromClient() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var database = client.GetDatabase("test-db"); + + await database.DeleteStreamAsync(); + + var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 5: Query Iterators + // ═══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetDatabaseQueryIterator_WithNullQuery_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + await client.CreateDatabaseAsync("db3"); + + var iterator = client.GetDatabaseQueryIterator(); + + var databases = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + databases.AddRange(response); + } + + databases.Should().HaveCount(3); + databases.Select(d => d.Id).Should().BeEquivalentTo(new[] { "db1", "db2", "db3" }); + } + + [Fact] + public async Task GetDatabaseQueryIterator_SelectAll_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + var iterator = client.GetDatabaseQueryIterator("SELECT * FROM c"); + + var databases = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + databases.AddRange(response); + } + + databases.Should().HaveCount(2); + } + + [Fact] + public async Task GetDatabaseQueryIterator_WithQueryDefinition_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + var queryDef = new QueryDefinition("SELECT * FROM c"); + var iterator = client.GetDatabaseQueryIterator(queryDef); + + var databases = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + databases.AddRange(response); + } + + databases.Should().HaveCount(2); + } + + [Fact] + public async Task GetDatabaseQueryIterator_EmptyAccount_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + + var iterator = client.GetDatabaseQueryIterator(); + + var databases = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + databases.AddRange(response); + } + + databases.Should().BeEmpty(); + } + + [Fact] + public async Task GetContainerQueryIterator_NullQuery_ReturnsAllContainers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + await db.CreateContainerAsync("container2", "/pk"); + await db.CreateContainerAsync("container3", "/pk"); + + var iterator = db.GetContainerQueryIterator(); + + var containers = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + containers.AddRange(response); + } + + containers.Should().HaveCount(3); + containers.Select(c => c.Id).Should().BeEquivalentTo( + new[] { "container1", "container2", "container3" }); + } + + [Fact] + public async Task GetContainerQueryIterator_SelectAll_ReturnsAllContainers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + + var iterator = db.GetContainerQueryIterator("SELECT * FROM c"); + + var containers = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + containers.AddRange(response); + } + + containers.Should().HaveCount(1); + containers[0].Id.Should().Be("container1"); + } + + [Fact] + public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsContainers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + + var queryDef = new QueryDefinition("SELECT * FROM c"); + var iterator = db.GetContainerQueryIterator(queryDef); + + var containers = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + containers.AddRange(response); + } + + containers.Should().HaveCount(1); + } + + [Fact] + public async Task GetContainerQueryIterator_EmptyDatabase_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + + var iterator = db.GetContainerQueryIterator(); + + var containers = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + containers.AddRange(response); + } + + containers.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 6: Additional ReadAsync Edge Cases + // ═══════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ReadAsync_ReturnsCorrectDatabaseProperties() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("my-db"); + var database = client.GetDatabase("my-db"); + + var response = await database.ReadAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Database.Should().NotBeNull(); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("my-db"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 7: Skipped Tests & Divergent Behavior Documentation + // ═══════════════════════════════════════════════════════════════════════ + + // ── A1.2 ClientOptions ────────────────────────────────────────────────── + + [Fact] + public void ClientOptions_ReturnsNonNull() + { + var client = new InMemoryCosmosClient(); + var options = client.ClientOptions; + options.Should().NotBeNull(); + } + + // ── A1.3 ResponseFactory ──────────────────────────────────────────────── + + [Fact] + public void ResponseFactory_ReturnsNonNull() + { + var client = new InMemoryCosmosClient(); + var factory = client.ResponseFactory; + factory.Should().NotBeNull(); + } + + // ── A5 ReadAccountAsync ───────────────────────────────────────────────── + + [Fact] + public async Task ReadAccountAsync_ReturnsAccountProperties() + { + var client = new InMemoryCosmosClient(); + var accountProperties = await client.ReadAccountAsync(); + accountProperties.Should().NotBeNull(); + accountProperties.Id.Should().NotBeNullOrEmpty(); + } + + // ── A9 GetDatabaseQueryStreamIterator ──────────────────────────────────── + + [Fact] + public async Task GetDatabaseQueryStreamIterator_WithNullQuery_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + var iterator = client.GetDatabaseQueryStreamIterator(); + var responses = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + responses.Add(response); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + responses.Should().NotBeEmpty(); + } + + // ── A10 CreateAndInitializeAsync ───────────────────────────────────────── + + /// + /// CreateAndInitializeAsync is a static factory method on CosmosClient that cannot be + /// overridden in InMemoryCosmosClient. However, it can still be used in tests by passing + /// a with an HttpClientFactory that points at a + /// . The real SDK method executes normally but all HTTP + /// traffic is served by the in-memory handler — same pattern as RealToFeedIteratorTests. + /// + [Fact] + public async Task CreateAndInitializeAsync_WorksWithFakeCosmosHandler() + { + // Arrange: set up in-memory container and FakeCosmosHandler + var inMemoryContainer = new InMemoryContainer("myContainer", "/partitionKey"); + await inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Seeded", Value = 42 }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(inMemoryContainer); + + var containers = new List<(string, string)> { ("fakeDb", "myContainer") }; + + // Act: call the REAL static factory method — FakeCosmosHandler serves all HTTP traffic + using var client = await CosmosClient.CreateAndInitializeAsync( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + containers, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + // Assert: client was created and can read pre-seeded data + client.Should().NotBeNull(); + + var container = client.GetContainer("fakeDb", "myContainer"); + var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Seeded"); + } + + [Fact] + public async Task CreateAndInitializeAsync_ConnectionString_WorksWithFakeCosmosHandler() + { + var inMemoryContainer = new InMemoryContainer("orders", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + var containers = new List<(string, string)> { ("shopDb", "orders") }; + + using var client = await CosmosClient.CreateAndInitializeAsync( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + containers, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + client.Should().NotBeNull(); + + // Verify we can write and read through the CreateAndInitializeAsync-created client + var container = client.GetContainer("shopDb", "orders"); + await container.CreateItemAsync( + new TestDocument { Id = "o1", PartitionKey = "pk1", Name = "Order1", Value = 100 }, + new PartitionKey("pk1")); + + var response = await container.ReadItemAsync("o1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Order1"); + } + + [Fact] + public async Task CreateAndInitializeAsync_MultiContainer_WorksWithRouter() + { + var usersContainer = new InMemoryContainer("users", "/partitionKey"); + var ordersContainer = new InMemoryContainer("orders", "/partitionKey"); + + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["users"] = new FakeCosmosHandler(usersContainer), + ["orders"] = new FakeCosmosHandler(ordersContainer) + }); + + var containers = new List<(string, string)> { ("myDb", "users"), ("myDb", "orders") }; + + using var client = await CosmosClient.CreateAndInitializeAsync( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + containers, + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + // Write to each container through the real SDK client + await client.GetContainer("myDb", "users").CreateItemAsync( + new TestDocument { Id = "u1", PartitionKey = "pk1", Name = "Alice", Value = 1 }, + new PartitionKey("pk1")); + await client.GetContainer("myDb", "orders").CreateItemAsync( + new TestDocument { Id = "o1", PartitionKey = "pk1", Name = "Order1", Value = 99 }, + new PartitionKey("pk1")); + + // Verify each container has only its own data + var userResp = await client.GetContainer("myDb", "users") + .ReadItemAsync("u1", new PartitionKey("pk1")); + userResp.Resource.Name.Should().Be("Alice"); + + var orderResp = await client.GetContainer("myDb", "orders") + .ReadItemAsync("o1", new PartitionKey("pk1")); + orderResp.Resource.Name.Should().Be("Order1"); + } + + // ── A6 GetDatabase proxy semantics ─────────────────────────────────────── + + /// + /// DIVERGENT BEHAVIOR: Real CosmosClient.GetDatabase returns a proxy that does NOT + /// create the database. Subsequent operations (ReadAsync) would fail with 404 if + /// the database doesn't exist. InMemoryCosmosClient auto-creates the database on + /// GetDatabase for test convenience. This means you can't test "database not found" + /// scenarios through this path. + /// + [Fact] + public void DivergentBehavior_GetDatabase_AutoCreatesDatabase() + { + var client = new InMemoryCosmosClient(); + + // In real SDK, this would just be a proxy — no database created. + // In emulator, this actually creates the database. + var database = client.GetDatabase("auto-created"); + + database.Should().NotBeNull(); + database.Id.Should().Be("auto-created"); + } + + // ── B12 GetContainerQueryStreamIterator ────────────────────────────────── + + [Fact] + public async Task GetContainerQueryStreamIterator_ReturnsAllContainers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + var iterator = db.GetContainerQueryStreamIterator(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } + + // ── B13 Throughput Management ──────────────────────────────────────────── + + [Fact] + public async Task ReadThroughputAsync_Returns400() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + + var throughput = await db.ReadThroughputAsync(); + + throughput.Should().Be(400); + } + + [Fact] + public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughputResponse() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + var response = await db.ReadThroughputAsync(new RequestOptions()); + response.Should().NotBeNull(); + } + + [Fact] + public async Task ReplaceThroughputAsync_Int_DoesNotThrow() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + var response = await db.ReplaceThroughputAsync(400); + response.Should().NotBeNull(); + } + + [Fact] + public async Task ReplaceThroughputAsync_ThroughputProperties_DoesNotThrow() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + var response = await db.ReplaceThroughputAsync( + ThroughputProperties.CreateManualThroughput(400)); + response.Should().NotBeNull(); + } + + // ── B14 DefineContainer ────────────────────────────────────────────────── + + [Fact] + public async Task DefineContainer_CreatesContainerViaFluentBuilder() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + var containerResponse = await db.DefineContainer("container1", "/pk") + .WithIndexingPolicy() + .WithAutomaticIndexing(true) + .Attach() + .CreateAsync(); + containerResponse.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ── B15 User Management ────────────────────────────────────────────────── + + [Fact] + public async Task CreateUserAsync_CreatesUser() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + + var response = await db.CreateUserAsync("user1"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("user1"); + } + + [Fact] + public async Task CreateUserAsync_Duplicate_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + + var act = () => db.CreateUserAsync("user1"); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task UpsertUserAsync_CreatesNewUser() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + + var response = await db.UpsertUserAsync("user1"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("user1"); + } + + [Fact] + public async Task UpsertUserAsync_UpdatesExistingUser() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + + var response = await db.UpsertUserAsync("user1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("user1"); + } + + [Fact] + public void GetUser_ReturnsUserProxy() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); + + var user = db.GetUser("user1"); + + user.Should().NotBeNull(); + user.Id.Should().Be("user1"); + } + + [Fact] + public async Task GetUserQueryIterator_ReturnsUsers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + await db.CreateUserAsync("user2"); + + var iterator = db.GetUserQueryIterator(); + var users = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + users.AddRange(response); + } + + users.Should().HaveCount(2); + users.Select(u => u.Id).Should().BeEquivalentTo(["user1", "user2"]); + } + + [Fact] + public async Task GetUserQueryIterator_QueryDefinition_ReturnsUsers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + + var iterator = db.GetUserQueryIterator( + new QueryDefinition("SELECT * FROM u")); + var users = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + users.AddRange(response); + } + + users.Should().HaveCount(1); + } + + [Fact] + public async Task User_ReadAsync_ReturnsUserProperties() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + + var response = await user.ReadAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("user1"); + } + + [Fact] + public async Task User_ReplaceAsync_UpdatesUser() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + + var response = await user.ReplaceAsync(new UserProperties("user1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("user1"); + } + + [Fact] + public async Task User_DeleteAsync_RemovesUser() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + + var response = await user.DeleteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify user is gone from query results + var iterator = db.GetUserQueryIterator(); + var users = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + users.AddRange(page); + } + users.Should().BeEmpty(); + } + + // ── B15b Permission Management ─────────────────────────────────────────── + + [Fact] + public async Task Permission_CreateAsync_CreatesPermission() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + + var permProps = new PermissionProperties("perm1", PermissionMode.All, container); + var response = await user.CreatePermissionAsync(permProps); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("perm1"); + response.Resource.PermissionMode.Should().Be(PermissionMode.All); + } + + [Fact] + public async Task Permission_CreateAsync_Duplicate_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + var act = () => user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task Permission_UpsertAsync_CreatesNew() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + + var response = await user.UpsertPermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Permission_UpsertAsync_UpdatesExisting() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); + + var response = await user.UpsertPermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.PermissionMode.Should().Be(PermissionMode.All); + } + + [Fact] + public async Task Permission_ReadAsync_ReturnsPermission() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + var perm = user.GetPermission("perm1"); + var response = await perm.ReadAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("perm1"); + response.Resource.PermissionMode.Should().Be(PermissionMode.All); + } + + [Fact] + public async Task Permission_ReplaceAsync_UpdatesPermission() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.Read, container)); + + var perm = user.GetPermission("perm1"); + var response = await perm.ReplaceAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.PermissionMode.Should().Be(PermissionMode.All); + } + + [Fact] + public async Task Permission_DeleteAsync_RemovesPermission() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + var perm = user.GetPermission("perm1"); + var response = await perm.DeleteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify permission is gone + var iterator = user.GetPermissionQueryIterator(); + var perms = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + perms.AddRange(page); + } + perms.Should().BeEmpty(); + } + + [Fact] + public async Task Permission_GetQueryIterator_ReturnsAll() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + await user.CreatePermissionAsync(new PermissionProperties("perm2", PermissionMode.Read, container)); + + var iterator = user.GetPermissionQueryIterator(); + var perms = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + perms.AddRange(page); + } + + perms.Should().HaveCount(2); + perms.Select(p => p.Id).Should().BeEquivalentTo(["perm1", "perm2"]); + } + + [Fact] + public async Task Permission_Token_IsSyntheticString() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + var perm = user.GetPermission("perm1"); + var response = await perm.ReadAsync(); + + response.Resource.Token.Should().NotBeNullOrEmpty(); + response.Resource.Token.Should().Contain("perm1"); + } + + /// + /// DIVERGENT BEHAVIOR: Real Cosmos DB permission tokens are cryptographic resource tokens + /// with a specific format, signed by the service, and time-limited. The in-memory emulator + /// returns synthetic tokens in the format "type=resource&ver=1&sig=stub_{permissionId}" + /// that are non-functional placeholders. Token expiry (tokenExpiryInSeconds) is accepted + /// but not enforced. No actual authorization is performed — all operations succeed regardless + /// of permission settings. + /// + [Fact] + public async Task DivergentBehavior_PermissionTokens_AreSyntheticNotCryptographic() + { + // In real Cosmos DB, permission tokens are cryptographic and time-limited. + // The emulator returns synthetic placeholder tokens. + // Token expiry is accepted but not enforced. + // No authorization is performed — all operations succeed regardless. + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var container = new InMemoryContainer("my-container", "/pk"); + await user.CreatePermissionAsync(new PermissionProperties("perm1", PermissionMode.All, container)); + + var response = await user.GetPermission("perm1").ReadAsync(); + + // Token is synthetic, not a real resource token + response.Resource.Token.Should().StartWith("type=resource&ver=1&sig=stub_"); + } + + // ── B16 Client Encryption Keys ─────────────────────────────────────────── + + [Fact(Skip = "SKIP REASON: Client encryption key management requires Azure Key Vault integration and deep SDK internals (MDE/Always Encrypted). Not meaningful for in-memory emulator.")] + public void GetClientEncryptionKey_ReturnsKey() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); + var key = db.GetClientEncryptionKey("key1"); + key.Should().NotBeNull(); + } + + [Fact(Skip = "SKIP REASON: Same as above - encryption key infrastructure not emulated.")] + public async Task CreateClientEncryptionKeyAsync_CreatesKey() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); + await db.CreateClientEncryptionKeyAsync(null); + } + + /// + /// DIVERGENT BEHAVIOR: Client encryption key management (GetClientEncryptionKey, + /// CreateClientEncryptionKeyAsync, GetClientEncryptionKeyQueryIterator) requires + /// Azure Key Vault integration. All methods throw NotImplementedException. + /// + [Fact] + public void DivergentBehavior_ClientEncryptionKeys_ThrowNotImplemented() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); + + ((Func)(() => db.GetClientEncryptionKey("key1"))).Should().Throw(); + } } public class CreateContainerIfNotExistsStatusCodeTests { - [Fact] - public async Task CreateContainerIfNotExistsAsync_NewContainer_Returns201() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_NewContainer_Returns201() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.CreateContainerIfNotExistsAsync("container1", "/pk"); + var response = await db.CreateContainerIfNotExistsAsync("container1", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task CreateContainerIfNotExistsAsync_ExistingContainer_Returns200() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_ExistingContainer_Returns200() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); - var response = await db.CreateContainerIfNotExistsAsync("container1", "/pk"); + var response = await db.CreateContainerIfNotExistsAsync("container1", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class ReplaceThroughputAsyncResponseTests { - [Fact] - public async Task ReplaceThroughputAsync_Int_ResponseContainsNewThroughput() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReplaceThroughputAsync_Int_ResponseContainsNewThroughput() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.ReplaceThroughputAsync(1000); + var response = await db.ReplaceThroughputAsync(1000); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } - [Fact] - public async Task ReplaceThroughputAsync_ThroughputProperties_ResponseContainsNewThroughput() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReplaceThroughputAsync_ThroughputProperties_ResponseContainsNewThroughput() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.ReplaceThroughputAsync( - ThroughputProperties.CreateManualThroughput(2000)); + var response = await db.ReplaceThroughputAsync( + ThroughputProperties.CreateManualThroughput(2000)); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } } public class CosmosClientInputValidationTests { - private readonly InMemoryCosmosClient _client = new(); - - [Fact] - public void GetContainer_WithNullDatabaseId_Throws() - { - var act = () => _client.GetContainer(null!, "container"); - act.Should().Throw(); - } - - [Fact] - public void GetContainer_WithNullContainerId_Throws() - { - var act = () => _client.GetContainer("db", null!); - act.Should().Throw(); - } - - [Fact] - public void GetContainer_WithEmptyDatabaseId_Throws() - { - var act = () => _client.GetContainer("", "container"); - act.Should().Throw(); - } - - [Fact] - public void GetDatabase_WithNullId_Throws() - { - var act = () => _client.GetDatabase(null!); - act.Should().Throw(); - } - - [Fact] - public void GetDatabase_WithEmptyId_Throws() - { - var act = () => _client.GetDatabase(""); - act.Should().Throw(); - } + private readonly InMemoryCosmosClient _client = new(); + + [Fact] + public void GetContainer_WithNullDatabaseId_Throws() + { + var act = () => _client.GetContainer(null!, "container"); + act.Should().Throw(); + } + + [Fact] + public void GetContainer_WithNullContainerId_Throws() + { + var act = () => _client.GetContainer("db", null!); + act.Should().Throw(); + } + + [Fact] + public void GetContainer_WithEmptyDatabaseId_Throws() + { + var act = () => _client.GetContainer("", "container"); + act.Should().Throw(); + } + + [Fact] + public void GetDatabase_WithNullId_Throws() + { + var act = () => _client.GetDatabase(null!); + act.Should().Throw(); + } + + [Fact] + public void GetDatabase_WithEmptyId_Throws() + { + var act = () => _client.GetDatabase(""); + act.Should().Throw(); + } } public class DeleteAsyncSubsequentOperationsTests { - [Fact] - public async Task DeleteAsync_GetContainerQueryIterator_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); + [Fact] + public async Task DeleteAsync_GetContainerQueryIterator_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); - await db.DeleteAsync(); + await db.DeleteAsync(); - // After delete, query iterator should return no containers - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - containers.Should().BeEmpty(); - } + // After delete, query iterator should return no containers + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + containers.Should().BeEmpty(); + } } public class GetContainerQueryIteratorAfterDeleteTests { - [Fact] - public async Task GetContainerQueryIterator_AfterContainerDelete_NoLongerListed() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - await db.CreateContainerAsync("container2", "/pk"); + [Fact] + public async Task GetContainerQueryIterator_AfterContainerDelete_NoLongerListed() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + await db.CreateContainerAsync("container2", "/pk"); - var container1 = db.GetContainer("container1"); - await container1.DeleteContainerAsync(); + var container1 = db.GetContainer("container1"); + await container1.DeleteContainerAsync(); - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } - // After delete, only container2 should remain - containers.Should().ContainSingle().Which.Id.Should().Be("container2"); - } + // After delete, only container2 should remain + containers.Should().ContainSingle().Which.Id.Should().Be("container2"); + } } public class ConcurrentDatabaseOperationTests { - [Fact] - public async Task ConcurrentCreateContainerAsync_DifferentIds_AllSucceed() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ConcurrentCreateContainerAsync_DifferentIds_AllSucceed() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var tasks = Enumerable.Range(0, 20).Select(i => - db.CreateContainerAsync($"container-{i}", "/pk")); + var tasks = Enumerable.Range(0, 20).Select(i => + db.CreateContainerAsync($"container-{i}", "/pk")); - var responses = await Task.WhenAll(tasks); + var responses = await Task.WhenAll(tasks); - responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - var uniqueIds = responses.Select(r => r.Resource.Id).Distinct(); - uniqueIds.Should().HaveCount(20); - } + responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + var uniqueIds = responses.Select(r => r.Resource.Id).Distinct(); + uniqueIds.Should().HaveCount(20); + } - [Fact] - public async Task ConcurrentCreateContainerIfNotExistsAsync_SameId_OnlyOneCreated() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ConcurrentCreateContainerIfNotExistsAsync_SameId_OnlyOneCreated() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var tasks = Enumerable.Range(0, 20).Select(_ => - db.CreateContainerIfNotExistsAsync("shared-container", "/pk")); + var tasks = Enumerable.Range(0, 20).Select(_ => + db.CreateContainerIfNotExistsAsync("shared-container", "/pk")); - var responses = await Task.WhenAll(tasks); + var responses = await Task.WhenAll(tasks); - // Exactly one should be Created (201), the rest should be OK (200) - responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); - responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); - } + // Exactly one should be Created (201), the rest should be OK (200) + responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); + responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); + } } public class NullGuardDivergentBehaviorTests { - /// - /// BEHAVIORAL DIFFERENCE: Real Cosmos DB SDK throws ArgumentNullException for null id - /// parameters on GetDatabase/GetContainer. InMemoryCosmosClient may need to add these - /// guards explicitly. If the guards are not present, the ConcurrentDictionary will - /// throw ArgumentNullException on its own, which is functionally equivalent. - /// - [Fact] - public void GetDatabase_NullId_ThrowsSomeException() - { - var client = new InMemoryCosmosClient(); - var act = () => client.GetDatabase(null!); - // Will throw either ArgumentNullException (if we add guards) or from ConcurrentDictionary - act.Should().Throw(); - } + /// + /// BEHAVIORAL DIFFERENCE: Real Cosmos DB SDK throws ArgumentNullException for null id + /// parameters on GetDatabase/GetContainer. InMemoryCosmosClient may need to add these + /// guards explicitly. If the guards are not present, the ConcurrentDictionary will + /// throw ArgumentNullException on its own, which is functionally equivalent. + /// + [Fact] + public void GetDatabase_NullId_ThrowsSomeException() + { + var client = new InMemoryCosmosClient(); + var act = () => client.GetDatabase(null!); + // Will throw either ArgumentNullException (if we add guards) or from ConcurrentDictionary + act.Should().Throw(); + } } public class CreateContainerIfNotExistsPropertiesTests { - /// - /// Per SDK docs: "Only the container id is used to verify if there is an existing container. - /// Other container properties such as throughput are not validated and can be different." - /// Calling with a different partition key path should NOT update the existing container. - /// - [Fact] - public async Task CreateContainerIfNotExistsAsync_DifferentPartitionKeyPath_DoesNotUpdateExisting() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + /// + /// Per SDK docs: "Only the container id is used to verify if there is an existing container. + /// Other container properties such as throughput are not validated and can be different." + /// Calling with a different partition key path should NOT update the existing container. + /// + [Fact] + public async Task CreateContainerIfNotExistsAsync_DifferentPartitionKeyPath_DoesNotUpdateExisting() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - // Create with /pk - await db.CreateContainerAsync("container1", "/pk"); + // Create with /pk + await db.CreateContainerAsync("container1", "/pk"); - // IfNotExists with /differentPk — should NOT change the existing container - var response = await db.CreateContainerIfNotExistsAsync("container1", "/differentPk"); + // IfNotExists with /differentPk — should NOT change the existing container + var response = await db.CreateContainerIfNotExistsAsync("container1", "/differentPk"); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - // Verify the container still uses the original partition key path - var container = (InMemoryContainer)db.GetContainer("container1"); - container.PartitionKeyPaths[0].Should().Be("/pk"); - } + // Verify the container still uses the original partition key path + var container = (InMemoryContainer)db.GetContainer("container1"); + container.PartitionKeyPaths[0].Should().Be("/pk"); + } - [Fact] - public async Task CreateContainerIfNotExistsAsync_ContainerProperties_DifferentPk_DoesNotUpdate() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_ContainerProperties_DifferentPk_DoesNotUpdate() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync(new ContainerProperties("container1", "/pk")); + await db.CreateContainerAsync(new ContainerProperties("container1", "/pk")); - var response = await db.CreateContainerIfNotExistsAsync( - new ContainerProperties("container1", "/otherPk")); + var response = await db.CreateContainerIfNotExistsAsync( + new ContainerProperties("container1", "/otherPk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var container = (InMemoryContainer)db.GetContainer("container1"); - container.PartitionKeyPaths[0].Should().Be("/pk"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + var container = (InMemoryContainer)db.GetContainer("container1"); + container.PartitionKeyPaths[0].Should().Be("/pk"); + } } public class ReadAsyncDatabaseResponseTests { - [Fact] - public async Task ReadAsync_ResponseDatabase_IsSameInstance() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReadAsync_ResponseDatabase_IsSameInstance() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.ReadAsync(); + var response = await db.ReadAsync(); - response.Database.Should().BeSameAs(db); - } + response.Database.Should().BeSameAs(db); + } } public class DatabaseIdPropertyTests { - [Fact] - public void Database_Id_ReturnsConstructorValue() - { - var db = new InMemoryDatabase("my-database-id"); + [Fact] + public void Database_Id_ReturnsConstructorValue() + { + var db = new InMemoryDatabase("my-database-id"); - db.Id.Should().Be("my-database-id"); - } + db.Id.Should().Be("my-database-id"); + } - [Fact] - public void Database_Id_ViaClient_ReturnsCorrectValue() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("another-db"); + [Fact] + public void Database_Id_ViaClient_ReturnsCorrectValue() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("another-db"); - db.Id.Should().Be("another-db"); - } + db.Id.Should().Be("another-db"); + } } public class ReadThroughputDivergentTests { - /// - /// DIVERGENT BEHAVIOR: Real Cosmos DB's ReadThroughputAsync(RequestOptions) throws - /// CosmosException with StatusCode 404 when the database does not exist or has no - /// throughput assigned. - /// InMemoryDatabase always returns a synthetic 400 RU/s throughput value and never - /// throws 404, because throughput is not meaningful in an in-memory emulator. - /// - [Fact] - public async Task DivergentBehavior_ReadThroughputAsync_NeverThrows404_AlwaysReturnsSynthetic() - { - var db = new InMemoryDatabase("standalone-db"); + /// + /// DIVERGENT BEHAVIOR: Real Cosmos DB's ReadThroughputAsync(RequestOptions) throws + /// CosmosException with StatusCode 404 when the database does not exist or has no + /// throughput assigned. + /// InMemoryDatabase always returns a synthetic 400 RU/s throughput value and never + /// throws 404, because throughput is not meaningful in an in-memory emulator. + /// + [Fact] + public async Task DivergentBehavior_ReadThroughputAsync_NeverThrows404_AlwaysReturnsSynthetic() + { + var db = new InMemoryDatabase("standalone-db"); - // Even a standalone database (not registered in any client) returns throughput - var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(400); + // Even a standalone database (not registered in any client) returns throughput + var throughput = await db.ReadThroughputAsync(); + throughput.Should().Be(400); - var response = await db.ReadThroughputAsync(new RequestOptions()); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + var response = await db.ReadThroughputAsync(new RequestOptions()); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class DefineContainerBuilderDatabaseTests { - [Fact] - public async Task DefineContainer_WithoutPolicies_CreatesContainer() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task DefineContainer_WithoutPolicies_CreatesContainer() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.DefineContainer("simple-container", "/pk") - .CreateAsync(); + var response = await db.DefineContainer("simple-container", "/pk") + .CreateAsync(); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("simple-container"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("simple-container"); + } - [Fact] - public void DefineContainer_FluentBuilder_ReturnsContainerBuilder() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); + [Fact] + public void DefineContainer_FluentBuilder_ReturnsContainerBuilder() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); - var builder = db.DefineContainer("container1", "/pk"); + var builder = db.DefineContainer("container1", "/pk"); - builder.Should().NotBeNull(); - builder.Should().BeOfType(); - } + builder.Should().NotBeNull(); + builder.Should().BeOfType(); + } } public class CreateContainerStreamResponseTests { - /// - /// DIVERGENT BEHAVIOR: Real Cosmos DB includes the container properties JSON - /// in the response stream body on CreateContainerStreamAsync. - /// InMemoryDatabase returns a bare ResponseMessage with no body content. - /// - [Fact] - public async Task DivergentBehavior_CreateContainerStreamAsync_ResponseBodyIsEmpty() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + /// + /// DIVERGENT BEHAVIOR: Real Cosmos DB includes the container properties JSON + /// in the response stream body on CreateContainerStreamAsync. + /// InMemoryDatabase returns a bare ResponseMessage with no body content. + /// + [Fact] + public async Task DivergentBehavior_CreateContainerStreamAsync_ResponseBodyIsEmpty() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - using var response = await db.CreateContainerStreamAsync( - new ContainerProperties("container1", "/pk")); + using var response = await db.CreateContainerStreamAsync( + new ContainerProperties("container1", "/pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - // InMemory returns no content in the stream body (diverges from real SDK) - response.Content.Should().BeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + // InMemory returns no content in the stream body (diverges from real SDK) + response.Content.Should().BeNull(); + } } public class DatabaseManagementEdgeCaseTests { - private readonly InMemoryCosmosClient _client = new(); - - [Fact] - public async Task CreateDatabaseAsync_WithNullId_Throws() - { - var act = () => _client.CreateDatabaseAsync(null!); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseAsync_WithEmptyId_Throws() - { - var act = () => _client.CreateDatabaseAsync(""); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_Returns201_Then200() - { - var first = await _client.CreateDatabaseIfNotExistsAsync("test-db"); - first.StatusCode.Should().Be(HttpStatusCode.Created); - - var second = await _client.CreateDatabaseIfNotExistsAsync("test-db"); - second.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteAsync_NonExistentDatabase_ThrowsNotFound() - { - var db = _client.GetDatabase("nonexistent-db"); - - // GetDatabase auto-creates, so we need to remove it first then try again - // Actually InMemoryDatabase.DeleteAsync clears containers and removes from client - // but doesn't throw. Let's verify the current behavior. - // The real Cosmos DB would throw NotFound for a non-existent database. - // InMemoryCosmosClient auto-creates databases on GetDatabase, so this tests - // that after deletion, a second delete should still succeed (no-op semantics). - await db.DeleteAsync(); - - // After deletion, creating a fresh reference and deleting should not throw - // because GetDatabase re-creates it. This is a divergent behavior. - var db2 = _client.GetDatabase("nonexistent-db"); - var response = await db2.DeleteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task GetDatabaseQueryIterator_WithContinuationToken_Resumes() - { - for (var i = 0; i < 5; i++) - await _client.CreateDatabaseAsync($"db-{i}"); - - var iterator = _client.GetDatabaseQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCountGreaterThanOrEqualTo(5); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() - { - // The stream overload with int? throughput is tested. This verifies the - // method works with DatabaseProperties parameter. - var response = await _client.CreateDatabaseStreamAsync( - new DatabaseProperties("stream-db-with-tp"), throughput: 400); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private readonly InMemoryCosmosClient _client = new(); + + [Fact] + public async Task CreateDatabaseAsync_WithNullId_Throws() + { + var act = () => _client.CreateDatabaseAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseAsync_WithEmptyId_Throws() + { + var act = () => _client.CreateDatabaseAsync(""); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_Returns201_Then200() + { + var first = await _client.CreateDatabaseIfNotExistsAsync("test-db"); + first.StatusCode.Should().Be(HttpStatusCode.Created); + + var second = await _client.CreateDatabaseIfNotExistsAsync("test-db"); + second.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteAsync_NonExistentDatabase_ThrowsNotFound() + { + var db = _client.GetDatabase("nonexistent-db"); + + // GetDatabase auto-creates, so we need to remove it first then try again + // Actually InMemoryDatabase.DeleteAsync clears containers and removes from client + // but doesn't throw. Let's verify the current behavior. + // The real Cosmos DB would throw NotFound for a non-existent database. + // InMemoryCosmosClient auto-creates databases on GetDatabase, so this tests + // that after deletion, a second delete should still succeed (no-op semantics). + await db.DeleteAsync(); + + // After deletion, creating a fresh reference and deleting should not throw + // because GetDatabase re-creates it. This is a divergent behavior. + var db2 = _client.GetDatabase("nonexistent-db"); + var response = await db2.DeleteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task GetDatabaseQueryIterator_WithContinuationToken_Resumes() + { + for (var i = 0; i < 5; i++) + await _client.CreateDatabaseAsync($"db-{i}"); + + var iterator = _client.GetDatabaseQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCountGreaterThanOrEqualTo(5); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() + { + // The stream overload with int? throughput is tested. This verifies the + // method works with DatabaseProperties parameter. + var response = await _client.CreateDatabaseStreamAsync( + new DatabaseProperties("stream-db-with-tp"), throughput: 400); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } public class GetContainerSameInstanceTests { - [Fact] - public async Task GetContainer_CalledTwice_ReturnsSameContainer() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/partitionKey"); + [Fact] + public async Task GetContainer_CalledTwice_ReturnsSameContainer() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/partitionKey"); - var ref1 = db.GetContainer("container1"); - var ref2 = db.GetContainer("container1"); + var ref1 = db.GetContainer("container1"); + var ref2 = db.GetContainer("container1"); - ref1.Should().BeSameAs(ref2); - } + ref1.Should().BeSameAs(ref2); + } - [Fact] - public async Task GetContainer_DataVisibleAcrossReferences() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/partitionKey"); + [Fact] + public async Task GetContainer_DataVisibleAcrossReferences() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/partitionKey"); - var ref1 = db.GetContainer("container1"); - var ref2 = db.GetContainer("container1"); + var ref1 = db.GetContainer("container1"); + var ref2 = db.GetContainer("container1"); - // Write through ref1 - await ref1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello" }, - new PartitionKey("pk1")); + // Write through ref1 + await ref1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello" }, + new PartitionKey("pk1")); - // Read through ref2 - var read = await ref2.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Hello"); - } + // Read through ref2 + var read = await ref2.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Hello"); + } } public class CreateContainerCustomIndexingTests { - [Fact] - public async Task CreateContainerAsync_WithCustomIndexingPolicy_CreatesSuccessfully() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_WithCustomIndexingPolicy_CreatesSuccessfully() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var props = new ContainerProperties("container1", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.Lazy - } - }; + var props = new ContainerProperties("container1", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.Lazy + } + }; - var response = await db.CreateContainerAsync(props); + var response = await db.CreateContainerAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("container1"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("container1"); + } } public class ReadStreamAsyncResponseTests { - /// - /// DIVERGENT BEHAVIOR: Real Cosmos DB returns the database properties JSON in the - /// ReadStreamAsync response body. InMemoryDatabase returns a bare ResponseMessage - /// with HttpStatusCode.OK and no body content. - /// - [Fact] - public async Task DivergentBehavior_ReadStreamAsync_ResponseBodyIsEmpty() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + /// + /// DIVERGENT BEHAVIOR: Real Cosmos DB returns the database properties JSON in the + /// ReadStreamAsync response body. InMemoryDatabase returns a bare ResponseMessage + /// with HttpStatusCode.OK and no body content. + /// + [Fact] + public async Task DivergentBehavior_ReadStreamAsync_ResponseBodyIsEmpty() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - using var response = await db.ReadStreamAsync(); + using var response = await db.ReadStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - // InMemory returns no content in the stream body (diverges from real SDK) - response.Content.Should().BeNull(); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + // InMemory returns no content in the stream body (diverges from real SDK) + response.Content.Should().BeNull(); + } } public class ReadThroughputAsyncDetailedTests { - [Fact] - public async Task ReadThroughputAsync_WithRequestOptions_ReturnsOkStatusCode() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReadThroughputAsync_WithRequestOptions_ReturnsOkStatusCode() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.ReadThroughputAsync(new RequestOptions()); + var response = await db.ReadThroughputAsync(new RequestOptions()); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughput400() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReadThroughputAsync_WithRequestOptions_ReturnsThroughput400() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.ReadThroughputAsync(new RequestOptions()); + var response = await db.ReadThroughputAsync(new RequestOptions()); - response.Resource.Should().NotBeNull(); - } + response.Resource.Should().NotBeNull(); + } } public class CreateContainerReturnedContainerTests { - [Fact] - public async Task CreateContainerAsync_ReturnedContainer_IsUsableForCrud() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_ReturnedContainer_IsUsableForCrud() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.CreateContainerAsync("container1", "/partitionKey"); - var container = response.Container; + var response = await db.CreateContainerAsync("container1", "/partitionKey"); + var container = response.Container; - container.Should().NotBeNull(); + container.Should().NotBeNull(); - // Use the returned container to create and read an item - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await container.CreateItemAsync(item, new PartitionKey("pk1")); + // Use the returned container to create and read an item + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await container.CreateItemAsync(item, new PartitionKey("pk1")); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } } public class GetContainerQueryIteratorPartitionKeyTests { - [Fact] - public async Task GetContainerQueryIterator_ReturnsActualPartitionKeyPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task GetContainerQueryIterator_ReturnsActualPartitionKeyPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("users", "/userId"); - await db.CreateContainerAsync("orders", "/orderId"); + await db.CreateContainerAsync("users", "/userId"); + await db.CreateContainerAsync("orders", "/orderId"); - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } - containers.Should().HaveCount(2); - var users = containers.Single(c => c.Id == "users"); - var orders = containers.Single(c => c.Id == "orders"); + containers.Should().HaveCount(2); + var users = containers.Single(c => c.Id == "users"); + var orders = containers.Single(c => c.Id == "orders"); - users.PartitionKeyPath.Should().Be("/userId"); - orders.PartitionKeyPath.Should().Be("/orderId"); - } + users.PartitionKeyPath.Should().Be("/userId"); + orders.PartitionKeyPath.Should().Be("/orderId"); + } - [Fact] - public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsActualPkPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task GetContainerQueryIterator_WithQueryDefinition_ReturnsActualPkPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("myContainer", "/tenantId"); + await db.CreateContainerAsync("myContainer", "/tenantId"); - var queryDef = new QueryDefinition("SELECT * FROM c"); - var iterator = db.GetContainerQueryIterator(queryDef); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } + var queryDef = new QueryDefinition("SELECT * FROM c"); + var iterator = db.GetContainerQueryIterator(queryDef); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } - containers.Should().ContainSingle() - .Which.PartitionKeyPath.Should().Be("/tenantId"); - } + containers.Should().ContainSingle() + .Which.PartitionKeyPath.Should().Be("/tenantId"); + } } public class CreateContainerResponseResourceTests { - [Fact] - public async Task CreateContainerAsync_ResponseResource_HasCorrectPartitionKeyPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_ResponseResource_HasCorrectPartitionKeyPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.CreateContainerAsync("container1", "/myPartitionKey"); + var response = await db.CreateContainerAsync("container1", "/myPartitionKey"); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("container1"); - response.Resource.PartitionKeyPath.Should().Be("/myPartitionKey"); - } + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("container1"); + response.Resource.PartitionKeyPath.Should().Be("/myPartitionKey"); + } - [Fact] - public async Task CreateContainerAsync_WithContainerProperties_ResponseHasCorrectPkPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_WithContainerProperties_ResponseHasCorrectPkPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.CreateContainerAsync( - new ContainerProperties("container1", "/category")); + var response = await db.CreateContainerAsync( + new ContainerProperties("container1", "/category")); - response.Resource.PartitionKeyPath.Should().Be("/category"); - } + response.Resource.PartitionKeyPath.Should().Be("/category"); + } - [Fact] - public async Task CreateContainerIfNotExistsAsync_ResponseResource_HasCorrectPkPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_ResponseResource_HasCorrectPkPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var response = await db.CreateContainerIfNotExistsAsync("container1", "/region"); + var response = await db.CreateContainerIfNotExistsAsync("container1", "/region"); - response.Resource.PartitionKeyPath.Should().Be("/region"); - } + response.Resource.PartitionKeyPath.Should().Be("/region"); + } } public class GetContainerQueryStreamIteratorOverloadTests { - [Fact] - public async Task GetContainerQueryStreamIterator_WithQueryDefinition_ReturnsContainers() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); + [Fact] + public async Task GetContainerQueryStreamIterator_WithQueryDefinition_ReturnsContainers() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); - var queryDef = new QueryDefinition("SELECT * FROM c"); - var iterator = db.GetContainerQueryStreamIterator(queryDef); + var queryDef = new QueryDefinition("SELECT * FROM c"); + var iterator = db.GetContainerQueryStreamIterator(queryDef); - var responses = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - responses.Add(response); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - responses.Should().NotBeEmpty(); - } + var responses = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + responses.Add(response); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + responses.Should().NotBeEmpty(); + } } public class DeleteStreamAsyncContainerTests { - [Fact] - public async Task DeleteStreamAsync_RemovesAllContainersInDatabase() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("container1", "/pk"); - var container = db.GetContainer("container1"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public async Task DeleteStreamAsync_RemovesAllContainersInDatabase() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("container1", "/pk"); + var container = db.GetContainer("container1"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - await db.DeleteStreamAsync(); + await db.DeleteStreamAsync(); - // Re-create database and container — should be empty - await client.CreateDatabaseAsync("test-db"); - var newDb = client.GetDatabase("test-db"); - await newDb.CreateContainerAsync("container1", "/pk"); - var newContainer = newDb.GetContainer("container1"); + // Re-create database and container — should be empty + await client.CreateDatabaseAsync("test-db"); + var newDb = client.GetDatabase("test-db"); + await newDb.CreateContainerAsync("container1", "/pk"); + var newContainer = newDb.GetContainer("container1"); - var act = () => newContainer.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => newContainer.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } } // ─── DeleteContainerAsync Removes From Parent DB ──────────────────────── public class DeleteContainerParentDbTests { - /// - /// In real Cosmos DB, deleting a container removes it from the database's container list. - /// In the emulator, DeleteContainerAsync clears internal data but does not remove itself - /// from the parent InMemoryDatabase._containers dictionary. - /// - [Fact] - public async Task DeleteContainer_ShouldRemoveFromDatabase_ContainerList() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); + /// + /// In real Cosmos DB, deleting a container removes it from the database's container list. + /// In the emulator, DeleteContainerAsync clears internal data but does not remove itself + /// from the parent InMemoryDatabase._containers dictionary. + /// + [Fact] + public async Task DeleteContainer_ShouldRemoveFromDatabase_ContainerList() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); - var container = db.GetContainer("ctr1"); - await container.DeleteContainerAsync(); + var container = db.GetContainer("ctr1"); + await container.DeleteContainerAsync(); - // After deletion, the container should not be listed - var iterator = db.GetContainerQueryIterator("SELECT * FROM c"); - var containers = new List(); - while (iterator.HasMoreResults) - containers.AddRange(await iterator.ReadNextAsync()); + // After deletion, the container should not be listed + var iterator = db.GetContainerQueryIterator("SELECT * FROM c"); + var containers = new List(); + while (iterator.HasMoreResults) + containers.AddRange(await iterator.ReadNextAsync()); - containers.Should().NotContain(c => c.Id == "ctr1"); - } + containers.Should().NotContain(c => c.Id == "ctr1"); + } } // ─── Client Encryption Key Operations ─────────────────────────────────── public class ClientEncryptionKeyTests { - /// - /// Client encryption key management (CreateClientEncryptionKeyAsync, - /// RewrapClientEncryptionKeyAsync, ReadClientEncryptionKeyAsync) requires integration - /// with Azure Key Vault and the Microsoft Data Encryption (MDE) SDK. These operations - /// manage envelope encryption where a data encryption key (DEK) is wrapped by a - /// customer-managed key (CMK) stored in Key Vault. - /// - [Fact(Skip = "Client encryption key operations require Azure Key Vault integration and " + - "the Microsoft Data Encryption SDK (MDE). CreateClientEncryptionKeyAsync wraps a " + - "data encryption key (DEK) with a customer-managed key from Key Vault. " + - "ReadClientEncryptionKeyAsync and RewrapClientEncryptionKeyAsync manage the DEK " + - "lifecycle. These deep SDK internals (EncryptionKeyWrapProvider, DataEncryptionKey) " + - "are not meaningful without actual Key Vault access. " + - "InMemoryDatabase currently throws NotImplementedException for these methods.")] - public async Task CreateClientEncryptionKey_ShouldCreateAndStoreKey() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - - // Real code would need: ClientEncryptionKeyProperties with EncryptionAlgorithm, - // KeyWrapMetadata pointing to Key Vault. The emulator throws NotImplementedException. - await db.CreateClientEncryptionKeyAsync( - new ClientEncryptionKeyProperties("dek1", "AEAD_AES_256_CBC_HMAC_SHA256", - new byte[] { 0x01, 0x02, 0x03 }, - new EncryptionKeyWrapMetadata("akvso", "masterkey1", "https://vault.azure.net/keys/key1/1", "RSA-OAEP"))); - } + /// + /// Client encryption key management (CreateClientEncryptionKeyAsync, + /// RewrapClientEncryptionKeyAsync, ReadClientEncryptionKeyAsync) requires integration + /// with Azure Key Vault and the Microsoft Data Encryption (MDE) SDK. These operations + /// manage envelope encryption where a data encryption key (DEK) is wrapped by a + /// customer-managed key (CMK) stored in Key Vault. + /// + [Fact(Skip = "Client encryption key operations require Azure Key Vault integration and " + + "the Microsoft Data Encryption SDK (MDE). CreateClientEncryptionKeyAsync wraps a " + + "data encryption key (DEK) with a customer-managed key from Key Vault. " + + "ReadClientEncryptionKeyAsync and RewrapClientEncryptionKeyAsync manage the DEK " + + "lifecycle. These deep SDK internals (EncryptionKeyWrapProvider, DataEncryptionKey) " + + "are not meaningful without actual Key Vault access. " + + "InMemoryDatabase currently throws NotImplementedException for these methods.")] + public async Task CreateClientEncryptionKey_ShouldCreateAndStoreKey() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + // Real code would need: ClientEncryptionKeyProperties with EncryptionAlgorithm, + // KeyWrapMetadata pointing to Key Vault. The emulator throws NotImplementedException. + await db.CreateClientEncryptionKeyAsync( + new ClientEncryptionKeyProperties("dek1", "AEAD_AES_256_CBC_HMAC_SHA256", + new byte[] { 0x01, 0x02, 0x03 }, + new EncryptionKeyWrapMetadata("akvso", "masterkey1", "https://vault.azure.net/keys/key1/1", "RSA-OAEP"))); + } } @@ -2017,47 +2017,47 @@ await db.CreateClientEncryptionKeyAsync( public class DeleteAsyncClearsUsersTests { - [Fact] - public async Task DeleteAsync_ClearsUsersFromDatabase() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - await db.CreateUserAsync("user2"); - - await db.DeleteAsync(); - - var iterator = db.GetUserQueryIterator(); - var users = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - users.AddRange(page); - } - users.Should().BeEmpty(); - } - - [Fact] - public async Task DeleteStreamAsync_ClearsUsersFromDatabase() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - await db.CreateUserAsync("user2"); - - await db.DeleteStreamAsync(); - - var iterator = db.GetUserQueryIterator(); - var users = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - users.AddRange(page); - } - users.Should().BeEmpty(); - } + [Fact] + public async Task DeleteAsync_ClearsUsersFromDatabase() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + await db.CreateUserAsync("user2"); + + await db.DeleteAsync(); + + var iterator = db.GetUserQueryIterator(); + var users = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + users.AddRange(page); + } + users.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteStreamAsync_ClearsUsersFromDatabase() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + await db.CreateUserAsync("user2"); + + await db.DeleteStreamAsync(); + + var iterator = db.GetUserQueryIterator(); + var users = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + users.AddRange(page); + } + users.Should().BeEmpty(); + } } @@ -2067,111 +2067,111 @@ public async Task DeleteStreamAsync_ClearsUsersFromDatabase() public class ConcurrentDatabaseCreationTests { - [Fact] - public async Task ConcurrentCreateDatabaseAsync_DifferentIds_AllSucceed() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task ConcurrentCreateDatabaseAsync_DifferentIds_AllSucceed() + { + var client = new InMemoryCosmosClient(); - var tasks = Enumerable.Range(0, 20).Select(i => - client.CreateDatabaseAsync($"db-{i}")); + var tasks = Enumerable.Range(0, 20).Select(i => + client.CreateDatabaseAsync($"db-{i}")); - var responses = await Task.WhenAll(tasks); + var responses = await Task.WhenAll(tasks); - responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - var uniqueIds = responses.Select(r => r.Resource.Id).Distinct(); - uniqueIds.Should().HaveCount(20); - } + responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + var uniqueIds = responses.Select(r => r.Resource.Id).Distinct(); + uniqueIds.Should().HaveCount(20); + } - [Fact] - public async Task ConcurrentCreateDatabaseIfNotExistsAsync_SameId_OnlyOneCreated() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task ConcurrentCreateDatabaseIfNotExistsAsync_SameId_OnlyOneCreated() + { + var client = new InMemoryCosmosClient(); - var tasks = Enumerable.Range(0, 20).Select(_ => - client.CreateDatabaseIfNotExistsAsync("shared-db")); + var tasks = Enumerable.Range(0, 20).Select(_ => + client.CreateDatabaseIfNotExistsAsync("shared-db")); - var responses = await Task.WhenAll(tasks); + var responses = await Task.WhenAll(tasks); - responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); - responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); - } + responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); + responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); + } } public class DatabaseQueryIteratorAfterDeleteTests { - [Fact] - public async Task GetDatabaseQueryIterator_AfterDatabaseDelete_NoLongerListed() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); + [Fact] + public async Task GetDatabaseQueryIterator_AfterDatabaseDelete_NoLongerListed() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); - var db1 = client.GetDatabase("db1"); - await db1.DeleteAsync(); + var db1 = client.GetDatabase("db1"); + await db1.DeleteAsync(); - var iterator = client.GetDatabaseQueryIterator(); - var databases = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - databases.AddRange(page); - } + var iterator = client.GetDatabaseQueryIterator(); + var databases = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + databases.AddRange(page); + } - databases.Should().ContainSingle().Which.Id.Should().Be("db2"); - } + databases.Should().ContainSingle().Which.Id.Should().Be("db2"); + } } public class GetDatabaseSameInstanceTests { - [Fact] - public void GetDatabase_CalledTwice_ReturnsSameInstance() - { - var client = new InMemoryCosmosClient(); + [Fact] + public void GetDatabase_CalledTwice_ReturnsSameInstance() + { + var client = new InMemoryCosmosClient(); - var ref1 = client.GetDatabase("my-db"); - var ref2 = client.GetDatabase("my-db"); + var ref1 = client.GetDatabase("my-db"); + var ref2 = client.GetDatabase("my-db"); - ref1.Should().BeSameAs(ref2); - } + ref1.Should().BeSameAs(ref2); + } } public class GetDatabaseQueryStreamIteratorOverloadTests { - [Fact] - public async Task GetDatabaseQueryStreamIterator_WithQueryDefinition_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); + [Fact] + public async Task GetDatabaseQueryStreamIterator_WithQueryDefinition_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); - var queryDef = new QueryDefinition("SELECT * FROM c"); - var iterator = client.GetDatabaseQueryStreamIterator(queryDef); + var queryDef = new QueryDefinition("SELECT * FROM c"); + var iterator = client.GetDatabaseQueryStreamIterator(queryDef); - var responses = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - responses.Add(response); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - responses.Should().NotBeEmpty(); - } + var responses = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + responses.Add(response); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + responses.Should().NotBeEmpty(); + } } public class ReadAccountAsyncDetailedTests { - [Fact] - public async Task ReadAccountAsync_ReturnsIdAsInMemoryEmulator() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task ReadAccountAsync_ReturnsIdAsInMemoryEmulator() + { + var client = new InMemoryCosmosClient(); - var account = await client.ReadAccountAsync(); + var account = await client.ReadAccountAsync(); - account.Id.Should().Be("in-memory-emulator"); - } + account.Id.Should().Be("in-memory-emulator"); + } } @@ -2181,175 +2181,175 @@ public async Task ReadAccountAsync_ReturnsIdAsInMemoryEmulator() public class CreateContainerInputValidationTests { - [Fact] - public async Task CreateContainerAsync_NullPartitionKeyPath_Throws() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_NullPartitionKeyPath_Throws() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var act = () => db.CreateContainerAsync("container1", null!); + var act = () => db.CreateContainerAsync("container1", null!); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task CreateContainerAsync_NullId_Throws() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_NullId_Throws() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var act = () => db.CreateContainerAsync(null!, "/pk"); + var act = () => db.CreateContainerAsync(null!, "/pk"); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task CreateContainerAsync_EmptyId_Throws() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerAsync_EmptyId_Throws() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var act = () => db.CreateContainerAsync("", "/pk"); + var act = () => db.CreateContainerAsync("", "/pk"); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } } public class CreateContainerIfNotExistsNullPkFallbackTests { - [Fact] - public async Task CreateContainerIfNotExistsAsync_ContainerProperties_NullPkPath_DefaultsToId() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_ContainerProperties_NullPkPath_DefaultsToId() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var props = new ContainerProperties { Id = "container1" }; - var response = await db.CreateContainerIfNotExistsAsync(props); + var props = new ContainerProperties { Id = "container1" }; + var response = await db.CreateContainerIfNotExistsAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.Created); - var container = (InMemoryContainer)db.GetContainer("container1"); - container.PartitionKeyPaths[0].Should().Be("/id"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + var container = (InMemoryContainer)db.GetContainer("container1"); + container.PartitionKeyPaths[0].Should().Be("/id"); + } } public class DeleteThenReuseReferenceTests { - [Fact] - public async Task DeleteAsync_ThenCreateContainer_OnSameReference_ShouldWork() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateContainerAsync("c1", "/pk"); + [Fact] + public async Task DeleteAsync_ThenCreateContainer_OnSameReference_ShouldWork() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateContainerAsync("c1", "/pk"); - await db.DeleteAsync(); + await db.DeleteAsync(); - // Containers were cleared, so we can create a new one - var response = await db.CreateContainerAsync("c2", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + // Containers were cleared, so we can create a new one + var response = await db.CreateContainerAsync("c2", "/pk"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } public class StandaloneDatabaseTests { - [Fact] - public async Task DeleteAsync_StandaloneDatabase_NoClient_DoesNotThrow() - { - var db = new InMemoryDatabase("standalone-db"); + [Fact] + public async Task DeleteAsync_StandaloneDatabase_NoClient_DoesNotThrow() + { + var db = new InMemoryDatabase("standalone-db"); - var response = await db.DeleteAsync(); + var response = await db.DeleteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } - [Fact] - public void StandaloneDatabase_Client_ReturnsNull() - { - var db = new InMemoryDatabase("standalone-db"); + [Fact] + public void StandaloneDatabase_Client_ReturnsNull() + { + var db = new InMemoryDatabase("standalone-db"); - db.Client.Should().BeNull(); - } + db.Client.Should().BeNull(); + } } public class UserQueryIteratorEmptyTests { - [Fact] - public async Task GetUserQueryIterator_EmptyDatabase_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task GetUserQueryIterator_EmptyDatabase_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - var iterator = db.GetUserQueryIterator(); - var users = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - users.AddRange(page); - } + var iterator = db.GetUserQueryIterator(); + var users = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + users.AddRange(page); + } - users.Should().BeEmpty(); - } + users.Should().BeEmpty(); + } } public class PermissionErrorHandlingTests { - [Fact] - public async Task Permission_ReadAsync_NonExistent_ThrowsNotFound() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var perm = user.GetPermission("nonexistent"); + [Fact] + public async Task Permission_ReadAsync_NonExistent_ThrowsNotFound() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var perm = user.GetPermission("nonexistent"); - var act = () => perm.ReadAsync(); + var act = () => perm.ReadAsync(); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } - [Fact] - public async Task Permission_ReplaceAsync_NonExistent_ThrowsNotFound() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - var perm = user.GetPermission("nonexistent"); - var container = new InMemoryContainer("my-container", "/pk"); + [Fact] + public async Task Permission_ReplaceAsync_NonExistent_ThrowsNotFound() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + var perm = user.GetPermission("nonexistent"); + var container = new InMemoryContainer("my-container", "/pk"); - var act = () => perm.ReplaceAsync(new PermissionProperties("nonexistent", PermissionMode.All, container)); + var act = () => perm.ReplaceAsync(new PermissionProperties("nonexistent", PermissionMode.All, container)); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } public class CreateDatabaseResponseUsabilityTests { - [Fact] - public async Task CreateDatabaseAsync_ResponseDatabase_IsUsable() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task CreateDatabaseAsync_ResponseDatabase_IsUsable() + { + var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("test-db"); + var response = await client.CreateDatabaseAsync("test-db"); - response.Database.Should().NotBeNull(); - response.Database.Should().BeSameAs(client.GetDatabase("test-db")); - } + response.Database.Should().NotBeNull(); + response.Database.Should().BeSameAs(client.GetDatabase("test-db")); + } } @@ -2359,68 +2359,68 @@ public async Task CreateDatabaseAsync_ResponseDatabase_IsUsable() public class DisposeAndContinueDivergentTests { - [Fact] - public async Task Dispose_ThenCreateDatabase_ShouldThrowObjectDisposed() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); + [Fact] + public async Task Dispose_ThenCreateDatabase_ShouldThrowObjectDisposed() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); - // Real SDK: ObjectDisposedException - var act = () => client.CreateDatabaseAsync("test-db"); - await act.Should().ThrowAsync(); - } + // Real SDK: ObjectDisposedException + var act = () => client.CreateDatabaseAsync("test-db"); + await act.Should().ThrowAsync(); + } } public class GetUserAutoCreateDivergentTests { - [Fact] - public async Task GetUser_NonExistent_ReadAsync_ThrowsNotFound() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task GetUser_NonExistent_ReadAsync_ThrowsNotFound() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); - var user = db.GetUser("nonexistent-user"); + var user = db.GetUser("nonexistent-user"); - // GetUser now returns a proxy — ReadAsync throws 404 for non-existent users - var act = () => user.ReadAsync(); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + // GetUser now returns a proxy — ReadAsync throws 404 for non-existent users + var act = () => user.ReadAsync(); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } - /// - /// Explicitly created users can be read via GetUser. - /// - [Fact] - public async Task GetUser_AfterCreate_ReadAsyncSucceeds() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("test-db"); + /// + /// Explicitly created users can be read via GetUser. + /// + [Fact] + public async Task GetUser_AfterCreate_ReadAsyncSucceeds() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("test-db"); - await ((InMemoryDatabase)db).CreateUserAsync("created-user"); - var user = db.GetUser("created-user"); + await ((InMemoryDatabase)db).CreateUserAsync("created-user"); + var user = db.GetUser("created-user"); - var response = await user.ReadAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("created-user"); - } + var response = await user.ReadAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("created-user"); + } } public class ThroughputNotPersistedDivergentTests { - [Fact] - public async Task ReplaceThroughputAsync_ThenRead_ShouldReturnNewValue() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("test-db"); - var db = client.GetDatabase("test-db"); + [Fact] + public async Task ReplaceThroughputAsync_ThenRead_ShouldReturnNewValue() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("test-db"); + var db = client.GetDatabase("test-db"); - await db.ReplaceThroughputAsync(1000); + await db.ReplaceThroughputAsync(1000); - var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(1000); - } + var throughput = await db.ReadThroughputAsync(); + throughput.Should().Be(1000); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2429,44 +2429,44 @@ public async Task ReplaceThroughputAsync_ThenRead_ShouldReturnNewValue() public class CreateDatabaseStreamThroughputPropertiesTests { - [Fact] - public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() - { - var client = new InMemoryCosmosClient(); - var props = new DatabaseProperties("test-db"); - var tp = ThroughputProperties.CreateManualThroughput(1000); + [Fact] + public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() + { + var client = new InMemoryCosmosClient(); + var props = new DatabaseProperties("test-db"); + var tp = ThroughputProperties.CreateManualThroughput(1000); - var response = await client.CreateDatabaseStreamAsync(props, tp); + var response = await client.CreateDatabaseStreamAsync(props, tp); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task CreateDatabaseStreamAsync_WithThroughputProperties_DuplicateId_ReturnsConflict() - { - var client = new InMemoryCosmosClient(); - var props = new DatabaseProperties("test-db"); - var tp = ThroughputProperties.CreateManualThroughput(1000); + [Fact] + public async Task CreateDatabaseStreamAsync_WithThroughputProperties_DuplicateId_ReturnsConflict() + { + var client = new InMemoryCosmosClient(); + var props = new DatabaseProperties("test-db"); + var tp = ThroughputProperties.CreateManualThroughput(1000); - await client.CreateDatabaseStreamAsync(props, tp); - var response = await client.CreateDatabaseStreamAsync(props, tp); + await client.CreateDatabaseStreamAsync(props, tp); + var response = await client.CreateDatabaseStreamAsync(props, tp); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } public class ClientEncryptionKeyQueryIteratorTests { - [Fact] - public async Task GetClientEncryptionKeyQueryIterator_ThrowsNotImplemented() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task GetClientEncryptionKeyQueryIterator_ThrowsNotImplemented() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - var act = () => db.GetClientEncryptionKeyQueryIterator(new QueryDefinition("SELECT * FROM c")); + var act = () => db.GetClientEncryptionKeyQueryIterator(new QueryDefinition("SELECT * FROM c")); - act.Should().Throw(); - } + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2475,88 +2475,88 @@ public async Task GetClientEncryptionKeyQueryIterator_ThrowsNotImplemented() public class DatabaseNameSpecialCharsTests { - [Fact] - public async Task CreateDatabaseAsync_NameWithSpaces_Succeeds() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("my database"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseAsync_NameWithUnicode_Succeeds() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("données"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseAsync_NameWithEmoji_Succeeds() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("test-🚀-db"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task CreateDatabaseAsync_NameWithSpaces_Succeeds() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("my database"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseAsync_NameWithUnicode_Succeeds() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("données"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseAsync_NameWithEmoji_Succeeds() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("test-🚀-db"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } public class DatabaseNameForbiddenCharsDivergentTests { - [Fact] - public async Task CreateDatabaseAsync_NameWithForwardSlash_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseAsync("db/name"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseAsync_NameWithBackslash_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseAsync("db\\name"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseAsync_NameWithHash_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseAsync("db#name"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseAsync_NameWithQuestionMark_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseAsync("db?name"); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateDatabaseAsync_NameWithForwardSlash_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseAsync("db/name"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseAsync_NameWithBackslash_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseAsync("db\\name"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseAsync_NameWithHash_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseAsync("db#name"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseAsync_NameWithQuestionMark_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseAsync("db?name"); + await act.Should().ThrowAsync(); + } } public class DatabaseNameLengthDivergentTests { - [Fact] - public async Task CreateDatabaseAsync_NameExceeds255Chars_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var longName = new string('a', 256); - var act = () => client.CreateDatabaseAsync(longName); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateDatabaseAsync_NameExceeds255Chars_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var longName = new string('a', 256); + var act = () => client.CreateDatabaseAsync(longName); + await act.Should().ThrowAsync(); + } } public class ContainerNameForbiddenCharsDivergentTests { - [Fact] - public async Task CreateContainerAsync_NameWithForwardSlash_ShouldThrowBadRequest() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - var act = () => db.CreateContainerAsync("ctr/name", "/pk"); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateContainerAsync_NameWithForwardSlash_ShouldThrowBadRequest() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + var act = () => db.CreateContainerAsync("ctr/name", "/pk"); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2565,113 +2565,113 @@ public async Task CreateContainerAsync_NameWithForwardSlash_ShouldThrowBadReques public class ReadAfterDeleteDivergentTests { - [Fact] - public async Task ReadAsync_AfterDelete_ShouldThrowNotFound() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.DeleteAsync(); + [Fact] + public async Task ReadAsync_AfterDelete_ShouldThrowNotFound() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.DeleteAsync(); - var act = () => db.ReadAsync(); - await act.Should().ThrowAsync(); - } + var act = () => db.ReadAsync(); + await act.Should().ThrowAsync(); + } } public class ConcurrentDeleteDatabaseTests { - [Fact] - public async Task ConcurrentDeleteAsync_SameDatabase_AllSucceed() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task ConcurrentDeleteAsync_SameDatabase_AllSucceed() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var tasks = Enumerable.Range(0, 10).Select(_ => db.DeleteAsync()); - var results = await Task.WhenAll(tasks); + var tasks = Enumerable.Range(0, 10).Select(_ => db.DeleteAsync()); + var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); - } + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.NoContent); + } } public class ContainerOpsAfterDbDeleteDivergentTests { - [Fact] - public async Task ContainerOps_AfterDatabaseDelete_ShouldThrowNotFound() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - await db.DeleteAsync(); - - var act = () => db.GetContainer("ctr1").ReadContainerAsync(); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DivergentBehavior_ContainerOps_AfterDatabaseDelete_ContainersCleared() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - await db.DeleteAsync(); - - // Containers are cleared but the DB object is alive — GetContainer auto-creates - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - containers.Should().BeEmpty("containers were cleared by DeleteAsync"); - } + [Fact] + public async Task ContainerOps_AfterDatabaseDelete_ShouldThrowNotFound() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + await db.DeleteAsync(); + + var act = () => db.GetContainer("ctr1").ReadContainerAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DivergentBehavior_ContainerOps_AfterDatabaseDelete_ContainersCleared() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + await db.DeleteAsync(); + + // Containers are cleared but the DB object is alive — GetContainer auto-creates + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + containers.Should().BeEmpty("containers were cleared by DeleteAsync"); + } } public class GetContainerAutoCreateDivergentTests { - [Fact] - public async Task GetContainer_NonExistent_ShouldBeProxyOnly() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - var container = db.GetContainer("nonexistent"); + [Fact] + public async Task GetContainer_NonExistent_ShouldBeProxyOnly() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + var container = db.GetContainer("nonexistent"); - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync(); - } + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task DivergentBehavior_GetContainer_AutoCreatesContainer() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - var container = db.GetContainer("auto-created"); + [Fact] + public async Task DivergentBehavior_GetContainer_AutoCreatesContainer() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + var container = db.GetContainer("auto-created"); - // Emulator auto-creates the container — CRUD works immediately - await container.CreateItemAsync( - JObject.FromObject(new { id = "1" }), new PartitionKey("1")); + // Emulator auto-creates the container — CRUD works immediately + await container.CreateItemAsync( + JObject.FromObject(new { id = "1" }), new PartitionKey("1")); - var item = await container.ReadItemAsync("1", new PartitionKey("1")); - item.StatusCode.Should().Be(HttpStatusCode.OK); - } + var item = await container.ReadItemAsync("1", new PartitionKey("1")); + item.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class CreateDbIfNotExistsNullGuardTests { - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_NullId_Throws() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseIfNotExistsAsync(null!); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_NullId_Throws() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseIfNotExistsAsync(null!); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_EmptyId_Throws() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseIfNotExistsAsync(""); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_EmptyId_Throws() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseIfNotExistsAsync(""); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2680,85 +2680,85 @@ public async Task CreateDatabaseIfNotExistsAsync_EmptyId_Throws() public class QueryIteratorFilteringDivergentTests { - [Fact] - public async Task GetDatabaseQueryIterator_WithWhereClause_ShouldFilter() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - var iterator = client.GetDatabaseQueryIterator( - "SELECT * FROM c WHERE c.id = 'db1'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(d => d.Id == "db1"); - } - - [Fact] - public async Task GetContainerQueryIterator_WithWhereClause_ShouldFilter() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - await db.CreateContainerAsync("ctr2", "/pk"); - - var iterator = db.GetContainerQueryIterator( - "SELECT * FROM c WHERE c.id = 'ctr1'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(c => c.Id == "ctr1"); - } + [Fact] + public async Task GetDatabaseQueryIterator_WithWhereClause_ShouldFilter() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + var iterator = client.GetDatabaseQueryIterator( + "SELECT * FROM c WHERE c.id = 'db1'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(d => d.Id == "db1"); + } + + [Fact] + public async Task GetContainerQueryIterator_WithWhereClause_ShouldFilter() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + await db.CreateContainerAsync("ctr2", "/pk"); + + var iterator = db.GetContainerQueryIterator( + "SELECT * FROM c WHERE c.id = 'ctr1'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(c => c.Id == "ctr1"); + } } public class QueryIteratorPagingDivergentTests { - [Fact] - public async Task GetDatabaseQueryIterator_WithMaxItemCount_Pages() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - await client.CreateDatabaseAsync("db3"); + [Fact] + public async Task GetDatabaseQueryIterator_WithMaxItemCount_Pages() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + await client.CreateDatabaseAsync("db3"); - var iterator = client.GetDatabaseQueryIterator( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().Be(1); - } + var iterator = client.GetDatabaseQueryIterator( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().Be(1); + } } public class QueryIteratorContinuationTokenDivergentTests { - [Fact] - public async Task GetDatabaseQueryIterator_WithContinuationToken_Resumes() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - // First page with MaxItemCount=1 - var iterator = client.GetDatabaseQueryIterator( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().Be(1); - - // Resume from continuation token - var token = firstPage.ContinuationToken; - var iterator2 = client.GetDatabaseQueryIterator( - continuationToken: token, - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var secondPage = await iterator2.ReadNextAsync(); - secondPage.Count.Should().Be(1); - secondPage.First().Id.Should().NotBe(firstPage.First().Id); - } + [Fact] + public async Task GetDatabaseQueryIterator_WithContinuationToken_Resumes() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + // First page with MaxItemCount=1 + var iterator = client.GetDatabaseQueryIterator( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().Be(1); + + // Resume from continuation token + var token = firstPage.ContinuationToken; + var iterator2 = client.GetDatabaseQueryIterator( + continuationToken: token, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var secondPage = await iterator2.ReadNextAsync(); + secondPage.Count.Should().Be(1); + secondPage.First().Id.Should().NotBe(firstPage.First().Id); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2767,25 +2767,25 @@ public async Task GetDatabaseQueryIterator_WithContinuationToken_Resumes() public class CreateDatabaseStreamResponseBodyDivergentTests { - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseBody_ShouldContainJson() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("test-db")); - response.Content.Should().NotBeNull(); - } + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseBody_ShouldContainJson() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("test-db")); + response.Content.Should().NotBeNull(); + } } public class DeleteStreamAsyncResponseBodyDivergentTests { - [Fact] - public async Task DivergentBehavior_DeleteStreamAsync_ResponseBodyIsNull() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - var response = await db.DeleteStreamAsync(); - response.Content.Should().BeNull("emulator returns ResponseMessage with no content body"); - } + [Fact] + public async Task DivergentBehavior_DeleteStreamAsync_ResponseBodyIsNull() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + var response = await db.DeleteStreamAsync(); + response.Content.Should().BeNull("emulator returns ResponseMessage with no content body"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2794,59 +2794,59 @@ public async Task DivergentBehavior_DeleteStreamAsync_ResponseBodyIsNull() public class PermissionDeleteNonExistentDivergentTests { - [Fact] - public async Task Permission_DeleteAsync_NonExistent_ShouldThrowNotFound() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - var user = (await db.CreateUserAsync("user1")).User; - await user.CreatePermissionAsync(new PermissionProperties("p1", PermissionMode.All, db.GetContainer("c1"))); + [Fact] + public async Task Permission_DeleteAsync_NonExistent_ShouldThrowNotFound() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + var user = (await db.CreateUserAsync("user1")).User; + await user.CreatePermissionAsync(new PermissionProperties("p1", PermissionMode.All, db.GetContainer("c1"))); - var perm = user.GetPermission("p1"); - await perm.DeleteAsync(); + var perm = user.GetPermission("p1"); + await perm.DeleteAsync(); - var act = () => perm.DeleteAsync(); - await act.Should().ThrowAsync(); - } + var act = () => perm.DeleteAsync(); + await act.Should().ThrowAsync(); + } } public class PermissionQueryDefinitionOverloadTests { - [Fact] - public async Task Permission_GetQueryIterator_QueryDefinition_ReturnsAll() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - var user = (await db.CreateUserAsync("user1")).User; - await user.CreatePermissionAsync(new PermissionProperties("p1", PermissionMode.All, db.GetContainer("c1"))); - await user.CreatePermissionAsync(new PermissionProperties("p2", PermissionMode.Read, db.GetContainer("c2"))); + [Fact] + public async Task Permission_GetQueryIterator_QueryDefinition_ReturnsAll() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + var user = (await db.CreateUserAsync("user1")).User; + await user.CreatePermissionAsync(new PermissionProperties("p1", PermissionMode.All, db.GetContainer("c1"))); + await user.CreatePermissionAsync(new PermissionProperties("p2", PermissionMode.Read, db.GetContainer("c2"))); - var iterator = user.GetPermissionQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = user.GetPermissionQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().HaveCount(2); - } + results.Should().HaveCount(2); + } } public class UserReplaceIdDivergentTests { - [Fact] - public async Task User_ReplaceAsync_ChangingId_ShouldPreserveConsistency() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - // Real SDK would reject this - var act = () => user.ReplaceAsync(new UserProperties("user1-renamed")); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task User_ReplaceAsync_ChangingId_ShouldPreserveConsistency() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + // Real SDK would reject this + var act = () => user.ReplaceAsync(new UserProperties("user1-renamed")); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2855,16 +2855,16 @@ public async Task User_ReplaceAsync_ChangingId_ShouldPreserveConsistency() public class DatabaseCancellationTokenDivergentTests { - [Fact] - public async Task CreateDatabaseAsync_WithCancelledToken_ShouldThrow() - { - var client = new InMemoryCosmosClient(); - using var cts = new CancellationTokenSource(); - cts.Cancel(); + [Fact] + public async Task CreateDatabaseAsync_WithCancelledToken_ShouldThrow() + { + var client = new InMemoryCosmosClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); - var act = () => client.CreateDatabaseAsync("test-db", cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } + var act = () => client.CreateDatabaseAsync("test-db", cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2873,59 +2873,59 @@ public async Task CreateDatabaseAsync_WithCancelledToken_ShouldThrow() public class MultiDatabaseIsolationTests { - [Fact] - public async Task SameContainerName_DifferentDatabases_Isolated() - { - var client = new InMemoryCosmosClient(); - var db1 = (await client.CreateDatabaseAsync("db1")).Database; - var db2 = (await client.CreateDatabaseAsync("db2")).Database; + [Fact] + public async Task SameContainerName_DifferentDatabases_Isolated() + { + var client = new InMemoryCosmosClient(); + var db1 = (await client.CreateDatabaseAsync("db1")).Database; + var db2 = (await client.CreateDatabaseAsync("db2")).Database; - var ctr1 = (InMemoryContainer)(await db1.CreateContainerAsync("orders", "/pk")).Container; - var ctr2 = (InMemoryContainer)(await db2.CreateContainerAsync("orders", "/pk")).Container; + var ctr1 = (InMemoryContainer)(await db1.CreateContainerAsync("orders", "/pk")).Container; + var ctr2 = (InMemoryContainer)(await db2.CreateContainerAsync("orders", "/pk")).Container; - await ctr1.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", source = "db1" }), new PartitionKey("a")); + await ctr1.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", source = "db1" }), new PartitionKey("a")); - ctr1.ItemCount.Should().Be(1); - ctr2.ItemCount.Should().Be(0, "db2's 'orders' container should be empty — data is isolated"); - } + ctr1.ItemCount.Should().Be(1); + ctr2.ItemCount.Should().Be(0, "db2's 'orders' container should be empty — data is isolated"); + } - [Fact] - public async Task DeleteAsync_OneDatabase_DoesNotAffectOther() - { - var client = new InMemoryCosmosClient(); - var db1 = (await client.CreateDatabaseAsync("db1")).Database; - var db2 = (await client.CreateDatabaseAsync("db2")).Database; + [Fact] + public async Task DeleteAsync_OneDatabase_DoesNotAffectOther() + { + var client = new InMemoryCosmosClient(); + var db1 = (await client.CreateDatabaseAsync("db1")).Database; + var db2 = (await client.CreateDatabaseAsync("db2")).Database; - var ctr1 = (InMemoryContainer)(await db1.CreateContainerAsync("ctr", "/pk")).Container; - var ctr2 = (InMemoryContainer)(await db2.CreateContainerAsync("ctr", "/pk")).Container; - await ctr2.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var ctr1 = (InMemoryContainer)(await db1.CreateContainerAsync("ctr", "/pk")).Container; + var ctr2 = (InMemoryContainer)(await db2.CreateContainerAsync("ctr", "/pk")).Container; + await ctr2.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await db1.DeleteAsync(); + await db1.DeleteAsync(); - ctr2.ItemCount.Should().Be(1, "db2's data should be unaffected by db1's deletion"); - } + ctr2.ItemCount.Should().Be(1, "db2's data should be unaffected by db1's deletion"); + } - [Fact] - public async Task GetContainer_TwoArg_AcrossDatabases_Isolated() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); + [Fact] + public async Task GetContainer_TwoArg_AcrossDatabases_Isolated() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); - var ctr1 = client.GetContainer("db1", "shared"); - var ctr2 = client.GetContainer("db2", "shared"); + var ctr1 = client.GetContainer("db1", "shared"); + var ctr2 = client.GetContainer("db2", "shared"); - await ctr1.CreateItemAsync( - JObject.FromObject(new { id = "1" }), new PartitionKey("1")); + await ctr1.CreateItemAsync( + JObject.FromObject(new { id = "1" }), new PartitionKey("1")); - var item = await ctr1.ReadItemAsync("1", new PartitionKey("1")); - item.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await ctr1.ReadItemAsync("1", new PartitionKey("1")); + item.StatusCode.Should().Be(HttpStatusCode.OK); - var act = () => ctr2.ReadItemAsync("1", new PartitionKey("1")); - await act.Should().ThrowAsync(); - } + var act = () => ctr2.ReadItemAsync("1", new PartitionKey("1")); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2934,67 +2934,67 @@ await ctr1.CreateItemAsync( public class DatabaseResponseMetadataTests { - [Fact] - public async Task CreateDatabaseAsync_Response_HasResourceWithId() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("test-db"); + [Fact] + public async Task CreateDatabaseAsync_Response_HasResourceWithId() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("test-db"); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("test-db"); - } + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("test-db"); + } - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_Response_HasResourceWithId_OnBothCalls() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_Response_HasResourceWithId_OnBothCalls() + { + var client = new InMemoryCosmosClient(); - var first = await client.CreateDatabaseIfNotExistsAsync("test-db"); - first.Resource.Should().NotBeNull(); - first.Resource.Id.Should().Be("test-db"); + var first = await client.CreateDatabaseIfNotExistsAsync("test-db"); + first.Resource.Should().NotBeNull(); + first.Resource.Id.Should().Be("test-db"); - var second = await client.CreateDatabaseIfNotExistsAsync("test-db"); - second.Resource.Should().NotBeNull(); - second.Resource.Id.Should().Be("test-db"); - } + var second = await client.CreateDatabaseIfNotExistsAsync("test-db"); + second.Resource.Should().NotBeNull(); + second.Resource.Id.Should().Be("test-db"); + } } public class ContainerResponseMetadataTests { - [Fact] - public async Task CreateContainerAsync_ResponseContainer_SameAsGetContainer() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task CreateContainerAsync_ResponseContainer_SameAsGetContainer() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var response = await db.CreateContainerAsync("ctr", "/pk"); + var response = await db.CreateContainerAsync("ctr", "/pk"); - var fromGet = db.GetContainer("ctr"); - response.Container.Should().BeSameAs(fromGet); - } + var fromGet = db.GetContainer("ctr"); + response.Container.Should().BeSameAs(fromGet); + } - [Fact] - public async Task CreateContainerIfNotExistsAsync_NewContainer_ResponseHasContainerReference() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task CreateContainerIfNotExistsAsync_NewContainer_ResponseHasContainerReference() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var response = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - response.Container.Should().NotBeNull(); - response.Container.Id.Should().Be("ctr"); - } + var response = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + response.Container.Should().NotBeNull(); + response.Container.Id.Should().Be("ctr"); + } - [Fact] - public async Task CreateContainerIfNotExistsAsync_ExistingContainer_ResponseHasContainerReference() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr", "/pk"); + [Fact] + public async Task CreateContainerIfNotExistsAsync_ExistingContainer_ResponseHasContainerReference() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr", "/pk"); - var response = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - response.Container.Should().NotBeNull(); - response.Container.Id.Should().Be("ctr"); - } + var response = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + response.Container.Should().NotBeNull(); + response.Container.Id.Should().Be("ctr"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -3003,46 +3003,46 @@ public async Task CreateContainerIfNotExistsAsync_ExistingContainer_ResponseHasC public class UserManagementEdgeCaseTests { - [Fact] - public async Task UpsertUserAsync_ThreeTimes_Returns201Then200Then200() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task UpsertUserAsync_ThreeTimes_Returns201Then200Then200() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var r1 = await db.UpsertUserAsync("user1"); - r1.StatusCode.Should().Be(HttpStatusCode.Created); + var r1 = await db.UpsertUserAsync("user1"); + r1.StatusCode.Should().Be(HttpStatusCode.Created); - var r2 = await db.UpsertUserAsync("user1"); - r2.StatusCode.Should().Be(HttpStatusCode.OK); + var r2 = await db.UpsertUserAsync("user1"); + r2.StatusCode.Should().Be(HttpStatusCode.OK); - var r3 = await db.UpsertUserAsync("user1"); - r3.StatusCode.Should().Be(HttpStatusCode.OK); - } + var r3 = await db.UpsertUserAsync("user1"); + r3.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task User_DeleteAsync_ThenCreateUserAsync_SameId_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task User_DeleteAsync_ThenCreateUserAsync_SameId_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateUserAsync("user1"); - var user = db.GetUser("user1"); - await user.DeleteAsync(); + await db.CreateUserAsync("user1"); + var user = db.GetUser("user1"); + await user.DeleteAsync(); - var response = await db.CreateUserAsync("user1"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + var response = await db.CreateUserAsync("user1"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task ConcurrentCreateUserAsync_DifferentIds_AllSucceed() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; + [Fact] + public async Task ConcurrentCreateUserAsync_DifferentIds_AllSucceed() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; - var tasks = Enumerable.Range(0, 10) - .Select(i => db.CreateUserAsync($"user-{i}")); - var results = await Task.WhenAll(tasks); + var tasks = Enumerable.Range(0, 10) + .Select(i => db.CreateUserAsync($"user-{i}")); + var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientDatabaseDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientDatabaseDeepDiveTests.cs index 0f687fe..397a620 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientDatabaseDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CosmosClientDatabaseDeepDiveTests.cs @@ -1,9 +1,9 @@ using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; -using Xunit; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -13,47 +13,47 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class DatabaseThroughputPropertiesRoundtripTests { - // T1: ReplaceThroughputAsync(ThroughputProperties) → ReadThroughputAsync roundtrip - [Fact] - public async Task ReplaceThroughputAsync_ThroughputProperties_ThenRead_ReturnsNewValue() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(2000)); - - var throughput = await db.ReadThroughputAsync(); - throughput.Should().Be(2000); - } - - // T2: ReplaceThroughputAsync(ThroughputProperties) → ReadThroughputAsync(RequestOptions) roundtrip - [Fact] - public async Task ReplaceThroughputAsync_ThroughputProperties_ThenReadWithRequestOptions_ReturnsNewValue() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(1500)); - - var response = await db.ReadThroughputAsync(new RequestOptions()); - response.Resource.Throughput.Should().Be(1500); - } - - // T3: Interleaved int → ThroughputProperties → Read roundtrip - [Fact] - public async Task ReplaceThroughputAsync_Int_ThenThroughputProperties_ThenRead_ReturnsLatest() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("test-db")).Database; - - await db.ReplaceThroughputAsync(1000); - var t1 = await db.ReadThroughputAsync(); - t1.Should().Be(1000); - - await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(3000)); - var t2 = await db.ReadThroughputAsync(); - t2.Should().Be(3000); - } + // T1: ReplaceThroughputAsync(ThroughputProperties) → ReadThroughputAsync roundtrip + [Fact] + public async Task ReplaceThroughputAsync_ThroughputProperties_ThenRead_ReturnsNewValue() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(2000)); + + var throughput = await db.ReadThroughputAsync(); + throughput.Should().Be(2000); + } + + // T2: ReplaceThroughputAsync(ThroughputProperties) → ReadThroughputAsync(RequestOptions) roundtrip + [Fact] + public async Task ReplaceThroughputAsync_ThroughputProperties_ThenReadWithRequestOptions_ReturnsNewValue() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(1500)); + + var response = await db.ReadThroughputAsync(new RequestOptions()); + response.Resource.Throughput.Should().Be(1500); + } + + // T3: Interleaved int → ThroughputProperties → Read roundtrip + [Fact] + public async Task ReplaceThroughputAsync_Int_ThenThroughputProperties_ThenRead_ReturnsLatest() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("test-db")).Database; + + await db.ReplaceThroughputAsync(1000); + var t1 = await db.ReadThroughputAsync(); + t1.Should().Be(1000); + + await db.ReplaceThroughputAsync(ThroughputProperties.CreateManualThroughput(3000)); + var t2 = await db.ReadThroughputAsync(); + t2.Should().Be(3000); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -62,56 +62,56 @@ public async Task ReplaceThroughputAsync_Int_ThenThroughputProperties_ThenRead_R public class CreateDatabaseStreamGuardTests { - // S1: Disposed client - [Fact] - public async Task CreateDatabaseStreamAsync_DisposedClient_ThrowsObjectDisposed() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); - - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("test-db")); - - await act.Should().ThrowAsync(); - } - - // S2: Cancelled token - [Fact] - public async Task CreateDatabaseStreamAsync_CancelledToken_Throws() - { - var client = new InMemoryCosmosClient(); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => client.CreateDatabaseStreamAsync( - new DatabaseProperties("test-db"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - // S3: Invalid name with forward slash - [Fact] - public async Task CreateDatabaseStreamAsync_NameWithForwardSlash_ThrowsBadRequest() - { - var client = new InMemoryCosmosClient(); - - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("test/db")); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // S4: Name exceeds 255 chars - [Fact] - public async Task CreateDatabaseStreamAsync_NameExceeds255Chars_ThrowsBadRequest() - { - var client = new InMemoryCosmosClient(); - var longName = new string('x', 256); - - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties(longName)); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + // S1: Disposed client + [Fact] + public async Task CreateDatabaseStreamAsync_DisposedClient_ThrowsObjectDisposed() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); + + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("test-db")); + + await act.Should().ThrowAsync(); + } + + // S2: Cancelled token + [Fact] + public async Task CreateDatabaseStreamAsync_CancelledToken_Throws() + { + var client = new InMemoryCosmosClient(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.CreateDatabaseStreamAsync( + new DatabaseProperties("test-db"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + // S3: Invalid name with forward slash + [Fact] + public async Task CreateDatabaseStreamAsync_NameWithForwardSlash_ThrowsBadRequest() + { + var client = new InMemoryCosmosClient(); + + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("test/db")); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // S4: Name exceeds 255 chars + [Fact] + public async Task CreateDatabaseStreamAsync_NameExceeds255Chars_ThrowsBadRequest() + { + var client = new InMemoryCosmosClient(); + var longName = new string('x', 256); + + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties(longName)); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -120,56 +120,56 @@ public async Task CreateDatabaseStreamAsync_NameExceeds255Chars_ThrowsBadRequest public class ExplicitlyCreatedContainersClearedOnDeleteTests { - // E1: DeleteAsync clears _explicitlyCreatedContainers - [Fact] - public async Task DeleteAsync_Clears_ExplicitlyCreatedContainers() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - - db.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); - - await db.DeleteAsync(); - - db.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); - } - - // E2: DeleteStreamAsync clears _explicitlyCreatedContainers - [Fact] - public async Task DeleteStreamAsync_Clears_ExplicitlyCreatedContainers() - { - var client = new InMemoryCosmosClient(); - var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - await db.CreateContainerAsync("ctr1", "/pk"); - - db.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); - - await db.DeleteStreamAsync(); - - db.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); - } - - // E3: Delete → recreate → auto-created container not stale explicit - [Fact] - public async Task DeleteAsync_ThenRecreate_ContainerStartsFresh() - { - var client = new InMemoryCosmosClient(); - var db1 = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - await db1.CreateContainerAsync("ctr1", "/pk"); - db1.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); - - await db1.DeleteAsync(); - - // Re-creating the database gives a new instance - var db2 = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; - db2.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); - - // GetContainer creates a lazy (not explicit) container - var container = db2.GetContainer("ctr1"); - container.Should().NotBeNull(); - db2.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); - } + // E1: DeleteAsync clears _explicitlyCreatedContainers + [Fact] + public async Task DeleteAsync_Clears_ExplicitlyCreatedContainers() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + + db.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); + + await db.DeleteAsync(); + + db.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); + } + + // E2: DeleteStreamAsync clears _explicitlyCreatedContainers + [Fact] + public async Task DeleteStreamAsync_Clears_ExplicitlyCreatedContainers() + { + var client = new InMemoryCosmosClient(); + var db = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + await db.CreateContainerAsync("ctr1", "/pk"); + + db.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); + + await db.DeleteStreamAsync(); + + db.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); + } + + // E3: Delete → recreate → auto-created container not stale explicit + [Fact] + public async Task DeleteAsync_ThenRecreate_ContainerStartsFresh() + { + var client = new InMemoryCosmosClient(); + var db1 = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + await db1.CreateContainerAsync("ctr1", "/pk"); + db1.IsContainerExplicitlyCreated("ctr1").Should().BeTrue(); + + await db1.DeleteAsync(); + + // Re-creating the database gives a new instance + var db2 = (InMemoryDatabase)(await client.CreateDatabaseAsync("test-db")).Database; + db2.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); + + // GetContainer creates a lazy (not explicit) container + var container = db2.GetContainer("ctr1"); + container.Should().NotBeNull(); + db2.IsContainerExplicitlyCreated("ctr1").Should().BeFalse(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -178,229 +178,229 @@ public async Task DeleteAsync_ThenRecreate_ContainerStartsFresh() public class DatabaseEdgeCaseDeepDiveTests { - // M1: ReadAsync on standalone database (no client) - [Fact] - public async Task ReadAsync_StandaloneDatabase_NoClient_ReturnsOk() - { - var db = new InMemoryDatabase("standalone-db"); - - var response = await db.ReadAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("standalone-db"); - } - - // M2: CreateContainerIfNotExistsAsync with DefaultTimeToLive - [Fact] - public async Task CreateContainerIfNotExistsAsync_WithDefaultTimeToLive_SetsOnContainer() - { - var db = new InMemoryDatabase("testdb"); - var props = new ContainerProperties("ctr", "/pk") - { - DefaultTimeToLive = 3600 - }; - - await db.CreateContainerIfNotExistsAsync(props); - - var container = (InMemoryContainer)db.GetContainer("ctr"); - container.DefaultTimeToLive.Should().Be(3600); - } - - // M3: CreateContainerAsync with DefaultTimeToLive - [Fact] - public async Task CreateContainerAsync_WithDefaultTimeToLive_SetsOnContainer() - { - var db = new InMemoryDatabase("testdb"); - var props = new ContainerProperties("ctr", "/pk") - { - DefaultTimeToLive = 1800 - }; - - await db.CreateContainerAsync(props); - - var container = (InMemoryContainer)db.GetContainer("ctr"); - container.DefaultTimeToLive.Should().Be(1800); - } - - // M4: CreateContainerAsync with IndexingPolicy - [Fact] - public async Task CreateContainerAsync_WithIndexingPolicy_PolicyApplied() - { - var db = new InMemoryDatabase("testdb"); - var policy = new IndexingPolicy { Automatic = false }; - var props = new ContainerProperties("ctr", "/pk") - { - IndexingPolicy = policy - }; - - await db.CreateContainerAsync(props); - - var container = (InMemoryContainer)db.GetContainer("ctr"); - container.IndexingPolicy.Automatic.Should().BeFalse(); - } - - // M5: CreateDatabaseIfNotExistsAsync with cancelled token - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_CancelledToken_Throws() - { - var client = new InMemoryCosmosClient(); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => client.CreateDatabaseIfNotExistsAsync("test-db", cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - // M6: User re-creation after database delete-recreate cycle - [Fact] - public async Task User_RecreateAfterDbDelete_Succeeds() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - await db.CreateUserAsync("user1"); - - await db.DeleteAsync(); - - var db2 = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db2.CreateUserAsync("user1"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // M7: CreateDatabaseStreamAsync response body deserializable - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseBody_DeserializesToDatabaseProperties() - { - var client = new InMemoryCosmosClient(); - - using var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("mydb")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().NotBeNull(); - response.Content.Position = 0; - using var reader = new System.IO.StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var dbProps = JsonConvert.DeserializeObject(json); - dbProps!.Id.Should().Be("mydb"); - } - - // M8: GetContainer via client 2-arg returns correct container Id - [Fact] - public async Task GetContainer_ViaClient_ReturnsContainerWithCorrectId() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("testdb"); - - var container = client.GetContainer("testdb", "mycontainer"); - - container.Id.Should().Be("mycontainer"); - } - - // M9: Endpoint returns localhost URI - [Fact] - public void Endpoint_ReturnsLocalhost8081() - { - var client = new InMemoryCosmosClient(); - - client.Endpoint.Should().Be(new Uri("https://localhost:8081/")); - } - - // M10: CreateContainerIfNotExistsAsync existing → returns same instance - [Fact] - public async Task CreateContainerIfNotExistsAsync_ExistingContainer_ReturnsSameInstance() - { - var db = new InMemoryDatabase("testdb"); - var r1 = await db.CreateContainerAsync("ctr", "/pk"); - var r2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); - - r2.StatusCode.Should().Be(HttpStatusCode.OK); - r2.Container.Id.Should().Be("ctr"); - } - - // M11: Concurrent GetDatabase same id → same instance - [Fact] - public async Task ConcurrentGetDatabase_SameId_ReturnsSameInstance() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("testdb"); - - var tasks = Enumerable.Range(0, 50) - .Select(_ => Task.Run(() => client.GetDatabase("testdb"))) - .ToArray(); - - var results = await Task.WhenAll(tasks); - results.Should().AllSatisfy(db => db.Id.Should().Be("testdb")); - // All should be the same instance - results.Distinct().Should().HaveCount(1); - } - - // M12: Concurrent create and delete — no exceptions - [Fact] - public async Task ConcurrentCreateAndDelete_NoExceptions() - { - var client = new InMemoryCosmosClient(); - var exceptions = new System.Collections.Concurrent.ConcurrentBag(); - - var tasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () => - { - try - { - var dbName = $"db-{i % 5}"; - try - { - await client.CreateDatabaseAsync(dbName); - } - catch (CosmosException) { /* Conflict expected */ } - - var db = client.GetDatabase(dbName); - try - { - await db.DeleteAsync(); - } - catch (CosmosException) { /* NotFound expected */ } - } - catch (Exception ex) - { - exceptions.Add(ex); - } - })).ToArray(); - - await Task.WhenAll(tasks); - exceptions.Should().BeEmpty(); - } - - // M13: Stream API response has headers - [Fact] - public async Task StreamApi_ResponseHasHeaders() - { - var client = new InMemoryCosmosClient(); - - using var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - // M14: GetContainerQueryIterator only shows own database's containers - [Fact] - public async Task GetContainerQueryIterator_OnlyShowsOwnDatabaseContainers() - { - var client = new InMemoryCosmosClient(); - var db1 = (await client.CreateDatabaseAsync("db1")).Database; - var db2 = (await client.CreateDatabaseAsync("db2")).Database; - - await db1.CreateContainerAsync("shared-name", "/pk"); - await db2.CreateContainerAsync("other-name", "/pk"); - - var iterator = db1.GetContainerQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Id.Should().Be("shared-name"); - } + // M1: ReadAsync on standalone database (no client) + [Fact] + public async Task ReadAsync_StandaloneDatabase_NoClient_ReturnsOk() + { + var db = new InMemoryDatabase("standalone-db"); + + var response = await db.ReadAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("standalone-db"); + } + + // M2: CreateContainerIfNotExistsAsync with DefaultTimeToLive + [Fact] + public async Task CreateContainerIfNotExistsAsync_WithDefaultTimeToLive_SetsOnContainer() + { + var db = new InMemoryDatabase("testdb"); + var props = new ContainerProperties("ctr", "/pk") + { + DefaultTimeToLive = 3600 + }; + + await db.CreateContainerIfNotExistsAsync(props); + + var container = (InMemoryContainer)db.GetContainer("ctr"); + container.DefaultTimeToLive.Should().Be(3600); + } + + // M3: CreateContainerAsync with DefaultTimeToLive + [Fact] + public async Task CreateContainerAsync_WithDefaultTimeToLive_SetsOnContainer() + { + var db = new InMemoryDatabase("testdb"); + var props = new ContainerProperties("ctr", "/pk") + { + DefaultTimeToLive = 1800 + }; + + await db.CreateContainerAsync(props); + + var container = (InMemoryContainer)db.GetContainer("ctr"); + container.DefaultTimeToLive.Should().Be(1800); + } + + // M4: CreateContainerAsync with IndexingPolicy + [Fact] + public async Task CreateContainerAsync_WithIndexingPolicy_PolicyApplied() + { + var db = new InMemoryDatabase("testdb"); + var policy = new IndexingPolicy { Automatic = false }; + var props = new ContainerProperties("ctr", "/pk") + { + IndexingPolicy = policy + }; + + await db.CreateContainerAsync(props); + + var container = (InMemoryContainer)db.GetContainer("ctr"); + container.IndexingPolicy.Automatic.Should().BeFalse(); + } + + // M5: CreateDatabaseIfNotExistsAsync with cancelled token + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_CancelledToken_Throws() + { + var client = new InMemoryCosmosClient(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.CreateDatabaseIfNotExistsAsync("test-db", cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + // M6: User re-creation after database delete-recreate cycle + [Fact] + public async Task User_RecreateAfterDbDelete_Succeeds() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + await db.CreateUserAsync("user1"); + + await db.DeleteAsync(); + + var db2 = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db2.CreateUserAsync("user1"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // M7: CreateDatabaseStreamAsync response body deserializable + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseBody_DeserializesToDatabaseProperties() + { + var client = new InMemoryCosmosClient(); + + using var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("mydb")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().NotBeNull(); + response.Content.Position = 0; + using var reader = new System.IO.StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var dbProps = JsonConvert.DeserializeObject(json); + dbProps!.Id.Should().Be("mydb"); + } + + // M8: GetContainer via client 2-arg returns correct container Id + [Fact] + public async Task GetContainer_ViaClient_ReturnsContainerWithCorrectId() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("testdb"); + + var container = client.GetContainer("testdb", "mycontainer"); + + container.Id.Should().Be("mycontainer"); + } + + // M9: Endpoint returns localhost URI + [Fact] + public void Endpoint_ReturnsLocalhost8081() + { + var client = new InMemoryCosmosClient(); + + client.Endpoint.Should().Be(new Uri("https://localhost:8081/")); + } + + // M10: CreateContainerIfNotExistsAsync existing → returns same instance + [Fact] + public async Task CreateContainerIfNotExistsAsync_ExistingContainer_ReturnsSameInstance() + { + var db = new InMemoryDatabase("testdb"); + var r1 = await db.CreateContainerAsync("ctr", "/pk"); + var r2 = await db.CreateContainerIfNotExistsAsync("ctr", "/pk"); + + r2.StatusCode.Should().Be(HttpStatusCode.OK); + r2.Container.Id.Should().Be("ctr"); + } + + // M11: Concurrent GetDatabase same id → same instance + [Fact] + public async Task ConcurrentGetDatabase_SameId_ReturnsSameInstance() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("testdb"); + + var tasks = Enumerable.Range(0, 50) + .Select(_ => Task.Run(() => client.GetDatabase("testdb"))) + .ToArray(); + + var results = await Task.WhenAll(tasks); + results.Should().AllSatisfy(db => db.Id.Should().Be("testdb")); + // All should be the same instance + results.Distinct().Should().HaveCount(1); + } + + // M12: Concurrent create and delete — no exceptions + [Fact] + public async Task ConcurrentCreateAndDelete_NoExceptions() + { + var client = new InMemoryCosmosClient(); + var exceptions = new System.Collections.Concurrent.ConcurrentBag(); + + var tasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () => + { + try + { + var dbName = $"db-{i % 5}"; + try + { + await client.CreateDatabaseAsync(dbName); + } + catch (CosmosException) { /* Conflict expected */ } + + var db = client.GetDatabase(dbName); + try + { + await db.DeleteAsync(); + } + catch (CosmosException) { /* NotFound expected */ } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + })).ToArray(); + + await Task.WhenAll(tasks); + exceptions.Should().BeEmpty(); + } + + // M13: Stream API response has headers + [Fact] + public async Task StreamApi_ResponseHasHeaders() + { + var client = new InMemoryCosmosClient(); + + using var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + // M14: GetContainerQueryIterator only shows own database's containers + [Fact] + public async Task GetContainerQueryIterator_OnlyShowsOwnDatabaseContainers() + { + var client = new InMemoryCosmosClient(); + var db1 = (await client.CreateDatabaseAsync("db1")).Database; + var db2 = (await client.CreateDatabaseAsync("db2")).Database; + + await db1.CreateContainerAsync("shared-name", "/pk"); + await db2.CreateContainerAsync("other-name", "/pk"); + + var iterator = db1.GetContainerQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Id.Should().Be("shared-name"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudDeepDiveTests.cs index fd7bd66..b11e2a1 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudDeepDiveTests.cs @@ -13,162 +13,162 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ReadManyItemsDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ReadMany_HappyPath_ReturnsAllItems() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("2", new PartitionKey("a")), - ("3", new PartitionKey("b")) + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ReadMany_HappyPath_ReturnsAllItems() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("2", new PartitionKey("a")), + ("3", new PartitionKey("b")) + }); + + result.Count.Should().Be(3); + } + + [Fact] + public async Task ReadMany_EmptyList_ReturnsEmptyFeedResponse() + { + var result = await _container.ReadManyItemsAsync( + new List<(string, PartitionKey)>()); + + result.Count.Should().Be(0); + } + + [Fact] + public async Task ReadMany_MissingItems_ReturnsPartialResults() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("2", new PartitionKey("a")), + ("3", new PartitionKey("a")), + ("99", new PartitionKey("a")) // doesn't exist }); - result.Count.Should().Be(3); - } - - [Fact] - public async Task ReadMany_EmptyList_ReturnsEmptyFeedResponse() - { - var result = await _container.ReadManyItemsAsync( - new List<(string, PartitionKey)>()); - - result.Count.Should().Be(0); - } - - [Fact] - public async Task ReadMany_MissingItems_ReturnsPartialResults() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("2", new PartitionKey("a")), - ("3", new PartitionKey("a")), - ("99", new PartitionKey("a")) // doesn't exist + result.Count.Should().Be(3); + } + + [Fact] + public async Task ReadMany_AllMissing_ReturnsEmptyFeedResponse() + { + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("99", new PartitionKey("a")), + ("100", new PartitionKey("b")) + }); + + result.Count.Should().Be(0); + } + + [Fact] + public async Task ReadMany_WrongPartitionKey_ItemNotReturned() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("b")) // wrong PK }); - result.Count.Should().Be(3); - } - - [Fact] - public async Task ReadMany_AllMissing_ReturnsEmptyFeedResponse() - { - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("99", new PartitionKey("a")), - ("100", new PartitionKey("b")) - }); - - result.Count.Should().Be(0); - } - - [Fact] - public async Task ReadMany_WrongPartitionKey_ItemNotReturned() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("b")) // wrong PK - }); - - result.Count.Should().Be(0); - } - - [Fact] - public async Task ReadMany_NullInput_ThrowsArgumentNullException() - { - var act = () => _container.ReadManyItemsAsync(null!); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadMany_DuplicateIds_ReturnsDuplicates() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("1", new PartitionKey("a")) - }); - - result.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_AcrossPartitions_ReturnsAllMatching() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "c" }), new PartitionKey("c")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("3", new PartitionKey("c")) - }); - - result.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_ResponseContainsETag() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")) - }); - - result.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadMany_WithCancelledToken_ThrowsOperationCancelled() - { - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => _container.ReadManyItemsAsync( - new List<(string, PartitionKey)>(), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadManyStream_HappyPath_ReturnsOk() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - using var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")) - }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - } - - [Fact] - public async Task ReadManyStream_EmptyList_ReturnsOk() - { - using var response = await _container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)>()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + result.Count.Should().Be(0); + } + + [Fact] + public async Task ReadMany_NullInput_ThrowsArgumentNullException() + { + var act = () => _container.ReadManyItemsAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadMany_DuplicateIds_ReturnsDuplicates() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("1", new PartitionKey("a")) + }); + + result.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_AcrossPartitions_ReturnsAllMatching() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "c" }), new PartitionKey("c")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("3", new PartitionKey("c")) + }); + + result.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_ResponseContainsETag() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")) + }); + + result.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadMany_WithCancelledToken_ThrowsOperationCancelled() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => _container.ReadManyItemsAsync( + new List<(string, PartitionKey)>(), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadManyStream_HappyPath_ReturnsOk() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + using var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")) + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + } + + [Fact] + public async Task ReadManyStream_EmptyList_ReturnsOk() + { + using var response = await _container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)>()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -177,167 +177,167 @@ public async Task ReadManyStream_EmptyList_ReturnsOk() public class PatchItemCrudEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); + private readonly InMemoryContainer _container = new("test", "/pk"); - private async Task CreateTestItem() - { - var item = JObject.FromObject(new { id = "item1", pk = "a", value = 10, name = "test" }); - await _container.CreateItemAsync(item, new PartitionKey("a")); - return item; - } - - [Fact] - public async Task Patch_EmptyOperations_ThrowsBadRequest() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List()); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_Over10Operations_ThrowsBadRequest() - { - await CreateTestItem(); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", i)) - .ToList(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), ops); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_OnSystemProperty_Ts_ThrowsBadRequest() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/_ts", 0) }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_OnSystemProperty_Etag_ThrowsBadRequest() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/_etag", "x") }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_OnId_ThrowsBadRequest() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/id", "newid") }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_OnPartitionKey_ThrowsBadRequest() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/pk", "newpk") }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_WithFilterPredicate_MatchesFalse_ThrowsPreconditionFailed() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Patch_WithFilterPredicate_MatchesTrue_Succeeds() - { - await CreateTestItem(); - - var result = await _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Resource["value"]!.Value().Should().Be(99); - } - - [Fact] - public async Task Patch_NonExistent_ThrowsNotFound() - { - var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_WithIfMatchStaleETag_ThrowsPreconditionFailed() - { - await CreateTestItem(); - - var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }, - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Patch_ChangesETag() - { - await CreateTestItem(); - var before = await _container.ReadItemAsync("item1", new PartitionKey("a")); - var etagBefore = before.ETag; - - await _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }); - - var after = await _container.ReadItemAsync("item1", new PartitionKey("a")); - after.ETag.Should().NotBe(etagBefore); - } - - [Fact] - public async Task Patch_RecordsInChangeFeed() - { - var checkpoint = _container.GetChangeFeedCheckpoint(); - await CreateTestItem(); - var afterCreate = _container.GetChangeFeedCheckpoint(); - afterCreate.Should().BeGreaterThan(checkpoint); - - await _container.PatchItemAsync("item1", new PartitionKey("a"), - new List { PatchOperation.Set("/value", 99) }); - - var afterPatch = _container.GetChangeFeedCheckpoint(); - afterPatch.Should().BeGreaterThan(afterCreate); - } + private async Task CreateTestItem() + { + var item = JObject.FromObject(new { id = "item1", pk = "a", value = 10, name = "test" }); + await _container.CreateItemAsync(item, new PartitionKey("a")); + return item; + } + + [Fact] + public async Task Patch_EmptyOperations_ThrowsBadRequest() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List()); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_Over10Operations_ThrowsBadRequest() + { + await CreateTestItem(); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", i)) + .ToList(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), ops); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_OnSystemProperty_Ts_ThrowsBadRequest() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/_ts", 0) }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_OnSystemProperty_Etag_ThrowsBadRequest() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/_etag", "x") }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_OnId_ThrowsBadRequest() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/id", "newid") }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_OnPartitionKey_ThrowsBadRequest() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/pk", "newpk") }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_WithFilterPredicate_MatchesFalse_ThrowsPreconditionFailed() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_WithFilterPredicate_MatchesTrue_Succeeds() + { + await CreateTestItem(); + + var result = await _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Resource["value"]!.Value().Should().Be(99); + } + + [Fact] + public async Task Patch_NonExistent_ThrowsNotFound() + { + var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_WithIfMatchStaleETag_ThrowsPreconditionFailed() + { + await CreateTestItem(); + + var act = () => _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }, + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_ChangesETag() + { + await CreateTestItem(); + var before = await _container.ReadItemAsync("item1", new PartitionKey("a")); + var etagBefore = before.ETag; + + await _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }); + + var after = await _container.ReadItemAsync("item1", new PartitionKey("a")); + after.ETag.Should().NotBe(etagBefore); + } + + [Fact] + public async Task Patch_RecordsInChangeFeed() + { + var checkpoint = _container.GetChangeFeedCheckpoint(); + await CreateTestItem(); + var afterCreate = _container.GetChangeFeedCheckpoint(); + afterCreate.Should().BeGreaterThan(checkpoint); + + await _container.PatchItemAsync("item1", new PartitionKey("a"), + new List { PatchOperation.Set("/value", 99) }); + + var afterPatch = _container.GetChangeFeedCheckpoint(); + afterPatch.Should().BeGreaterThan(afterCreate); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -346,77 +346,77 @@ public async Task Patch_RecordsInChangeFeed() public class ReplaceItemExtendedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Replace_WithIfMatchWildcard_NonExistentItem_ThrowsNotFound() - { - var act = () => _container.ReplaceItemAsync( - JObject.FromObject(new { id = "notexist", pk = "a" }), - "notexist", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Replace_ReplacesEntireDocument_NotMerge() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", field1 = "value1", field2 = "value2" }), - new PartitionKey("a")); - - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", field3 = "value3" }), - "1", new PartitionKey("a")); - - var item = await _container.ReadItemAsync("1", new PartitionKey("a")); - item.Resource["field1"].Should().BeNull(); - item.Resource["field2"].Should().BeNull(); - item.Resource["field3"]!.Value().Should().Be("value3"); - } - - [Fact] - public async Task Replace_ChangesETag() - { - var created = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var etagBefore = created.ETag; - - var replaced = await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", newField = true }), - "1", new PartitionKey("a")); - - replaced.ETag.Should().NotBe(etagBefore); - } - - [Fact] - public async Task Replace_ResponseResource_ContainsUpdatedData() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = "old" }), - new PartitionKey("a")); - - var response = await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = "new" }), - "1", new PartitionKey("a")); - - response.Resource["value"]!.Value().Should().Be("new"); - } - - [Fact] - public async Task Replace_WithNullPartitionKey_ExtractsFromDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var response = await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", updated = true }), - "1"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Replace_WithIfMatchWildcard_NonExistentItem_ThrowsNotFound() + { + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "notexist", pk = "a" }), + "notexist", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Replace_ReplacesEntireDocument_NotMerge() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", field1 = "value1", field2 = "value2" }), + new PartitionKey("a")); + + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", field3 = "value3" }), + "1", new PartitionKey("a")); + + var item = await _container.ReadItemAsync("1", new PartitionKey("a")); + item.Resource["field1"].Should().BeNull(); + item.Resource["field2"].Should().BeNull(); + item.Resource["field3"]!.Value().Should().Be("value3"); + } + + [Fact] + public async Task Replace_ChangesETag() + { + var created = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var etagBefore = created.ETag; + + var replaced = await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", newField = true }), + "1", new PartitionKey("a")); + + replaced.ETag.Should().NotBe(etagBefore); + } + + [Fact] + public async Task Replace_ResponseResource_ContainsUpdatedData() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = "old" }), + new PartitionKey("a")); + + var response = await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = "new" }), + "1", new PartitionKey("a")); + + response.Resource["value"]!.Value().Should().Be("new"); + } + + [Fact] + public async Task Replace_WithNullPartitionKey_ExtractsFromDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var response = await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", updated = true }), + "1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -425,44 +425,44 @@ await _container.CreateItemAsync( public class UpsertItemExtendedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Upsert_WithIfNoneMatchEtag_IsIgnoredOnWrites() - { - // IfNoneMatch is a read-only concept, upsert should ignore it - var response = await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - // Upsert should succeed or return appropriate status - response.Should().NotBeNull(); - } - - [Fact] - public async Task Upsert_WithEnableContentResponseOnWrite_True_ContainsResource() - { - var response = await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = 42 }), - new PartitionKey("a"), - new ItemRequestOptions { EnableContentResponseOnWrite = true }); - - response.Resource.Should().NotBeNull(); - response.Resource["value"]!.Value().Should().Be(42); - } - - [Fact] - public async Task Upsert_WithMissingPartitionKeyPath_InBody_FallsBackToId() - { - // container has PK /pk, but item body has no "pk" property - var container = new InMemoryContainer("test", "/id"); - var response = await container.UpsertItemAsync( - JObject.FromObject(new { id = "myid" }), - new PartitionKey("myid")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Upsert_WithIfNoneMatchEtag_IsIgnoredOnWrites() + { + // IfNoneMatch is a read-only concept, upsert should ignore it + var response = await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + // Upsert should succeed or return appropriate status + response.Should().NotBeNull(); + } + + [Fact] + public async Task Upsert_WithEnableContentResponseOnWrite_True_ContainsResource() + { + var response = await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = 42 }), + new PartitionKey("a"), + new ItemRequestOptions { EnableContentResponseOnWrite = true }); + + response.Resource.Should().NotBeNull(); + response.Resource["value"]!.Value().Should().Be(42); + } + + [Fact] + public async Task Upsert_WithMissingPartitionKeyPath_InBody_FallsBackToId() + { + // container has PK /pk, but item body has no "pk" property + var container = new InMemoryContainer("test", "/id"); + var response = await container.UpsertItemAsync( + JObject.FromObject(new { id = "myid" }), + new PartitionKey("myid")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -471,68 +471,68 @@ public async Task Upsert_WithMissingPartitionKeyPath_InBody_FallsBackToId() public class CreateItemExtendedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Create_WithWhitespaceOnlyId_Succeeds() - { - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = " ", pk = "a" }), - new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_JustUnder2MB_Succeeds() - { - var largeValue = new string('x', 1_900_000); // well under 2MB - var item = JObject.FromObject(new { id = "large", pk = "a", data = largeValue }); - - var response = await _container.CreateItemAsync(item, new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_ResponseContainsSessionToken() - { - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - response.Headers.Session.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Create_ManyItemsSamePartition_AllRetrievable() - { - for (var i = 0; i < 100; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"item-{i}", pk = "same" }), - new PartitionKey("same")); - } - - for (var i = 0; i < 100; i++) - { - var response = await _container.ReadItemAsync($"item-{i}", new PartitionKey("same")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - [Fact] - public async Task Create_WithNumericId_Succeeds() - { - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "12345", pk = "a" }), - new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await _container.ReadItemAsync("12345", new PartitionKey("a")); - read.Resource["id"]!.Value().Should().Be("12345"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Create_WithWhitespaceOnlyId_Succeeds() + { + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = " ", pk = "a" }), + new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_JustUnder2MB_Succeeds() + { + var largeValue = new string('x', 1_900_000); // well under 2MB + var item = JObject.FromObject(new { id = "large", pk = "a", data = largeValue }); + + var response = await _container.CreateItemAsync(item, new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_ResponseContainsSessionToken() + { + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + response.Headers.Session.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Create_ManyItemsSamePartition_AllRetrievable() + { + for (var i = 0; i < 100; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"item-{i}", pk = "same" }), + new PartitionKey("same")); + } + + for (var i = 0; i < 100; i++) + { + var response = await _container.ReadItemAsync($"item-{i}", new PartitionKey("same")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } + + [Fact] + public async Task Create_WithNumericId_Succeeds() + { + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "12345", pk = "a" }), + new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await _container.ReadItemAsync("12345", new PartitionKey("a")); + read.Resource["id"]!.Value().Should().Be("12345"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -541,42 +541,42 @@ public async Task Create_WithNumericId_Succeeds() public class DeleteItemExtendedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); + private readonly InMemoryContainer _container = new("test", "/pk"); - [Fact] - public async Task Delete_DoubleDelete_SecondCallThrows404() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Delete_DoubleDelete_SecondCallThrows404() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await _container.DeleteItemAsync("1", new PartitionKey("a")); + await _container.DeleteItemAsync("1", new PartitionKey("a")); - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a")); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a")); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task Delete_ReturnedRequestCharge_IsOnePointZero() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Delete_ReturnedRequestCharge_IsOnePointZero() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("a")); + var response = await _container.DeleteItemAsync("1", new PartitionKey("a")); - response.RequestCharge.Should().Be(1.0); - } + response.RequestCharge.Should().Be(1.0); + } - [Fact] - public async Task Delete_ResponseContainsHeaders() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Delete_ResponseContainsHeaders() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("a")); + var response = await _container.DeleteItemAsync("1", new PartitionKey("a")); - response.Headers.Session.Should().NotBeNullOrEmpty(); - } + response.Headers.Session.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -585,51 +585,51 @@ await _container.CreateItemAsync( public class ReadItemExtendedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/pk"); + private readonly InMemoryContainer _container = new("test", "/pk"); - [Fact] - public async Task Read_WithIfNoneMatch_WildcardStar_NonExistentItem_Throws404() - { - var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("a"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); + [Fact] + public async Task Read_WithIfNoneMatch_WildcardStar_NonExistentItem_Throws404() + { + var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("a"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task Read_MultipleReads_SameETag() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Read_MultipleReads_SameETag() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var r1 = await _container.ReadItemAsync("1", new PartitionKey("a")); - var r2 = await _container.ReadItemAsync("1", new PartitionKey("a")); + var r1 = await _container.ReadItemAsync("1", new PartitionKey("a")); + var r2 = await _container.ReadItemAsync("1", new PartitionKey("a")); - r1.ETag.Should().Be(r2.ETag); - } + r1.ETag.Should().Be(r2.ETag); + } - [Fact] - public async Task Read_ResponseHasDiagnostics_NonNull() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Read_ResponseHasDiagnostics_NonNull() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.ReadItemAsync("1", new PartitionKey("a")); + var response = await _container.ReadItemAsync("1", new PartitionKey("a")); - response.Diagnostics.Should().NotBeNull(); - } + response.Diagnostics.Should().NotBeNull(); + } - [Fact] - public async Task Read_ResponseHasActivityId_NonNull() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Read_ResponseHasActivityId_NonNull() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.ReadItemAsync("1", new PartitionKey("a")); + var response = await _container.ReadItemAsync("1", new PartitionKey("a")); - response.ActivityId.Should().NotBeNullOrEmpty(); - } + response.ActivityId.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -638,61 +638,61 @@ await _container.CreateItemAsync( public class DeleteAllByPKDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task DeleteAll_EmptyPartition_ReturnsOk() - { - using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("nonexistent")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteAll_WithPartitionKeyNone_DeletesNullPkItems() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), - new PartitionKey("a")); - - using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.None); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - // Item with PK.None should be gone - var act = () => _container.ReadItemAsync("1", PartitionKey.None); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - - // Item with other PK should remain - var r = await _container.ReadItemAsync("2", new PartitionKey("a")); - r.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteAll_LargePartition_100Items_AllDeleted() - { - for (var i = 0; i < 100; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"item-{i}", pk = "bulk" }), - new PartitionKey("bulk")); - } - - using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("bulk")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - // All should be gone - for (var i = 0; i < 5; i++) // spot check - { - var act = () => _container.ReadItemAsync($"item-{i}", new PartitionKey("bulk")); - (await act.Should().ThrowAsync()).Which - .StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task DeleteAll_EmptyPartition_ReturnsOk() + { + using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("nonexistent")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteAll_WithPartitionKeyNone_DeletesNullPkItems() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.None); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Item with PK.None should be gone + var act = () => _container.ReadItemAsync("1", PartitionKey.None); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Item with other PK should remain + var r = await _container.ReadItemAsync("2", new PartitionKey("a")); + r.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteAll_LargePartition_100Items_AllDeleted() + { + for (var i = 0; i < 100; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"item-{i}", pk = "bulk" }), + new PartitionKey("bulk")); + } + + using var response = await _container.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("bulk")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // All should be gone + for (var i = 0; i < 5; i++) // spot check + { + var act = () => _container.ReadItemAsync($"item-{i}", new PartitionKey("bulk")); + (await act.Should().ThrowAsync()).Which + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -701,98 +701,98 @@ await _container.CreateItemAsync( public class CrudInteractionTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Create_Replace_Delete_Create_FullLifecycle() - { - // Create - var created = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", state = "created" }), - new PartitionKey("a")); - created.StatusCode.Should().Be(HttpStatusCode.Created); - - // Replace - var replaced = await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", state = "replaced" }), - "1", new PartitionKey("a")); - replaced.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete - var deleted = await _container.DeleteItemAsync("1", new PartitionKey("a")); - deleted.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Re-create - var recreated = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", state = "recreated" }), - new PartitionKey("a")); - recreated.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await _container.ReadItemAsync("1", new PartitionKey("a")); - read.Resource["state"]!.Value().Should().Be("recreated"); - } - - [Fact] - public async Task Upsert_Then_ReadMany_ReturnsLatestVersion() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", version = 1 }), - new PartitionKey("a")); - - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", version = 2 }), - new PartitionKey("a")); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")) - }); - - result.Count.Should().Be(1); - result.First()["version"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Patch_Then_Replace_ReplacesAllIncludingPatchedFields() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", field1 = "original", field2 = "keep" }), - new PartitionKey("a")); - - await _container.PatchItemAsync("1", new PartitionKey("a"), - new List { PatchOperation.Set("/field1", "patched") }); - - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", field3 = "replaced" }), - "1", new PartitionKey("a")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("a")); - read.Resource["field1"].Should().BeNull(); // gone after replace - read.Resource["field2"].Should().BeNull(); // gone after replace - read.Resource["field3"]!.Value().Should().Be("replaced"); - } - - [Fact] - public async Task Create_WithPreTrigger_ThenReadMany_TriggerFieldsPresent() - { - _container.RegisterTrigger("addField", Microsoft.Azure.Cosmos.Scripts.TriggerType.Pre, - Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, - (Func)(doc => - { - doc["triggerField"] = "added-by-trigger"; - return doc; - })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")) - }); - - result.First()["triggerField"]!.Value().Should().Be("added-by-trigger"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Create_Replace_Delete_Create_FullLifecycle() + { + // Create + var created = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", state = "created" }), + new PartitionKey("a")); + created.StatusCode.Should().Be(HttpStatusCode.Created); + + // Replace + var replaced = await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", state = "replaced" }), + "1", new PartitionKey("a")); + replaced.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete + var deleted = await _container.DeleteItemAsync("1", new PartitionKey("a")); + deleted.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Re-create + var recreated = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", state = "recreated" }), + new PartitionKey("a")); + recreated.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await _container.ReadItemAsync("1", new PartitionKey("a")); + read.Resource["state"]!.Value().Should().Be("recreated"); + } + + [Fact] + public async Task Upsert_Then_ReadMany_ReturnsLatestVersion() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", version = 1 }), + new PartitionKey("a")); + + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", version = 2 }), + new PartitionKey("a")); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")) + }); + + result.Count.Should().Be(1); + result.First()["version"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Patch_Then_Replace_ReplacesAllIncludingPatchedFields() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", field1 = "original", field2 = "keep" }), + new PartitionKey("a")); + + await _container.PatchItemAsync("1", new PartitionKey("a"), + new List { PatchOperation.Set("/field1", "patched") }); + + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", field3 = "replaced" }), + "1", new PartitionKey("a")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("a")); + read.Resource["field1"].Should().BeNull(); // gone after replace + read.Resource["field2"].Should().BeNull(); // gone after replace + read.Resource["field3"]!.Value().Should().Be("replaced"); + } + + [Fact] + public async Task Create_WithPreTrigger_ThenReadMany_TriggerFieldsPresent() + { + _container.RegisterTrigger("addField", Microsoft.Azure.Cosmos.Scripts.TriggerType.Pre, + Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, + (Func)(doc => + { + doc["triggerField"] = "added-by-trigger"; + return doc; + })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")) + }); + + result.First()["triggerField"]!.Value().Should().Be("added-by-trigger"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudTests.cs index 52e3620..e5efa78 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/CrudTests.cs @@ -1,405 +1,405 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Scripts; using Newtonsoft.Json; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class CreateItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task CreateItemAsync_WithValidItem_ReturnsCreatedResponse() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task CreateItemAsync_WithValidItem_ReturnsCreatedResponse() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("1"); - response.Resource.Name.Should().Be("Test"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("1"); + response.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task CreateItemAsync_WithValidItem_SetsETag() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task CreateItemAsync_WithValidItem_SetsETag() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task CreateItemAsync_DuplicateId_ThrowsConflict() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task CreateItemAsync_DuplicateId_ThrowsConflict() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1")); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } - [Fact] - public async Task CreateItemAsync_SameIdDifferentPartitionKey_BothSucceed() - { - var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; - var item2 = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Second" }; + [Fact] + public async Task CreateItemAsync_SameIdDifferentPartitionKey_BothSucceed() + { + var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; + var item2 = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Second" }; - await _container.CreateItemAsync(item1, new PartitionKey("pk1")); - var response = await _container.CreateItemAsync(item2, new PartitionKey("pk2")); + await _container.CreateItemAsync(item1, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item2, new PartitionKey("pk2")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task CreateItemAsync_WithoutId_ThrowsInvalidOperation() - { - var item = new { notAnId = "test" }; + [Fact] + public async Task CreateItemAsync_WithoutId_ThrowsInvalidOperation() + { + var item = new { notAnId = "test" }; - var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task CreateItemAsync_WithNullPartitionKey_ExtractsFromPartitionKeyPath() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task CreateItemAsync_WithNullPartitionKey_ExtractsFromPartitionKeyPath() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item); + var response = await _container.CreateItemAsync(item); - response.StatusCode.Should().Be(HttpStatusCode.Created); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Test"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task CreateItemAsync_ItemIsRetrievable() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 42 }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task CreateItemAsync_ItemIsRetrievable() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 42 }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Test"); - readResponse.Resource.Value.Should().Be(42); - } + readResponse.Resource.Name.Should().Be("Test"); + readResponse.Resource.Value.Should().Be(42); + } } public class ReadItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task ReadItemAsync_ExistingItem_ReturnsOk() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task ReadItemAsync_ExistingItem_ReturnsOk() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Test"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task ReadItemAsync_NonExistentItem_ThrowsNotFound() - { - var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + [Fact] + public async Task ReadItemAsync_NonExistentItem_ThrowsNotFound() + { + var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task ReadItemAsync_WrongPartitionKey_ThrowsNotFound() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task ReadItemAsync_WrongPartitionKey_ThrowsNotFound() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var act = () => _container.ReadItemAsync("1", new PartitionKey("wrong-pk")); + var act = () => _container.ReadItemAsync("1", new PartitionKey("wrong-pk")); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task ReadItemAsync_ReturnsETag() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task ReadItemAsync_ReturnsETag() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task ReadItemAsync_WithIfNoneMatchCurrentETag_ThrowsNotModified() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var etag = createResponse.ETag; + [Fact] + public async Task ReadItemAsync_WithIfNoneMatchCurrentETag_ThrowsNotModified() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var etag = createResponse.ETag; - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etag }); + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etag }); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } - [Fact] - public async Task ReadItemAsync_WithIfNoneMatchStaleETag_ReturnsOk() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); + [Fact] + public async Task ReadItemAsync_WithIfNoneMatchStaleETag_ReturnsOk() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class UpsertItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task UpsertItemAsync_NewItem_ReturnsCreated() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task UpsertItemAsync_ExistingItem_ReturnsOkWithUpdatedData() - { - var original = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(original, new PartitionKey("pk1")); - - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task UpsertItemAsync_WithIfMatchCurrentETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task UpsertItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var act = () => _container.UpsertItemAsync(updated, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task UpsertItemAsync_UpdatesETag() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var originalEtag = createResponse.ETag; - - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var upsertResponse = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); - - upsertResponse.ETag.Should().NotBe(originalEtag); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task UpsertItemAsync_NewItem_ReturnsCreated() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task UpsertItemAsync_ExistingItem_ReturnsOkWithUpdatedData() + { + var original = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(original, new PartitionKey("pk1")); + + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task UpsertItemAsync_WithIfMatchCurrentETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var act = () => _container.UpsertItemAsync(updated, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task UpsertItemAsync_UpdatesETag() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var originalEtag = createResponse.ETag; + + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var upsertResponse = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); + + upsertResponse.ETag.Should().NotBe(originalEtag); + } } public class ReplaceItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ReplaceItemAsync_ExistingItem_ReturnsOk() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - var response = await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task ReplaceItemAsync_NonExistentItem_ThrowsNotFound() - { - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItemAsync_WithIfMatchCurrentETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - var response = await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task ReplaceItemAsync_DataIsUpdated() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced", Value = 99 }; - await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Replaced"); - readResponse.Resource.Value.Should().Be(99); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ReplaceItemAsync_ExistingItem_ReturnsOk() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + var response = await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task ReplaceItemAsync_NonExistentItem_ThrowsNotFound() + { + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItemAsync_WithIfMatchCurrentETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + var response = await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ReplaceItemAsync_DataIsUpdated() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced", Value = 99 }; + await _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Replaced"); + readResponse.Resource.Value.Should().Be(99); + } } public class DeleteItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task DeleteItemAsync_ExistingItem_ReturnsNoContent() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteItemAsync_NonExistentItem_ThrowsNotFound() - { - var act = () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItemAsync_ItemNoLongerReadable() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItemAsync_WithIfMatchCurrentETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task DeleteItemAsync_OnlyAffectsSpecifiedPartitionKey() - { - var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Keep" }; - var item2 = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Delete" }; - await _container.CreateItemAsync(item1, new PartitionKey("pk1")); - await _container.CreateItemAsync(item2, new PartitionKey("pk2")); - - await _container.DeleteItemAsync("1", new PartitionKey("pk2")); - - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Keep"); - } - - [Fact] - public async Task DeleteItemAsync_CanRecreateAfterDelete() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var newItem = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }; - var response = await _container.CreateItemAsync(newItem, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Recreated"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task DeleteItemAsync_ExistingItem_ReturnsNoContent() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteItemAsync_NonExistentItem_ThrowsNotFound() + { + var act = () => _container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItemAsync_ItemNoLongerReadable() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItemAsync_WithIfMatchCurrentETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task DeleteItemAsync_OnlyAffectsSpecifiedPartitionKey() + { + var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Keep" }; + var item2 = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Delete" }; + await _container.CreateItemAsync(item1, new PartitionKey("pk1")); + await _container.CreateItemAsync(item2, new PartitionKey("pk2")); + + await _container.DeleteItemAsync("1", new PartitionKey("pk2")); + + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Keep"); + } + + [Fact] + public async Task DeleteItemAsync_CanRecreateAfterDelete() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var newItem = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }; + var response = await _container.CreateItemAsync(newItem, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Recreated"); + } } @@ -411,131 +411,131 @@ public async Task DeleteItemAsync_CanRecreateAfterDelete() /// public class CancellationTokenTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task CreateItem_WithCancelledToken_ThrowsOperationCancelled() - { - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1"), - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadItem_WithCancelledToken_ThrowsOperationCancelled() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => _container.ReadItemAsync( - "1", new PartitionKey("pk1"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task UpsertItem_WithCancelledToken_ThrowsOperationCancelled() - { - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var act = () => _container.UpsertItemAsync(item, new PartitionKey("pk1"), - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteItem_WithCancelledToken_ThrowsOperationCancelled() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => _container.DeleteItemAsync( - "1", new PartitionKey("pk1"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PatchItem_WithCancelledToken_ThrowsOperationCancelled() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var act = () => _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task CreateItem_WithCancelledToken_ThrowsOperationCancelled() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1"), + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadItem_WithCancelledToken_ThrowsOperationCancelled() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => _container.ReadItemAsync( + "1", new PartitionKey("pk1"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpsertItem_WithCancelledToken_ThrowsOperationCancelled() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var act = () => _container.UpsertItemAsync(item, new PartitionKey("pk1"), + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteItem_WithCancelledToken_ThrowsOperationCancelled() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => _container.DeleteItemAsync( + "1", new PartitionKey("pk1"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PatchItem_WithCancelledToken_ThrowsOperationCancelled() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var act = () => _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } } public class ReplaceItemGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_WithNullItem_Throws() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Orig" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.ReplaceItemAsync(null!, "1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Replace_IdParameterDiffersFromBody_ThrowsBadRequest() - { - // Create an item with id "1" - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - // Replace with id parameter = "1" but item body has id = "different" - // SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - var replacement = new TestDocument { Id = "different", PartitionKey = "pk1", Name = "Replaced" }; - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_WithNullItem_Throws() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Orig" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.ReplaceItemAsync(null!, "1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Replace_IdParameterDiffersFromBody_ThrowsBadRequest() + { + // Create an item with id "1" + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + // Replace with id parameter = "1" but item body has id = "different" + // SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + var replacement = new TestDocument { Id = "different", PartitionKey = "pk1", Name = "Replaced" }; + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -547,164 +547,164 @@ public async Task Replace_IdParameterDiffersFromBody_ThrowsBadRequest() /// public class ReplacePartitionKeyImmutabilityTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_WithDifferentPkInBody_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Replace with a different partition key value in the body — should throw BadRequest - var replacement = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }; - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_WithDifferentPkInBody_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Replace with a different partition key value in the body — should throw BadRequest + var replacement = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }; + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } } public class ReadItemGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Read_WrongPartitionKey_ThrowsNotFound() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("wrong-pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Read_AfterUpdate_ReturnsUpdatedData() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Read_NonExistent_ThrowsCosmosExceptionWith404() - { - var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Read_IfNoneMatch_WithStaleETag_ReturnsItem() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task Read_IfNoneMatch_WithCurrentETag_Returns304() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = createResponse.ETag }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Read_WrongPartitionKey_ThrowsNotFound() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("wrong-pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Read_AfterUpdate_ReturnsUpdatedData() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Read_NonExistent_ThrowsCosmosExceptionWith404() + { + var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Read_IfNoneMatch_WithStaleETag_ReturnsItem() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task Read_IfNoneMatch_WithCurrentETag_Returns304() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = createResponse.ETag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } } public class DeleteItemGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Delete_WithNullId_Throws() - { - var act = () => _container.DeleteItemAsync(null!, new PartitionKey("pk1")); + [Fact] + public async Task Delete_WithNullId_Throws() + { + var act = () => _container.DeleteItemAsync(null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Delete_RecordsTombstoneInChangeFeed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); + [Fact] + public async Task Delete_RecordsTombstoneInChangeFeed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var checkpointAfterCreate = _container.GetChangeFeedCheckpoint(); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - var checkpointAfterDelete = _container.GetChangeFeedCheckpoint(); - checkpointAfterDelete.Should().Be(checkpointAfterCreate + 1); - } + var checkpointAfterDelete = _container.GetChangeFeedCheckpoint(); + checkpointAfterDelete.Should().Be(checkpointAfterCreate + 1); + } } public class ReadItemGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Read_ResponseContainsETag() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Read_IfMatchEtag_IsNotEnforcedOnRead() - { - // Per docs: IfMatch is not supported on reads - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Should succeed even with a bogus IfMatch — reads don't use IfMatch - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"bogus\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Read_AfterDelete_Returns404() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Read_ResponseContainsETag() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Read_IfMatchEtag_IsNotEnforcedOnRead() + { + // Per docs: IfMatch is not supported on reads + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Should succeed even with a bogus IfMatch — reads don't use IfMatch + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"bogus\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Read_AfterDelete_Returns404() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } @@ -717,217 +717,217 @@ await _container.CreateItemAsync( /// public class SystemMetadataPropertyTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Read_ResponseBody_Contains_Ts_SystemProperty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - - // Real Cosmos DB includes _ts (Unix epoch seconds) in the response body - response.Resource["_ts"].Should().NotBeNull(); - response.Resource["_ts"]!.Type.Should().Be(JTokenType.Integer); - response.Resource["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task Read_ResponseBody_Contains_Etag_SystemProperty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - - // Real Cosmos DB includes _etag in the response body - response.Resource["_etag"].Should().NotBeNull(); - response.Resource["_etag"]!.Type.Should().Be(JTokenType.String); - response.Resource["_etag"]!.ToString().Should().NotBeEmpty(); - } - - [Fact] - public async Task Read_Ts_UpdatedOnReplace() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var readBefore = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = readBefore.Resource["_ts"]?.Value() ?? 0; - - // Small delay to ensure timestamp changes - await Task.Delay(10); - - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - var readAfter = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = readAfter.Resource["_ts"]?.Value() ?? 0; - - tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Read_ResponseBody_Contains_Ts_SystemProperty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + + // Real Cosmos DB includes _ts (Unix epoch seconds) in the response body + response.Resource["_ts"].Should().NotBeNull(); + response.Resource["_ts"]!.Type.Should().Be(JTokenType.Integer); + response.Resource["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task Read_ResponseBody_Contains_Etag_SystemProperty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + + // Real Cosmos DB includes _etag in the response body + response.Resource["_etag"].Should().NotBeNull(); + response.Resource["_etag"]!.Type.Should().Be(JTokenType.String); + response.Resource["_etag"]!.ToString().Should().NotBeEmpty(); + } + + [Fact] + public async Task Read_Ts_UpdatedOnReplace() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var readBefore = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = readBefore.Resource["_ts"]?.Value() ?? 0; + + // Small delay to ensure timestamp changes + await Task.Delay(10); + + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + var readAfter = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = readAfter.Resource["_ts"]?.Value() ?? 0; + + tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); + } } public class ReplaceItemGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_NonExistent_ThrowsNotFound() - { - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Replace_UpdatesETag() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replaceResponse = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - replaceResponse.ETag.Should().NotBe(createResponse.ETag); - } - - [Fact] - public async Task Replace_ResponseIs200() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Replace_RecordsInChangeFeed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var beforeReplace = _container.GetChangeFeedCheckpoint(); - - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - var afterReplace = _container.GetChangeFeedCheckpoint(); - afterReplace.Should().BeGreaterThan(beforeReplace); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_NonExistent_ThrowsNotFound() + { + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Replace_UpdatesETag() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replaceResponse = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + replaceResponse.ETag.Should().NotBe(createResponse.ETag); + } + + [Fact] + public async Task Replace_ResponseIs200() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Replace_RecordsInChangeFeed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var beforeReplace = _container.GetChangeFeedCheckpoint(); + + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + var afterReplace = _container.GetChangeFeedCheckpoint(); + afterReplace.Should().BeGreaterThan(beforeReplace); + } } public class UpsertItemGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Upsert_WithIfMatch_CorrectETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = create.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Upsert_WithIfMatch_OnNewItem_CreatesItem() - { - // If-Match is "applicable only on PUT and DELETE" per REST API docs. - // Upsert uses POST, so If-Match is ignored on the insert path. - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"nonexistent\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Upsert_WithNullItem_Throws() - { - var act = () => _container.UpsertItemAsync(null!, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Upsert_WithIfMatch_CorrectETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Upsert_WithIfMatch_OnNewItem_CreatesItem() + { + // If-Match is "applicable only on PUT and DELETE" per REST API docs. + // Upsert uses POST, so If-Match is ignored on the insert path. + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"nonexistent\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_WithNullItem_Throws() + { + var act = () => _container.UpsertItemAsync(null!, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } } public class DeleteItemGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Delete_ResponseIs204() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Delete_WithIfMatch_CorrectETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Delete_WithIfMatch_WrongETag_ThrowsPreconditionFailed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Delete_AfterDelete_CanRecreate() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var recreated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }; - var response = await _container.CreateItemAsync(recreated, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Recreated"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Delete_ResponseIs204() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Delete_WithIfMatch_CorrectETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = createResponse.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Delete_WithIfMatch_WrongETag_ThrowsPreconditionFailed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Delete_AfterDelete_CanRecreate() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var recreated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }; + var response = await _container.CreateItemAsync(recreated, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Recreated"); + } } @@ -938,898 +938,898 @@ public async Task Delete_AfterDelete_CanRecreate() /// public class DeleteEdgeCaseTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Delete_WithIfNoneMatchEtag_IsIgnored_DeleteSucceeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // IfNoneMatch is for conditional reads, not deletes. Should be ignored. - var response = await _container.DeleteItemAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Delete_WithIfNoneMatchEtag_IsIgnored_DeleteSucceeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // IfNoneMatch is for conditional reads, not deletes. Should be ignored. + var response = await _container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } public class CreateItemGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Create_WithPartitionKeyNone_ExtractsFromDocument() - { - // PartitionKey.None falls through to extract PK from document body - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task Create_WithPartitionKeyNone_ExtractsFromDocument() + { + // PartitionKey.None falls through to extract PK from document body + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, PartitionKey.None); + var response = await _container.CreateItemAsync(item, PartitionKey.None); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - // Retrievable with the document's PK value - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } + // Retrievable with the document's PK value + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task Create_ResponseContainsCorrectStatusCode_201() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task Create_ResponseContainsCorrectStatusCode_201() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task Create_ResponseContainsRequestCharge() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task Create_ResponseContainsRequestCharge() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThan(0); - } + response.RequestCharge.Should().BeGreaterThan(0); + } - [Fact] - public async Task Create_ItemWithSystemProperties_OverwrittenBySystem() - { - // Create item that includes _ts and _etag in the JSON - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ts":999999,"_etag":"\"fake\""}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + [Fact] + public async Task Create_ItemWithSystemProperties_OverwrittenBySystem() + { + // Create item that includes _ts and _etag in the JSON + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ts":999999,"_etag":"\"fake\""}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; - // _etag should be overwritten by the system, not "fake" - etag.Should().NotBe("\"fake\""); - } + // _etag should be overwritten by the system, not "fake" + etag.Should().NotBe("\"fake\""); + } } public class CrudNullGuardTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_WithNullItem_Throws() - { - var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReplaceItemAsync_WithNullId_Throws() - { - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReplaceItemAsync_WithEmptyId_ThrowsBadRequest() - { - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - "", new PartitionKey("pk1")); - - // Body id "1" differs from parameter id "" — returns BadRequest - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReadItemAsync_WithNullId_Throws() - { - var act = () => _container.ReadItemAsync(null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteItemAsync_WithNullId_Throws() - { - var act = () => _container.DeleteItemAsync(null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PatchItemAsync_WithNullId_Throws() - { - var act = () => _container.PatchItemAsync( - null!, new PartitionKey("pk1"), - [PatchOperation.Set("/name", "test")]); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PatchItemAsync_WithNullOperations_Throws() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), null!); - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_WithNullItem_Throws() + { + var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceItemAsync_WithNullId_Throws() + { + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + null!, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceItemAsync_WithEmptyId_ThrowsBadRequest() + { + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + "", new PartitionKey("pk1")); + + // Body id "1" differs from parameter id "" — returns BadRequest + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReadItemAsync_WithNullId_Throws() + { + var act = () => _container.ReadItemAsync(null!, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteItemAsync_WithNullId_Throws() + { + var act = () => _container.DeleteItemAsync(null!, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PatchItemAsync_WithNullId_Throws() + { + var act = () => _container.PatchItemAsync( + null!, new PartitionKey("pk1"), + [PatchOperation.Set("/name", "test")]); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PatchItemAsync_WithNullOperations_Throws() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), null!); + await act.Should().ThrowAsync(); + } } public class DeleteAllItemsByPartitionKeyTests { - [Fact] - public async Task DeleteAllByPartitionKey_RemovesOnlyThatPartition() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie" }, - new PartitionKey("pk1")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - container.ItemCount.Should().Be(1); - - var read = await container.ReadItemAsync("2", new PartitionKey("pk2")); - read.Resource.Name.Should().Be("Bob"); - } + [Fact] + public async Task DeleteAllByPartitionKey_RemovesOnlyThatPartition() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie" }, + new PartitionKey("pk1")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + container.ItemCount.Should().Be(1); + + var read = await container.ReadItemAsync("2", new PartitionKey("pk2")); + read.Resource.Name.Should().Be("Bob"); + } } public class ReplaceItemGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_WithDifferentPartitionKey_InBody_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Replace with a different PK value in the body but same PK parameter — should throw - var replacement = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }; - var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_WithDifferentPartitionKey_InBody_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Replace with a different PK value in the body but same PK parameter — should throw + var replacement = new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }; + var act = () => _container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } } public class CreateItemGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Create_WithExplicitPartitionKey_ExtractsFromDocument_WhenNoneProvided() - { - var item = new TestDocument { Id = "auto-pk", PartitionKey = "pk1", Name = "Auto PK" }; + [Fact] + public async Task Create_WithExplicitPartitionKey_ExtractsFromDocument_WhenNoneProvided() + { + var item = new TestDocument { Id = "auto-pk", PartitionKey = "pk1", Name = "Auto PK" }; - var response = await _container.CreateItemAsync(item); + var response = await _container.CreateItemAsync(item); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("auto-pk", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Auto PK"); - } + var read = await _container.ReadItemAsync("auto-pk", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Auto PK"); + } - [Fact] - public async Task Create_ResponseContainsETag_NonNullNonEmpty() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + [Fact] + public async Task Create_ResponseContainsETag_NonNullNonEmpty() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } + response.ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Create_ResponseBodyMatchesInput() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 42 }; + [Fact] + public async Task Create_ResponseBodyMatchesInput() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 42 }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.Resource.Id.Should().Be("1"); - response.Resource.Name.Should().Be("Test"); - response.Resource.Value.Should().Be(42); - } + response.Resource.Id.Should().Be("1"); + response.Resource.Name.Should().Be("Test"); + response.Resource.Value.Should().Be(42); + } - [Fact] - public async Task Create_WithNestedPartitionKeyPath_ExtractsCorrectly() - { - var container = new InMemoryContainer("test-container", "/nested/description"); - var item = new TestDocument - { - Id = "1", - PartitionKey = "ignored", - Name = "Test", - Nested = new NestedObject { Description = "deep-pk", Score = 1.0 } - }; + [Fact] + public async Task Create_WithNestedPartitionKeyPath_ExtractsCorrectly() + { + var container = new InMemoryContainer("test-container", "/nested/description"); + var item = new TestDocument + { + Id = "1", + PartitionKey = "ignored", + Name = "Test", + Nested = new NestedObject { Description = "deep-pk", Score = 1.0 } + }; - var response = await container.CreateItemAsync(item, new PartitionKey("deep-pk")); + var response = await container.CreateItemAsync(item, new PartitionKey("deep-pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await container.ReadItemAsync("1", new PartitionKey("deep-pk")); - read.Resource.Name.Should().Be("Test"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("deep-pk")); + read.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task Create_WithIdContainingSpecialCharacters_Succeeds() - { - var item = new TestDocument { Id = "id/with?special#chars", PartitionKey = "pk1", Name = "Special" }; + [Fact] + public async Task Create_WithIdContainingSpecialCharacters_Succeeds() + { + var item = new TestDocument { Id = "id/with?special#chars", PartitionKey = "pk1", Name = "Special" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("id/with?special#chars", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Special"); - } + var read = await _container.ReadItemAsync("id/with?special#chars", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Special"); + } - [Fact] - public async Task Create_WithIdContainingUnicode_Succeeds() - { - var item = new TestDocument { Id = "日本語-id", PartitionKey = "pk1", Name = "Unicode" }; + [Fact] + public async Task Create_WithIdContainingUnicode_Succeeds() + { + var item = new TestDocument { Id = "日本語-id", PartitionKey = "pk1", Name = "Unicode" }; - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - var read = await _container.ReadItemAsync("日本語-id", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Unicode"); - } + var read = await _container.ReadItemAsync("日本語-id", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Unicode"); + } - [Fact] - public async Task Create_SameIdDifferentPartitionKey_BothRetrievable() - { - var item1 = new TestDocument { Id = "same", PartitionKey = "pk1", Name = "First" }; - var item2 = new TestDocument { Id = "same", PartitionKey = "pk2", Name = "Second" }; + [Fact] + public async Task Create_SameIdDifferentPartitionKey_BothRetrievable() + { + var item1 = new TestDocument { Id = "same", PartitionKey = "pk1", Name = "First" }; + var item2 = new TestDocument { Id = "same", PartitionKey = "pk2", Name = "Second" }; - await _container.CreateItemAsync(item1, new PartitionKey("pk1")); - await _container.CreateItemAsync(item2, new PartitionKey("pk2")); + await _container.CreateItemAsync(item1, new PartitionKey("pk1")); + await _container.CreateItemAsync(item2, new PartitionKey("pk2")); - var read1 = await _container.ReadItemAsync("same", new PartitionKey("pk1")); - var read2 = await _container.ReadItemAsync("same", new PartitionKey("pk2")); + var read1 = await _container.ReadItemAsync("same", new PartitionKey("pk1")); + var read2 = await _container.ReadItemAsync("same", new PartitionKey("pk2")); - read1.Resource.Name.Should().Be("First"); - read2.Resource.Name.Should().Be("Second"); - } + read1.Resource.Name.Should().Be("First"); + read2.Resource.Name.Should().Be("Second"); + } } public class CreateItemGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Create_WithNullItem_ThrowsArgumentNullException() - { - var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Create_WithEmptyId_Succeeds_OrThrows() - { - // Cosmos rejects empty ID with 400; InMemoryContainer may differ - var item = new TestDocument { Id = "", PartitionKey = "pk1", Name = "EmptyId" }; - - try - { - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - // If it succeeds, that's a behavioral difference — it should still be retrievable - response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.BadRequest); - } - catch (CosmosException ex) - { - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - } - - [Fact] - public async Task Create_WithVeryLongId_255Chars_Succeeds() - { - var longId = new string('a', 255); - var item = new TestDocument { Id = longId, PartitionKey = "pk1", Name = "LongId" }; - - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await _container.ReadItemAsync(longId, new PartitionKey("pk1")); - read.Resource.Name.Should().Be("LongId"); - } - - [Fact] - public async Task Create_WithEnableContentResponseOnWrite_False_ResourceIsNull() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task Create_WithCompositePartitionKey_TwoPaths() - { - var container = new InMemoryContainer("test", new[] { "/partitionKey", "/name" }); - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await container.CreateItemAsync(item); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_WithCancellationToken_Cancelled_ThrowsOperationCanceledException() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1"), - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Create_WithNullItem_ThrowsArgumentNullException() + { + var act = () => _container.CreateItemAsync(null!, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Create_WithEmptyId_Succeeds_OrThrows() + { + // Cosmos rejects empty ID with 400; InMemoryContainer may differ + var item = new TestDocument { Id = "", PartitionKey = "pk1", Name = "EmptyId" }; + + try + { + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + // If it succeeds, that's a behavioral difference — it should still be retrievable + response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.BadRequest); + } + catch (CosmosException ex) + { + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + } + + [Fact] + public async Task Create_WithVeryLongId_255Chars_Succeeds() + { + var longId = new string('a', 255); + var item = new TestDocument { Id = longId, PartitionKey = "pk1", Name = "LongId" }; + + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await _container.ReadItemAsync(longId, new PartitionKey("pk1")); + read.Resource.Name.Should().Be("LongId"); + } + + [Fact] + public async Task Create_WithEnableContentResponseOnWrite_False_ResourceIsNull() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task Create_WithCompositePartitionKey_TwoPaths() + { + var container = new InMemoryContainer("test", new[] { "/partitionKey", "/name" }); + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await container.CreateItemAsync(item); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_WithCancellationToken_Cancelled_ThrowsOperationCanceledException() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var act = () => _container.CreateItemAsync(item, new PartitionKey("pk1"), + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } } public class ReadItemGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Read_WithNullId_Throws() - { - var act = () => _container.ReadItemAsync(null!, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Read_WithEmptyId_ThrowsNotFound() - { - var act = () => _container.ReadItemAsync("", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Read_CosmosException_Contains404StatusCode() - { - var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Which.Message.Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Read_WithNullId_Throws() + { + var act = () => _container.ReadItemAsync(null!, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Read_WithEmptyId_ThrowsNotFound() + { + var act = () => _container.ReadItemAsync("", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Read_CosmosException_Contains404StatusCode() + { + var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Which.Message.Should().NotBeNullOrEmpty(); + } } public class EnableContentResponseOnWriteTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Upsert_WithEnableContentResponseOnWrite_False_ResourceIsNull() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Replace_WithEnableContentResponseOnWrite_False_ResourceIsNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Patch_WithEnableContentResponseOnWrite_False_ResourceIsNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Create_WithEnableContentResponseOnWrite_True_ResourceIsPopulated() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = true }); - - response.Resource.Should().NotBeNull(); - response.Resource.Name.Should().Be("Test"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Upsert_WithEnableContentResponseOnWrite_False_ResourceIsNull() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Replace_WithEnableContentResponseOnWrite_False_ResourceIsNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_WithEnableContentResponseOnWrite_False_ResourceIsNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Create_WithEnableContentResponseOnWrite_True_ResourceIsPopulated() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = true }); + + response.Resource.Should().NotBeNull(); + response.Resource.Name.Should().Be("Test"); + } } public class ItemRequestOptionsEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ReadItemAsync_WithIfNoneMatch_WildcardStar_ThrowsNotModified() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task UpsertItemAsync_WithIfMatch_WildcardStar_AlwaysSucceeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task CreateItemAsync_WithSessionToken_DoesNotThrow() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), - new ItemRequestOptions { SessionToken = "0:some-session-token" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReadItemAsync_WithConsistencyLevel_DoesNotThrow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteItemAsync_WithIfMatch_WildcardStar_AlwaysSucceeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ReadItemAsync_WithIfNoneMatch_WildcardStar_ThrowsNotModified() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task UpsertItemAsync_WithIfMatch_WildcardStar_AlwaysSucceeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task CreateItemAsync_WithSessionToken_DoesNotThrow() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), + new ItemRequestOptions { SessionToken = "0:some-session-token" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReadItemAsync_WithConsistencyLevel_DoesNotThrow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteItemAsync_WithIfMatch_WildcardStar_AlwaysSucceeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } public class DeleteItemGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Delete_ResponseResource_IsAlwaysNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - response.Resource.Should().BeNull(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Delete_WithEnableContentResponseOnWrite_StillReturnsNoContent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - response.Resource.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Delete_ResponseResource_IsAlwaysNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + response.Resource.Should().BeNull(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Delete_WithEnableContentResponseOnWrite_StillReturnsNoContent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Resource.Should().BeNull(); + } } public class UpsertItemGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Upsert_NewItem_Returns201() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }; - - var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Upsert_ExistingItem_Returns200() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; - var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Upsert_StatusCodeDistinguishes_CreateVsReplace() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; - - var createResponse = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var replaceResponse = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Upsert_WithIfMatch_StaleETag_ThrowsPreconditionFailed() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Upsert_ReplacesEntireDocument_NotMerge() - { - var item = new TestDocument - { - Id = "1", - PartitionKey = "pk1", - Name = "Original", - Value = 42, - Tags = ["tag1", "tag2"] - }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; - await _container.UpsertItemAsync(replacement, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Replaced"); - read.Resource.Value.Should().Be(0); - read.Resource.Tags.Should().BeEmpty(); - } - - [Fact] - public async Task Upsert_UpdatesETag() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var upsertResponse = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - upsertResponse.ETag.Should().NotBe(createResponse.ETag); - } - - [Fact] - public async Task Upsert_RecordsInChangeFeed() - { - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - checkpoint.Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Upsert_NewItem_Returns201() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }; + + var response = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_ExistingItem_Returns200() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }; + var response = await _container.UpsertItemAsync(updated, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Upsert_StatusCodeDistinguishes_CreateVsReplace() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; + + var createResponse = await _container.UpsertItemAsync(item, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var replaceResponse = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Upsert_WithIfMatch_StaleETag_ThrowsPreconditionFailed() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Upsert_ReplacesEntireDocument_NotMerge() + { + var item = new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Original", + Value = 42, + Tags = ["tag1", "tag2"] + }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var replacement = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }; + await _container.UpsertItemAsync(replacement, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Replaced"); + read.Resource.Value.Should().Be(0); + read.Resource.Tags.Should().BeEmpty(); + } + + [Fact] + public async Task Upsert_UpdatesETag() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var upsertResponse = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + upsertResponse.ETag.Should().NotBe(createResponse.ETag); + } + + [Fact] + public async Task Upsert_RecordsInChangeFeed() + { + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + checkpoint.Should().BeGreaterThan(0); + } } // ─── ReplaceItemAsync Unique Key Validation ───────────────────────────── public class ReplaceItemUniqueKeyTests { - [Fact] - public async Task ReplaceItem_ViolatesUniqueKey_ThrowsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - 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")); - - // Replace item 2 changing email to collide with item 1 - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), - "2", new PartitionKey("a")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + [Fact] + public async Task ReplaceItem_ViolatesUniqueKey_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + 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")); + + // Replace item 2 changing email to collide with item 1 + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), + "2", new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ─── PatchItemAsync Unique Key Validation ─────────────────────────────── public class PatchItemUniqueKeyTests { - [Fact] - public async Task PatchItem_ViolatesUniqueKey_ThrowsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - 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")); - - // Patch item 2 changing email to collide with item 1 - var act = () => container.PatchItemAsync( - "2", new PartitionKey("a"), - new[] { PatchOperation.Replace("/email", "alice@test.com") }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + [Fact] + public async Task PatchItem_ViolatesUniqueKey_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + 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")); + + // Patch item 2 changing email to collide with item 1 + var act = () => container.PatchItemAsync( + "2", new PartitionKey("a"), + new[] { PatchOperation.Replace("/email", "alice@test.com") }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ─── Stream CRUD Unique Key Validation ────────────────────────────────── public class StreamCrudUniqueKeyTests { - private static MemoryStream ToStream(object obj) => - new(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj))); - - [Fact] - public async Task CreateItemStream_ViolatesUniqueKey_ReturnsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - 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); - } - - [Fact] - public async Task UpsertItemStream_ViolatesUniqueKey_OfDifferentItem_ReturnsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemStreamAsync( - ToStream(new { id = "1", pk = "a", email = "alice@test.com" }), - new PartitionKey("a")); - - var response = await container.UpsertItemStreamAsync( - ToStream(new { id = "2", pk = "a", email = "alice@test.com" }), - new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReplaceItemStream_ViolatesUniqueKey_ReturnsConflict() - { - var properties = new ContainerProperties("unique-ctr", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - 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); - } + private static MemoryStream ToStream(object obj) => + new(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(obj))); + + [Fact] + public async Task CreateItemStream_ViolatesUniqueKey_ReturnsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + 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); + } + + [Fact] + public async Task UpsertItemStream_ViolatesUniqueKey_OfDifferentItem_ReturnsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemStreamAsync( + ToStream(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var response = await container.UpsertItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceItemStream_ViolatesUniqueKey_ReturnsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + 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); + } } // ─── Trigger Execution ────────────────────────────────────────────────── public class TriggerExecutionTests { - /// - /// Pre-triggers can now modify documents via C# handlers registered with RegisterTrigger. - /// The trigger is registered as a C# Func<JObject, JObject> and fires when - /// PreTriggers is specified in ItemRequestOptions. - /// - [Fact] - public async Task PreTrigger_ShouldModifyDocumentOnCreate() - { - var container = new InMemoryContainer("test-container", "/pk"); - - // Register a C# pre-trigger that adds a 'createdBy' field - container.RegisterTrigger("addCreatedBy", TriggerType.Pre, TriggerOperation.Create, - preHandler: doc => - { - doc["createdBy"] = "trigger"; - return doc; - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addCreatedBy" } }); - - var response = await container.ReadItemAsync("1", new PartitionKey("a")); - response.Resource["createdBy"]!.Value().Should().Be("trigger"); - } - - /// - /// Demonstrates that CreateTriggerAsync alone (without RegisterTrigger) stores trigger - /// metadata but does not cause trigger execution. JavaScript bodies are not interpreted. - /// To get trigger execution, use RegisterTrigger with a C# handler. - /// - [Fact] - public async Task PreTrigger_CreateTriggerAsyncAlone_DoesNotFireWithoutRegisterTrigger() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: trigger body is executed as server-side JavaScript. - // The trigger can read/modify the incoming document before it is committed. - // In-Memory Emulator: CreateTriggerAsync stores trigger metadata (returns 201 Created) - // but does not execute JavaScript bodies. To get trigger execution, register a - // C# handler via container.RegisterTrigger(). If PreTriggers is specified in - // ItemRequestOptions but no C# handler is registered, the trigger is not found - // and a BadRequest (400) is thrown. - var container = new InMemoryContainer("test-container", "/pk"); - - // This succeeds (201 Created) — metadata is stored. - var triggerResponse = await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addCreatedBy", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = @"function() { /* would add createdBy */ }" - }); - triggerResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Create an item without specifying PreTriggers — no trigger fires. - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Verify the trigger did NOT modify the document. - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["createdBy"].Should().BeNull( - "CreateTriggerAsync alone does not enable trigger execution — use RegisterTrigger"); - } + /// + /// Pre-triggers can now modify documents via C# handlers registered with RegisterTrigger. + /// The trigger is registered as a C# Func<JObject, JObject> and fires when + /// PreTriggers is specified in ItemRequestOptions. + /// + [Fact] + public async Task PreTrigger_ShouldModifyDocumentOnCreate() + { + var container = new InMemoryContainer("test-container", "/pk"); + + // Register a C# pre-trigger that adds a 'createdBy' field + container.RegisterTrigger("addCreatedBy", TriggerType.Pre, TriggerOperation.Create, + preHandler: doc => + { + doc["createdBy"] = "trigger"; + return doc; + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addCreatedBy" } }); + + var response = await container.ReadItemAsync("1", new PartitionKey("a")); + response.Resource["createdBy"]!.Value().Should().Be("trigger"); + } + + /// + /// Demonstrates that CreateTriggerAsync alone (without RegisterTrigger) stores trigger + /// metadata but does not cause trigger execution. JavaScript bodies are not interpreted. + /// To get trigger execution, use RegisterTrigger with a C# handler. + /// + [Fact] + public async Task PreTrigger_CreateTriggerAsyncAlone_DoesNotFireWithoutRegisterTrigger() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: trigger body is executed as server-side JavaScript. + // The trigger can read/modify the incoming document before it is committed. + // In-Memory Emulator: CreateTriggerAsync stores trigger metadata (returns 201 Created) + // but does not execute JavaScript bodies. To get trigger execution, register a + // C# handler via container.RegisterTrigger(). If PreTriggers is specified in + // ItemRequestOptions but no C# handler is registered, the trigger is not found + // and a BadRequest (400) is thrown. + var container = new InMemoryContainer("test-container", "/pk"); + + // This succeeds (201 Created) — metadata is stored. + var triggerResponse = await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addCreatedBy", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = @"function() { /* would add createdBy */ }" + }); + triggerResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Create an item without specifying PreTriggers — no trigger fires. + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Verify the trigger did NOT modify the document. + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["createdBy"].Should().BeNull( + "CreateTriggerAsync alone does not enable trigger execution — use RegisterTrigger"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1838,16 +1838,16 @@ await container.CreateItemAsync( public class ReadItemErrorMessageTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task ReadItemAsync_NotFound_ErrorMessageDoesNotSayAlreadyExists() - { - var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Which.Message.Should().NotContain("already exists"); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReadItemAsync_NotFound_ErrorMessageDoesNotSayAlreadyExists() + { + var act = () => _container.ReadItemAsync("missing", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Which.Message.Should().NotContain("already exists"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1856,108 +1856,108 @@ public async Task ReadItemAsync_NotFound_ErrorMessageDoesNotSayAlreadyExists() public class CrudIfMatchWildcardTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Upsert_WithIfMatchWildcard_ExistingItem_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Replace_WithIfMatchWildcard_ExistingItem_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Delete_WithIfMatchWildcard_ExistingItem_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Upsert_WithIfMatchWildcard_ExistingItem_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Replace_WithIfMatchWildcard_ExistingItem_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Delete_WithIfMatchWildcard_ExistingItem_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } public class CrudETagFormatTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Create_ETagHasQuotedHexFormat() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - response.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); - } - - [Fact] - public async Task Create_TwoConsecutiveCreates_DifferentETags() - { - var r1 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var r2 = await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - - r1.ETag.Should().NotBe(r2.ETag); - } - - [Fact] - public async Task Upsert_SameDataUpserted_StillChangesETag() - { - var r1 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var r2 = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - r2.ETag.Should().NotBe(r1.ETag); - } - - [Fact] - public async Task ETagInBody_MatchesResponseHeader_OnCreate() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_etag"]?.ToString().Should().Be(read.ETag); - } - - [Fact] - public async Task ETagInBody_MatchesResponseHeader_OnReplace() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_etag"]?.ToString().Should().Be(read.ETag); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Create_ETagHasQuotedHexFormat() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + response.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); + } + + [Fact] + public async Task Create_TwoConsecutiveCreates_DifferentETags() + { + var r1 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var r2 = await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + + r1.ETag.Should().NotBe(r2.ETag); + } + + [Fact] + public async Task Upsert_SameDataUpserted_StillChangesETag() + { + var r1 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var r2 = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + r2.ETag.Should().NotBe(r1.ETag); + } + + [Fact] + public async Task ETagInBody_MatchesResponseHeader_OnCreate() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_etag"]?.ToString().Should().Be(read.ETag); + } + + [Fact] + public async Task ETagInBody_MatchesResponseHeader_OnReplace() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_etag"]?.ToString().Should().Be(read.ETag); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1966,52 +1966,52 @@ await _container.ReplaceItemAsync( public class CrudResponseMetadataTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Read_RequestCharge_IsGreaterThanZero() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task Upsert_RequestCharge_IsGreaterThanZero() - { - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task Replace_RequestCharge_IsGreaterThanZero() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task Delete_RequestCharge_IsGreaterThanZero() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task Delete_ResponseResource_IsNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - response.Resource.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Read_RequestCharge_IsGreaterThanZero() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task Upsert_RequestCharge_IsGreaterThanZero() + { + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task Replace_RequestCharge_IsGreaterThanZero() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task Delete_RequestCharge_IsGreaterThanZero() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task Delete_ResponseResource_IsNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + response.Resource.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2020,55 +2020,55 @@ await _container.CreateItemAsync( public class CrudTimestampTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Create_Ts_IsWithin60SecondsOfNow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var ts = read.Resource["_ts"]!.Value(); - var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - - ts.Should().BeGreaterThanOrEqualTo(now - 60); - ts.Should().BeLessThanOrEqualTo(now + 5); - } - - [Fact] - public async Task Replace_Ts_IsGreaterThanOrEqualToCreateTs() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var before = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) - .Resource["_ts"]!.Value(); - - await Task.Delay(10); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); - var after = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) - .Resource["_ts"]!.Value(); - - after.Should().BeGreaterThanOrEqualTo(before); - } - - [Fact] - public async Task Upsert_Update_Ts_IsGreaterThanOrEqualToCreateTs() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var before = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) - .Resource["_ts"]!.Value(); - - await Task.Delay(10); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - var after = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) - .Resource["_ts"]!.Value(); - - after.Should().BeGreaterThanOrEqualTo(before); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Create_Ts_IsWithin60SecondsOfNow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var ts = read.Resource["_ts"]!.Value(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + ts.Should().BeGreaterThanOrEqualTo(now - 60); + ts.Should().BeLessThanOrEqualTo(now + 5); + } + + [Fact] + public async Task Replace_Ts_IsGreaterThanOrEqualToCreateTs() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var before = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) + .Resource["_ts"]!.Value(); + + await Task.Delay(10); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, "1", new PartitionKey("pk1")); + var after = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) + .Resource["_ts"]!.Value(); + + after.Should().BeGreaterThanOrEqualTo(before); + } + + [Fact] + public async Task Upsert_Update_Ts_IsGreaterThanOrEqualToCreateTs() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var before = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) + .Resource["_ts"]!.Value(); + + await Task.Delay(10); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + var after = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))) + .Resource["_ts"]!.Value(); + + after.Should().BeGreaterThanOrEqualTo(before); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2077,54 +2077,54 @@ await _container.UpsertItemAsync( public class CrudPartitionKeyEdgeCaseTests { - [Fact] - public async Task Create_WithEmptyStringPartitionKey_Succeeds() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "" }), new PartitionKey("")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await container.ReadItemAsync("1", new PartitionKey("")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Create_WithNullPropertyValues_Preserved() - { - var container = new InMemoryContainer("test", "/pk"); - var doc = JObject.FromObject(new { id = "1", pk = "a", name = (string?)null }); - await container.CreateItemAsync(doc, new PartitionKey("a")); - - var read = await container.ReadItemAsync("1", new PartitionKey("a")); - read.Resource["name"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Create_WithExtraProperties_Preserved() - { - var container = new InMemoryContainer("test", "/pk"); - var doc = JObject.FromObject(new { id = "1", pk = "a", extraProp = "value", nested = new { x = 1 } }); - await container.CreateItemAsync(doc, new PartitionKey("a")); - - var read = await container.ReadItemAsync("1", new PartitionKey("a")); - read.Resource["extraProp"]?.ToString().Should().Be("value"); - read.Resource["nested"]?["x"]?.Value().Should().Be(1); - } - - [Fact] - public async Task Upsert_WithPartitionKeyNone_ExtractsFromDocument() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task Create_WithEmptyStringPartitionKey_Succeeds() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "" }), new PartitionKey("")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await container.ReadItemAsync("1", new PartitionKey("")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Create_WithNullPropertyValues_Preserved() + { + var container = new InMemoryContainer("test", "/pk"); + var doc = JObject.FromObject(new { id = "1", pk = "a", name = (string?)null }); + await container.CreateItemAsync(doc, new PartitionKey("a")); + + var read = await container.ReadItemAsync("1", new PartitionKey("a")); + read.Resource["name"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Create_WithExtraProperties_Preserved() + { + var container = new InMemoryContainer("test", "/pk"); + var doc = JObject.FromObject(new { id = "1", pk = "a", extraProp = "value", nested = new { x = 1 } }); + await container.CreateItemAsync(doc, new PartitionKey("a")); + + var read = await container.ReadItemAsync("1", new PartitionKey("a")); + read.Resource["extraProp"]?.ToString().Should().Be("value"); + read.Resource["nested"]?["x"]?.Value().Should().Be(1); + } + + [Fact] + public async Task Upsert_WithPartitionKeyNone_ExtractsFromDocument() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2133,40 +2133,40 @@ public async Task Upsert_WithPartitionKeyNone_ExtractsFromDocument() public class CrudDocumentSizeValidationTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Create_Over2MB_ThrowsRequestEntityTooLarge() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Replace_Over2MB_ThrowsRequestEntityTooLarge() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, new PartitionKey("pk1")); - - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; - var act = () => _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Upsert_Over2MB_ThrowsRequestEntityTooLarge() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; - var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Create_Over2MB_ThrowsRequestEntityTooLarge() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Replace_Over2MB_ThrowsRequestEntityTooLarge() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, new PartitionKey("pk1")); + + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; + var act = () => _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Upsert_Over2MB_ThrowsRequestEntityTooLarge() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('x', 2_100_000) }; + var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2175,40 +2175,40 @@ public async Task Upsert_Over2MB_ThrowsRequestEntityTooLarge() public class CrudEmptyIdEdgeCaseTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); + private readonly InMemoryContainer _container = new("test", "/partitionKey"); - [Fact] - public async Task Replace_WithEmptyStringId_ThrowsOrFails() - { - var doc = new TestDocument { Id = "", PartitionKey = "pk1", Name = "A" }; - var act = () => _container.ReplaceItemAsync(doc, "", new PartitionKey("pk1")); + [Fact] + public async Task Replace_WithEmptyStringId_ThrowsOrFails() + { + var doc = new TestDocument { Id = "", PartitionKey = "pk1", Name = "A" }; + var act = () => _container.ReplaceItemAsync(doc, "", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Delete_WithEmptyStringId_ThrowsOrFails() - { - var act = () => _container.DeleteItemAsync("", new PartitionKey("pk1")); + [Fact] + public async Task Delete_WithEmptyStringId_ThrowsOrFails() + { + var act = () => _container.DeleteItemAsync("", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Replace_WithIfNoneMatchEtag_IsIgnoredOnWrites() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + [Fact] + public async Task Replace_WithIfNoneMatchEtag_IsIgnoredOnWrites() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = read.ETag }); + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = read.ETag }); - response.StatusCode.Should().Be(HttpStatusCode.OK, "IfNoneMatch should be ignored on write operations"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK, "IfNoneMatch should be ignored on write operations"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2217,43 +2217,43 @@ await _container.CreateItemAsync( public class CrudChangeFeedIntegrationTests { - [Fact] - public async Task Create_RecordsChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var before = container.GetChangeFeedCheckpoint(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - container.GetChangeFeedCheckpoint().Should().Be(before + 1); - } - - [Fact] - public async Task Upsert_InsertPath_RecordsChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var before = container.GetChangeFeedCheckpoint(); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - container.GetChangeFeedCheckpoint().Should().Be(before + 1); - } - - [Fact] - public async Task Upsert_UpdatePath_RecordsChangeFeedEntry() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - var before = container.GetChangeFeedCheckpoint(); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - - container.GetChangeFeedCheckpoint().Should().Be(before + 1); - } + [Fact] + public async Task Create_RecordsChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var before = container.GetChangeFeedCheckpoint(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + container.GetChangeFeedCheckpoint().Should().Be(before + 1); + } + + [Fact] + public async Task Upsert_InsertPath_RecordsChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var before = container.GetChangeFeedCheckpoint(); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + container.GetChangeFeedCheckpoint().Should().Be(before + 1); + } + + [Fact] + public async Task Upsert_UpdatePath_RecordsChangeFeedEntry() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + var before = container.GetChangeFeedCheckpoint(); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + + container.GetChangeFeedCheckpoint().Should().Be(before + 1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2262,54 +2262,54 @@ await container.UpsertItemAsync( public class DeleteAllByPKExtendedGapTests { - [Fact] - public async Task DeleteAll_RecordsChangeFeedTombstones() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - var before = container.GetChangeFeedCheckpoint(); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - container.GetChangeFeedCheckpoint().Should().Be(before + 2); - } - - [Fact] - public async Task DeleteAll_ThenRecreateItemsInSamePartition() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteAll_DoesNotAffectOtherPartitions_ItemCounts() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "D" }, new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "5", PartitionKey = "pk2", Name = "E" }, new PartitionKey("pk2")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - container.ItemCount.Should().Be(2); - } + [Fact] + public async Task DeleteAll_RecordsChangeFeedTombstones() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + var before = container.GetChangeFeedCheckpoint(); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + container.GetChangeFeedCheckpoint().Should().Be(before + 2); + } + + [Fact] + public async Task DeleteAll_ThenRecreateItemsInSamePartition() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteAll_DoesNotAffectOtherPartitions_ItemCounts() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "D" }, new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "5", PartitionKey = "pk2", Name = "E" }, new PartitionKey("pk2")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + container.ItemCount.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2318,22 +2318,22 @@ await container.CreateItemAsync( public class ReplaceItemCancelledTokenTests { - [Fact] - public async Task ReplaceItem_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - "1", new PartitionKey("pk1"), cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } + [Fact] + public async Task ReplaceItem_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + "1", new PartitionKey("pk1"), cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2342,23 +2342,23 @@ await container.CreateItemAsync( public class CrudSystemPropertyDivergentTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Read_ResponseBody_Contains_Rid_SystemProperty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource["_rid"].Should().NotBeNull(); - } - - [Fact] - public async Task Read_ResponseBody_Contains_Self_SystemProperty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource["_self"].Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Read_ResponseBody_Contains_Rid_SystemProperty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource["_rid"].Should().NotBeNull(); + } + + [Fact] + public async Task Read_ResponseBody_Contains_Self_SystemProperty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource["_self"].Should().NotBeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingDeepDiveTests.cs index ba270cf..ddb48e3 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingDeepDiveTests.cs @@ -12,436 +12,436 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class DateHandlingDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task SeedOneItem() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - } - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> QueryWithParams(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 1: Bug Fixes - // ═════════════════════════════════════════════════════════════════════ - - // BUG-A: GETCURRENTTICKS should return same value as GETCURRENTTICKSSTATIC within same query - [Fact] - public async Task GetCurrentTicks_MatchesGetCurrentTicksStatic() - { - await SeedOneItem(); - - var results = await Query( - "SELECT GetCurrentTicks() as ticks, GetCurrentTicksStatic() as ticksStatic FROM c"); - - var item = results.Single(); - item["ticks"]!.Value().Should().Be(item["ticksStatic"]!.Value()); - } - - // BUG-B: DATETIMEBIN invalid part should return undefined (omitted from SELECT VALUE) - [Fact] - public async Task DateTimeBin_InvalidPart_UndefinedOmittedFromSelectValue() - { - await SeedOneItem(); - - var results = await Query( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00Z', 'invalid', 1) FROM c"); - - results.Should().BeEmpty("invalid part should return undefined, omitted from SELECT VALUE"); - } - - // BUG-E: DATETIMEBIN non-string origin should return undefined - [Fact] - public async Task DateTimeBin_NumericOrigin_ReturnsUndefined() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", epoch = 0 }), - new PartitionKey("a")); - - var results = await Query( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00Z', 'hh', 1, c.epoch) FROM c"); - - results.Should().BeEmpty("non-string origin should return undefined"); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 2: DATETIMEPART Weekday - // ═════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData("2023-01-01T00:00:00Z", 1)] // Sunday - [InlineData("2023-01-02T00:00:00Z", 2)] // Monday - [InlineData("2023-01-04T00:00:00Z", 4)] // Wednesday - [InlineData("2023-01-07T00:00:00Z", 7)] // Saturday - public async Task DateTimePart_Weekday_Returns1Through7(string dt, long expected) - { - await SeedOneItem(); - var results = await Query( - $"SELECT VALUE DateTimePart('weekday', '{dt}') FROM c"); - results.Single().Should().Be(expected); - } - - [Theory] - [InlineData("weekday")] - [InlineData("dw")] - [InlineData("w")] - public async Task DateTimePart_WeekdayAliases_AllWork(string alias) - { - await SeedOneItem(); - var results = await Query( - $"SELECT VALUE DateTimePart('{alias}', '2023-01-01T00:00:00Z') FROM c"); - results.Single().Should().Be(1); // Sunday = 1 - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 3: DATETIMEADD Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DateTimeAdd_OverflowYear9999_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeAdd('yyyy', 1, '9999-12-31T00:00:00Z') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeAdd_UnderflowYear1_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeAdd('yyyy', -1, '0001-01-01T00:00:00Z') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeAdd_24Months_CorrectResult() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeAdd('mm', 24, '2020-01-15T10:30:00.0000000Z') FROM c"); - results.Single().Should().Be("2022-01-15T10:30:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_ParameterizedQuery_WithDateValue() - { - await SeedOneItem(); - var query = new QueryDefinition( - "SELECT VALUE DateTimeAdd('dd', 5, @dt) FROM c") - .WithParameter("@dt", "2020-01-15T00:00:00.0000000Z"); - - var results = await QueryWithParams(query); - results.Single().Should().Be("2020-01-20T00:00:00.0000000Z"); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 4: DATETIMEDIFF Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DateTimeDiff_PastSeconds_ReturnsNegative() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeDiff('ss', '2021-01-01T00:00:00Z', '2020-01-01T00:00:00Z') FROM c"); - results.Single().Should().BeLessThan(0); - } - - [Fact] - public async Task DateTimeDiff_FutureSeconds_ReturnsPositive() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeDiff('ss', '2020-01-01T00:00:00Z', '2021-01-01T00:00:00Z') FROM c"); - results.Single().Should().BeGreaterThan(0); - } - - [Fact] - public async Task DateTimeDiff_WeekdayPart_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeDiff('weekday', '2020-01-01T00:00:00Z', '2020-01-02T00:00:00Z') FROM c"); - results.Should().BeEmpty("weekday is not supported in DateTimeDiff"); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 5: DATETIMEFROMPARTS Boundary Tests - // ═════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData("2020, 0, 15")] // Month = 0 - [InlineData("2020, 1, 0")] // Day = 0 - [InlineData("2020, 1, 1, 24")] // Hour > 23 - [InlineData("2020, 1, 1, 0, 60")] // Minute > 59 - [InlineData("2020, 1, 1, 0, 0, 60")] // Second > 59 - public async Task DateTimeFromParts_OutOfRangeArgs_ReturnsUndefined(string args) - { - await SeedOneItem(); - var results = await Query( - $"SELECT VALUE DateTimeFromParts({args}) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeFromParts_NegativeFraction_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, -1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeFromParts_FractionOver9999999_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, 10000000) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeFromParts_ExtraArgsIgnored() - { - await SeedOneItem(); - // 8 args — the 8th should be ignored - var results = await Query( - "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10, 30, 45, 0, 999) FROM c"); - results.Single().Should().Be("2020-06-15T10:30:45.0000000Z"); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 6: DATETIMEBIN Extended - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DateTimeBin_OriginEqualsInput_ReturnsSameValue() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeBin('2021-01-01T00:00:00.0000000Z', 'dd', 7, '2021-01-01T00:00:00.0000000Z') FROM c"); - results.Single().Should().Be("2021-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_AcrossYearBoundary() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeBin('2021-06-15T00:00:00.0000000Z', 'yyyy', 2) FROM c"); - results.Single().Should().NotBeNullOrEmpty(); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 7: Conversion Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task TicksToDateTime_NegativeTicks_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE TicksToDateTime(-1) FROM c"); - results.Should().BeEmpty("negative ticks are before DateTime.MinValue"); - } - - [Fact] - public async Task TimestampToDateTime_NonNumericArg_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE TimestampToDateTime('not-a-number') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task TicksToDateTime_NonNumericArg_ReturnsUndefined() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE TicksToDateTime('abc') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeToTicks_NoZSuffix_VerifyUtcAssumption() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeToTicks('2020-01-01T00:00:00') FROM c"); - // Should assume UTC - var expected = new System.DateTime(2020, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks; - results.Single().Should().Be(expected); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 8: GetCurrent* Static Parameter Injection - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task GetCurrentDateTime_IsConsistentWithinQuery() - { - await SeedOneItem(); - - var results = await Query( - "SELECT GetCurrentDateTime() as dt, GetCurrentDateTimeStatic() as dtStatic FROM c"); - - var item = results.Single(); - item["dt"]!.Value().Should().Be(item["dtStatic"]!.Value()); - } - - [Fact] - public async Task GetCurrentTimestamp_IsConsistentWithinQuery() - { - await SeedOneItem(); - - var results = await Query( - "SELECT GetCurrentTimestamp() as ts, GetCurrentTimestampStatic() as tsStatic FROM c"); - - var item = results.Single(); - item["ts"]!.Value().Should().Be(item["tsStatic"]!.Value()); - } - - [Fact] - public async Task GetCurrentTicksStatic_ReturnsPositiveValue() - { - await SeedOneItem(); - - var results = await Query( - "SELECT VALUE GetCurrentTicksStatic() FROM c"); - - results.Single().Should().BeGreaterThan(0); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 9: Integration / Composition - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task GroupBy_DateTimePart_GroupsByYear() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2020-11-01T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", ts = "2021-03-01T00:00:00Z" }), - new PartitionKey("a")); - - var results = await Query( - "SELECT DateTimePart('yyyy', c.ts) as yr, COUNT(1) as cnt FROM c GROUP BY DateTimePart('yyyy', c.ts)"); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task OrderBy_DateTimePart_OrdersByResult() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2021-03-15T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2020-06-15T00:00:00Z" }), - new PartitionKey("a")); - - var results = await Query( - "SELECT c.id FROM c ORDER BY DateTimePart('yyyy', c.ts)"); - - results[0]["id"]!.Value().Should().Be("2"); - results[1]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task ThreeFunctionRoundTrip_DateTimeFromParts_ToTicks_ToDateTime() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE TicksToDateTime(DateTimeToTicks(DateTimeFromParts(2023, 6, 15, 10, 30, 45, 1234567))) FROM c"); - results.Single().Should().Be("2023-06-15T10:30:45.1234567Z"); - } - - [Fact] - public async Task DateTimeBin_InWhereClause() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2021-01-08T10:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2021-01-15T10:00:00Z" }), - new PartitionKey("a")); - - var results = await Query( - "SELECT c.id FROM c WHERE DateTimeBin(c.ts, 'dd', 7) = '2021-01-07T00:00:00.0000000Z'"); - - results.Should().Contain(r => r["id"]!.Value() == "1"); - } - - [Fact] - public async Task Distinct_WithDateFunctionResults() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:30:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2020-11-20T14:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", ts = "2020-03-01T08:00:00Z" }), - new PartitionKey("a")); - - var results = await Query( - "SELECT DISTINCT VALUE DateTimePart('yyyy', c.ts) FROM c"); - - results.Should().HaveCount(1); - results.Single().Should().Be(2020); - } - - // ═════════════════════════════════════════════════════════════════════ - // Phase 10: Cross-Platform / Format Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData("2020-01-15T10:30:00", "2020-01-15T10:30:00.0000000Z")] // No Z suffix - [InlineData("2020-01-15T10:30:00.0000000Z", "2020-01-15T10:30:00.0000000Z")] // With Z - public async Task DateTimeAdd_VariousInputFormats_ProducesConsistentOutput(string input, string expected) - { - await SeedOneItem(); - var results = await Query( - $"SELECT VALUE DateTimeAdd('dd', 0, '{input}') FROM c"); - results.Single().Should().Be(expected); - } - - [Fact] - public async Task DateTimeAdd_DateOnlyInput_ProducesFullDateTime() - { - await SeedOneItem(); - var results = await Query( - "SELECT VALUE DateTimeAdd('dd', 0, '2020-01-15') FROM c"); - results.Single().Should().Be("2020-01-15T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task SeedOneItem() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + } + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> QueryWithParams(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 1: Bug Fixes + // ═════════════════════════════════════════════════════════════════════ + + // BUG-A: GETCURRENTTICKS should return same value as GETCURRENTTICKSSTATIC within same query + [Fact] + public async Task GetCurrentTicks_MatchesGetCurrentTicksStatic() + { + await SeedOneItem(); + + var results = await Query( + "SELECT GetCurrentTicks() as ticks, GetCurrentTicksStatic() as ticksStatic FROM c"); + + var item = results.Single(); + item["ticks"]!.Value().Should().Be(item["ticksStatic"]!.Value()); + } + + // BUG-B: DATETIMEBIN invalid part should return undefined (omitted from SELECT VALUE) + [Fact] + public async Task DateTimeBin_InvalidPart_UndefinedOmittedFromSelectValue() + { + await SeedOneItem(); + + var results = await Query( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00Z', 'invalid', 1) FROM c"); + + results.Should().BeEmpty("invalid part should return undefined, omitted from SELECT VALUE"); + } + + // BUG-E: DATETIMEBIN non-string origin should return undefined + [Fact] + public async Task DateTimeBin_NumericOrigin_ReturnsUndefined() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", epoch = 0 }), + new PartitionKey("a")); + + var results = await Query( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00Z', 'hh', 1, c.epoch) FROM c"); + + results.Should().BeEmpty("non-string origin should return undefined"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 2: DATETIMEPART Weekday + // ═════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("2023-01-01T00:00:00Z", 1)] // Sunday + [InlineData("2023-01-02T00:00:00Z", 2)] // Monday + [InlineData("2023-01-04T00:00:00Z", 4)] // Wednesday + [InlineData("2023-01-07T00:00:00Z", 7)] // Saturday + public async Task DateTimePart_Weekday_Returns1Through7(string dt, long expected) + { + await SeedOneItem(); + var results = await Query( + $"SELECT VALUE DateTimePart('weekday', '{dt}') FROM c"); + results.Single().Should().Be(expected); + } + + [Theory] + [InlineData("weekday")] + [InlineData("dw")] + [InlineData("w")] + public async Task DateTimePart_WeekdayAliases_AllWork(string alias) + { + await SeedOneItem(); + var results = await Query( + $"SELECT VALUE DateTimePart('{alias}', '2023-01-01T00:00:00Z') FROM c"); + results.Single().Should().Be(1); // Sunday = 1 + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 3: DATETIMEADD Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DateTimeAdd_OverflowYear9999_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeAdd('yyyy', 1, '9999-12-31T00:00:00Z') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeAdd_UnderflowYear1_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeAdd('yyyy', -1, '0001-01-01T00:00:00Z') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeAdd_24Months_CorrectResult() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeAdd('mm', 24, '2020-01-15T10:30:00.0000000Z') FROM c"); + results.Single().Should().Be("2022-01-15T10:30:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_ParameterizedQuery_WithDateValue() + { + await SeedOneItem(); + var query = new QueryDefinition( + "SELECT VALUE DateTimeAdd('dd', 5, @dt) FROM c") + .WithParameter("@dt", "2020-01-15T00:00:00.0000000Z"); + + var results = await QueryWithParams(query); + results.Single().Should().Be("2020-01-20T00:00:00.0000000Z"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 4: DATETIMEDIFF Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DateTimeDiff_PastSeconds_ReturnsNegative() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeDiff('ss', '2021-01-01T00:00:00Z', '2020-01-01T00:00:00Z') FROM c"); + results.Single().Should().BeLessThan(0); + } + + [Fact] + public async Task DateTimeDiff_FutureSeconds_ReturnsPositive() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeDiff('ss', '2020-01-01T00:00:00Z', '2021-01-01T00:00:00Z') FROM c"); + results.Single().Should().BeGreaterThan(0); + } + + [Fact] + public async Task DateTimeDiff_WeekdayPart_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeDiff('weekday', '2020-01-01T00:00:00Z', '2020-01-02T00:00:00Z') FROM c"); + results.Should().BeEmpty("weekday is not supported in DateTimeDiff"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 5: DATETIMEFROMPARTS Boundary Tests + // ═════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("2020, 0, 15")] // Month = 0 + [InlineData("2020, 1, 0")] // Day = 0 + [InlineData("2020, 1, 1, 24")] // Hour > 23 + [InlineData("2020, 1, 1, 0, 60")] // Minute > 59 + [InlineData("2020, 1, 1, 0, 0, 60")] // Second > 59 + public async Task DateTimeFromParts_OutOfRangeArgs_ReturnsUndefined(string args) + { + await SeedOneItem(); + var results = await Query( + $"SELECT VALUE DateTimeFromParts({args}) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeFromParts_NegativeFraction_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, -1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeFromParts_FractionOver9999999_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, 10000000) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeFromParts_ExtraArgsIgnored() + { + await SeedOneItem(); + // 8 args — the 8th should be ignored + var results = await Query( + "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10, 30, 45, 0, 999) FROM c"); + results.Single().Should().Be("2020-06-15T10:30:45.0000000Z"); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 6: DATETIMEBIN Extended + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DateTimeBin_OriginEqualsInput_ReturnsSameValue() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeBin('2021-01-01T00:00:00.0000000Z', 'dd', 7, '2021-01-01T00:00:00.0000000Z') FROM c"); + results.Single().Should().Be("2021-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_AcrossYearBoundary() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeBin('2021-06-15T00:00:00.0000000Z', 'yyyy', 2) FROM c"); + results.Single().Should().NotBeNullOrEmpty(); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 7: Conversion Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task TicksToDateTime_NegativeTicks_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE TicksToDateTime(-1) FROM c"); + results.Should().BeEmpty("negative ticks are before DateTime.MinValue"); + } + + [Fact] + public async Task TimestampToDateTime_NonNumericArg_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE TimestampToDateTime('not-a-number') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task TicksToDateTime_NonNumericArg_ReturnsUndefined() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE TicksToDateTime('abc') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeToTicks_NoZSuffix_VerifyUtcAssumption() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeToTicks('2020-01-01T00:00:00') FROM c"); + // Should assume UTC + var expected = new System.DateTime(2020, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks; + results.Single().Should().Be(expected); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 8: GetCurrent* Static Parameter Injection + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetCurrentDateTime_IsConsistentWithinQuery() + { + await SeedOneItem(); + + var results = await Query( + "SELECT GetCurrentDateTime() as dt, GetCurrentDateTimeStatic() as dtStatic FROM c"); + + var item = results.Single(); + item["dt"]!.Value().Should().Be(item["dtStatic"]!.Value()); + } + + [Fact] + public async Task GetCurrentTimestamp_IsConsistentWithinQuery() + { + await SeedOneItem(); + + var results = await Query( + "SELECT GetCurrentTimestamp() as ts, GetCurrentTimestampStatic() as tsStatic FROM c"); + + var item = results.Single(); + item["ts"]!.Value().Should().Be(item["tsStatic"]!.Value()); + } + + [Fact] + public async Task GetCurrentTicksStatic_ReturnsPositiveValue() + { + await SeedOneItem(); + + var results = await Query( + "SELECT VALUE GetCurrentTicksStatic() FROM c"); + + results.Single().Should().BeGreaterThan(0); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 9: Integration / Composition + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GroupBy_DateTimePart_GroupsByYear() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2020-11-01T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", ts = "2021-03-01T00:00:00Z" }), + new PartitionKey("a")); + + var results = await Query( + "SELECT DateTimePart('yyyy', c.ts) as yr, COUNT(1) as cnt FROM c GROUP BY DateTimePart('yyyy', c.ts)"); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task OrderBy_DateTimePart_OrdersByResult() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2021-03-15T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2020-06-15T00:00:00Z" }), + new PartitionKey("a")); + + var results = await Query( + "SELECT c.id FROM c ORDER BY DateTimePart('yyyy', c.ts)"); + + results[0]["id"]!.Value().Should().Be("2"); + results[1]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task ThreeFunctionRoundTrip_DateTimeFromParts_ToTicks_ToDateTime() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE TicksToDateTime(DateTimeToTicks(DateTimeFromParts(2023, 6, 15, 10, 30, 45, 1234567))) FROM c"); + results.Single().Should().Be("2023-06-15T10:30:45.1234567Z"); + } + + [Fact] + public async Task DateTimeBin_InWhereClause() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2021-01-08T10:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2021-01-15T10:00:00Z" }), + new PartitionKey("a")); + + var results = await Query( + "SELECT c.id FROM c WHERE DateTimeBin(c.ts, 'dd', 7) = '2021-01-07T00:00:00.0000000Z'"); + + results.Should().Contain(r => r["id"]!.Value() == "1"); + } + + [Fact] + public async Task Distinct_WithDateFunctionResults() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:30:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2020-11-20T14:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", ts = "2020-03-01T08:00:00Z" }), + new PartitionKey("a")); + + var results = await Query( + "SELECT DISTINCT VALUE DateTimePart('yyyy', c.ts) FROM c"); + + results.Should().HaveCount(1); + results.Single().Should().Be(2020); + } + + // ═════════════════════════════════════════════════════════════════════ + // Phase 10: Cross-Platform / Format Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("2020-01-15T10:30:00", "2020-01-15T10:30:00.0000000Z")] // No Z suffix + [InlineData("2020-01-15T10:30:00.0000000Z", "2020-01-15T10:30:00.0000000Z")] // With Z + public async Task DateTimeAdd_VariousInputFormats_ProducesConsistentOutput(string input, string expected) + { + await SeedOneItem(); + var results = await Query( + $"SELECT VALUE DateTimeAdd('dd', 0, '{input}') FROM c"); + results.Single().Should().Be(expected); + } + + [Fact] + public async Task DateTimeAdd_DateOnlyInput_ProducesFullDateTime() + { + await SeedOneItem(); + var results = await Query( + "SELECT VALUE DateTimeAdd('dd', 0, '2020-01-15') FROM c"); + results.Single().Should().Be("2020-01-15T00:00:00.0000000Z"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingTests.cs index d2909ca..85ccd43 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DateHandlingTests.cs @@ -9,1224 +9,1224 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class DateDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("eventDate")] - public DateTimeOffset EventDate { get; set; } + [JsonProperty("eventDate")] + public DateTimeOffset EventDate { get; set; } } public class MetadataDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("metadata")] - public Dictionary Metadata { get; set; } = default!; + [JsonProperty("metadata")] + public Dictionary Metadata { get; set; } = default!; } public class DateHandlingTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task DateTimeOffset_RoundTrips_WithOriginalOffset() - { - var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); - var document = new DateDocument - { - Id = "date-test-1", - PartitionKey = "pk1", - EventDate = original - }; - - await _container.CreateItemAsync(document, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("date-test-1", new PartitionKey("pk1")); - - response.Resource.EventDate.Should().Be(original); - } - - [Fact] - public async Task DateTimeOffset_RoundTrips_WithPositiveOffset() - { - var original = new DateTimeOffset(2026, 3, 29, 17, 55, 46, TimeSpan.FromHours(1)); - var document = new DateDocument - { - Id = "date-test-2", - PartitionKey = "pk1", - EventDate = original - }; - - await _container.CreateItemAsync(document, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("date-test-2", new PartitionKey("pk1")); - - response.Resource.EventDate.Should().Be(original); - } - - [Fact] - public async Task DateTimeOffset_PreservedInQuery() - { - var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); - var document = new DateDocument - { - Id = "date-test-3", - PartitionKey = "pk1", - EventDate = original - }; - - await _container.CreateItemAsync(document, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.id = 'date-test-3'"); - var iterator = _container.GetItemQueryIterator(query); - var results = await iterator.ReadNextAsync(); - - results.Resource.Should().ContainSingle() - .Which.EventDate.Should().Be(original); - } - - [Fact] - public async Task DateTimeOffset_InDictionary_PreservedAsString() - { - var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); - var document = new MetadataDocument - { - Id = "date-test-4", - PartitionKey = "pk1", - Metadata = new() - { - ["deceasedDate"] = original - } - }; - - await _container.CreateItemAsync(document, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("date-test-4", new PartitionKey("pk1")); - - var returnedValue = response.Resource.Metadata["deceasedDate"].ToString()!; - var parsed = DateTimeOffset.Parse(returnedValue); - parsed.Should().Be(original); - } - - [Fact] - public async Task DateTime_Utc_InDictionary_RoundTripsViaStreamRead() - { - var utcDate = new DateTime(2026, 3, 29, 19, 52, 37, DateTimeKind.Utc); - var document = new MetadataDocument - { - Id = "date-test-stream", - PartitionKey = "pk1", - Metadata = new() - { - ["deceasedDate"] = utcDate - } - }; - - await _container.CreateItemAsync(document, new PartitionKey("pk1")); - - // Simulate the framework path: ReadItemStreamAsync → manual deserialization - var streamResponse = await _container.ReadItemStreamAsync("date-test-stream", new PartitionKey("pk1")); - using var reader = new System.IO.StreamReader(streamResponse.Content); - var json = await reader.ReadToEndAsync(); - - // Deserialize with default Newtonsoft settings (what the framework does) - var deserialized = JsonConvert.DeserializeObject(json); - var dateValue = deserialized!.Metadata["deceasedDate"]; - - // Check what type and value we get - var actualType = dateValue.GetType().Name; - if (dateValue is DateTime dt) - { - dt.Kind.Should().Be(DateTimeKind.Utc, $"DateTime should remain UTC (actual: {dt}, kind: {dt.Kind}, type: {actualType})"); - dt.Should().Be(utcDate); - } - else - { - // If it's a string (from DateParseHandling.None), verify it parses correctly - var parsed = DateTime.Parse(dateValue.ToString()!).ToUniversalTime(); - parsed.Should().Be(utcDate, $"Value was {actualType}: {dateValue}"); - } - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task DateTimeOffset_RoundTrips_WithOriginalOffset() + { + var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); + var document = new DateDocument + { + Id = "date-test-1", + PartitionKey = "pk1", + EventDate = original + }; + + await _container.CreateItemAsync(document, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("date-test-1", new PartitionKey("pk1")); + + response.Resource.EventDate.Should().Be(original); + } + + [Fact] + public async Task DateTimeOffset_RoundTrips_WithPositiveOffset() + { + var original = new DateTimeOffset(2026, 3, 29, 17, 55, 46, TimeSpan.FromHours(1)); + var document = new DateDocument + { + Id = "date-test-2", + PartitionKey = "pk1", + EventDate = original + }; + + await _container.CreateItemAsync(document, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("date-test-2", new PartitionKey("pk1")); + + response.Resource.EventDate.Should().Be(original); + } + + [Fact] + public async Task DateTimeOffset_PreservedInQuery() + { + var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); + var document = new DateDocument + { + Id = "date-test-3", + PartitionKey = "pk1", + EventDate = original + }; + + await _container.CreateItemAsync(document, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.id = 'date-test-3'"); + var iterator = _container.GetItemQueryIterator(query); + var results = await iterator.ReadNextAsync(); + + results.Resource.Should().ContainSingle() + .Which.EventDate.Should().Be(original); + } + + [Fact] + public async Task DateTimeOffset_InDictionary_PreservedAsString() + { + var original = new DateTimeOffset(2026, 3, 29, 16, 55, 46, TimeSpan.Zero); + var document = new MetadataDocument + { + Id = "date-test-4", + PartitionKey = "pk1", + Metadata = new() + { + ["deceasedDate"] = original + } + }; + + await _container.CreateItemAsync(document, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("date-test-4", new PartitionKey("pk1")); + + var returnedValue = response.Resource.Metadata["deceasedDate"].ToString()!; + var parsed = DateTimeOffset.Parse(returnedValue); + parsed.Should().Be(original); + } + + [Fact] + public async Task DateTime_Utc_InDictionary_RoundTripsViaStreamRead() + { + var utcDate = new DateTime(2026, 3, 29, 19, 52, 37, DateTimeKind.Utc); + var document = new MetadataDocument + { + Id = "date-test-stream", + PartitionKey = "pk1", + Metadata = new() + { + ["deceasedDate"] = utcDate + } + }; + + await _container.CreateItemAsync(document, new PartitionKey("pk1")); + + // Simulate the framework path: ReadItemStreamAsync → manual deserialization + var streamResponse = await _container.ReadItemStreamAsync("date-test-stream", new PartitionKey("pk1")); + using var reader = new System.IO.StreamReader(streamResponse.Content); + var json = await reader.ReadToEndAsync(); + + // Deserialize with default Newtonsoft settings (what the framework does) + var deserialized = JsonConvert.DeserializeObject(json); + var dateValue = deserialized!.Metadata["deceasedDate"]; + + // Check what type and value we get + var actualType = dateValue.GetType().Name; + if (dateValue is DateTime dt) + { + dt.Kind.Should().Be(DateTimeKind.Utc, $"DateTime should remain UTC (actual: {dt}, kind: {dt.Kind}, type: {actualType})"); + dt.Should().Be(utcDate); + } + else + { + // If it's a string (from DateParseHandling.None), verify it parses correctly + var parsed = DateTime.Parse(dateValue.ToString()!).ToUniversalTime(); + parsed.Should().Be(utcDate, $"Value was {actualType}: {dateValue}"); + } + } } // ─── DateTimeDiff ──────────────────────────────────────────────────────── public class DateTimeDiffTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Theory] - [InlineData("day", "2020-01-01T00:00:00Z", "2020-01-10T00:00:00Z", 9)] - [InlineData("hour", "2020-01-01T00:00:00Z", "2020-01-01T05:00:00Z", 5)] - [InlineData("minute", "2020-01-01T00:00:00Z", "2020-01-01T00:30:00Z", 30)] - [InlineData("second", "2020-01-01T00:00:00Z", "2020-01-01T00:00:45Z", 45)] - [InlineData("millisecond", "2020-01-01T00:00:00.000Z", "2020-01-01T00:00:00.500Z", 500)] - [InlineData("year", "2020-01-15T00:00:00Z", "2023-06-15T00:00:00Z", 3)] - [InlineData("month", "2020-01-15T00:00:00Z", "2020-04-15T00:00:00Z", 3)] - public async Task DateTimeDiff_ReturnsCorrectDifference(string part, string start, string end, long expected) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", startDate = start, endDate = end }), - new PartitionKey("a")); - - var query = new QueryDefinition( - $"SELECT VALUE DateTimeDiff('{part}', c.startDate, c.endDate) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(expected); - } - - [Fact] - public async Task DateTimeDiff_NegativeDiff_ReturnsNegative() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", startDate = "2020-01-10T00:00:00Z", endDate = "2020-01-01T00:00:00Z" }), - new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT VALUE DateTimeDiff('day', c.startDate, c.endDate) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(-9); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Theory] + [InlineData("day", "2020-01-01T00:00:00Z", "2020-01-10T00:00:00Z", 9)] + [InlineData("hour", "2020-01-01T00:00:00Z", "2020-01-01T05:00:00Z", 5)] + [InlineData("minute", "2020-01-01T00:00:00Z", "2020-01-01T00:30:00Z", 30)] + [InlineData("second", "2020-01-01T00:00:00Z", "2020-01-01T00:00:45Z", 45)] + [InlineData("millisecond", "2020-01-01T00:00:00.000Z", "2020-01-01T00:00:00.500Z", 500)] + [InlineData("year", "2020-01-15T00:00:00Z", "2023-06-15T00:00:00Z", 3)] + [InlineData("month", "2020-01-15T00:00:00Z", "2020-04-15T00:00:00Z", 3)] + public async Task DateTimeDiff_ReturnsCorrectDifference(string part, string start, string end, long expected) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", startDate = start, endDate = end }), + new PartitionKey("a")); + + var query = new QueryDefinition( + $"SELECT VALUE DateTimeDiff('{part}', c.startDate, c.endDate) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(expected); + } + + [Fact] + public async Task DateTimeDiff_NegativeDiff_ReturnsNegative() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", startDate = "2020-01-10T00:00:00Z", endDate = "2020-01-01T00:00:00Z" }), + new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT VALUE DateTimeDiff('day', c.startDate, c.endDate) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(-9); + } } // ─── DateTimeFromParts ────────────────────────────────────────────────── public class DateTimeFromPartsTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task DateTimeFromParts_ReturnsFormattedDateTime() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT VALUE DateTimeFromParts(2020, 3, 15, 10, 30, 0, 0) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("2020-03-15T10:30:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task DateTimeFromParts_ReturnsFormattedDateTime() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT VALUE DateTimeFromParts(2020, 3, 15, 10, 30, 0, 0) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("2020-03-15T10:30:00.0000000Z"); + } } // ─── DateTimeBin ──────────────────────────────────────────────────────── public class DateTimeBinTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task DateTimeBin_BinsByHour() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:35:22.1234567Z" }), - new PartitionKey("a")); - - // DateTimeBin(dateTime, datePart, binSize [, origin]) - // Bin by 1 hour: 10:35 → 10:00 - var query = new QueryDefinition( - "SELECT VALUE DateTimeBin(c.ts, 'hh', 1) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("2020-06-15T10:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_BinsByDay() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:35:22Z" }), - new PartitionKey("a")); - - // Bin by 1 day: keeps date, zeros time - var query = new QueryDefinition( - "SELECT VALUE DateTimeBin(c.ts, 'dd', 1) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("2020-06-15T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task DateTimeBin_BinsByHour() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:35:22.1234567Z" }), + new PartitionKey("a")); + + // DateTimeBin(dateTime, datePart, binSize [, origin]) + // Bin by 1 hour: 10:35 → 10:00 + var query = new QueryDefinition( + "SELECT VALUE DateTimeBin(c.ts, 'hh', 1) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("2020-06-15T10:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_BinsByDay() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:35:22Z" }), + new PartitionKey("a")); + + // Bin by 1 day: keeps date, zeros time + var query = new QueryDefinition( + "SELECT VALUE DateTimeBin(c.ts, 'dd', 1) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("2020-06-15T00:00:00.0000000Z"); + } } // ─── DateTime/Ticks Conversion Functions ──────────────────────────────── public class DateTimeTicksConversionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task GetCurrentTicks_ReturnsReasonableValue() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT VALUE GetCurrentTicks() FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Ticks for 2020-01-01 = 637134336000000000, should be well above that - results.Should().ContainSingle().Which.Should().BeGreaterThan(637134336000000000); - } - - [Fact] - public async Task DateTimeToTicks_ConvertsCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT VALUE DateTimeToTicks(c.ts) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(637134336000000000); - } - - [Fact] - public async Task TicksToDateTime_ConvertsCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ticks = 637134336000000000 }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT VALUE TicksToDateTime(c.ticks) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("2020-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeToTimestamp_ReturnsUnixMillis() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT VALUE DateTimeToTimestamp(c.ts) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // 2020-01-01T00:00:00Z = 1577836800000 ms since epoch - results.Should().ContainSingle().Which.Should().Be(1577836800000); - } - - [Fact] - public async Task TimestampToDateTime_ConvertsFromUnixMillis() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ms = 1577836800000 }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT VALUE TimestampToDateTime(c.ms) FROM c"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("2020-01-01T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task GetCurrentTicks_ReturnsReasonableValue() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT VALUE GetCurrentTicks() FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Ticks for 2020-01-01 = 637134336000000000, should be well above that + results.Should().ContainSingle().Which.Should().BeGreaterThan(637134336000000000); + } + + [Fact] + public async Task DateTimeToTicks_ConvertsCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT VALUE DateTimeToTicks(c.ts) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(637134336000000000); + } + + [Fact] + public async Task TicksToDateTime_ConvertsCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ticks = 637134336000000000 }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT VALUE TicksToDateTime(c.ticks) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("2020-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeToTimestamp_ReturnsUnixMillis() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT VALUE DateTimeToTimestamp(c.ts) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // 2020-01-01T00:00:00Z = 1577836800000 ms since epoch + results.Should().ContainSingle().Which.Should().Be(1577836800000); + } + + [Fact] + public async Task TimestampToDateTime_ConvertsFromUnixMillis() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ms = 1577836800000 }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT VALUE TimestampToDateTime(c.ms) FROM c"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("2020-01-01T00:00:00.0000000Z"); + } } // ─── Static DateTime Functions ────────────────────────────────────────── public class StaticDateTimeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task GetCurrentDateTimeStatic_ReturnsSameValueForAllItems() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentDateTimeStatic() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - // All three values should be identical (static = same for entire query) - results.Distinct().Should().ContainSingle(); - } - - [Fact] - public async Task GetCurrentTicksStatic_ReturnsSameValueForAllItems() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentTicksStatic() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results.Distinct().Should().ContainSingle(); - } - - [Fact] - public async Task GetCurrentTimestampStatic_ReturnsSameValueForAllItems() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentTimestampStatic() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results.Distinct().Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task GetCurrentDateTimeStatic_ReturnsSameValueForAllItems() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentDateTimeStatic() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + // All three values should be identical (static = same for entire query) + results.Distinct().Should().ContainSingle(); + } + + [Fact] + public async Task GetCurrentTicksStatic_ReturnsSameValueForAllItems() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentTicksStatic() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results.Distinct().Should().ContainSingle(); + } + + [Fact] + public async Task GetCurrentTimestampStatic_ReturnsSameValueForAllItems() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentTimestampStatic() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results.Distinct().Should().ContainSingle(); + } } // ─── DateTimeBin Year/Month Support ───────────────────────────────────── public class DateTimeBinYearMonthTests { - [Fact] - public async Task DateTimeBin_Year_BinsToYearBoundary() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-07-15T10:30:00.0000000Z"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE DateTimeBin(c.dt, 'year', 1) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Binning to 1 year => 2023-01-01T00:00:00.0000000Z - results.Should().ContainSingle().Which.Should().Be("2023-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_Month_BinsToMonthBoundary() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-07-15T10:30:00.0000000Z"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE DateTimeBin(c.dt, 'month', 1) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Binning to 1 month => 2023-07-01T00:00:00.0000000Z - results.Should().ContainSingle().Which.Should().Be("2023-07-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_Quarter_BinsTo3MonthBoundary() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-08-15T10:30:00.0000000Z"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE DateTimeBin(c.dt, 'month', 3) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Binning to 3 months from origin 1970-01-01 => - // Aug 2023 is in the Q3 2023 bin starting at 2023-07-01 - results.Should().ContainSingle().Which.Should().Be("2023-07-01T00:00:00.0000000Z"); - } + [Fact] + public async Task DateTimeBin_Year_BinsToYearBoundary() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-07-15T10:30:00.0000000Z"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE DateTimeBin(c.dt, 'year', 1) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Binning to 1 year => 2023-01-01T00:00:00.0000000Z + results.Should().ContainSingle().Which.Should().Be("2023-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_Month_BinsToMonthBoundary() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-07-15T10:30:00.0000000Z"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE DateTimeBin(c.dt, 'month', 1) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Binning to 1 month => 2023-07-01T00:00:00.0000000Z + results.Should().ContainSingle().Which.Should().Be("2023-07-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_Quarter_BinsTo3MonthBoundary() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","dt":"2023-08-15T10:30:00.0000000Z"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE DateTimeBin(c.dt, 'month', 3) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Binning to 3 months from origin 1970-01-01 => + // Aug 2023 is in the Q3 2023 bin starting at 2023-07-01 + results.Should().ContainSingle().Which.Should().Be("2023-07-01T00:00:00.0000000Z"); + } } // ─── DateTimeAdd Tests ────────────────────────────────────────────────── public class DateTimeAddTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task QuerySingleValue(string sql) - { - await EnsureSeedItem(); - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task EnsureSeedItem() - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - } - - [Fact] - public async Task DateTimeAdd_Year_AddsCorrectly() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('yyyy', 1, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2021-07-03T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_Month_AddsCorrectly() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('mm', 1, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-08-03T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_Day_AddsCorrectly() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('dd', 1, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-04T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_Hour_AddsCorrectly() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('hh', 1, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-03T01:00:00.0000000Z"); - } - - [Theory] - [InlineData("minute", 30)] - [InlineData("mi", 30)] - [InlineData("n", 30)] - public async Task DateTimeAdd_Minute_AllAliases(string alias, int amount) - { - var result = await QuerySingleValue( - $"SELECT VALUE DateTimeAdd('{alias}', {amount}, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-03T00:30:00.0000000Z"); - } - - [Theory] - [InlineData("second")] - [InlineData("ss")] - [InlineData("s")] - public async Task DateTimeAdd_Second_AllAliases(string alias) - { - var result = await QuerySingleValue( - $"SELECT VALUE DateTimeAdd('{alias}', 45, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-03T00:00:45.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_Millisecond_AddsCorrectly() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('ms', 500, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-03T00:00:00.5000000Z"); - } - - [Fact] - public async Task DateTimeAdd_NegativeValue_Subtracts() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('yyyy', -1, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2019-07-03T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_NegativeSubtractExpression_MatchesCosmosDoc() - { - // Cosmos docs example: DATETIMEADD("ss", 5 * -5, "2020-07-03T00:00:00.0000000") - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('ss', -25, '2020-07-03T00:00:00.0000000') FROM c"); - result.Should().Be("2020-07-02T23:59:35.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_LeapYear_Jan31PlusOneMonth_NonLeap() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('mm', 1, '2023-01-31T00:00:00.0000000') FROM c"); - result.Should().Be("2023-02-28T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_LeapYear_Jan31PlusOneMonth_LeapYear() - { - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('mm', 1, '2024-01-31T00:00:00.0000000') FROM c"); - result.Should().Be("2024-02-29T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_NullDate_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"n1","pk":"a","dt":null}""")), - new PartitionKey("a")); - - // DateTimeAdd with null input returns undefined which is omitted from SELECT VALUE - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeAdd('dd', 1, c.dt) FROM c WHERE c.id = 'n1'", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Undefined is omitted from SELECT VALUE projection - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeAdd_Microsecond_AddsCorrectly() - { - // 1 microsecond = 10 ticks = 0.0000010 seconds; 500 µs = 5000 ticks = 0.0005000s - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('mcs', 500, '2020-01-01T00:00:00.0000000') FROM c"); - result.Should().Be("2020-01-01T00:00:00.0005000Z"); - } - - [Fact] - public async Task DateTimeAdd_Nanosecond_AddsCorrectly() - { - // 1 nanosecond = 0.01 ticks; .NET rounds to 100ns resolution - // 500 nanoseconds = 5 ticks = 0.0000005 seconds - var result = await QuerySingleValue( - "SELECT VALUE DateTimeAdd('ns', 500, '2020-01-01T00:00:00.0000000') FROM c"); - result.Should().Be("2020-01-01T00:00:00.0000005Z"); - } - - [Theory] - [InlineData("year")] - [InlineData("yyyy")] - [InlineData("yy")] - public async Task DateTimeAdd_YearAliases_AllWork(string alias) - { - var result = await QuerySingleValue( - $"SELECT VALUE DateTimeAdd('{alias}', 1, '2020-01-01T00:00:00.0000000') FROM c"); - result.Should().Be("2021-01-01T00:00:00.0000000Z"); - } - - [Theory] - [InlineData("month")] - [InlineData("mm")] - [InlineData("m")] - public async Task DateTimeAdd_MonthAliases_AllWork(string alias) - { - var result = await QuerySingleValue( - $"SELECT VALUE DateTimeAdd('{alias}', 1, '2020-01-01T00:00:00.0000000') FROM c"); - result.Should().Be("2020-02-01T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task QuerySingleValue(string sql) + { + await EnsureSeedItem(); + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task EnsureSeedItem() + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + } + + [Fact] + public async Task DateTimeAdd_Year_AddsCorrectly() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('yyyy', 1, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2021-07-03T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_Month_AddsCorrectly() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('mm', 1, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-08-03T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_Day_AddsCorrectly() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('dd', 1, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-04T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_Hour_AddsCorrectly() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('hh', 1, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-03T01:00:00.0000000Z"); + } + + [Theory] + [InlineData("minute", 30)] + [InlineData("mi", 30)] + [InlineData("n", 30)] + public async Task DateTimeAdd_Minute_AllAliases(string alias, int amount) + { + var result = await QuerySingleValue( + $"SELECT VALUE DateTimeAdd('{alias}', {amount}, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-03T00:30:00.0000000Z"); + } + + [Theory] + [InlineData("second")] + [InlineData("ss")] + [InlineData("s")] + public async Task DateTimeAdd_Second_AllAliases(string alias) + { + var result = await QuerySingleValue( + $"SELECT VALUE DateTimeAdd('{alias}', 45, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-03T00:00:45.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_Millisecond_AddsCorrectly() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('ms', 500, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-03T00:00:00.5000000Z"); + } + + [Fact] + public async Task DateTimeAdd_NegativeValue_Subtracts() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('yyyy', -1, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2019-07-03T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_NegativeSubtractExpression_MatchesCosmosDoc() + { + // Cosmos docs example: DATETIMEADD("ss", 5 * -5, "2020-07-03T00:00:00.0000000") + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('ss', -25, '2020-07-03T00:00:00.0000000') FROM c"); + result.Should().Be("2020-07-02T23:59:35.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_LeapYear_Jan31PlusOneMonth_NonLeap() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('mm', 1, '2023-01-31T00:00:00.0000000') FROM c"); + result.Should().Be("2023-02-28T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_LeapYear_Jan31PlusOneMonth_LeapYear() + { + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('mm', 1, '2024-01-31T00:00:00.0000000') FROM c"); + result.Should().Be("2024-02-29T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_NullDate_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"n1","pk":"a","dt":null}""")), + new PartitionKey("a")); + + // DateTimeAdd with null input returns undefined which is omitted from SELECT VALUE + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeAdd('dd', 1, c.dt) FROM c WHERE c.id = 'n1'", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Undefined is omitted from SELECT VALUE projection + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeAdd_Microsecond_AddsCorrectly() + { + // 1 microsecond = 10 ticks = 0.0000010 seconds; 500 µs = 5000 ticks = 0.0005000s + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('mcs', 500, '2020-01-01T00:00:00.0000000') FROM c"); + result.Should().Be("2020-01-01T00:00:00.0005000Z"); + } + + [Fact] + public async Task DateTimeAdd_Nanosecond_AddsCorrectly() + { + // 1 nanosecond = 0.01 ticks; .NET rounds to 100ns resolution + // 500 nanoseconds = 5 ticks = 0.0000005 seconds + var result = await QuerySingleValue( + "SELECT VALUE DateTimeAdd('ns', 500, '2020-01-01T00:00:00.0000000') FROM c"); + result.Should().Be("2020-01-01T00:00:00.0000005Z"); + } + + [Theory] + [InlineData("year")] + [InlineData("yyyy")] + [InlineData("yy")] + public async Task DateTimeAdd_YearAliases_AllWork(string alias) + { + var result = await QuerySingleValue( + $"SELECT VALUE DateTimeAdd('{alias}', 1, '2020-01-01T00:00:00.0000000') FROM c"); + result.Should().Be("2021-01-01T00:00:00.0000000Z"); + } + + [Theory] + [InlineData("month")] + [InlineData("mm")] + [InlineData("m")] + public async Task DateTimeAdd_MonthAliases_AllWork(string alias) + { + var result = await QuerySingleValue( + $"SELECT VALUE DateTimeAdd('{alias}', 1, '2020-01-01T00:00:00.0000000') FROM c"); + result.Should().Be("2020-02-01T00:00:00.0000000Z"); + } } // ─── DateTimePart Tests ───────────────────────────────────────────────── public class DateTimePartTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task QuerySingleLong(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - // Cosmos DB docs example: DATETIMEPART on "2016-05-29T08:30:00.1301617" - [Theory] - [InlineData("yyyy", 2016)] - [InlineData("mm", 5)] - [InlineData("dd", 29)] - [InlineData("hh", 8)] - [InlineData("mi", 30)] - [InlineData("ss", 0)] - [InlineData("ms", 130)] - public async Task DateTimePart_ExtractsAllParts(string part, long expected) - { - var result = await QuerySingleLong( - $"SELECT VALUE DateTimePart('{part}', '2016-05-29T08:30:00.1301617') FROM c"); - result.Should().Be(expected); - } - - [Fact] - public async Task DateTimePart_Microsecond_ExtractsCorrectly() - { - // Cosmos docs: DateTimePart("mcs", "2016-05-29T08:30:00.1301617") → 130161 - var result = await QuerySingleLong( - "SELECT VALUE DateTimePart('mcs', '2016-05-29T08:30:00.1301617') FROM c"); - result.Should().Be(130161); - } - - [Fact] - public async Task DateTimePart_Nanosecond_ExtractsCorrectly() - { - // Cosmos docs: DateTimePart("ns", "2016-05-29T08:30:00.1301617") → 130161700 - var result = await QuerySingleLong( - "SELECT VALUE DateTimePart('ns', '2016-05-29T08:30:00.1301617') FROM c"); - result.Should().Be(130161700); - } - - [Theory] - [InlineData("year")] - [InlineData("yyyy")] - [InlineData("yy")] - public async Task DateTimePart_YearAliases_AllWork(string alias) - { - var result = await QuerySingleLong( - $"SELECT VALUE DateTimePart('{alias}', '2020-06-15T00:00:00.0000000') FROM c"); - result.Should().Be(2020); - } - - [Theory] - [InlineData("month")] - [InlineData("mm")] - [InlineData("m")] - public async Task DateTimePart_MonthAliases_AllWork(string alias) - { - var result = await QuerySingleLong( - $"SELECT VALUE DateTimePart('{alias}', '2020-06-15T00:00:00.0000000') FROM c"); - result.Should().Be(6); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task QuerySingleLong(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + // Cosmos DB docs example: DATETIMEPART on "2016-05-29T08:30:00.1301617" + [Theory] + [InlineData("yyyy", 2016)] + [InlineData("mm", 5)] + [InlineData("dd", 29)] + [InlineData("hh", 8)] + [InlineData("mi", 30)] + [InlineData("ss", 0)] + [InlineData("ms", 130)] + public async Task DateTimePart_ExtractsAllParts(string part, long expected) + { + var result = await QuerySingleLong( + $"SELECT VALUE DateTimePart('{part}', '2016-05-29T08:30:00.1301617') FROM c"); + result.Should().Be(expected); + } + + [Fact] + public async Task DateTimePart_Microsecond_ExtractsCorrectly() + { + // Cosmos docs: DateTimePart("mcs", "2016-05-29T08:30:00.1301617") → 130161 + var result = await QuerySingleLong( + "SELECT VALUE DateTimePart('mcs', '2016-05-29T08:30:00.1301617') FROM c"); + result.Should().Be(130161); + } + + [Fact] + public async Task DateTimePart_Nanosecond_ExtractsCorrectly() + { + // Cosmos docs: DateTimePart("ns", "2016-05-29T08:30:00.1301617") → 130161700 + var result = await QuerySingleLong( + "SELECT VALUE DateTimePart('ns', '2016-05-29T08:30:00.1301617') FROM c"); + result.Should().Be(130161700); + } + + [Theory] + [InlineData("year")] + [InlineData("yyyy")] + [InlineData("yy")] + public async Task DateTimePart_YearAliases_AllWork(string alias) + { + var result = await QuerySingleLong( + $"SELECT VALUE DateTimePart('{alias}', '2020-06-15T00:00:00.0000000') FROM c"); + result.Should().Be(2020); + } + + [Theory] + [InlineData("month")] + [InlineData("mm")] + [InlineData("m")] + public async Task DateTimePart_MonthAliases_AllWork(string alias) + { + var result = await QuerySingleLong( + $"SELECT VALUE DateTimePart('{alias}', '2020-06-15T00:00:00.0000000') FROM c"); + result.Should().Be(6); + } } // ─── DateTimeBin Extended Tests ───────────────────────────────────────── public class DateTimeBinExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task QuerySingleString(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - [Fact] - public async Task DateTimeBin_7DayBins_DefaultOriginUnixEpoch() - { - // Cosmos DB docs example: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd", 7) → "2021-01-07T00:00:00.0000000Z" - // 7-day bins from Unix epoch 1970-01-01 - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 7) FROM c"); - result.Should().Be("2021-01-07T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_7DayBins_CustomWindowsEpochOrigin() - { - // Cosmos DB docs example with Windows epoch origin - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 7, '1601-01-01T00:00:00.0000000') FROM c"); - result.Should().Be("2021-01-04T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_Month_WithMAlias_BinsCorrectly() - { - // BUG-2 regression: "m" alias should bin by month, not return unchanged - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2023-07-15T10:30:00.0000000', 'm', 1) FROM c"); - result.Should().Be("2023-07-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_5Hour_BinsCorrectly() - { - // Cosmos DB docs: DATETIMEBIN("2021-01-08T18:35:00.0000000", "hh", 5) → "2021-01-08T15:00:00.0000000Z" - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'hh', 5) FROM c"); - result.Should().Be("2021-01-08T15:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_Minute_15MinBins() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:37:00.0000000', 'mi', 15) FROM c"); - result.Should().Be("2021-01-08T18:30:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_Second_30SecBins() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:42.0000000', 'ss', 30) FROM c"); - result.Should().Be("2021-01-08T18:35:30.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_BinDay_DefaultBinSize() - { - // Cosmos DB docs: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd") → "2021-01-08T00:00:00.0000000Z" - // binSize defaults to 1 - // Note: Our parser passes binSize from args, and docs say default is 1. - // This tests the explicit binSize=1 case which is equivalent. - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 1) FROM c"); - result.Should().Be("2021-01-08T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task QuerySingleString(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + [Fact] + public async Task DateTimeBin_7DayBins_DefaultOriginUnixEpoch() + { + // Cosmos DB docs example: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd", 7) → "2021-01-07T00:00:00.0000000Z" + // 7-day bins from Unix epoch 1970-01-01 + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 7) FROM c"); + result.Should().Be("2021-01-07T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_7DayBins_CustomWindowsEpochOrigin() + { + // Cosmos DB docs example with Windows epoch origin + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 7, '1601-01-01T00:00:00.0000000') FROM c"); + result.Should().Be("2021-01-04T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_Month_WithMAlias_BinsCorrectly() + { + // BUG-2 regression: "m" alias should bin by month, not return unchanged + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2023-07-15T10:30:00.0000000', 'm', 1) FROM c"); + result.Should().Be("2023-07-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_5Hour_BinsCorrectly() + { + // Cosmos DB docs: DATETIMEBIN("2021-01-08T18:35:00.0000000", "hh", 5) → "2021-01-08T15:00:00.0000000Z" + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'hh', 5) FROM c"); + result.Should().Be("2021-01-08T15:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_Minute_15MinBins() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:37:00.0000000', 'mi', 15) FROM c"); + result.Should().Be("2021-01-08T18:30:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_Second_30SecBins() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:42.0000000', 'ss', 30) FROM c"); + result.Should().Be("2021-01-08T18:35:30.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_BinDay_DefaultBinSize() + { + // Cosmos DB docs: DATETIMEBIN("2021-01-08T18:35:00.0000000", "dd") → "2021-01-08T00:00:00.0000000Z" + // binSize defaults to 1 + // Note: Our parser passes binSize from args, and docs say default is 1. + // This tests the explicit binSize=1 case which is equivalent. + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000', 'dd', 1) FROM c"); + result.Should().Be("2021-01-08T00:00:00.0000000Z"); + } } // ─── DateTimeFromParts Extended Tests ─────────────────────────────────── public class DateTimeFromPartsExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task QuerySingleStringOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - var token = results.Single(); - return token.Type == JTokenType.Null ? null : token.ToString(); - } - - [Fact] - public async Task DateTimeFromParts_MinArgs_YearMonthDay() - { - // BUG-3: Cosmos docs: DATETIMEFROMPARTS(2017, 4, 20) → "2017-04-20T00:00:00.0000000Z" - var result = await QuerySingleStringOrNull( - "SELECT VALUE DateTimeFromParts(2017, 4, 20) FROM c"); - result.Should().Be("2017-04-20T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeFromParts_PartialArgs_5() - { - // BUG-3: DATETIMEFROMPARTS(2017, 4, 20, 13, 15) → "2017-04-20T13:15:00.0000000Z" - var result = await QuerySingleStringOrNull( - "SELECT VALUE DateTimeFromParts(2017, 4, 20, 13, 15) FROM c"); - result.Should().Be("2017-04-20T13:15:00.0000000Z"); - } - - [Fact] - public async Task DateTimeFromParts_AllArgs_WithSubSecondFraction() - { - // BUG-4: DATETIMEFROMPARTS(2017, 4, 20, 13, 15, 20, 3456789) → "2017-04-20T13:15:20.3456789Z" - // 7th arg is sub-second fraction in 100ns ticks, not milliseconds - var result = await QuerySingleStringOrNull( - "SELECT VALUE DateTimeFromParts(2017, 4, 20, 13, 15, 20, 3456789) FROM c"); - result.Should().Be("2017-04-20T13:15:20.3456789Z"); - } - - [Fact] - public async Task DateTimeFromParts_ZeroFraction() - { - var result = await QuerySingleStringOrNull( - "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, 0) FROM c"); - result.Should().Be("2020-01-01T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task QuerySingleStringOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + var token = results.Single(); + return token.Type == JTokenType.Null ? null : token.ToString(); + } + + [Fact] + public async Task DateTimeFromParts_MinArgs_YearMonthDay() + { + // BUG-3: Cosmos docs: DATETIMEFROMPARTS(2017, 4, 20) → "2017-04-20T00:00:00.0000000Z" + var result = await QuerySingleStringOrNull( + "SELECT VALUE DateTimeFromParts(2017, 4, 20) FROM c"); + result.Should().Be("2017-04-20T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeFromParts_PartialArgs_5() + { + // BUG-3: DATETIMEFROMPARTS(2017, 4, 20, 13, 15) → "2017-04-20T13:15:00.0000000Z" + var result = await QuerySingleStringOrNull( + "SELECT VALUE DateTimeFromParts(2017, 4, 20, 13, 15) FROM c"); + result.Should().Be("2017-04-20T13:15:00.0000000Z"); + } + + [Fact] + public async Task DateTimeFromParts_AllArgs_WithSubSecondFraction() + { + // BUG-4: DATETIMEFROMPARTS(2017, 4, 20, 13, 15, 20, 3456789) → "2017-04-20T13:15:20.3456789Z" + // 7th arg is sub-second fraction in 100ns ticks, not milliseconds + var result = await QuerySingleStringOrNull( + "SELECT VALUE DateTimeFromParts(2017, 4, 20, 13, 15, 20, 3456789) FROM c"); + result.Should().Be("2017-04-20T13:15:20.3456789Z"); + } + + [Fact] + public async Task DateTimeFromParts_ZeroFraction() + { + var result = await QuerySingleStringOrNull( + "SELECT VALUE DateTimeFromParts(2020, 1, 1, 0, 0, 0, 0) FROM c"); + result.Should().Be("2020-01-01T00:00:00.0000000Z"); + } } // ─── DateTimeDiff Extended Tests ──────────────────────────────────────── public class DateTimeDiffExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task QuerySingleLong(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - [Fact] - public async Task DateTimeDiff_DocsExample_PastYears() - { - // Cosmos docs: DATETIMEDIFF("yyyy", "2019-02-04T16:00:00.0000000", "2018-03-05T05:00:00.0000000") → -1 - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('yyyy', '2019-02-04T16:00:00.0000000', '2018-03-05T05:00:00.0000000') FROM c"); - result.Should().Be(-1); - } - - [Fact] - public async Task DateTimeDiff_DocsExample_PastMonths() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('mm', '2019-02-04T16:00:00.0000000', '2018-03-05T05:00:00.0000000') FROM c"); - result.Should().Be(-11); - } - - [Fact] - public async Task DateTimeDiff_DocsExample_FutureDays() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('dd', '2018-03-05T05:00:00.0000000', '2019-02-04T16:00:00.0000000') FROM c"); - result.Should().Be(336); - } - - [Fact] - public async Task DateTimeDiff_DocsExample_FutureHours() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('hh', '2018-03-05T05:00:00.0000000', '2019-02-04T16:00:00.0000000') FROM c"); - result.Should().Be(8075); - } - - [Theory] - [InlineData("year", "yyyy")] - [InlineData("year", "yy")] - [InlineData("month", "mm")] - [InlineData("month", "m")] - [InlineData("day", "dd")] - [InlineData("day", "d")] - [InlineData("hour", "hh")] - [InlineData("minute", "mi")] - [InlineData("minute", "n")] - [InlineData("second", "ss")] - [InlineData("second", "s")] - [InlineData("millisecond", "ms")] - public async Task DateTimeDiff_Aliases_ProduceSameResult(string longAlias, string shortAlias) - { - var resultLong = await QuerySingleLong( - $"SELECT VALUE DateTimeDiff('{longAlias}', '2020-01-01T00:00:00.0000000', '2020-06-15T12:30:00.0000000') FROM c"); - var resultShort = await QuerySingleLong( - $"SELECT VALUE DateTimeDiff('{shortAlias}', '2020-01-01T00:00:00.0000000', '2020-06-15T12:30:00.0000000') FROM c"); - resultLong.Should().Be(resultShort); - } - - [Fact] - public async Task DateTimeDiff_Microsecond_CalculatesCorrectly() - { - // 500ms = 500000 microseconds - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('mcs', '2020-01-01T00:00:00.0000000', '2020-01-01T00:00:00.5000000') FROM c"); - result.Should().Be(500000); - } - - [Fact] - public async Task DateTimeDiff_Nanosecond_CalculatesCorrectly() - { - // 500ms = 500000000 nanoseconds (limited by 100ns tick precision) - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('ns', '2020-01-01T00:00:00.0000000', '2020-01-01T00:00:00.5000000') FROM c"); - result.Should().Be(500000000); - } - - [Fact] - public async Task DateTimeDiff_Month_AcrossLeapYear() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('mm', '2024-01-15T00:00:00.0000000', '2024-03-15T00:00:00.0000000') FROM c"); - result.Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task QuerySingleLong(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + [Fact] + public async Task DateTimeDiff_DocsExample_PastYears() + { + // Cosmos docs: DATETIMEDIFF("yyyy", "2019-02-04T16:00:00.0000000", "2018-03-05T05:00:00.0000000") → -1 + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('yyyy', '2019-02-04T16:00:00.0000000', '2018-03-05T05:00:00.0000000') FROM c"); + result.Should().Be(-1); + } + + [Fact] + public async Task DateTimeDiff_DocsExample_PastMonths() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('mm', '2019-02-04T16:00:00.0000000', '2018-03-05T05:00:00.0000000') FROM c"); + result.Should().Be(-11); + } + + [Fact] + public async Task DateTimeDiff_DocsExample_FutureDays() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('dd', '2018-03-05T05:00:00.0000000', '2019-02-04T16:00:00.0000000') FROM c"); + result.Should().Be(336); + } + + [Fact] + public async Task DateTimeDiff_DocsExample_FutureHours() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('hh', '2018-03-05T05:00:00.0000000', '2019-02-04T16:00:00.0000000') FROM c"); + result.Should().Be(8075); + } + + [Theory] + [InlineData("year", "yyyy")] + [InlineData("year", "yy")] + [InlineData("month", "mm")] + [InlineData("month", "m")] + [InlineData("day", "dd")] + [InlineData("day", "d")] + [InlineData("hour", "hh")] + [InlineData("minute", "mi")] + [InlineData("minute", "n")] + [InlineData("second", "ss")] + [InlineData("second", "s")] + [InlineData("millisecond", "ms")] + public async Task DateTimeDiff_Aliases_ProduceSameResult(string longAlias, string shortAlias) + { + var resultLong = await QuerySingleLong( + $"SELECT VALUE DateTimeDiff('{longAlias}', '2020-01-01T00:00:00.0000000', '2020-06-15T12:30:00.0000000') FROM c"); + var resultShort = await QuerySingleLong( + $"SELECT VALUE DateTimeDiff('{shortAlias}', '2020-01-01T00:00:00.0000000', '2020-06-15T12:30:00.0000000') FROM c"); + resultLong.Should().Be(resultShort); + } + + [Fact] + public async Task DateTimeDiff_Microsecond_CalculatesCorrectly() + { + // 500ms = 500000 microseconds + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('mcs', '2020-01-01T00:00:00.0000000', '2020-01-01T00:00:00.5000000') FROM c"); + result.Should().Be(500000); + } + + [Fact] + public async Task DateTimeDiff_Nanosecond_CalculatesCorrectly() + { + // 500ms = 500000000 nanoseconds (limited by 100ns tick precision) + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('ns', '2020-01-01T00:00:00.0000000', '2020-01-01T00:00:00.5000000') FROM c"); + result.Should().Be(500000000); + } + + [Fact] + public async Task DateTimeDiff_Month_AcrossLeapYear() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('mm', '2024-01-15T00:00:00.0000000', '2024-03-15T00:00:00.0000000') FROM c"); + result.Should().Be(2); + } } // ─── DateTimeDiff Boundary-Crossing Divergence ────────────────────────── public class DateTimeDiffBoundaryCrossingTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - // ── BUG-5: DateTimeDiff uses interval truncation for sub-day parts ── - // Real Cosmos DB counts boundary crossings: 23:59→00:01 crosses the hour - // boundary once, so DATETIMEDIFF('hour',...) = 1. - // Our emulator uses (long)TotalHours which truncates the 0.033h interval to 0. - // Implementing boundary-crossing semantics for all sub-day parts is complex - // and the interval-based approach matches for all whole-unit intervals. - [Fact] - public async Task DateTimeDiff_BoundaryCrossing_Hour_ShouldReturn1() - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeDiff('hh', '2020-01-01T23:59:00.0000000', '2020-01-02T00:01:00.0000000') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_BoundaryCrossing_Hour_EmulatorBehavior_NowCorrect() - { - // Previously divergent: emulator returned 0 for a 2-minute span crossing the hour boundary. - // Now fixed: uses boundary-crossing semantics matching real Cosmos DB. - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeDiff('hh', '2020-01-01T23:59:00.0000000', '2020-01-02T00:01:00.0000000') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Emulator now correctly returns 1 (boundary crossing), matching real Cosmos DB - results.Should().ContainSingle().Which.Should().Be(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + // ── BUG-5: DateTimeDiff uses interval truncation for sub-day parts ── + // Real Cosmos DB counts boundary crossings: 23:59→00:01 crosses the hour + // boundary once, so DATETIMEDIFF('hour',...) = 1. + // Our emulator uses (long)TotalHours which truncates the 0.033h interval to 0. + // Implementing boundary-crossing semantics for all sub-day parts is complex + // and the interval-based approach matches for all whole-unit intervals. + [Fact] + public async Task DateTimeDiff_BoundaryCrossing_Hour_ShouldReturn1() + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeDiff('hh', '2020-01-01T23:59:00.0000000', '2020-01-02T00:01:00.0000000') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_BoundaryCrossing_Hour_EmulatorBehavior_NowCorrect() + { + // Previously divergent: emulator returned 0 for a 2-minute span crossing the hour boundary. + // Now fixed: uses boundary-crossing semantics matching real Cosmos DB. + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeDiff('hh', '2020-01-01T23:59:00.0000000', '2020-01-02T00:01:00.0000000') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Emulator now correctly returns 1 (boundary crossing), matching real Cosmos DB + results.Should().ContainSingle().Which.Should().Be(1); + } } // ─── Conversion Round-Trip and Edge Case Tests ────────────────────────── public class DateTimeConversionExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task EnsureSeedItem() - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - } - - [Fact] - public async Task DateTimeToTicks_And_TicksToDateTime_RoundTrip() - { - await EnsureSeedItem(); - - var ticksIterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeToTicks('2023-06-15T10:30:45.1234567') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var ticks = new List(); - while (ticksIterator.HasMoreResults) ticks.AddRange(await ticksIterator.ReadNextAsync()); - - var dtIterator = _container.GetItemQueryIterator( - $"SELECT VALUE TicksToDateTime({ticks.Single()}) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var dates = new List(); - while (dtIterator.HasMoreResults) dates.AddRange(await dtIterator.ReadNextAsync()); - - dates.Single().Should().Be("2023-06-15T10:30:45.1234567Z"); - } - - [Fact] - public async Task DateTimeToTimestamp_And_TimestampToDateTime_RoundTrip() - { - await EnsureSeedItem(); - - var tsIterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeToTimestamp('2023-06-15T10:30:45.0000000') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var timestamps = new List(); - while (tsIterator.HasMoreResults) timestamps.AddRange(await tsIterator.ReadNextAsync()); - - var dtIterator = _container.GetItemQueryIterator( - $"SELECT VALUE TimestampToDateTime({timestamps.Single()}) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var dates = new List(); - while (dtIterator.HasMoreResults) dates.AddRange(await dtIterator.ReadNextAsync()); - - dates.Single().Should().Be("2023-06-15T10:30:45.0000000Z"); - } - - [Fact] - public async Task DateTimeToTimestamp_PreUnixEpoch_NegativeValue() - { - await EnsureSeedItem(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeToTimestamp('1969-06-15T00:00:00.0000000') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single().Should().BeNegative(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task EnsureSeedItem() + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + } + + [Fact] + public async Task DateTimeToTicks_And_TicksToDateTime_RoundTrip() + { + await EnsureSeedItem(); + + var ticksIterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeToTicks('2023-06-15T10:30:45.1234567') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var ticks = new List(); + while (ticksIterator.HasMoreResults) ticks.AddRange(await ticksIterator.ReadNextAsync()); + + var dtIterator = _container.GetItemQueryIterator( + $"SELECT VALUE TicksToDateTime({ticks.Single()}) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var dates = new List(); + while (dtIterator.HasMoreResults) dates.AddRange(await dtIterator.ReadNextAsync()); + + dates.Single().Should().Be("2023-06-15T10:30:45.1234567Z"); + } + + [Fact] + public async Task DateTimeToTimestamp_And_TimestampToDateTime_RoundTrip() + { + await EnsureSeedItem(); + + var tsIterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeToTimestamp('2023-06-15T10:30:45.0000000') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var timestamps = new List(); + while (tsIterator.HasMoreResults) timestamps.AddRange(await tsIterator.ReadNextAsync()); + + var dtIterator = _container.GetItemQueryIterator( + $"SELECT VALUE TimestampToDateTime({timestamps.Single()}) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var dates = new List(); + while (dtIterator.HasMoreResults) dates.AddRange(await dtIterator.ReadNextAsync()); + + dates.Single().Should().Be("2023-06-15T10:30:45.0000000Z"); + } + + [Fact] + public async Task DateTimeToTimestamp_PreUnixEpoch_NegativeValue() + { + await EnsureSeedItem(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeToTimestamp('1969-06-15T00:00:00.0000000') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single().Should().BeNegative(); + } } // ─── GetCurrentDateTime / GetCurrentTimestamp Value Tests ─────────────── public class GetCurrentDateTimeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task GetCurrentDateTime_ReturnsIso8601InUtc() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentDateTime() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - var result = results.Single(); - result.Should().EndWith("Z"); - result.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$"); - } - - [Fact] - public async Task GetCurrentTimestamp_ReturnsReasonableUnixMs() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentTimestamp() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - // Should be greater than 2020-01-01 timestamp (1577836800000) - results.Single().Should().BeGreaterThan(1577836800000); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task GetCurrentDateTime_ReturnsIso8601InUtc() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentDateTime() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + var result = results.Single(); + result.Should().EndWith("Z"); + result.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$"); + } + + [Fact] + public async Task GetCurrentTimestamp_ReturnsReasonableUnixMs() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentTimestamp() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + // Should be greater than 2020-01-01 timestamp (1577836800000) + results.Single().Should().BeGreaterThan(1577836800000); + } } // ─── Composed / Integration Date Query Tests ──────────────────────────── public class DateComposedQueryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task DateFilter_WhereClause_FiltersOnDateComparison() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "old", pk = "a", ts = "2019-01-01T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "new", pk = "a", ts = "2021-06-01T00:00:00Z" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.ts > '2020-01-01T00:00:00Z'", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("new"); - } - - [Fact] - public async Task DateTimeAdd_InsideDateTimeDiff_Composes() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeDiff('dd', c.ts, DateTimeAdd('dd', 7, c.ts)) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single().Should().Be(7); - } - - [Fact] - public async Task OrderBy_DateTimeField_SortsChronologically() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "c", pk = "a", ts = "2022-03-01T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "a", pk = "a", ts = "2020-01-01T00:00:00Z" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "b", pk = "a", ts = "2021-06-15T00:00:00Z" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.ts ASC", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Select(r => r["id"]!.ToString()).Should().ContainInOrder("a", "b", "c"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task DateFilter_WhereClause_FiltersOnDateComparison() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "old", pk = "a", ts = "2019-01-01T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "new", pk = "a", ts = "2021-06-01T00:00:00Z" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.ts > '2020-01-01T00:00:00Z'", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("new"); + } + + [Fact] + public async Task DateTimeAdd_InsideDateTimeDiff_Composes() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00.0000000Z" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeDiff('dd', c.ts, DateTimeAdd('dd', 7, c.ts)) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single().Should().Be(7); + } + + [Fact] + public async Task OrderBy_DateTimeField_SortsChronologically() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "c", pk = "a", ts = "2022-03-01T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "a", pk = "a", ts = "2020-01-01T00:00:00Z" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "b", pk = "a", ts = "2021-06-15T00:00:00Z" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.ts ASC", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Select(r => r["id"]!.ToString()).Should().ContainInOrder("a", "b", "c"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1235,183 +1235,183 @@ await _container.CreateItemAsync( public class DateTimeFromPartsInvalidArgsTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeFromParts_NegativeYear_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(-2000, 1, 1) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeFromParts_InvalidMonth13_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(2020, 13, 1) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeFromParts_InvalidDay32_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(2020, 1, 32) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeFromParts_Feb29NonLeapYear_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(2023, 2, 29) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeFromParts_DocsExample_NegativeArgs_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(-2000, -1, -1) FROM c"); - result.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeFromParts_NegativeYear_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(-2000, 1, 1) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeFromParts_InvalidMonth13_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(2020, 13, 1) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeFromParts_InvalidDay32_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(2020, 1, 32) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeFromParts_Feb29NonLeapYear_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(2023, 2, 29) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeFromParts_DocsExample_NegativeArgs_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(-2000, -1, -1) FROM c"); + result.Should().BeNull(); + } } public class DateTimeAddInvalidPartTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeAdd_InvalidPart_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeAdd('invalid', 1, '2020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeAdd_InvalidPart_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeAdd('invalid', 1, '2020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().BeNull(); + } } public class DateTimeBinBugFixTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleString(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeBin_ZeroBinSize_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh', 0) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeBin_NegativeBinSize_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh', -1) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeBin_Microsecond_BinsCorrectly() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.1234567Z', 'mcs', 100) FROM c"); - result.Should().NotBeNull(); - result.Should().StartWith("2021-06-28T17:24:29.123"); - } - - [Fact] - public async Task DateTimeBin_Nanosecond_BinsCorrectly() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.1234567Z', 'ns', 1000) FROM c"); - result.Should().NotBeNull(); - result.Should().StartWith("2021-06-28T17:24:29.123"); - } - - [Fact] - public async Task DateTimeBin_TwoArgForm_DefaultBinSize1() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh') FROM c"); - result.Should().Be("2021-06-28T17:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_OriginBefore1601_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29Z', 'hh', 1, '1500-01-01T00:00:00Z') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeBin_InvalidPart_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29Z', 'invalid', 1) FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeBin_InvalidDateTime_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeBin('not-a-date', 'hh', 1) FROM c"); - result.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleString(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeBin_ZeroBinSize_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh', 0) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeBin_NegativeBinSize_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh', -1) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeBin_Microsecond_BinsCorrectly() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.1234567Z', 'mcs', 100) FROM c"); + result.Should().NotBeNull(); + result.Should().StartWith("2021-06-28T17:24:29.123"); + } + + [Fact] + public async Task DateTimeBin_Nanosecond_BinsCorrectly() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.1234567Z', 'ns', 1000) FROM c"); + result.Should().NotBeNull(); + result.Should().StartWith("2021-06-28T17:24:29.123"); + } + + [Fact] + public async Task DateTimeBin_TwoArgForm_DefaultBinSize1() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', 'hh') FROM c"); + result.Should().Be("2021-06-28T17:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_OriginBefore1601_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29Z', 'hh', 1, '1500-01-01T00:00:00Z') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeBin_InvalidPart_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29Z', 'invalid', 1) FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeBin_InvalidDateTime_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeBin('not-a-date', 'hh', 1) FROM c"); + result.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1420,105 +1420,105 @@ public async Task DateTimeBin_InvalidDateTime_ReturnsNull() public class DateTimeAddExtendedTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleString(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeAdd_DayLongAlias_AddsCorrectly() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('day', 5, '2020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().Be("2020-01-06T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_DAlias_AddsCorrectly() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('d', 5, '2020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().Be("2020-01-06T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_HourLongAlias_AddsCorrectly() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('hour', 3, '2020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().Be("2020-01-01T03:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_SubtractMonthCrossYearBoundary() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('mm', -2, '2020-01-15T00:00:00.0000000Z') FROM c"); - result.Should().Be("2019-11-15T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_AddMonth_Mar31Plus1_ClampsToApr30() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('mm', 1, '2020-03-31T00:00:00.0000000Z') FROM c"); - result.Should().Be("2020-04-30T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_InvalidDateTimeString_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeAdd('dd', 1, 'not-a-date') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeAdd_PreserveSubSecondPrecision() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('hh', 1, '2020-01-01T00:00:00.1234567Z') FROM c"); - result.Should().Be("2020-01-01T01:00:00.1234567Z"); - } - - [Fact] - public async Task DateTimeAdd_ZeroAmount_ReturnsUnchanged() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('dd', 0, '2020-06-15T12:30:00.0000000Z') FROM c"); - result.Should().Be("2020-06-15T12:30:00.0000000Z"); - } - - [Fact] - public async Task DateTimeAdd_LargeYearAddition() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeAdd('yyyy', 1000, '1020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().Be("2020-01-01T00:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleString(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeAdd_DayLongAlias_AddsCorrectly() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('day', 5, '2020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().Be("2020-01-06T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_DAlias_AddsCorrectly() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('d', 5, '2020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().Be("2020-01-06T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_HourLongAlias_AddsCorrectly() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('hour', 3, '2020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().Be("2020-01-01T03:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_SubtractMonthCrossYearBoundary() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('mm', -2, '2020-01-15T00:00:00.0000000Z') FROM c"); + result.Should().Be("2019-11-15T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_AddMonth_Mar31Plus1_ClampsToApr30() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('mm', 1, '2020-03-31T00:00:00.0000000Z') FROM c"); + result.Should().Be("2020-04-30T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_InvalidDateTimeString_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeAdd('dd', 1, 'not-a-date') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeAdd_PreserveSubSecondPrecision() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('hh', 1, '2020-01-01T00:00:00.1234567Z') FROM c"); + result.Should().Be("2020-01-01T01:00:00.1234567Z"); + } + + [Fact] + public async Task DateTimeAdd_ZeroAmount_ReturnsUnchanged() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('dd', 0, '2020-06-15T12:30:00.0000000Z') FROM c"); + result.Should().Be("2020-06-15T12:30:00.0000000Z"); + } + + [Fact] + public async Task DateTimeAdd_LargeYearAddition() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeAdd('yyyy', 1000, '1020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().Be("2020-01-01T00:00:00.0000000Z"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1527,86 +1527,86 @@ public async Task DateTimeAdd_LargeYearAddition() public class DateTimePartAliasTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleLong(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Theory] - [InlineData("day", 29)] - [InlineData("d", 29)] - [InlineData("hour", 8)] - [InlineData("hh", 8)] - [InlineData("minute", 30)] - [InlineData("mi", 30)] - [InlineData("n", 30)] - [InlineData("second", 0)] - [InlineData("ss", 0)] - [InlineData("s", 0)] - [InlineData("millisecond", 130)] - [InlineData("microsecond", 130161)] - [InlineData("nanosecond", 130161700)] - public async Task DateTimePart_AllAliases(string part, long expected) - { - var result = await QuerySingleLong( - $"SELECT VALUE DateTimePart('{part}', '2016-05-29T08:30:00.1301617Z') FROM c"); - result.Should().Be(expected); - } - - [Fact] - public async Task DateTimePart_InvalidPart_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimePart('invalid', '2020-01-01T00:00:00Z') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimePart_InvalidDateTime_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimePart('yyyy', 'not-a-date') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimePart_MidnightZeroValues() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimePart('hh', '2020-01-01T00:00:00.0000000Z') FROM c"); - result.Should().Be(0); - } - - [Fact] - public async Task DateTimePart_EndOfDay_235959() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimePart('ss', '2020-12-31T23:59:59.9999999Z') FROM c"); - result.Should().Be(59); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleLong(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Theory] + [InlineData("day", 29)] + [InlineData("d", 29)] + [InlineData("hour", 8)] + [InlineData("hh", 8)] + [InlineData("minute", 30)] + [InlineData("mi", 30)] + [InlineData("n", 30)] + [InlineData("second", 0)] + [InlineData("ss", 0)] + [InlineData("s", 0)] + [InlineData("millisecond", 130)] + [InlineData("microsecond", 130161)] + [InlineData("nanosecond", 130161700)] + public async Task DateTimePart_AllAliases(string part, long expected) + { + var result = await QuerySingleLong( + $"SELECT VALUE DateTimePart('{part}', '2016-05-29T08:30:00.1301617Z') FROM c"); + result.Should().Be(expected); + } + + [Fact] + public async Task DateTimePart_InvalidPart_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimePart('invalid', '2020-01-01T00:00:00Z') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimePart_InvalidDateTime_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimePart('yyyy', 'not-a-date') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimePart_MidnightZeroValues() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimePart('hh', '2020-01-01T00:00:00.0000000Z') FROM c"); + result.Should().Be(0); + } + + [Fact] + public async Task DateTimePart_EndOfDay_235959() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimePart('ss', '2020-12-31T23:59:59.9999999Z') FROM c"); + result.Should().Be(59); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1615,129 +1615,129 @@ public async Task DateTimePart_EndOfDay_235959() public class DateTimeDiffExtendedDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleLong(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeDiff_SameDateTime_ZeroForAllParts() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('yyyy', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z') FROM c"); - result.Should().Be(0); - } - - [Fact] - public async Task DateTimeDiff_BoundaryCrossing_Minute() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('mi', '2020-01-01T12:59:30Z', '2020-01-01T13:00:30Z') FROM c"); - result.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_BoundaryCrossing_Second() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('ss', '2020-01-01T00:00:00.9000000Z', '2020-01-01T00:00:01.1000000Z') FROM c"); - result.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_BoundaryCrossing_Millisecond() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('ms', '2020-01-01T00:00:00.0009000Z', '2020-01-01T00:00:00.0011000Z') FROM c"); - result.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_VeryLargeSpan_Centuries() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('yyyy', '1900-01-01T00:00:00Z', '2100-01-01T00:00:00Z') FROM c"); - result.Should().Be(200); - } - - [Fact] - public async Task DateTimeDiff_InvalidPart_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeDiff('invalid', '2020-01-01T00:00:00Z', '2021-01-01T00:00:00Z') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeDiff_InvalidStartDateTime_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeDiff('dd', 'not-a-date', '2020-01-01T00:00:00Z') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeDiff_InvalidEndDateTime_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeDiff('dd', '2020-01-01T00:00:00Z', 'not-a-date') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeDiff_DayWithPartialDays() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('dd', '2020-01-01T23:59:00Z', '2020-01-02T00:01:00Z') FROM c"); - result.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_NegativeBoundaryCrossing_Hour() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('hh', '2020-01-01T13:00:30Z', '2020-01-01T12:59:30Z') FROM c"); - result.Should().Be(-1); - } - - [Fact] - public async Task DateTimeDiff_MonthPartialMonth() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('mm', '2020-01-31T00:00:00Z', '2020-02-15T00:00:00Z') FROM c"); - result.Should().Be(1); - } - - [Fact] - public async Task DateTimeDiff_YearPartialYear() - { - var result = await QuerySingleLong( - "SELECT VALUE DateTimeDiff('yyyy', '2020-12-15T00:00:00Z', '2021-01-15T00:00:00Z') FROM c"); - result.Should().Be(1); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleLong(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeDiff_SameDateTime_ZeroForAllParts() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('yyyy', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z') FROM c"); + result.Should().Be(0); + } + + [Fact] + public async Task DateTimeDiff_BoundaryCrossing_Minute() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('mi', '2020-01-01T12:59:30Z', '2020-01-01T13:00:30Z') FROM c"); + result.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_BoundaryCrossing_Second() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('ss', '2020-01-01T00:00:00.9000000Z', '2020-01-01T00:00:01.1000000Z') FROM c"); + result.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_BoundaryCrossing_Millisecond() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('ms', '2020-01-01T00:00:00.0009000Z', '2020-01-01T00:00:00.0011000Z') FROM c"); + result.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_VeryLargeSpan_Centuries() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('yyyy', '1900-01-01T00:00:00Z', '2100-01-01T00:00:00Z') FROM c"); + result.Should().Be(200); + } + + [Fact] + public async Task DateTimeDiff_InvalidPart_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeDiff('invalid', '2020-01-01T00:00:00Z', '2021-01-01T00:00:00Z') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeDiff_InvalidStartDateTime_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeDiff('dd', 'not-a-date', '2020-01-01T00:00:00Z') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeDiff_InvalidEndDateTime_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeDiff('dd', '2020-01-01T00:00:00Z', 'not-a-date') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeDiff_DayWithPartialDays() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('dd', '2020-01-01T23:59:00Z', '2020-01-02T00:01:00Z') FROM c"); + result.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_NegativeBoundaryCrossing_Hour() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('hh', '2020-01-01T13:00:30Z', '2020-01-01T12:59:30Z') FROM c"); + result.Should().Be(-1); + } + + [Fact] + public async Task DateTimeDiff_MonthPartialMonth() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('mm', '2020-01-31T00:00:00Z', '2020-02-15T00:00:00Z') FROM c"); + result.Should().Be(1); + } + + [Fact] + public async Task DateTimeDiff_YearPartialYear() + { + var result = await QuerySingleLong( + "SELECT VALUE DateTimeDiff('yyyy', '2020-12-15T00:00:00Z', '2021-01-15T00:00:00Z') FROM c"); + result.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1746,72 +1746,72 @@ public async Task DateTimeDiff_YearPartialYear() public class DateTimeBinExtendedDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleString(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - [Theory] - [InlineData("year", "2021-01-01T00:00:00.0000000Z")] - [InlineData("yyyy", "2021-01-01T00:00:00.0000000Z")] - [InlineData("month", "2021-06-01T00:00:00.0000000Z")] - [InlineData("mm", "2021-06-01T00:00:00.0000000Z")] - [InlineData("day", "2021-06-28T00:00:00.0000000Z")] - [InlineData("dd", "2021-06-28T00:00:00.0000000Z")] - [InlineData("hour", "2021-06-28T17:00:00.0000000Z")] - [InlineData("hh", "2021-06-28T17:00:00.0000000Z")] - [InlineData("minute", "2021-06-28T17:24:00.0000000Z")] - [InlineData("mi", "2021-06-28T17:24:00.0000000Z")] - [InlineData("second", "2021-06-28T17:24:29.0000000Z")] - [InlineData("ss", "2021-06-28T17:24:29.0000000Z")] - public async Task DateTimeBin_LongFormAliases(string part, string expected) - { - var result = await QuerySingleString( - $"SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', '{part}', 1) FROM c"); - result.Should().Be(expected); - } - - [Fact] - public async Task DateTimeBin_FiveYearBins() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2023-06-15T00:00:00Z', 'yyyy', 5) FROM c"); - result.Should().Be("2020-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_MillisecondBins500() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.7501234Z', 'ms', 500) FROM c"); - result.Should().Be("2021-06-28T17:24:29.5000000Z"); - } - - [Fact] - public async Task DateTimeBin_DocsExample_BinSecond() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000Z', 'ss', 1) FROM c"); - result.Should().Be("2021-01-08T18:35:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_DocsExample_BinHour() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000Z', 'hh', 1) FROM c"); - result.Should().Be("2021-01-08T18:00:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleString(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + [Theory] + [InlineData("year", "2021-01-01T00:00:00.0000000Z")] + [InlineData("yyyy", "2021-01-01T00:00:00.0000000Z")] + [InlineData("month", "2021-06-01T00:00:00.0000000Z")] + [InlineData("mm", "2021-06-01T00:00:00.0000000Z")] + [InlineData("day", "2021-06-28T00:00:00.0000000Z")] + [InlineData("dd", "2021-06-28T00:00:00.0000000Z")] + [InlineData("hour", "2021-06-28T17:00:00.0000000Z")] + [InlineData("hh", "2021-06-28T17:00:00.0000000Z")] + [InlineData("minute", "2021-06-28T17:24:00.0000000Z")] + [InlineData("mi", "2021-06-28T17:24:00.0000000Z")] + [InlineData("second", "2021-06-28T17:24:29.0000000Z")] + [InlineData("ss", "2021-06-28T17:24:29.0000000Z")] + public async Task DateTimeBin_LongFormAliases(string part, string expected) + { + var result = await QuerySingleString( + $"SELECT VALUE DateTimeBin('2021-06-28T17:24:29.2991234Z', '{part}', 1) FROM c"); + result.Should().Be(expected); + } + + [Fact] + public async Task DateTimeBin_FiveYearBins() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2023-06-15T00:00:00Z', 'yyyy', 5) FROM c"); + result.Should().Be("2020-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_MillisecondBins500() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-06-28T17:24:29.7501234Z', 'ms', 500) FROM c"); + result.Should().Be("2021-06-28T17:24:29.5000000Z"); + } + + [Fact] + public async Task DateTimeBin_DocsExample_BinSecond() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000Z', 'ss', 1) FROM c"); + result.Should().Be("2021-01-08T18:35:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_DocsExample_BinHour() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeBin('2021-01-08T18:35:00.0000000Z', 'hh', 1) FROM c"); + result.Should().Be("2021-01-08T18:00:00.0000000Z"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1820,73 +1820,73 @@ public async Task DateTimeBin_DocsExample_BinHour() public class DateTimeFromPartsExtendedDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleString(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task DateTimeFromParts_4Args_YearMonthDayHour() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10) FROM c"); - result.Should().Be("2020-06-15T10:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeFromParts_6Args_NoFraction() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10, 30, 45) FROM c"); - result.Should().Be("2020-06-15T10:30:45.0000000Z"); - } - - [Fact] - public async Task DateTimeFromParts_BoundaryValues_Dec31() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeFromParts(2020, 12, 31, 23, 59, 59, 9999999) FROM c"); - result.Should().Be("2020-12-31T23:59:59.9999999Z"); - } - - [Fact] - public async Task DateTimeFromParts_MinimumDate() - { - var result = await QuerySingleString( - "SELECT VALUE DateTimeFromParts(1, 1, 1) FROM c"); - result.Should().Be("0001-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeFromParts_LessThan3Args_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeFromParts(2020, 6) FROM c"); - result.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleString(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task DateTimeFromParts_4Args_YearMonthDayHour() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10) FROM c"); + result.Should().Be("2020-06-15T10:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeFromParts_6Args_NoFraction() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeFromParts(2020, 6, 15, 10, 30, 45) FROM c"); + result.Should().Be("2020-06-15T10:30:45.0000000Z"); + } + + [Fact] + public async Task DateTimeFromParts_BoundaryValues_Dec31() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeFromParts(2020, 12, 31, 23, 59, 59, 9999999) FROM c"); + result.Should().Be("2020-12-31T23:59:59.9999999Z"); + } + + [Fact] + public async Task DateTimeFromParts_MinimumDate() + { + var result = await QuerySingleString( + "SELECT VALUE DateTimeFromParts(1, 1, 1) FROM c"); + result.Should().Be("0001-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeFromParts_LessThan3Args_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeFromParts(2020, 6) FROM c"); + result.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1895,93 +1895,93 @@ public async Task DateTimeFromParts_LessThan3Args_ReturnsNull() public class DateConversionDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task QuerySingleValue(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.Single(); - } - - private async Task QuerySingleOrNull(string sql) - { - try { await _container.ReadItemAsync("1", new PartitionKey("a")); } - catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results.SingleOrDefault(); - } - - [Fact] - public async Task TicksToDateTime_ZeroTicks_Returns0001_01_01() - { - var result = await QuerySingleValue( - "SELECT VALUE TicksToDateTime(0) FROM c"); - result.Should().Be("0001-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task TimestampToDateTime_ZeroMs_ReturnsUnixEpoch() - { - var result = await QuerySingleValue( - "SELECT VALUE TimestampToDateTime(0) FROM c"); - result.Should().Be("1970-01-01T00:00:00.0000000Z"); - } - - [Fact] - public async Task TimestampToDateTime_NegativeMs_ReturnsPreEpoch() - { - var result = await QuerySingleValue( - "SELECT VALUE TimestampToDateTime(-86400000) FROM c"); - result.Should().Be("1969-12-31T00:00:00.0000000Z"); - } - - [Fact] - public async Task DateTimeToTicks_InvalidString_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeToTicks('not-a-date') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeToTimestamp_InvalidString_ReturnsNull() - { - var result = await QuerySingleOrNull( - "SELECT VALUE DateTimeToTimestamp('not-a-date') FROM c"); - result.Should().BeNull(); - } - - [Fact] - public async Task DateTimeToTicks_RoundTripWithTicksToDateTime() - { - var ticks = await QuerySingleValue( - "SELECT VALUE DateTimeToTicks('2020-06-15T12:30:00.1234567Z') FROM c"); - var roundTrip = await QuerySingleValue( - $"SELECT VALUE TicksToDateTime({ticks}) FROM c"); - roundTrip.Should().Be("2020-06-15T12:30:00.1234567Z"); - } - - [Fact] - public async Task DateTimeToTimestamp_RoundTripWithTimestampToDateTime() - { - var ms = await QuerySingleValue( - "SELECT VALUE DateTimeToTimestamp('2020-06-15T12:30:00.0000000Z') FROM c"); - var roundTrip = await QuerySingleValue( - $"SELECT VALUE TimestampToDateTime({ms}) FROM c"); - roundTrip.Should().Be("2020-06-15T12:30:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task QuerySingleValue(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.Single(); + } + + private async Task QuerySingleOrNull(string sql) + { + try { await _container.ReadItemAsync("1", new PartitionKey("a")); } + catch { await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); } + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results.SingleOrDefault(); + } + + [Fact] + public async Task TicksToDateTime_ZeroTicks_Returns0001_01_01() + { + var result = await QuerySingleValue( + "SELECT VALUE TicksToDateTime(0) FROM c"); + result.Should().Be("0001-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task TimestampToDateTime_ZeroMs_ReturnsUnixEpoch() + { + var result = await QuerySingleValue( + "SELECT VALUE TimestampToDateTime(0) FROM c"); + result.Should().Be("1970-01-01T00:00:00.0000000Z"); + } + + [Fact] + public async Task TimestampToDateTime_NegativeMs_ReturnsPreEpoch() + { + var result = await QuerySingleValue( + "SELECT VALUE TimestampToDateTime(-86400000) FROM c"); + result.Should().Be("1969-12-31T00:00:00.0000000Z"); + } + + [Fact] + public async Task DateTimeToTicks_InvalidString_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeToTicks('not-a-date') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeToTimestamp_InvalidString_ReturnsNull() + { + var result = await QuerySingleOrNull( + "SELECT VALUE DateTimeToTimestamp('not-a-date') FROM c"); + result.Should().BeNull(); + } + + [Fact] + public async Task DateTimeToTicks_RoundTripWithTicksToDateTime() + { + var ticks = await QuerySingleValue( + "SELECT VALUE DateTimeToTicks('2020-06-15T12:30:00.1234567Z') FROM c"); + var roundTrip = await QuerySingleValue( + $"SELECT VALUE TicksToDateTime({ticks}) FROM c"); + roundTrip.Should().Be("2020-06-15T12:30:00.1234567Z"); + } + + [Fact] + public async Task DateTimeToTimestamp_RoundTripWithTimestampToDateTime() + { + var ms = await QuerySingleValue( + "SELECT VALUE DateTimeToTimestamp('2020-06-15T12:30:00.0000000Z') FROM c"); + var roundTrip = await QuerySingleValue( + $"SELECT VALUE TimestampToDateTime({ms}) FROM c"); + roundTrip.Should().Be("2020-06-15T12:30:00.0000000Z"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1990,37 +1990,37 @@ public async Task DateTimeToTimestamp_RoundTripWithTimestampToDateTime() public class GetCurrentDateTimeDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task GetCurrentDateTime_FormatHas7DecimalPlaces() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentDateTime() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single().Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z"); - } - - [Fact] - public async Task GetCurrentTicks_TypeIsLong() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentTicks() FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single().Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task GetCurrentDateTime_FormatHas7DecimalPlaces() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentDateTime() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single().Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z"); + } + + [Fact] + public async Task GetCurrentTicks_TypeIsLong() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentTicks() FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single().Should().BeGreaterThan(0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2029,93 +2029,93 @@ await _container.CreateItemAsync( public class DateFunctionIntegrationDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task DateTimePart_InWhereClause() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T00:00:00Z" }), new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2021-06-15T00:00:00Z" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE DateTimePart('yyyy', c.ts) = 2020", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task DateTimeAdd_InWhereComparison() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00Z" }), new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2020-12-01T00:00:00Z" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE DateTimeAdd('dd', 30, c.ts) > '2020-01-15T00:00:00Z'", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task MultipleDateFunctions_InSingleSelect() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:30:00.0000000Z" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT DateTimePart('yyyy', c.ts) as yr, DateTimePart('mm', c.ts) as mo FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single()["yr"]!.Value().Should().Be(2020); - results.Single()["mo"]!.Value().Should().Be(6); - } - - [Fact] - public async Task DateFunction_WithNullDocumentProperty_ReturnsNull() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = (string?)null }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DateTimePart('yyyy', c.ts) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.SingleOrDefault().Should().BeNull(); - } - - [Fact] - public async Task DateTimeToTicks_InWhereClause() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00Z" }), new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", ts = "2021-06-15T00:00:00Z" }), new PartitionKey("a")); - - // 2020-06-01 ticks - var cutoff = new DateTime(2020, 6, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; - var iterator = _container.GetItemQueryIterator( - $"SELECT * FROM c WHERE DateTimeToTicks(c.ts) > {cutoff}", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("2"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task DateTimePart_InWhereClause() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T00:00:00Z" }), new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2021-06-15T00:00:00Z" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE DateTimePart('yyyy', c.ts) = 2020", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task DateTimeAdd_InWhereComparison() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00Z" }), new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2020-12-01T00:00:00Z" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE DateTimeAdd('dd', 30, c.ts) > '2020-01-15T00:00:00Z'", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task MultipleDateFunctions_InSingleSelect() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-06-15T10:30:00.0000000Z" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT DateTimePart('yyyy', c.ts) as yr, DateTimePart('mm', c.ts) as mo FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single()["yr"]!.Value().Should().Be(2020); + results.Single()["mo"]!.Value().Should().Be(6); + } + + [Fact] + public async Task DateFunction_WithNullDocumentProperty_ReturnsNull() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = (string?)null }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DateTimePart('yyyy', c.ts) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.SingleOrDefault().Should().BeNull(); + } + + [Fact] + public async Task DateTimeToTicks_InWhereClause() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", ts = "2020-01-01T00:00:00Z" }), new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", ts = "2021-06-15T00:00:00Z" }), new PartitionKey("a")); + + // 2020-06-01 ticks + var cutoff = new DateTime(2020, 6, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var iterator = _container.GetItemQueryIterator( + $"SELECT * FROM c WHERE DateTimeToTicks(c.ts) > {cutoff}", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2124,27 +2124,27 @@ await _container.CreateItemAsync( public class DateTimeOffsetRoundTripDeepDiveTests { - [Fact] - public async Task DateTimeOffset_NegativeOffset_RoundTrips() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var dto = new DateTimeOffset(2020, 6, 15, 10, 30, 0, TimeSpan.FromHours(-5)); - var doc = new DateDocument { Id = "1", PartitionKey = "pk1", EventDate = dto }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.EventDate.Should().Be(dto); - } - - [Fact] - public async Task DateTimeOffset_MaxOffset_RoundTrips() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var dto = new DateTimeOffset(2020, 6, 15, 10, 30, 0, TimeSpan.FromHours(14)); - var doc = new DateDocument { Id = "1", PartitionKey = "pk1", EventDate = dto }; - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.EventDate.Should().Be(dto); - } + [Fact] + public async Task DateTimeOffset_NegativeOffset_RoundTrips() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var dto = new DateTimeOffset(2020, 6, 15, 10, 30, 0, TimeSpan.FromHours(-5)); + var doc = new DateDocument { Id = "1", PartitionKey = "pk1", EventDate = dto }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.EventDate.Should().Be(dto); + } + + [Fact] + public async Task DateTimeOffset_MaxOffset_RoundTrips() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var dto = new DateTimeOffset(2020, 6, 15, 10, 30, 0, TimeSpan.FromHours(14)); + var doc = new DateDocument { Id = "1", PartitionKey = "pk1", EventDate = dto }; + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.EventDate.Should().Be(dto); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DivergentBehaviorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DivergentBehaviorTests.cs index 4a18212..d7c2a3a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DivergentBehaviorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DivergentBehaviorTests.cs @@ -19,127 +19,127 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class CrossPartitionAggregateTests { - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task CrossPartition_Count_ShouldNotMultiplyResults() - { - var container = new InMemoryContainer("test-agg", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "c" }), new PartitionKey("c")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 2 - }); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test-agg"); - - var count = await cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE COUNT(1) FROM c")) - .ReadNextAsync(); - count.Resource.Single().Should().Be(3); - - // SUM should return correct value, not doubled - var sum = await cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE SUM(1) FROM c")) - .ReadNextAsync(); - sum.Resource.Single().Should().Be(3); - } + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task CrossPartition_Count_ShouldNotMultiplyResults() + { + var container = new InMemoryContainer("test-agg", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "c" }), new PartitionKey("c")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 2 + }); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test-agg"); + + var count = await cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE COUNT(1) FROM c")) + .ReadNextAsync(); + count.Resource.Single().Should().Be(3); + + // SUM should return correct value, not doubled + var sum = await cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE SUM(1) FROM c")) + .ReadNextAsync(); + sum.Resource.Single().Should().Be(3); + } } // ─── M9: Subquery ORDER BY and OFFSET/LIMIT — RESOLVED ────────────────── public class SubqueryOrderByTests { - [Fact] - public async Task Subquery_WithOrderByAndLimit_ShouldReturnOrderedSubset() - { - var container = new InMemoryContainer("subq-m9", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), - new PartitionKey("a")); - - // Subquery ORDER BY + OFFSET/LIMIT: sort ascending, skip first, take 3 → [20, 30, 40] - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC OFFSET 1 LIMIT 3) AS result FROM c WHERE c.id = '1'"); - - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - var result = results[0]["result"]!.ToObject(); - result.Should().Equal(20, 30, 40); - } + [Fact] + public async Task Subquery_WithOrderByAndLimit_ShouldReturnOrderedSubset() + { + var container = new InMemoryContainer("subq-m9", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), + new PartitionKey("a")); + + // Subquery ORDER BY + OFFSET/LIMIT: sort ascending, skip first, take 3 → [20, 30, 40] + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC OFFSET 1 LIMIT 3) AS result FROM c WHERE c.id = '1'"); + + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + var result = results[0]["result"]!.ToObject(); + result.Should().Equal(20, 30, 40); + } } // ─── L2: Array functions only accept identifiers, not literal arrays ──── public class ArrayFunctionLiteralTests { - [Fact] - public async Task ArrayContains_WithLiteralArray_ShouldWork() - { - var container = new InMemoryContainer("test-l2-literal", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - // ARRAY_CONTAINS with literal array - var iter1 = container.GetItemQueryIterator( - "SELECT VALUE ARRAY_CONTAINS([1,2,3], 2) FROM c"); - var r1 = await iter1.ReadNextAsync(); - r1.First().Value().Should().BeTrue(); - - // ARRAY_LENGTH with literal array - var iter2 = container.GetItemQueryIterator( - "SELECT VALUE ARRAY_LENGTH([10,20,30,40]) FROM c"); - var r2 = await iter2.ReadNextAsync(); - r2.First().Value().Should().Be(4); - - // ARRAY_SLICE with literal array - var iter3 = container.GetItemQueryIterator( - "SELECT VALUE ARRAY_SLICE([10,20,30,40,50], 1, 2) FROM c"); - var r3 = await iter3.ReadNextAsync(); - var arr = r3.First() as JArray ?? JArray.Parse(r3.First().ToString()); - arr.Select(t => t.Value()).Should().Equal(20, 30); - } + [Fact] + public async Task ArrayContains_WithLiteralArray_ShouldWork() + { + var container = new InMemoryContainer("test-l2-literal", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + // ARRAY_CONTAINS with literal array + var iter1 = container.GetItemQueryIterator( + "SELECT VALUE ARRAY_CONTAINS([1,2,3], 2) FROM c"); + var r1 = await iter1.ReadNextAsync(); + r1.First().Value().Should().BeTrue(); + + // ARRAY_LENGTH with literal array + var iter2 = container.GetItemQueryIterator( + "SELECT VALUE ARRAY_LENGTH([10,20,30,40]) FROM c"); + var r2 = await iter2.ReadNextAsync(); + r2.First().Value().Should().Be(4); + + // ARRAY_SLICE with literal array + var iter3 = container.GetItemQueryIterator( + "SELECT VALUE ARRAY_SLICE([10,20,30,40,50], 1, 2) FROM c"); + var r3 = await iter3.ReadNextAsync(); + var arr = r3.First() as JArray ?? JArray.Parse(r3.First().ToString()); + arr.Select(t => t.Value()).Should().Equal(20, 30); + } } // ─── L3: GetCurrentDateTime() not consistent across rows ──────────────── public class GetCurrentDateTimeConsistencyTests { - // Previously divergent: Each evaluation called DateTime.UtcNow independently per row. - // Now fixed: GetCurrentDateTime() returns/uses the per-query snapshot like GetCurrentDateTimeStatic(). - [Fact] - public async Task GetCurrentDateTime_ShouldReturnSameValueForAllRows() - { - var container = new InMemoryContainer("test-l3", "/pk"); - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"doc{i}", pk = "a" }), new PartitionKey("a")); - - var results = await container.GetItemQueryIterator( - new QueryDefinition("SELECT GetCurrentDateTime() AS ts FROM c")).ReadNextAsync(); - - var timestamps = results.Resource.Select(r => r["ts"]!.ToString()).Distinct().ToList(); - Assert.Single(timestamps); - } + // Previously divergent: Each evaluation called DateTime.UtcNow independently per row. + // Now fixed: GetCurrentDateTime() returns/uses the per-query snapshot like GetCurrentDateTimeStatic(). + [Fact] + public async Task GetCurrentDateTime_ShouldReturnSameValueForAllRows() + { + var container = new InMemoryContainer("test-l3", "/pk"); + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"doc{i}", pk = "a" }), new PartitionKey("a")); + + var results = await container.GetItemQueryIterator( + new QueryDefinition("SELECT GetCurrentDateTime() AS ts FROM c")).ReadNextAsync(); + + var timestamps = results.Resource.Select(r => r["ts"]!.ToString()).Distinct().ToList(); + Assert.Single(timestamps); + } } // ─── L4: linqSerializerOptions and continuationToken on ───────────────── @@ -147,50 +147,50 @@ await container.CreateItemAsync( public class LinqQueryableOptionsTests { - // APPROACH 1 PERMISSIVENESS: GetItemLinqQueryable ignores linqSerializerOptions and continuationToken. - [Fact(Skip = "APPROACH 1 PERMISSIVENESS (L4): InMemoryContainer ignores linqSerializerOptions " + - "and continuationToken on GetItemLinqQueryable because it uses Newtonsoft.Json " + - "internally and materializes all items via LINQ-to-Objects. This divergence only " + - "affects Approach 1 (direct InMemoryContainer). With Approach 3 (CosmosClient + " + - "FakeCosmosHandler), the real SDK's CosmosLinqQueryProvider handles serializer " + - "options and continuation tokens through the HTTP pipeline.")] - public void GetItemLinqQueryable_WithSerializerOptions_ShouldRespectOptions() - { - // Expected real Cosmos behavior: - // LINQ queries should respect custom CosmosLinqSerializerOptions - // (e.g., PropertyNamingPolicy), and continuationToken should resume iteration. - } + // APPROACH 1 PERMISSIVENESS: GetItemLinqQueryable ignores linqSerializerOptions and continuationToken. + [Fact(Skip = "APPROACH 1 PERMISSIVENESS (L4): InMemoryContainer ignores linqSerializerOptions " + + "and continuationToken on GetItemLinqQueryable because it uses Newtonsoft.Json " + + "internally and materializes all items via LINQ-to-Objects. This divergence only " + + "affects Approach 1 (direct InMemoryContainer). With Approach 3 (CosmosClient + " + + "FakeCosmosHandler), the real SDK's CosmosLinqQueryProvider handles serializer " + + "options and continuation tokens through the HTTP pipeline.")] + public void GetItemLinqQueryable_WithSerializerOptions_ShouldRespectOptions() + { + // Expected real Cosmos behavior: + // LINQ queries should respect custom CosmosLinqSerializerOptions + // (e.g., PropertyNamingPolicy), and continuationToken should resume iteration. + } } // ─── L6: Undefined vs null not distinguished in ORDER BY ──────────────── public class UndefinedNullOrderByTests { - // DIVERGENT: undefined and null are treated identically in ORDER BY. - // Real Cosmos DB has a specific type ordering: undefined < null < boolean < number < string < array < object. - [Fact] - public async Task OrderBy_ShouldDistinguishUndefinedFromNull() - { - var container = new InMemoryContainer("test-l6", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"2\",\"pk\":\"a\",\"val\":null}"), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); // undefined val - - var query = new QueryDefinition("SELECT c.id, c.val FROM c ORDER BY c.val ASC"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Cosmos type ordering: undefined < null < number - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("3", "undefined sorts first"); - results[1]["id"]!.ToString().Should().Be("2", "null sorts second"); - results[2]["id"]!.ToString().Should().Be("1", "number sorts last"); - } + // DIVERGENT: undefined and null are treated identically in ORDER BY. + // Real Cosmos DB has a specific type ordering: undefined < null < boolean < number < string < array < object. + [Fact] + public async Task OrderBy_ShouldDistinguishUndefinedFromNull() + { + var container = new InMemoryContainer("test-l6", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"2\",\"pk\":\"a\",\"val\":null}"), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); // undefined val + + var query = new QueryDefinition("SELECT c.id, c.val FROM c ORDER BY c.val ASC"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Cosmos type ordering: undefined < null < number + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("3", "undefined sorts first"); + results[1]["id"]!.ToString().Should().Be("2", "null sorts second"); + results[2]["id"]!.ToString().Should().Be("1", "number sorts last"); + } } @@ -202,92 +202,92 @@ await container.CreateItemAsync( public class CrossPartitionAggregateSisterTests { - [Fact] - public async Task CrossPartition_Count_WithDefaultRange_ReturnsCorrectCount() - { - // EMULATOR: With default PartitionKeyRangeCount=1, aggregates work correctly - var container = new InMemoryContainer("test-m7", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 20 }), new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = 30 }), new PartitionKey("c")); - - var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(3); - } + [Fact] + public async Task CrossPartition_Count_WithDefaultRange_ReturnsCorrectCount() + { + // EMULATOR: With default PartitionKeyRangeCount=1, aggregates work correctly + var container = new InMemoryContainer("test-m7", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 20 }), new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = 30 }), new PartitionKey("c")); + + var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(3); + } } // ─── L2 sister: Array functions with identifiers work, literals don't ─── public class ArrayFunctionLiteralSisterTests { - [Fact] - public async Task ArrayContains_WithIdentifier_Works() - { - // EMULATOR: Array functions work correctly with document field references - var container = new InMemoryContainer("test-l2", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "red", "green", "blue" } }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT c.id FROM c WHERE ARRAY_CONTAINS(c.tags, 'green')"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task ArrayContains_WithIdentifier_Works() + { + // EMULATOR: Array functions work correctly with document field references + var container = new InMemoryContainer("test-l2", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "red", "green", "blue" } }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT c.id FROM c WHERE ARRAY_CONTAINS(c.tags, 'green')"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } // ─── L3 sister: GetCurrentDateTime per-row behavior ───────────────────── public class GetCurrentDateTimeSisterTests { - [Fact] - public async Task GetCurrentDateTime_EmulatorBehavior_ReturnsValidTimestamps() - { - // EMULATOR: GetCurrentDateTime() works but may return slightly different - // values per-row (evaluated per-row, not per-query like real Cosmos) - var container = new InMemoryContainer("test-l3", "/pk"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), new PartitionKey("a")); - - var query = new QueryDefinition("SELECT GetCurrentDateTime() AS ts FROM c"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(5); - results.Should().OnlyContain(r => r["ts"] != null); - } + [Fact] + public async Task GetCurrentDateTime_EmulatorBehavior_ReturnsValidTimestamps() + { + // EMULATOR: GetCurrentDateTime() works but may return slightly different + // values per-row (evaluated per-row, not per-query like real Cosmos) + var container = new InMemoryContainer("test-l3", "/pk"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), new PartitionKey("a")); + + var query = new QueryDefinition("SELECT GetCurrentDateTime() AS ts FROM c"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(5); + results.Should().OnlyContain(r => r["ts"] != null); + } } // ─── L4 sister: LINQ queryable ignores custom options ─────────────────── public class LinqQueryableOptionsSisterTests { - [Fact] - public async Task GetItemLinqQueryable_EmulatorBehavior_WorksWithoutOptions() - { - // EMULATOR: LINQ queryable works but ignores linqSerializerOptions - var container = new InMemoryContainer("test-l4", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - - var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); - var results = queryable.ToList(); - - results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); - } + [Fact] + public async Task GetItemLinqQueryable_EmulatorBehavior_WorksWithoutOptions() + { + // EMULATOR: LINQ queryable works but ignores linqSerializerOptions + var container = new InMemoryContainer("test-l4", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + + var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); + var results = queryable.ToList(); + + results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); + } } @@ -299,255 +299,255 @@ await container.CreateItemAsync( public class ConsistencyLevelDivergenceTests { - [Fact(Skip = "D1: Consistency levels are ignored. Real Cosmos DB supports 5 consistency " + - "levels (Strong, Bounded Staleness, Session, Consistent Prefix, Eventual) " + - "that affect read behavior. The emulator always returns the latest write " + - "immediately regardless of the configured consistency level.")] - public void ConsistencyLevel_ShouldAffectReadBehavior() { } - - [Fact] - public async Task ConsistencyLevel_EmulatorBehavior_AllLevelsReturnSameResult() - { - // EMULATOR: All consistency levels return the latest write immediately - var container = new InMemoryContainer("test-d1", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - - // Regardless of consistency level, the read always returns the latest state - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("Alice"); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), - new PartitionKey("a")); - - result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("Bob"); - } + [Fact(Skip = "D1: Consistency levels are ignored. Real Cosmos DB supports 5 consistency " + + "levels (Strong, Bounded Staleness, Session, Consistent Prefix, Eventual) " + + "that affect read behavior. The emulator always returns the latest write " + + "immediately regardless of the configured consistency level.")] + public void ConsistencyLevel_ShouldAffectReadBehavior() { } + + [Fact] + public async Task ConsistencyLevel_EmulatorBehavior_AllLevelsReturnSameResult() + { + // EMULATOR: All consistency levels return the latest write immediately + var container = new InMemoryContainer("test-d1", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + + // Regardless of consistency level, the read always returns the latest state + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("Alice"); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), + new PartitionKey("a")); + + result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("Bob"); + } } // ─── D2: Request charge always 1.0 RU ────────────────────────────────── public class RequestChargeDivergenceTests { - [Fact(Skip = "D2: Request charge always returns 1.0 RU regardless of operation complexity. " + - "Real Cosmos DB charges vary by operation type (reads ~1 RU, writes ~5-10 RU), " + - "document size, index paths, and cross-partition fan-out. Simulating real RU " + - "costs would require a complete cost model.")] - public void RequestCharge_ShouldVaryByOperationComplexity() { } - - [Fact] - public async Task RequestCharge_EmulatorBehavior_AlwaysReturns1RU() - { - // EMULATOR: All operations return exactly 1.0 RU - var container = new InMemoryContainer("test-d2", "/pk"); - - var create = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - create.RequestCharge.Should().Be(1.0); - - var read = await container.ReadItemAsync("1", new PartitionKey("a")); - read.RequestCharge.Should().Be(1.0); - - var replace = await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), - "1", new PartitionKey("a")); - replace.RequestCharge.Should().Be(1.0); - - var delete = await container.DeleteItemAsync("1", new PartitionKey("a")); - delete.RequestCharge.Should().Be(1.0); - } + [Fact(Skip = "D2: Request charge always returns 1.0 RU regardless of operation complexity. " + + "Real Cosmos DB charges vary by operation type (reads ~1 RU, writes ~5-10 RU), " + + "document size, index paths, and cross-partition fan-out. Simulating real RU " + + "costs would require a complete cost model.")] + public void RequestCharge_ShouldVaryByOperationComplexity() { } + + [Fact] + public async Task RequestCharge_EmulatorBehavior_AlwaysReturns1RU() + { + // EMULATOR: All operations return exactly 1.0 RU + var container = new InMemoryContainer("test-d2", "/pk"); + + var create = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + create.RequestCharge.Should().Be(1.0); + + var read = await container.ReadItemAsync("1", new PartitionKey("a")); + read.RequestCharge.Should().Be(1.0); + + var replace = await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), + "1", new PartitionKey("a")); + replace.RequestCharge.Should().Be(1.0); + + var delete = await container.DeleteItemAsync("1", new PartitionKey("a")); + delete.RequestCharge.Should().Be(1.0); + } } // ─── D3: Continuation tokens are plain integers ──────────────────────── public class ContinuationTokenDivergenceTests { - [Fact(Skip = "D3: Continuation tokens are plain integer offsets instead of opaque base64-encoded " + - "JSON. Real Cosmos DB tokens are opaque and contain partition key ranges, composite " + - "continuation info, and resource IDs. Could implement base64-encoded tokens but " + - "would break existing consumer code that may parse them.")] - public void ContinuationToken_ShouldBeOpaqueBase64EncodedJson() { } - - [Fact] - public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() - { - // EMULATOR: Continuation tokens are simple integer strings - var container = new InMemoryContainer("test-d3", "/pk"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - var response = await iterator.ReadNextAsync(); - var token = response.ContinuationToken; - - // Token should be a parseable integer (page offset) - int.TryParse(token, out _).Should().BeTrue( - "emulator uses plain integer continuation tokens, not opaque base64"); - } + [Fact(Skip = "D3: Continuation tokens are plain integer offsets instead of opaque base64-encoded " + + "JSON. Real Cosmos DB tokens are opaque and contain partition key ranges, composite " + + "continuation info, and resource IDs. Could implement base64-encoded tokens but " + + "would break existing consumer code that may parse them.")] + public void ContinuationToken_ShouldBeOpaqueBase64EncodedJson() { } + + [Fact] + public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() + { + // EMULATOR: Continuation tokens are simple integer strings + var container = new InMemoryContainer("test-d3", "/pk"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + + var response = await iterator.ReadNextAsync(); + var token = response.ContinuationToken; + + // Token should be a parseable integer (page offset) + int.TryParse(token, out _).Should().BeTrue( + "emulator uses plain integer continuation tokens, not opaque base64"); + } } // ─── D4: System properties format differences ────────────────────────── public class SystemPropertiesDivergenceTests { - [Fact] - public async Task SystemProperties_RidIsHierarchicalBase64() - { - var container = new InMemoryContainer("test-d4", "/pk"); - var created = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var doc = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - var rid = doc["_rid"]!.ToString(); - - // _rid should be base64 of exactly 8 bytes (hierarchical: 4-byte container + 4-byte doc counter) - var ridBytes = Convert.FromBase64String(rid); - ridBytes.Length.Should().Be(8); - } - - [Fact] - public async Task SystemProperties_EmulatorBehavior_HasTsAndEtag() - { - // EMULATOR: _ts and _etag are present and functional, _rid/_self may differ - var container = new InMemoryContainer("test-d4", "/pk"); - var created = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var doc = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - - doc["_ts"].Should().NotBeNull(); - doc["_etag"].Should().NotBeNull(); - ((long)doc["_ts"]!).Should().BeGreaterThan(0); - doc["_etag"]!.ToString().Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task SystemProperties_RidIsHierarchicalBase64() + { + var container = new InMemoryContainer("test-d4", "/pk"); + var created = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var doc = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + var rid = doc["_rid"]!.ToString(); + + // _rid should be base64 of exactly 8 bytes (hierarchical: 4-byte container + 4-byte doc counter) + var ridBytes = Convert.FromBase64String(rid); + ridBytes.Length.Should().Be(8); + } + + [Fact] + public async Task SystemProperties_EmulatorBehavior_HasTsAndEtag() + { + // EMULATOR: _ts and _etag are present and functional, _rid/_self may differ + var container = new InMemoryContainer("test-d4", "/pk"); + var created = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var doc = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + + doc["_ts"].Should().NotBeNull(); + doc["_etag"].Should().NotBeNull(); + ((long)doc["_ts"]!).Should().BeGreaterThan(0); + doc["_etag"]!.ToString().Should().NotBeNullOrEmpty(); + } } // ─── D5: IndexingPolicy stored but not enforced ──────────────────────── public class IndexingPolicyDivergenceTests { - [Fact(Skip = "D5: IndexingPolicy is stored but not enforced. Real Cosmos DB uses indexes " + - "to optimize queries — excluding a path from indexing would cause queries on " + - "that path to fail or require a full scan. The emulator always does full scans " + - "so queries work regardless of indexing policy.")] - public void IndexingPolicy_ShouldAffectQueryPerformance() { } - - [Fact] - public async Task IndexingPolicy_EmulatorBehavior_StoredButNotEnforced() - { - // EMULATOR: IndexingPolicy is stored on the container but has no query impact - var props = new ContainerProperties("test-d5", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - IndexingMode = IndexingMode.None - } - }; - var container = new InMemoryContainer(props); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - - // Query still works even with IndexingMode.None - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var response = await iterator.ReadNextAsync(); - - response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + [Fact(Skip = "D5: IndexingPolicy is stored but not enforced. Real Cosmos DB uses indexes " + + "to optimize queries — excluding a path from indexing would cause queries on " + + "that path to fail or require a full scan. The emulator always does full scans " + + "so queries work regardless of indexing policy.")] + public void IndexingPolicy_ShouldAffectQueryPerformance() { } + + [Fact] + public async Task IndexingPolicy_EmulatorBehavior_StoredButNotEnforced() + { + // EMULATOR: IndexingPolicy is stored on the container but has no query impact + var props = new ContainerProperties("test-d5", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + IndexingMode = IndexingMode.None + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + + // Query still works even with IndexingMode.None + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var response = await iterator.ReadNextAsync(); + + response.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } // ─── D6: TTL eviction is lazy (on next read) ────────────────────────── public class TtlEvictionDivergenceTests { - [Fact(Skip = "D6: TTL eviction is lazy — expired items are removed on next read/query, not " + - "proactively by a background process. Real Cosmos DB has a background TTL " + - "eviction process that removes items independently of reads. The emulator's " + - "lazy approach means expired items still consume memory until accessed.")] - public void TTL_ShouldProactivelyEvictExpiredItems() { } - - [Fact] - public async Task TTL_EmulatorBehavior_LazyEvictionOnRead() - { - // EMULATOR: TTL items are evicted lazily when the container is accessed - var props = new ContainerProperties("test-d6", "/pk") { DefaultTimeToLive = 1 }; - var container = new InMemoryContainer(props); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - // Item is immediately visible after creation - var iterator1 = container.GetItemQueryIterator("SELECT * FROM c"); - var response1 = await iterator1.ReadNextAsync(); - response1.Should().ContainSingle("item should be visible immediately after creation"); - - // Wait for TTL to expire - await Task.Delay(2000); - - // After TTL expires, a query/read triggers eviction — item is no longer returned. - // Note: the exact eviction behaviour may vary; the item might still appear if - // eviction is deferred until the next write or explicit eviction pass. - // The key divergence is that eviction is NOT proactive. - try - { - var read = await container.ReadItemAsync("1", new PartitionKey("a")); - // If read succeeds, the item hasn't been lazily evicted yet — still a valid divergence - } - catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Item was evicted — also valid - } - } + [Fact(Skip = "D6: TTL eviction is lazy — expired items are removed on next read/query, not " + + "proactively by a background process. Real Cosmos DB has a background TTL " + + "eviction process that removes items independently of reads. The emulator's " + + "lazy approach means expired items still consume memory until accessed.")] + public void TTL_ShouldProactivelyEvictExpiredItems() { } + + [Fact] + public async Task TTL_EmulatorBehavior_LazyEvictionOnRead() + { + // EMULATOR: TTL items are evicted lazily when the container is accessed + var props = new ContainerProperties("test-d6", "/pk") { DefaultTimeToLive = 1 }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + // Item is immediately visible after creation + var iterator1 = container.GetItemQueryIterator("SELECT * FROM c"); + var response1 = await iterator1.ReadNextAsync(); + response1.Should().ContainSingle("item should be visible immediately after creation"); + + // Wait for TTL to expire + await Task.Delay(2000); + + // After TTL expires, a query/read triggers eviction — item is no longer returned. + // Note: the exact eviction behaviour may vary; the item might still appear if + // eviction is deferred until the next write or explicit eviction pass. + // The key divergence is that eviction is NOT proactive. + try + { + var read = await container.ReadItemAsync("1", new PartitionKey("a")); + // If read succeeds, the item hasn't been lazily evicted yet — still a valid divergence + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Item was evicted — also valid + } + } } // ─── D7: Analytical store not simulated ──────────────────────────────── public class AnalyticalStoreDivergenceTests { - [Fact(Skip = "D7: Analytical store (Azure Synapse Link) is not simulated. Real Cosmos DB " + - "supports an analytical store for OLAP workloads with column-store format " + - "accessible via Synapse notebooks and serverless SQL. This is out of scope " + - "for the in-memory emulator.")] - public void AnalyticalStore_ShouldBeAvailable() { } + [Fact(Skip = "D7: Analytical store (Azure Synapse Link) is not simulated. Real Cosmos DB " + + "supports an analytical store for OLAP workloads with column-store format " + + "accessible via Synapse notebooks and serverless SQL. This is out of scope " + + "for the in-memory emulator.")] + public void AnalyticalStore_ShouldBeAvailable() { } } // ─── D9: LINQ accepts operations real Cosmos rejects ─────────────────── public class LinqOperatorDivergenceTests { - [Fact(Skip = "D9: LINQ accepts all LINQ-to-Objects operators that real Cosmos SQL rejects. " + - "String.Format, Regex, custom comparers, local method calls, etc. all work " + - "because the emulator evaluates LINQ in-process. Real Cosmos converts LINQ " + - "to SQL and rejects unsupported operators.")] - public void Linq_ShouldRejectUnsupportedOperators() { } - - [Fact] - public async Task Linq_EmulatorBehavior_AcceptsStringContains() - { - // EMULATOR: LINQ-to-Objects accepts operators that real Cosmos SQL would reject - var container = new InMemoryContainer("test-d9", "/pk"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "Alice" }, - new PartitionKey("a")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "a", Name = "Bob" }, - new PartitionKey("a")); - - var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); - // String.Contains with StringComparison would fail in real Cosmos LINQ-to-SQL - var results = queryable.Where(d => d.Name.Contains("li")).ToList(); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } + [Fact(Skip = "D9: LINQ accepts all LINQ-to-Objects operators that real Cosmos SQL rejects. " + + "String.Format, Regex, custom comparers, local method calls, etc. all work " + + "because the emulator evaluates LINQ in-process. Real Cosmos converts LINQ " + + "to SQL and rejects unsupported operators.")] + public void Linq_ShouldRejectUnsupportedOperators() { } + + [Fact] + public async Task Linq_EmulatorBehavior_AcceptsStringContains() + { + // EMULATOR: LINQ-to-Objects accepts operators that real Cosmos SQL would reject + var container = new InMemoryContainer("test-d9", "/pk"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "Alice" }, + new PartitionKey("a")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "a", Name = "Bob" }, + new PartitionKey("a")); + + var queryable = container.GetItemLinqQueryable(allowSynchronousQueryExecution: true); + // String.Contains with StringComparison would fail in real Cosmos LINQ-to-SQL + var results = queryable.Where(d => d.Name.Contains("li")).ToList(); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -556,18 +556,18 @@ await container.CreateItemAsync( public class CosmosResponseFactoryDivergenceTests { - [Fact(Skip = "D10: CosmosResponseFactory is a non-functional NSubstitute stub. " + - "CosmosClient.ResponseFactory returns a mock whose methods return default/null values. " + - "Real Cosmos DB returns a factory that can deserialize response messages.")] - public void CosmosResponseFactory_ShouldDeserializeResponses() { } - - [Fact] - public void CosmosResponseFactory_EmulatorBehavior_ReturnsStub() - { - var client = new InMemoryCosmosClient(); - var factory = client.ResponseFactory; - factory.Should().NotBeNull("the stub should exist"); - } + [Fact(Skip = "D10: CosmosResponseFactory is a non-functional NSubstitute stub. " + + "CosmosClient.ResponseFactory returns a mock whose methods return default/null values. " + + "Real Cosmos DB returns a factory that can deserialize response messages.")] + public void CosmosResponseFactory_ShouldDeserializeResponses() { } + + [Fact] + public void CosmosResponseFactory_EmulatorBehavior_ReturnsStub() + { + var client = new InMemoryCosmosClient(); + var factory = client.ResponseFactory; + factory.Should().NotBeNull("the stub should exist"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -576,19 +576,19 @@ public void CosmosResponseFactory_EmulatorBehavior_ReturnsStub() public class UnsupportedSqlFunctionDivergenceTests { - [Fact] - public async Task UnsupportedSqlFunction_ShouldThrowCosmosExceptionBadRequest() - { - var container = new InMemoryContainer("test-d11", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var act = () => container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE NONEXISTENT_FUNCTION(c.id) FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task UnsupportedSqlFunction_ShouldThrowCosmosExceptionBadRequest() + { + var container = new InMemoryContainer("test-d11", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var act = () => container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE NONEXISTENT_FUNCTION(c.id) FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -597,19 +597,19 @@ await container.CreateItemAsync( public class SqlParseErrorDivergenceTests { - [Fact] - public async Task MalformedSql_ShouldThrowCosmosExceptionBadRequest() - { - var container = new InMemoryContainer("test-d12", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var act = () => container.GetItemQueryIterator( - new QueryDefinition("SELECTTTTT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task MalformedSql_ShouldThrowCosmosExceptionBadRequest() + { + var container = new InMemoryContainer("test-d12", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var act = () => container.GetItemQueryIterator( + new QueryDefinition("SELECTTTTT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + act.Should().Throw().Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -618,19 +618,19 @@ await container.CreateItemAsync( public class InvalidContinuationTokenEdgeCaseTests { - [Fact] - public void ContinuationToken_InvalidToken_ThrowsBadRequest() - { - var container = new InMemoryContainer("test-d18", "/pk"); - - var act = () => container.GetItemQueryIterator( - "SELECT * FROM c", - continuationToken: "not-a-valid-token", - requestOptions: new QueryRequestOptions { MaxItemCount = 10, PartitionKey = new PartitionKey("a") }); - - act.Should().Throw() - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public void ContinuationToken_InvalidToken_ThrowsBadRequest() + { + var container = new InMemoryContainer("test-d18", "/pk"); + + var act = () => container.GetItemQueryIterator( + "SELECT * FROM c", + continuationToken: "not-a-valid-token", + requestOptions: new QueryRequestOptions { MaxItemCount = 10, PartitionKey = new PartitionKey("a") }); + + act.Should().Throw() + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -639,25 +639,25 @@ public void ContinuationToken_InvalidToken_ThrowsBadRequest() public class PermissionTokenDivergenceTests { - [Fact(Skip = "D23: Permission tokens are synthetic stubs. Real Cosmos DB generates " + - "HMAC-signed resource tokens that are cryptographically valid. Emulator tokens " + - "have format 'type=resource&ver=1&sig=stub_' with fake signatures.")] - public void PermissionToken_ShouldBeCryptographicallyValid() { } - - [Fact] - public async Task PermissionToken_EmulatorBehavior_IsSyntheticStub() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("db"); - var user = db.GetUser("user1"); - var permission = await user.CreatePermissionAsync( - new PermissionProperties("perm1", PermissionMode.Read, - client.GetDatabase("db").GetContainer("c1"))); - - var token = permission.Resource.Token; - token.Should().Contain("stub_", "emulator uses synthetic tokens"); - token.Should().StartWith("type=resource&ver=1&sig="); - } + [Fact(Skip = "D23: Permission tokens are synthetic stubs. Real Cosmos DB generates " + + "HMAC-signed resource tokens that are cryptographically valid. Emulator tokens " + + "have format 'type=resource&ver=1&sig=stub_' with fake signatures.")] + public void PermissionToken_ShouldBeCryptographicallyValid() { } + + [Fact] + public async Task PermissionToken_EmulatorBehavior_IsSyntheticStub() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("db"); + var user = db.GetUser("user1"); + var permission = await user.CreatePermissionAsync( + new PermissionProperties("perm1", PermissionMode.Read, + client.GetDatabase("db").GetContainer("c1"))); + + var token = permission.Resource.Token; + token.Should().Contain("stub_", "emulator uses synthetic tokens"); + token.Should().StartWith("type=resource&ver=1&sig="); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -666,24 +666,24 @@ public async Task PermissionToken_EmulatorBehavior_IsSyntheticStub() public class CrossPartitionAggregateEdgeCaseTests { - [Fact] - public async Task CrossPartition_MinMax_WithMultipleRanges_StillCorrect() - { - var container = new InMemoryContainer("test-m7", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 20 }), new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = 30 }), new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE MIN(c.val) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Single().Should().Be(10); - } + [Fact] + public async Task CrossPartition_MinMax_WithMultipleRanges_StillCorrect() + { + var container = new InMemoryContainer("test-m7", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 20 }), new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = 30 }), new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE MIN(c.val) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Single().Should().Be(10); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -692,22 +692,22 @@ await container.CreateItemAsync( public class RequestChargeQueryDivergenceTests { - [Fact] - public async Task RequestCharge_Query_EmulatorBehavior_ReturnsFixedRU() - { - var container = new InMemoryContainer("test-d2q", "/pk"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a", val = i }), new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val > 5", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var response = await iterator.ReadNextAsync(); - - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0, - "emulator returns a fixed RU regardless of query complexity"); - } + [Fact] + public async Task RequestCharge_Query_EmulatorBehavior_ReturnsFixedRU() + { + var container = new InMemoryContainer("test-d2q", "/pk"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a", val = i }), new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val > 5", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var response = await iterator.ReadNextAsync(); + + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0, + "emulator returns a fixed RU regardless of query complexity"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -716,25 +716,25 @@ await container.CreateItemAsync( public class CompositeIndexDivergenceTests { - [Fact] - public async Task IndexingPolicy_CompositeIndex_EmulatorBehavior_NotRequired() - { - var container = new InMemoryContainer("test-d5", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Charlie", age = 30 }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "Alice", age = 25 }), new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", name = "Alice", age = 35 }), new PartitionKey("a")); - - // Real Cosmos requires a composite index for multi-field ORDER BY. - // Emulator does not enforce this — it just works. - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC, c.age DESC", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Select(r => r["id"]!.ToString()).Should().ContainInOrder("3", "2", "1"); - } + [Fact] + public async Task IndexingPolicy_CompositeIndex_EmulatorBehavior_NotRequired() + { + var container = new InMemoryContainer("test-d5", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Charlie", age = 30 }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "Alice", age = 25 }), new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", name = "Alice", age = 35 }), new PartitionKey("a")); + + // Real Cosmos requires a composite index for multi-field ORDER BY. + // Emulator does not enforce this — it just works. + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC, c.age DESC", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Select(r => r["id"]!.ToString()).Should().ContainInOrder("3", "2", "1"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DocumentSizeLimitTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DocumentSizeLimitTests.cs index bf3c667..e0c4dab 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DocumentSizeLimitTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/DocumentSizeLimitTests.cs @@ -1,429 +1,429 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class DocumentSizeLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private const int TwoMB = 2 * 1024 * 1024; + private const int TwoMB = 2 * 1024 * 1024; - private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); + private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); - private static string BuildJsonDocument(string id, string pk, string dataValue) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; + private static string BuildJsonDocument(string id, string pk, string dataValue) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; - private static MemoryStream ToStream(string json) => - new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => + new(Encoding.UTF8.GetBytes(json)); - #region Typed Operations — Over Size Limit + #region Typed Operations — Over Size Limit - [Fact] - public async Task Create_OverSizeLimit_Returns413() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_Returns413() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Upsert_OverSizeLimit_Returns413() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Upsert_OverSizeLimit_Returns413() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Replace_OverSizeLimit_Returns413() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Small" }, - new PartitionKey("pk1")); + [Fact] + public async Task Replace_OverSizeLimit_Returns413() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Small" }, + new PartitionKey("pk1")); - var largeDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeOversizedValue() }; + var largeDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.ReplaceItemAsync(largeDoc, "1", new PartitionKey("pk1")); + var act = () => _container.ReplaceItemAsync(largeDoc, "1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Patch_ResultExceedsSizeLimit_Returns413() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, - new PartitionKey("pk1")); + [Fact] + public async Task Patch_ResultExceedsSizeLimit_Returns413() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", MakeOversizedValue())]); + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", MakeOversizedValue())]); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Create_OverSizeLimit_ErrorMessageContainsSizeInfo() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_ErrorMessageContainsSizeInfo() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.Message.Should().Contain("Request size is too large"); - ex.And.Message.Should().Contain("Max allowed size in bytes:"); - ex.And.Message.Should().Contain("Found:"); - } + var ex = await act.Should().ThrowAsync(); + ex.And.Message.Should().Contain("Request size is too large"); + ex.And.Message.Should().Contain("Max allowed size in bytes:"); + ex.And.Message.Should().Contain("Found:"); + } - #endregion + #endregion - #region Stream Operations — Over Size Limit + #region Stream Operations — Over Size Limit - [Fact] - public async Task StreamCreate_OverSizeLimit_Returns413() - { - var json = BuildJsonDocument("1", "pk1", MakeOversizedValue()); + [Fact] + public async Task StreamCreate_OverSizeLimit_Returns413() + { + var json = BuildJsonDocument("1", "pk1", MakeOversizedValue()); - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task StreamUpsert_OverSizeLimit_Returns413() - { - var json = BuildJsonDocument("1", "pk1", MakeOversizedValue()); + [Fact] + public async Task StreamUpsert_OverSizeLimit_Returns413() + { + var json = BuildJsonDocument("1", "pk1", MakeOversizedValue()); - var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task StreamReplace_OverSizeLimit_Returns413() - { - var smallJson = BuildJsonDocument("1", "pk1", "small"); - await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); + [Fact] + public async Task StreamReplace_OverSizeLimit_Returns413() + { + var smallJson = BuildJsonDocument("1", "pk1", "small"); + await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); - var largeJson = BuildJsonDocument("1", "pk1", MakeOversizedValue()); + var largeJson = BuildJsonDocument("1", "pk1", MakeOversizedValue()); - var response = await _container.ReplaceItemStreamAsync(ToStream(largeJson), "1", new PartitionKey("pk1")); + var response = await _container.ReplaceItemStreamAsync(ToStream(largeJson), "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task StreamPatch_ResultExceedsSizeLimit_Returns413() - { - var smallJson = BuildJsonDocument("1", "pk1", "small"); - await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); + [Fact] + public async Task StreamPatch_ResultExceedsSizeLimit_Returns413() + { + var smallJson = BuildJsonDocument("1", "pk1", "small"); + await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); - var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/data", MakeOversizedValue())]); + var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/data", MakeOversizedValue())]); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - #endregion + #endregion - #region Boundary / Edge Cases + #region Boundary / Edge Cases - [Fact] - public async Task Create_Exactly2MB_Succeeds() - { - // Build a JSON document that fits within 2MB INCLUDING system properties (~200 bytes). - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var fillSize = TwoMB - envelopeBytes - 300; // Leave room for system property overhead - var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); + [Fact] + public async Task Create_Exactly2MB_Succeeds() + { + // Build a JSON document that fits within 2MB INCLUDING system properties (~200 bytes). + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var fillSize = TwoMB - envelopeBytes - 300; // Leave room for system property overhead + var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task Create_2MBPlus1Byte_Fails() - { - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var fillSize = TwoMB - envelopeBytes + 1; - var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); + [Fact] + public async Task Create_2MBPlus1Byte_Fails() + { + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var fillSize = TwoMB - envelopeBytes + 1; + var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); - Encoding.UTF8.GetByteCount(json).Should().Be(TwoMB + 1); + Encoding.UTF8.GetByteCount(json).Should().Be(TwoMB + 1); - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Create_WithMultiByteUtf8_EnforcesOnByteCountNotCharCount() - { - // Each emoji is 4 bytes in UTF-8. A string of ~524,288 emojis ≈ 2MB in bytes - // but only ~524K in char count (each emoji is 2 C# chars / 1 surrogate pair). - // This demonstrates that the limit is on UTF-8 byte count, not .NET string length. - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + [Fact] + public async Task Create_WithMultiByteUtf8_EnforcesOnByteCountNotCharCount() + { + // Each emoji is 4 bytes in UTF-8. A string of ~524,288 emojis ≈ 2MB in bytes + // but only ~524K in char count (each emoji is 2 C# chars / 1 surrogate pair). + // This demonstrates that the limit is on UTF-8 byte count, not .NET string length. + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - // Use 'é' (U+00E9) = 2 bytes in UTF-8, 1 char in C# - // To exceed 2MB via bytes while keeping char count under 2MB, - // we need: charCount * 2 > 2MB, so charCount > 1MB - var charCount = TwoMB - envelopeBytes + 1; // This many 'é' chars = 2× that many bytes - var multiByte = new string('é', charCount); - var json = BuildJsonDocument("1", "pk1", multiByte); + // Use 'é' (U+00E9) = 2 bytes in UTF-8, 1 char in C# + // To exceed 2MB via bytes while keeping char count under 2MB, + // we need: charCount * 2 > 2MB, so charCount > 1MB + var charCount = TwoMB - envelopeBytes + 1; // This many 'é' chars = 2× that many bytes + var multiByte = new string('é', charCount); + var json = BuildJsonDocument("1", "pk1", multiByte); - // Char count of the data is under 2MB+envelope but byte count is well over - json.Length.Should().BeLessThan(3 * 1024 * 1024); - Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Create_SmallDocument_Succeeds() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello" }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_JustUnder2MB_Succeeds() - { - // Document at 2MB - 200 bytes for comfortable margin under limit - var targetSize = TwoMB - 200; - var json = BuildJsonDocument("1", "pk1", new string('x', targetSize)); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - #endregion - - #region Batch Size Limits - - [Fact] - public async Task Batch_Over100Operations_ThrowsBadRequest() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 101; i++) - { - batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", name = "x" }); - } - - var act = () => batch.ExecuteAsync(); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Batch_Exactly100Operations_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 100; i++) - { - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "x" }); - } - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_TotalPayloadExceeds2MB_Returns413() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - { - var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total - batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); - } - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_SingleOversizedItem_FailsOnIndividualDocumentSize() - { - // A single 3MB item exceeds the individual 2MB document limit. - // The batch size tracking may or may not catch this since the individual - // create also validates. Both checks should reject it. - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - var hugeValue = new string('x', 3 * 1024 * 1024); - batch.CreateItem(new { id = "1", partitionKey = "pk1", data = hugeValue }); - - // The batch's estimated size (3MB) exceeds 2MB so it fails at batch level with 413 - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_PatchOperation_ContributesToBatchSize() - { - // Seed an item to patch - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - - // Add multiple patch operations with large values that together exceed 2MB - for (var i = 0; i < 10; i++) - { - var largeValue = new string('y', 300 * 1024); // 300KB each - batch.PatchItem($"1", [PatchOperation.Set("/name", largeValue)]); - } - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - #endregion - - #region Divergent Behaviour — Pre-trigger Size Revalidation - - /// - /// KNOWN DIVERGENCE: Real Cosmos DB validates document size after pre-trigger execution. - /// The emulator validates before pre-triggers run, so a pre-trigger that inflates the - /// document past 2MB is not caught. Implementing post-trigger revalidation is possible - /// but low-priority since pre-triggers rarely inflate documents significantly. - /// - /// In real Cosmos DB, this would return 413 RequestEntityTooLarge because the final - /// document (after the trigger adds ~2MB of data) exceeds the 2MB limit, even though - /// the input document was small. - /// - [Fact] - public async Task PreTrigger_InflatesDocumentPast2MB_RealCosmosWouldReject() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterTrigger("inflate", Microsoft.Azure.Cosmos.Scripts.TriggerType.Pre, - Microsoft.Azure.Cosmos.Scripts.TriggerOperation.Create, - doc => - { - doc["hugeField"] = new string('z', 3 * 1024 * 1024); - return doc; - }); - - var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; - - // Real Cosmos: would reject with 413 after trigger inflates the document - var act = () => container.CreateItemAsync(smallDoc, new PartitionKey("pk1"), - new ItemRequestOptions { PreTriggers = ["inflate"] }); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - #endregion - - #region Divergent Behaviour — System Property Size Overhead - - /// - /// KNOWN DIVERGENCE: Real Cosmos DB includes system properties (_ts, _rid, _self, _etag, - /// _attachments) in the 2MB document size calculation. The emulator checks the input JSON - /// size before system properties are added, making it ~200 bytes more permissive at the - /// boundary. - /// - /// In real Cosmos DB, a document whose input JSON is exactly 2MB would be rejected because - /// after the server adds system properties (~200 bytes), the total exceeds 2MB. - /// - [Fact] - public async Task SizeCheck_Exactly2MB_RealCosmosWouldRejectDueToSystemProperties() - { - // Build a document whose input JSON is exactly 2MB - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var fillSize = TwoMB - envelopeBytes; - var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); - - Encoding.UTF8.GetByteCount(json).Should().Be(TwoMB); - - // Real Cosmos: would reject because system properties push it over 2MB - var act = () => _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - /// - /// Confirms that a document just under 2MB (accounting for system property overhead) - /// is accepted, proving the post-enrichment size check works correctly. - /// - [Fact] - public async Task SizeCheck_JustUnder2MB_WithSystemPropertyOverhead_Accepted() - { - // Leave ~300 byte margin for system properties (_rid, _self, _etag, _ts, _attachments) - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var fillSize = TwoMB - envelopeBytes - 300; - var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - #endregion + // Char count of the data is under 2MB+envelope but byte count is well over + json.Length.Should().BeLessThan(3 * 1024 * 1024); + Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Create_SmallDocument_Succeeds() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello" }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_JustUnder2MB_Succeeds() + { + // Document at 2MB - 200 bytes for comfortable margin under limit + var targetSize = TwoMB - 200; + var json = BuildJsonDocument("1", "pk1", new string('x', targetSize)); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + #endregion + + #region Batch Size Limits + + [Fact] + public async Task Batch_Over100Operations_ThrowsBadRequest() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 101; i++) + { + batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", name = "x" }); + } + + var act = () => batch.ExecuteAsync(); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Batch_Exactly100Operations_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 100; i++) + { + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "x" }); + } + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_TotalPayloadExceeds2MB_Returns413() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + { + var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total + batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); + } + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_SingleOversizedItem_FailsOnIndividualDocumentSize() + { + // A single 3MB item exceeds the individual 2MB document limit. + // The batch size tracking may or may not catch this since the individual + // create also validates. Both checks should reject it. + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var hugeValue = new string('x', 3 * 1024 * 1024); + batch.CreateItem(new { id = "1", partitionKey = "pk1", data = hugeValue }); + + // The batch's estimated size (3MB) exceeds 2MB so it fails at batch level with 413 + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_PatchOperation_ContributesToBatchSize() + { + // Seed an item to patch + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + + // Add multiple patch operations with large values that together exceed 2MB + for (var i = 0; i < 10; i++) + { + var largeValue = new string('y', 300 * 1024); // 300KB each + batch.PatchItem($"1", [PatchOperation.Set("/name", largeValue)]); + } + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + #endregion + + #region Divergent Behaviour — Pre-trigger Size Revalidation + + /// + /// KNOWN DIVERGENCE: Real Cosmos DB validates document size after pre-trigger execution. + /// The emulator validates before pre-triggers run, so a pre-trigger that inflates the + /// document past 2MB is not caught. Implementing post-trigger revalidation is possible + /// but low-priority since pre-triggers rarely inflate documents significantly. + /// + /// In real Cosmos DB, this would return 413 RequestEntityTooLarge because the final + /// document (after the trigger adds ~2MB of data) exceeds the 2MB limit, even though + /// the input document was small. + /// + [Fact] + public async Task PreTrigger_InflatesDocumentPast2MB_RealCosmosWouldReject() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterTrigger("inflate", Microsoft.Azure.Cosmos.Scripts.TriggerType.Pre, + Microsoft.Azure.Cosmos.Scripts.TriggerOperation.Create, + doc => + { + doc["hugeField"] = new string('z', 3 * 1024 * 1024); + return doc; + }); + + var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; + + // Real Cosmos: would reject with 413 after trigger inflates the document + var act = () => container.CreateItemAsync(smallDoc, new PartitionKey("pk1"), + new ItemRequestOptions { PreTriggers = ["inflate"] }); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + #endregion + + #region Divergent Behaviour — System Property Size Overhead + + /// + /// KNOWN DIVERGENCE: Real Cosmos DB includes system properties (_ts, _rid, _self, _etag, + /// _attachments) in the 2MB document size calculation. The emulator checks the input JSON + /// size before system properties are added, making it ~200 bytes more permissive at the + /// boundary. + /// + /// In real Cosmos DB, a document whose input JSON is exactly 2MB would be rejected because + /// after the server adds system properties (~200 bytes), the total exceeds 2MB. + /// + [Fact] + public async Task SizeCheck_Exactly2MB_RealCosmosWouldRejectDueToSystemProperties() + { + // Build a document whose input JSON is exactly 2MB + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var fillSize = TwoMB - envelopeBytes; + var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); + + Encoding.UTF8.GetByteCount(json).Should().Be(TwoMB); + + // Real Cosmos: would reject because system properties push it over 2MB + var act = () => _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + /// + /// Confirms that a document just under 2MB (accounting for system property overhead) + /// is accepted, proving the post-enrichment size check works correctly. + /// + [Fact] + public async Task SizeCheck_JustUnder2MB_WithSystemPropertyOverhead_Accepted() + { + // Leave ~300 byte margin for system properties (_rid, _self, _etag, _ts, _attachments) + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var fillSize = TwoMB - envelopeBytes - 300; + var json = BuildJsonDocument("1", "pk1", new string('a', fillSize)); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + #endregion } #region BUG-3: Error Message Size Info public class DocumentSizeErrorMessageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); + private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); - [Fact] - public async Task Create_OverSizeLimit_ErrorMessageContainsMaxAllowedSize() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_ErrorMessageContainsMaxAllowedSize() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.Message.Should().Contain("2097152"); - } + var ex = await act.Should().ThrowAsync(); + ex.And.Message.Should().Contain("2097152"); + } - [Fact] - public async Task Create_OverSizeLimit_ErrorMessageContainsActualSize() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_ErrorMessageContainsActualSize() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - // The message should contain a number larger than 2MB (the actual doc size) - ex.And.Message.Should().Contain("Found:"); - } + var ex = await act.Should().ThrowAsync(); + // The message should contain a number larger than 2MB (the actual doc size) + ex.And.Message.Should().Contain("Found:"); + } } #endregion @@ -432,66 +432,66 @@ public async Task Create_OverSizeLimit_ErrorMessageContainsActualSize() public class BatchDeleteReadSizeAccountingTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos counts delete operation metadata toward batch size. " + - "Emulator does not track delete byte contribution. Impact: negligible for typical ID lengths.")] - public async Task Batch_DeleteOperations_ContributeToBatchSize_RealCosmosWouldCount() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 100; i++) - batch.DeleteItem($"item-{i}"); - - using var response = await batch.ExecuteAsync(); - // Real Cosmos: if accumulated delete metadata exceeds 2MB, returns 413 - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_DeleteOperations_DoNotContributeToBatchSize_Divergence() - { - // Seed items to delete - for (var i = 0; i < 50; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = "x" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 50; i++) - batch.DeleteItem($"item-{i}"); - - using var response = await batch.ExecuteAsync(); - // Emulator: deletes don't contribute to byte count, only the 100-op limit matters - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos counts read operation metadata toward batch size. " + - "Emulator does not track read byte contribution.")] - public async Task Batch_ReadOperations_ContributeToBatchSize_RealCosmosWouldCount() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 100; i++) - batch.ReadItem($"item-{i}"); - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_ReadOperations_DoNotContributeToBatchSize_Divergence() - { - for (var i = 0; i < 50; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = "x" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 50; i++) - batch.ReadItem($"item-{i}"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos counts delete operation metadata toward batch size. " + + "Emulator does not track delete byte contribution. Impact: negligible for typical ID lengths.")] + public async Task Batch_DeleteOperations_ContributeToBatchSize_RealCosmosWouldCount() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 100; i++) + batch.DeleteItem($"item-{i}"); + + using var response = await batch.ExecuteAsync(); + // Real Cosmos: if accumulated delete metadata exceeds 2MB, returns 413 + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_DeleteOperations_DoNotContributeToBatchSize_Divergence() + { + // Seed items to delete + for (var i = 0; i < 50; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = "x" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 50; i++) + batch.DeleteItem($"item-{i}"); + + using var response = await batch.ExecuteAsync(); + // Emulator: deletes don't contribute to byte count, only the 100-op limit matters + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos counts read operation metadata toward batch size. " + + "Emulator does not track read byte contribution.")] + public async Task Batch_ReadOperations_ContributeToBatchSize_RealCosmosWouldCount() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 100; i++) + batch.ReadItem($"item-{i}"); + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_ReadOperations_DoNotContributeToBatchSize_Divergence() + { + for (var i = 0; i < 50; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = "x" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 50; i++) + batch.ReadItem($"item-{i}"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } } #endregion @@ -500,131 +500,131 @@ await _container.CreateItemAsync( public class TypedBoundaryPrecisionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private const int TwoMB = 2 * 1024 * 1024; - - private static string BuildJsonDocument(string id, string pk, string dataValue) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; - - private static string MakePaddedName(int targetTotalBytes) - { - // Serialize a TestDocument with empty Name to measure envelope overhead - var envelope = Newtonsoft.Json.JsonConvert.SerializeObject( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "" }); - var overhead = Encoding.UTF8.GetByteCount(envelope); - return new string('a', targetTotalBytes - overhead); - } - - [Fact] - public async Task TypedCreate_Exactly2MB_Succeeds() - { - // System properties (~200 bytes) are added during enrichment, so input must leave room - var name = MakePaddedName(TwoMB - 300); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task TypedCreate_2MBPlus1Byte_Fails() - { - var name = MakePaddedName(TwoMB + 1); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Upsert_Exactly2MB_Succeeds() - { - var name = MakePaddedName(TwoMB - 300); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Upsert_2MBPlus1Byte_Fails() - { - var name = MakePaddedName(TwoMB + 1); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Replace_Exactly2MB_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - var name = MakePaddedName(TwoMB - 300); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var response = await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Replace_2MBPlus1Byte_Fails() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - var name = MakePaddedName(TwoMB + 1); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; - - var act = () => _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Patch_ResultExactly2MB_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "x", Value = 1 }, - new PartitionKey("pk1")); - - // Read back to get the enriched size, then compute patch value to hit exactly 2MB - var existing = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var existingJson = existing.Resource.ToString(Newtonsoft.Json.Formatting.None); - var existingBytes = Encoding.UTF8.GetByteCount(existingJson); - // Remove current "name":"x" and add enough to reach 2MB - var currentNameBytes = Encoding.UTF8.GetByteCount("\"x\""); - var targetNameLength = TwoMB - existingBytes + 1; // +1 for the 'x' we're replacing - if (targetNameLength <= 0) targetNameLength = 1; - - var patchValue = new string('z', targetNameLength); - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", patchValue)]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Patch_Result2MBPlus1Byte_Fails() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "x", Value = 1 }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", new string('z', 3 * 1024 * 1024))]); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private const int TwoMB = 2 * 1024 * 1024; + + private static string BuildJsonDocument(string id, string pk, string dataValue) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; + + private static string MakePaddedName(int targetTotalBytes) + { + // Serialize a TestDocument with empty Name to measure envelope overhead + var envelope = Newtonsoft.Json.JsonConvert.SerializeObject( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "" }); + var overhead = Encoding.UTF8.GetByteCount(envelope); + return new string('a', targetTotalBytes - overhead); + } + + [Fact] + public async Task TypedCreate_Exactly2MB_Succeeds() + { + // System properties (~200 bytes) are added during enrichment, so input must leave room + var name = MakePaddedName(TwoMB - 300); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task TypedCreate_2MBPlus1Byte_Fails() + { + var name = MakePaddedName(TwoMB + 1); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Upsert_Exactly2MB_Succeeds() + { + var name = MakePaddedName(TwoMB - 300); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_2MBPlus1Byte_Fails() + { + var name = MakePaddedName(TwoMB + 1); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var act = () => _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Replace_Exactly2MB_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + var name = MakePaddedName(TwoMB - 300); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var response = await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Replace_2MBPlus1Byte_Fails() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + var name = MakePaddedName(TwoMB + 1); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = name }; + + var act = () => _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Patch_ResultExactly2MB_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "x", Value = 1 }, + new PartitionKey("pk1")); + + // Read back to get the enriched size, then compute patch value to hit exactly 2MB + var existing = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var existingJson = existing.Resource.ToString(Newtonsoft.Json.Formatting.None); + var existingBytes = Encoding.UTF8.GetByteCount(existingJson); + // Remove current "name":"x" and add enough to reach 2MB + var currentNameBytes = Encoding.UTF8.GetByteCount("\"x\""); + var targetNameLength = TwoMB - existingBytes + 1; // +1 for the 'x' we're replacing + if (targetNameLength <= 0) targetNameLength = 1; + + var patchValue = new string('z', targetNameLength); + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", patchValue)]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_Result2MBPlus1Byte_Fails() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "x", Value = 1 }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", new string('z', 3 * 1024 * 1024))]); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -633,63 +633,63 @@ await _container.CreateItemAsync( public class StreamBoundaryPrecisionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private const int TwoMB = 2 * 1024 * 1024; - - private static string BuildJsonDocument(string id, string pk, string dataValue) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task StreamUpsert_Exactly2MB_Succeeds() - { - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes - 300)); - - var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - ((int)response.StatusCode).Should().BeOneOf(200, 201); - } - - [Fact] - public async Task StreamUpsert_2MBPlus1Byte_Returns413() - { - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes + 1)); - - var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task StreamReplace_Exactly2MB_Succeeds() - { - var smallJson = BuildJsonDocument("1", "pk1", "small"); - await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); - - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes - 300)); - - var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task StreamReplace_2MBPlus1Byte_Returns413() - { - var smallJson = BuildJsonDocument("1", "pk1", "small"); - await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); - - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes + 1)); - - var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private const int TwoMB = 2 * 1024 * 1024; + + private static string BuildJsonDocument(string id, string pk, string dataValue) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task StreamUpsert_Exactly2MB_Succeeds() + { + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes - 300)); + + var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + ((int)response.StatusCode).Should().BeOneOf(200, 201); + } + + [Fact] + public async Task StreamUpsert_2MBPlus1Byte_Returns413() + { + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes + 1)); + + var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task StreamReplace_Exactly2MB_Succeeds() + { + var smallJson = BuildJsonDocument("1", "pk1", "small"); + await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); + + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes - 300)); + + var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamReplace_2MBPlus1Byte_Returns413() + { + var smallJson = BuildJsonDocument("1", "pk1", "small"); + await _container.CreateItemStreamAsync(ToStream(smallJson), new PartitionKey("pk1")); + + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var json = BuildJsonDocument("1", "pk1", new string('a', TwoMB - envelopeBytes + 1)); + + var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -698,122 +698,122 @@ public async Task StreamReplace_2MBPlus1Byte_Returns413() public class DocumentSizeStateIntegrityTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); - - private static string BuildJsonDocument(string id, string pk, string dataValue) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Create_OverSizeLimit_DoesNotStoreDocument() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - var readAct = () => _container.ReadItemAsync("large", new PartitionKey("pk1")); - var readEx = await readAct.Should().ThrowAsync(); - readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Replace_OverSizeLimit_OriginalDocumentUnchanged() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "original" }, - new PartitionKey("pk1")); - - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeOversizedValue() }, - "1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - var item = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("original"); - } - - [Fact] - public async Task Upsert_OverSizeLimit_DoesNotCreateOrUpdateDocument() - { - // New item: oversized upsert should not create - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "new", PartitionKey = "pk1", Name = MakeOversizedValue() }, - new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - var readAct = () => _container.ReadItemAsync("new", new PartitionKey("pk1")); - var readEx = await readAct.Should().ThrowAsync(); - readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - - // Existing item: oversized upsert should not modify - await _container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "safe" }, - new PartitionKey("pk1")); - - var act2 = () => _container.UpsertItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = MakeOversizedValue() }, - new PartitionKey("pk1")); - await act2.Should().ThrowAsync(); - - var item = await _container.ReadItemAsync("existing", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("safe"); - } - - [Fact] - public async Task Patch_OverSizeLimit_OriginalDocumentUnchanged() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "original", Value = 42 }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", MakeOversizedValue())]); - await act.Should().ThrowAsync(); - - var item = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("original"); - item.Resource.Value.Should().Be(42); - } - - [Fact] - public async Task StreamCreate_OverSizeLimit_DoesNotStoreDocument() - { - var json = BuildJsonDocument("large", "pk1", MakeOversizedValue()); - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - - var readAct = () => _container.ReadItemAsync("large", new PartitionKey("pk1")); - var readEx = await readAct.Should().ThrowAsync(); - readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Batch_OverSizeLimit_RollsBackAllOperations() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - { - var value = new string('x', 300 * 1024); // 300KB each = 3MB total - batch.CreateItem(new { id = $"batch-{i}", partitionKey = "pk1", data = value }); - } - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - - // None of the items should exist - for (var i = 0; i < 3; i++) - { - var readAct = () => _container.ReadItemAsync($"batch-{i}", new PartitionKey("pk1")); - var readEx = await readAct.Should().ThrowAsync(); - readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); + + private static string BuildJsonDocument(string id, string pk, string dataValue) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Create_OverSizeLimit_DoesNotStoreDocument() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + var readAct = () => _container.ReadItemAsync("large", new PartitionKey("pk1")); + var readEx = await readAct.Should().ThrowAsync(); + readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Replace_OverSizeLimit_OriginalDocumentUnchanged() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "original" }, + new PartitionKey("pk1")); + + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeOversizedValue() }, + "1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + var item = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("original"); + } + + [Fact] + public async Task Upsert_OverSizeLimit_DoesNotCreateOrUpdateDocument() + { + // New item: oversized upsert should not create + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "new", PartitionKey = "pk1", Name = MakeOversizedValue() }, + new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + var readAct = () => _container.ReadItemAsync("new", new PartitionKey("pk1")); + var readEx = await readAct.Should().ThrowAsync(); + readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Existing item: oversized upsert should not modify + await _container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "safe" }, + new PartitionKey("pk1")); + + var act2 = () => _container.UpsertItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = MakeOversizedValue() }, + new PartitionKey("pk1")); + await act2.Should().ThrowAsync(); + + var item = await _container.ReadItemAsync("existing", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("safe"); + } + + [Fact] + public async Task Patch_OverSizeLimit_OriginalDocumentUnchanged() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "original", Value = 42 }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", MakeOversizedValue())]); + await act.Should().ThrowAsync(); + + var item = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("original"); + item.Resource.Value.Should().Be(42); + } + + [Fact] + public async Task StreamCreate_OverSizeLimit_DoesNotStoreDocument() + { + var json = BuildJsonDocument("large", "pk1", MakeOversizedValue()); + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + + var readAct = () => _container.ReadItemAsync("large", new PartitionKey("pk1")); + var readEx = await readAct.Should().ThrowAsync(); + readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Batch_OverSizeLimit_RollsBackAllOperations() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + { + var value = new string('x', 300 * 1024); // 300KB each = 3MB total + batch.CreateItem(new { id = $"batch-{i}", partitionKey = "pk1", data = value }); + } + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + + // None of the items should exist + for (var i = 0; i < 3; i++) + { + var readAct = () => _container.ReadItemAsync($"batch-{i}", new PartitionKey("pk1")); + var readEx = await readAct.Should().ThrowAsync(); + readEx.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } } #endregion @@ -822,100 +822,100 @@ public async Task Batch_OverSizeLimit_RollsBackAllOperations() public class BatchSizeExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_99Operations_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 99; i++) - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "x" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_1Operation_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "single", PartitionKey = "pk1", Name = "x" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_MixedOperationTypes_TotalSizeExceeds2MB_Returns413() - { - // Seed some items for replace/patch - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"exist-{i}", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - // Mix creates, upserts, replaces — each 400KB ≈ 2.4MB total - for (var i = 0; i < 3; i++) - { - var largeValue = new string('x', 400 * 1024); - batch.CreateItem(new { id = $"new-{i}", partitionKey = "pk1", data = largeValue }); - } - for (var i = 0; i < 3; i++) - { - var largeValue = new string('y', 400 * 1024); - batch.UpsertItem(new { id = $"exist-{i}", partitionKey = "pk1", data = largeValue }); - } - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_UpsertOperations_ContributeToBatchSize() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - { - var largeValue = new string('x', 300 * 1024); // 300KB × 10 = 3MB - batch.UpsertItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); - } - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_ReplaceOperations_ContributeToBatchSize() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - { - var largeValue = new string('x', 300 * 1024); - batch.ReplaceItem($"{i}", new { id = $"{i}", partitionKey = "pk1", data = largeValue }); - } - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_Over100Operations_ThrowsBadRequest_NotReturn413() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 101; i++) - batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", name = "x" }); - - // 100-op limit throws CosmosException (not returns ResponseMessage) - var act = () => batch.ExecuteAsync(); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_99Operations_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 99; i++) + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "x" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_1Operation_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "single", PartitionKey = "pk1", Name = "x" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_MixedOperationTypes_TotalSizeExceeds2MB_Returns413() + { + // Seed some items for replace/patch + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"exist-{i}", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + // Mix creates, upserts, replaces — each 400KB ≈ 2.4MB total + for (var i = 0; i < 3; i++) + { + var largeValue = new string('x', 400 * 1024); + batch.CreateItem(new { id = $"new-{i}", partitionKey = "pk1", data = largeValue }); + } + for (var i = 0; i < 3; i++) + { + var largeValue = new string('y', 400 * 1024); + batch.UpsertItem(new { id = $"exist-{i}", partitionKey = "pk1", data = largeValue }); + } + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_UpsertOperations_ContributeToBatchSize() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + { + var largeValue = new string('x', 300 * 1024); // 300KB × 10 = 3MB + batch.UpsertItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); + } + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_ReplaceOperations_ContributeToBatchSize() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + { + var largeValue = new string('x', 300 * 1024); + batch.ReplaceItem($"{i}", new { id = $"{i}", partitionKey = "pk1", data = largeValue }); + } + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_Over100Operations_ThrowsBadRequest_NotReturn413() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 101; i++) + batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", name = "x" }); + + // 100-op limit throws CosmosException (not returns ResponseMessage) + var act = () => batch.ExecuteAsync(); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } #endregion @@ -924,97 +924,97 @@ public async Task Batch_Over100Operations_ThrowsBadRequest_NotReturn413() public class PatchSizeEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static string MakeValue(int sizeKB) => new('z', sizeKB * 1024); - - [Fact] - public async Task Patch_Add_LargeField_ExceedsSize_Returns413() - { - // Seed 1.5MB doc - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeValue(1500) }, - new PartitionKey("pk1")); - - // Add 0.6MB field → result > 2MB - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Add("/extra", MakeValue(600))]); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Patch_Remove_LargeField_BringsUnderSize_Succeeds() - { - // Seed doc with large field via stream API (anonymous types can't be proxied) - var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"bigField\":\"{MakeValue(1500)}\",\"extra\":\"small\"}}"; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); - - // Remove large field, add smaller one — result under 2MB - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Remove("/bigField"), - PatchOperation.Set("/extra", "replaced") - ]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Patch_MultipleSets_CumulativelyExceedSize_Returns413() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - // Multiple Sets that together push past 2MB - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", MakeValue(800)), - PatchOperation.Set("/field2", MakeValue(800)), - PatchOperation.Set("/field3", MakeValue(800)) - ]); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Patch_Replace_LargeValue_WithSmallValue_Succeeds() - { - // Seed near-2MB doc via stream API - var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{MakeValue(1900)}\"}}"; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); - - // Replace large field with small value → doc shrinks - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/data", "tiny")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Patch_Increment_CannotExceedSizeLimit() - { - // Seed near-2MB doc with numeric field via stream API - var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{MakeValue(1900)}\",\"counter\":1}}"; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); - - // Increment a number — doesn't meaningfully change size - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/counter", 1)]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static string MakeValue(int sizeKB) => new('z', sizeKB * 1024); + + [Fact] + public async Task Patch_Add_LargeField_ExceedsSize_Returns413() + { + // Seed 1.5MB doc + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = MakeValue(1500) }, + new PartitionKey("pk1")); + + // Add 0.6MB field → result > 2MB + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Add("/extra", MakeValue(600))]); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Patch_Remove_LargeField_BringsUnderSize_Succeeds() + { + // Seed doc with large field via stream API (anonymous types can't be proxied) + var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"bigField\":\"{MakeValue(1500)}\",\"extra\":\"small\"}}"; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); + + // Remove large field, add smaller one — result under 2MB + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Remove("/bigField"), + PatchOperation.Set("/extra", "replaced") + ]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_MultipleSets_CumulativelyExceedSize_Returns413() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + // Multiple Sets that together push past 2MB + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", MakeValue(800)), + PatchOperation.Set("/field2", MakeValue(800)), + PatchOperation.Set("/field3", MakeValue(800)) + ]); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Patch_Replace_LargeValue_WithSmallValue_Succeeds() + { + // Seed near-2MB doc via stream API + var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{MakeValue(1900)}\"}}"; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); + + // Replace large field with small value → doc shrinks + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/data", "tiny")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_Increment_CannotExceedSizeLimit() + { + // Seed near-2MB doc with numeric field via stream API + var seedJson = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{MakeValue(1900)}\",\"counter\":1}}"; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(seedJson)), new PartitionKey("pk1")); + + // Increment a number — doesn't meaningfully change size + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/counter", 1)]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } #endregion @@ -1023,64 +1023,64 @@ await _container.CreateItemStreamAsync( public class MultiByteCharacterSizeTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private const int TwoMB = 2 * 1024 * 1024; - - private static string BuildJsonDocument(string id, string pk, string dataValue) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Create_With4ByteEmoji_EnforcesOnByteCount() - { - // 😀 is U+1F600 = 4 bytes in UTF-8, 2 chars in C# (surrogate pair) - var emoji = "😀"; - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - // Need enough emojis so byte count > 2MB - var emojiCount = (TwoMB - envelopeBytes) / 4 + 1; - var data = string.Concat(Enumerable.Repeat(emoji, emojiCount)); - var json = BuildJsonDocument("1", "pk1", data); - - Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Create_With3ByteCjk_EnforcesOnByteCount() - { - // 中 is U+4E2D = 3 bytes in UTF-8, 1 char in C# - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var charCount = (TwoMB - envelopeBytes) / 3 + 1; - var data = new string('中', charCount); - var json = BuildJsonDocument("1", "pk1", data); - - Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Create_WithEscapedUnicode_SizeBasedOnSerializedForm() - { - // When we pass raw JSON, the size is based on the actual byte count of the JSON string - var envelope = BuildJsonDocument("1", "pk1", ""); - var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); - var fillSize = TwoMB - envelopeBytes + 1; - // Use regular ASCII chars — escaped unicode doesn't change size in raw JSON - var data = new string('a', fillSize); - var json = BuildJsonDocument("1", "pk1", data); - - Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private const int TwoMB = 2 * 1024 * 1024; + + private static string BuildJsonDocument(string id, string pk, string dataValue) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"{pk}\",\"data\":\"{dataValue}\"}}"; + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Create_With4ByteEmoji_EnforcesOnByteCount() + { + // 😀 is U+1F600 = 4 bytes in UTF-8, 2 chars in C# (surrogate pair) + var emoji = "😀"; + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + // Need enough emojis so byte count > 2MB + var emojiCount = (TwoMB - envelopeBytes) / 4 + 1; + var data = string.Concat(Enumerable.Repeat(emoji, emojiCount)); + var json = BuildJsonDocument("1", "pk1", data); + + Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Create_With3ByteCjk_EnforcesOnByteCount() + { + // 中 is U+4E2D = 3 bytes in UTF-8, 1 char in C# + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var charCount = (TwoMB - envelopeBytes) / 3 + 1; + var data = new string('中', charCount); + var json = BuildJsonDocument("1", "pk1", data); + + Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Create_WithEscapedUnicode_SizeBasedOnSerializedForm() + { + // When we pass raw JSON, the size is based on the actual byte count of the JSON string + var envelope = BuildJsonDocument("1", "pk1", ""); + var envelopeBytes = Encoding.UTF8.GetByteCount(envelope); + var fillSize = TwoMB - envelopeBytes + 1; + // Use regular ASCII chars — escaped unicode doesn't change size in raw JSON + var data = new string('a', fillSize); + var json = BuildJsonDocument("1", "pk1", data); + + Encoding.UTF8.GetByteCount(json).Should().BeGreaterThan(TwoMB); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1089,42 +1089,42 @@ public async Task Create_WithEscapedUnicode_SizeBasedOnSerializedForm() public class DocumentSizeErrorResponseDetailTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); + private static string MakeOversizedValue() => new('x', 3 * 1024 * 1024); - [Fact] - public async Task Create_OverSizeLimit_SubStatusCodeIsZero() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_SubStatusCodeIsZero() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.SubStatusCode.Should().Be(0); - } + var ex = await act.Should().ThrowAsync(); + ex.And.SubStatusCode.Should().Be(0); + } - [Fact] - public async Task Create_OverSizeLimit_ActivityIdIsPopulated() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_ActivityIdIsPopulated() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.ActivityId.Should().NotBeNullOrEmpty(); - } + var ex = await act.Should().ThrowAsync(); + ex.And.ActivityId.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Create_OverSizeLimit_RequestChargeIsGreaterThanZero() - { - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; + [Fact] + public async Task Create_OverSizeLimit_RequestChargeIsGreaterThanZero() + { + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = MakeOversizedValue() }; - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.RequestCharge.Should().BeGreaterThan(0); - } + var ex = await act.Should().ThrowAsync(); + ex.And.RequestCharge.Should().BeGreaterThan(0); + } } #endregion @@ -1133,53 +1133,53 @@ public async Task Create_OverSizeLimit_RequestChargeIsGreaterThanZero() public class ConcurrentSizeValidationTests { - [Fact] - public async Task ConcurrentCreates_MixOfOversizedAndValid_AllHandledCorrectly() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - var oversizedValue = new string('x', 3 * 1024 * 1024); - - var tasks = new List>(); - - for (var i = 0; i < 50; i++) - { - var id = $"item-{i}"; - var isOversized = i % 2 == 0; // Even = oversized, odd = valid - var doc = new TestDocument - { - Id = id, - PartitionKey = "pk1", - Name = isOversized ? oversizedValue : "small" - }; - - tasks.Add(Task.Run(async () => - { - try - { - var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); - return (id, shouldSucceed: !isOversized, didSucceed: true); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.RequestEntityTooLarge) - { - return (id, shouldSucceed: !isOversized, didSucceed: false); - } - })); - } - - var results = await Task.WhenAll(tasks); - - foreach (var (id, shouldSucceed, didSucceed) in results) - { - didSucceed.Should().Be(shouldSucceed, $"item {id} expected success={shouldSucceed}"); - } - - // Verify valid items are readable - for (var i = 1; i < 50; i += 2) - { - var item = await container.ReadItemAsync($"item-{i}", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("small"); - } - } + [Fact] + public async Task ConcurrentCreates_MixOfOversizedAndValid_AllHandledCorrectly() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + var oversizedValue = new string('x', 3 * 1024 * 1024); + + var tasks = new List>(); + + for (var i = 0; i < 50; i++) + { + var id = $"item-{i}"; + var isOversized = i % 2 == 0; // Even = oversized, odd = valid + var doc = new TestDocument + { + Id = id, + PartitionKey = "pk1", + Name = isOversized ? oversizedValue : "small" + }; + + tasks.Add(Task.Run(async () => + { + try + { + var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); + return (id, shouldSucceed: !isOversized, didSucceed: true); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.RequestEntityTooLarge) + { + return (id, shouldSucceed: !isOversized, didSucceed: false); + } + })); + } + + var results = await Task.WhenAll(tasks); + + foreach (var (id, shouldSucceed, didSucceed) in results) + { + didSucceed.Should().Be(shouldSucceed, $"item {id} expected success={shouldSucceed}"); + } + + // Verify valid items are readable + for (var i = 1; i < 50; i += 2) + { + var item = await container.ReadItemAsync($"item-{i}", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("small"); + } + } } #endregion @@ -1188,19 +1188,19 @@ public async Task ConcurrentCreates_MixOfOversizedAndValid_AllHandledCorrectly() public class StoredProcedureSizeLimitDivergenceTests { - [Fact] - public async Task StoredProcedure_OversizedResponse_RealCosmosWouldReject() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterStoredProcedure("bigSproc", (pk, args) => new string('x', 5 * 1024 * 1024)); - - var scripts = container.Scripts; - var ex = await Assert.ThrowsAnyAsync(() => - scripts.ExecuteStoredProcedureAsync( - "bigSproc", new PartitionKey("pk1"), [])); - - ex.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task StoredProcedure_OversizedResponse_RealCosmosWouldReject() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterStoredProcedure("bigSproc", (pk, args) => new string('x', 5 * 1024 * 1024)); + + var scripts = container.Scripts; + var ex = await Assert.ThrowsAnyAsync(() => + scripts.ExecuteStoredProcedureAsync( + "bigSproc", new PartitionKey("pk1"), [])); + + ex.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1209,36 +1209,36 @@ public async Task StoredProcedure_OversizedResponse_RealCosmosWouldReject() public class BatchOverheadDivergenceTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private const int TwoMB = 2 * 1024 * 1024; - - [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos includes HTTP/protocol overhead in the 2MB batch size. " + - "Emulator counts only item JSON bytes, making it slightly more permissive. " + - "Impact: only affects batches within ~1-5KB of the 2MB limit.")] - public async Task Batch_NearlyExactly2MB_RealCosmosWouldRejectDueToOverhead() - { - // Build batch right at 2MB of JSON — real Cosmos adds overhead and rejects - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - var valueSize = TwoMB / 2 - 100; // Two items each ~1MB - batch.CreateItem(new { id = "1", partitionKey = "pk1", data = new string('a', valueSize) }); - batch.CreateItem(new { id = "2", partitionKey = "pk1", data = new string('b', valueSize) }); - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_NearlyExactly2MB_EmulatorAcceptsWithoutOverhead_Divergence() - { - // Build batch with items just under 2MB of JSON — emulator accepts (no overhead accounting) - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - var valueSize = TwoMB / 2 - 200; // Two items totaling just under 2MB - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('a', valueSize) }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = new string('b', valueSize) }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private const int TwoMB = 2 * 1024 * 1024; + + [Fact(Skip = "KNOWN DIVERGENCE: Real Cosmos includes HTTP/protocol overhead in the 2MB batch size. " + + "Emulator counts only item JSON bytes, making it slightly more permissive. " + + "Impact: only affects batches within ~1-5KB of the 2MB limit.")] + public async Task Batch_NearlyExactly2MB_RealCosmosWouldRejectDueToOverhead() + { + // Build batch right at 2MB of JSON — real Cosmos adds overhead and rejects + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var valueSize = TwoMB / 2 - 100; // Two items each ~1MB + batch.CreateItem(new { id = "1", partitionKey = "pk1", data = new string('a', valueSize) }); + batch.CreateItem(new { id = "2", partitionKey = "pk1", data = new string('b', valueSize) }); + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_NearlyExactly2MB_EmulatorAcceptsWithoutOverhead_Divergence() + { + // Build batch with items just under 2MB of JSON — emulator accepts (no overhead accounting) + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var valueSize = TwoMB / 2 - 200; // Two items totaling just under 2MB + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = new string('a', valueSize) }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = new string('b', valueSize) }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } } #endregion @@ -1247,64 +1247,64 @@ public async Task Batch_NearlyExactly2MB_EmulatorAcceptsWithoutOverhead_Divergen public class PostTriggerSizeDivergenceTests { - [Fact] - public async Task PostTrigger_InflatesDocumentPast2MB_RealCosmosWouldReject() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, - Microsoft.Azure.Cosmos.Scripts.TriggerOperation.Create, - (Action)(doc => - { - doc["hugeField"] = new string('z', 3 * 1024 * 1024); - })); - - var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; - var act = () => container.CreateItemAsync(smallDoc, new PartitionKey("pk1"), - new ItemRequestOptions { PostTriggers = ["inflatePost"] }); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task PostTrigger_InflatesDocumentPast2MB_TypedUpsert_CorrectlyRejected() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, - Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, - (Action)(doc => - { - doc["hugeField"] = new string('z', 3 * 1024 * 1024); - })); - - var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; - var act = () => container.UpsertItemAsync(smallDoc, new PartitionKey("pk1"), - new ItemRequestOptions { PostTriggers = ["inflatePost"] }); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task PostTrigger_InflatesDocumentPast2MB_TypedReplace_CorrectlyRejected() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, - Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, - (Action)(doc => - { - doc["hugeField"] = new string('z', 3 * 1024 * 1024); - })); - - var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; - await container.CreateItemAsync(smallDoc, new PartitionKey("pk1")); - - var act = () => container.ReplaceItemAsync(smallDoc, "1", new PartitionKey("pk1"), - new ItemRequestOptions { PostTriggers = ["inflatePost"] }); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task PostTrigger_InflatesDocumentPast2MB_RealCosmosWouldReject() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, + Microsoft.Azure.Cosmos.Scripts.TriggerOperation.Create, + (Action)(doc => + { + doc["hugeField"] = new string('z', 3 * 1024 * 1024); + })); + + var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; + var act = () => container.CreateItemAsync(smallDoc, new PartitionKey("pk1"), + new ItemRequestOptions { PostTriggers = ["inflatePost"] }); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task PostTrigger_InflatesDocumentPast2MB_TypedUpsert_CorrectlyRejected() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, + Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, + (Action)(doc => + { + doc["hugeField"] = new string('z', 3 * 1024 * 1024); + })); + + var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; + var act = () => container.UpsertItemAsync(smallDoc, new PartitionKey("pk1"), + new ItemRequestOptions { PostTriggers = ["inflatePost"] }); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task PostTrigger_InflatesDocumentPast2MB_TypedReplace_CorrectlyRejected() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterTrigger("inflatePost", Microsoft.Azure.Cosmos.Scripts.TriggerType.Post, + Microsoft.Azure.Cosmos.Scripts.TriggerOperation.All, + (Action)(doc => + { + doc["hugeField"] = new string('z', 3 * 1024 * 1024); + })); + + var smallDoc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "tiny" }; + await container.CreateItemAsync(smallDoc, new PartitionKey("pk1")); + + var act = () => container.ReplaceItemAsync(smallDoc, "1", new PartitionKey("pk1"), + new ItemRequestOptions { PostTriggers = ["inflatePost"] }); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1313,42 +1313,42 @@ public async Task PostTrigger_InflatesDocumentPast2MB_TypedReplace_CorrectlyReje public class PatchOperationLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_Over10Operations_Returns400_Stream() - { - var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", $"value{i}")) - .ToList(); - - var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), ops); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task TypedPatch_Over10Operations_Throws400() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "test" }, - new PartitionKey("pk1")); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", $"value{i}")) - .ToList(); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), ops); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_Over10Operations_Returns400_Stream() + { + var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", $"value{i}")) + .ToList(); + + var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), ops); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task TypedPatch_Over10Operations_Throws400() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "test" }, + new PartitionKey("pk1")); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", $"value{i}")) + .ToList(); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), ops); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } #endregion @@ -1357,53 +1357,53 @@ await _container.CreateItemAsync( public class BatchStreamOperationSizeTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static string MakeLargeJson(string id) => - $"{{\"id\":\"{id}\",\"partitionKey\":\"pk1\",\"data\":\"{new string('x', 300 * 1024)}\"}}"; + private static string MakeLargeJson(string id) => + $"{{\"id\":\"{id}\",\"partitionKey\":\"pk1\",\"data\":\"{new string('x', 300 * 1024)}\"}}"; - [Fact] - public async Task Batch_StreamCreate_ContributesToBatchSize() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - batch.CreateItemStream(ToStream(MakeLargeJson($"item{i}"))); + [Fact] + public async Task Batch_StreamCreate_ContributesToBatchSize() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + batch.CreateItemStream(ToStream(MakeLargeJson($"item{i}"))); - var response = await batch.ExecuteAsync(); + var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Batch_StreamUpsert_ContributesToBatchSize() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - batch.UpsertItemStream(ToStream(MakeLargeJson($"item{i}"))); + [Fact] + public async Task Batch_StreamUpsert_ContributesToBatchSize() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + batch.UpsertItemStream(ToStream(MakeLargeJson($"item{i}"))); - var response = await batch.ExecuteAsync(); + var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } - [Fact] - public async Task Batch_StreamReplace_ContributesToBatchSize() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemStreamAsync( - ToStream($"{{\"id\":\"item{i}\",\"partitionKey\":\"pk1\",\"data\":\"small\"}}"), - new PartitionKey("pk1")); + [Fact] + public async Task Batch_StreamReplace_ContributesToBatchSize() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemStreamAsync( + ToStream($"{{\"id\":\"item{i}\",\"partitionKey\":\"pk1\",\"data\":\"small\"}}"), + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - batch.ReplaceItemStream($"item{i}", ToStream(MakeLargeJson($"item{i}"))); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + batch.ReplaceItemStream($"item{i}", ToStream(MakeLargeJson($"item{i}"))); - var response = await batch.ExecuteAsync(); + var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1412,19 +1412,19 @@ await _container.CreateItemStreamAsync( public class DeleteLargeDocumentTests { - [Fact] - public async Task Delete_LargeDocument_AlwaysSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var largeValue = new string('x', 1_900_000); - var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{largeValue}\"}}"; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var response = await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + [Fact] + public async Task Delete_LargeDocument_AlwaysSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var largeValue = new string('x', 1_900_000); + var json = $"{{\"id\":\"1\",\"partitionKey\":\"pk1\",\"data\":\"{largeValue}\"}}"; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var response = await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } #endregion @@ -1433,36 +1433,36 @@ await container.CreateItemStreamAsync( public class MinimalDocumentTests { - [Fact] - public async Task Create_MinimalDocument_JustIdAndPk_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\"}"; - - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Create_EmptyStringFields_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var doc = JObject.FromObject(new - { - id = "1", - partitionKey = "pk1", - name = "", - description = "", - notes = "", - tags = Array.Empty() - }); - - var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task Create_MinimalDocument_JustIdAndPk_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\"}"; + + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Create_EmptyStringFields_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var doc = JObject.FromObject(new + { + id = "1", + partitionKey = "pk1", + name = "", + description = "", + notes = "", + tags = Array.Empty() + }); + + var response = await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } #endregion @@ -1471,30 +1471,30 @@ public async Task Create_EmptyStringFields_Succeeds() public class FakeCosmosHandlerDocumentSizeTests { - [Fact] - public async Task FakeCosmosHandler_Create_OversizedDocument_Returns413() - { - var backingContainer = new InMemoryContainer("testcontainer", "/partitionKey"); - using var handler = new FakeCosmosHandler(backingContainer); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var container = client.GetContainer("fakeDb", "testcontainer"); - var oversizedValue = new string('x', 3 * 1024 * 1024); - var doc = new { id = "1", partitionKey = "pk1", data = oversizedValue }; - - var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task FakeCosmosHandler_Create_OversizedDocument_Returns413() + { + var backingContainer = new InMemoryContainer("testcontainer", "/partitionKey"); + using var handler = new FakeCosmosHandler(backingContainer); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var container = client.GetContainer("fakeDb", "testcontainer"); + var oversizedValue = new string('x', 3 * 1024 * 1024); + var doc = new { id = "1", partitionKey = "pk1", data = oversizedValue }; + + var act = () => container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1503,20 +1503,20 @@ public async Task FakeCosmosHandler_Create_OversizedDocument_Returns413() public class HierarchicalPartitionKeyDocumentSizeTests { - [Fact] - public async Task Create_HierarchicalPartitionKey_OversizedDocument_Returns413() - { - var container = new InMemoryContainer("test", - new[] { "/tenantId", "/categoryId" }); - var oversizedValue = new string('x', 3 * 1024 * 1024); - var json = $"{{\"id\":\"1\",\"tenantId\":\"t1\",\"categoryId\":\"c1\",\"data\":\"{oversizedValue}\"}}"; - - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKeyBuilder().Add("t1").Add("c1").Build()); - - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task Create_HierarchicalPartitionKey_OversizedDocument_Returns413() + { + var container = new InMemoryContainer("test", + new[] { "/tenantId", "/categoryId" }); + var oversizedValue = new string('x', 3 * 1024 * 1024); + var json = $"{{\"id\":\"1\",\"tenantId\":\"t1\",\"categoryId\":\"c1\",\"data\":\"{oversizedValue}\"}}"; + + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKeyBuilder().Add("t1").Add("c1").Build()); + + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion @@ -1525,20 +1525,20 @@ public async Task Create_HierarchicalPartitionKey_OversizedDocument_Returns413() public class PatchInputSizeTests { - [Fact] - public async Task Patch_LargeInputButSmallResult_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var largeVal = new string('x', 1_500_000); - var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", data = largeVal }); - await container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var response = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/data", "small") }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task Patch_LargeInputButSmallResult_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var largeVal = new string('x', 1_500_000); + var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", data = largeVal }); + await container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var response = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/data", "small") }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } #endregion @@ -1547,24 +1547,24 @@ public async Task Patch_LargeInputButSmallResult_Succeeds() public class StoredProcedureDocumentSizeTests { - [Fact] - public async Task StoredProcedure_CreatesOversizedDocument_Returns413() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterStoredProcedure("createOversized", (pk, args) => - { - var oversizedValue = new string('x', 3 * 1024 * 1024); - var doc = JObject.FromObject(new { id = "sproc1", partitionKey = "pk1", data = oversizedValue }); - container.CreateItemAsync(doc, new PartitionKey("pk1")).GetAwaiter().GetResult(); - return "done"; - }); - - var act = () => container.Scripts.ExecuteStoredProcedureAsync( - "createOversized", new PartitionKey("pk1"), null); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task StoredProcedure_CreatesOversizedDocument_Returns413() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterStoredProcedure("createOversized", (pk, args) => + { + var oversizedValue = new string('x', 3 * 1024 * 1024); + var doc = JObject.FromObject(new { id = "sproc1", partitionKey = "pk1", data = oversizedValue }); + container.CreateItemAsync(doc, new PartitionKey("pk1")).GetAwaiter().GetResult(); + return "done"; + }); + + var act = () => container.Scripts.ExecuteStoredProcedureAsync( + "createOversized", new PartitionKey("pk1"), null); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } #endregion diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs index 8004c41..b7c13d9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ETagTests.cs @@ -1,8 +1,8 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -10,244 +10,244 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ETagGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task IfMatch_WithWildcard_Star_AlwaysSucceeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task IfNoneMatch_WithWildcard_Star_Returns304WhenExists() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task IfMatch_WithWildcard_Star_AlwaysSucceeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfNoneMatch_WithWildcard_Star_Returns304WhenExists() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotModified); + } } public class ETagGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ETag_ChangesOnEveryWrite() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var firstEtag = create.ETag; - - var upsert = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - var secondEtag = upsert.ETag; - - firstEtag.Should().NotBe(secondEtag); - } - - [Fact] - public async Task ETag_ConsistentAcrossMultipleReads() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - - read1.ETag.Should().Be(read2.ETag); - } - - [Fact] - public async Task ConcurrentUpsert_IfMatch_SecondWriteFails() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - var etag = create.ETag; - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First Writer" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second Writer" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ETag_ChangesOnEveryWrite() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }; + var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var firstEtag = create.ETag; + + var upsert = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + var secondEtag = upsert.ETag; + + firstEtag.Should().NotBe(secondEtag); + } + + [Fact] + public async Task ETag_ConsistentAcrossMultipleReads() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + + read1.ETag.Should().Be(read2.ETag); + } + + [Fact] + public async Task ConcurrentUpsert_IfMatch_SecondWriteFails() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }; + var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + var etag = create.ETag; + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First Writer" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second Writer" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } public class ETagGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task IfMatch_OnCreate_IsIgnored() - { - // Create doesn't have a prior version, so IfMatch should be irrelevant - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"nonexistent\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task IfMatch_OnPatch_WithCorrectETag_Succeeds() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; - var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = create.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task IfMatch_OnPatch_WithStaleETag_Fails412() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task IfMatch_OnCreate_IsIgnored() + { + // Create doesn't have a prior version, so IfMatch should be irrelevant + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + var response = await _container.CreateItemAsync(item, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"nonexistent\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task IfMatch_OnPatch_WithCorrectETag_Succeeds() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; + var create = await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = create.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task IfMatch_OnPatch_WithStaleETag_Fails412() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } #region ETag Response Tests public class ETagResponseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Delete_TypedResponse_ETag_ShouldBeNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - response.ETag.Should().BeNull(); - } - - [Fact] - public async Task Replace_ResponseETag_ChangesFromCreate() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var createETag = create.ETag; - - var replace = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - "1", new PartitionKey("pk1")); - - replace.ETag.Should().NotBeNullOrEmpty(); - replace.ETag.Should().NotBe(createETag); - } - - [Fact] - public async Task Patch_ResponseETag_ChangesFromCreate() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }, - new PartitionKey("pk1")); - var createETag = create.ETag; - - var patch = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - patch.ETag.Should().NotBeNullOrEmpty(); - patch.ETag.Should().NotBe(createETag); - } - - [Fact] - public async Task ETag_Format_IsQuotedHex_OnAllWriteOperations() - { - var hexPattern = "^\"[0-9a-f]{16}\"$"; - - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, - new PartitionKey("pk1")); - create.ETag.Should().MatchRegex(hexPattern); - - var upsert = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, - new PartitionKey("pk1")); - upsert.ETag.Should().MatchRegex(hexPattern); - - var replace = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - replace.ETag.Should().MatchRegex(hexPattern); - - var patch = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - patch.ETag.Should().MatchRegex(hexPattern); - } - - [Fact] - public async Task DocumentBody_ETag_MatchesResponseETag() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var bodyETag = read.Resource["_etag"]?.ToString(); - - bodyETag.Should().Be(create.ETag); - } - - [Fact] - public async Task DocumentBody_ETag_UpdatesOnEveryWrite() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - - var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag1 = read1.Resource["_etag"]?.ToString(); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - - var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag2 = read2.Resource["_etag"]?.ToString(); - - etag1.Should().NotBe(etag2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Delete_TypedResponse_ETag_ShouldBeNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + response.ETag.Should().BeNull(); + } + + [Fact] + public async Task Replace_ResponseETag_ChangesFromCreate() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var createETag = create.ETag; + + var replace = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + replace.ETag.Should().NotBeNullOrEmpty(); + replace.ETag.Should().NotBe(createETag); + } + + [Fact] + public async Task Patch_ResponseETag_ChangesFromCreate() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }, + new PartitionKey("pk1")); + var createETag = create.ETag; + + var patch = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + patch.ETag.Should().NotBeNullOrEmpty(); + patch.ETag.Should().NotBe(createETag); + } + + [Fact] + public async Task ETag_Format_IsQuotedHex_OnAllWriteOperations() + { + var hexPattern = "^\"[0-9a-f]{16}\"$"; + + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); + create.ETag.Should().MatchRegex(hexPattern); + + var upsert = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, + new PartitionKey("pk1")); + upsert.ETag.Should().MatchRegex(hexPattern); + + var replace = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + replace.ETag.Should().MatchRegex(hexPattern); + + var patch = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + patch.ETag.Should().MatchRegex(hexPattern); + } + + [Fact] + public async Task DocumentBody_ETag_MatchesResponseETag() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var bodyETag = read.Resource["_etag"]?.ToString(); + + bodyETag.Should().Be(create.ETag); + } + + [Fact] + public async Task DocumentBody_ETag_UpdatesOnEveryWrite() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + + var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag1 = read1.Resource["_etag"]?.ToString(); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + + var read2 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag2 = read2.Resource["_etag"]?.ToString(); + + etag1.Should().NotBe(etag2); + } } #endregion @@ -256,51 +256,51 @@ await _container.UpsertItemAsync( public class ETagIfMatchWildcardTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task IfMatch_Wildcard_OnReplace_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task IfMatch_Wildcard_OnDelete_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task IfMatch_Wildcard_OnPatch_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task IfMatch_Wildcard_OnReplace_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfMatch_Wildcard_OnDelete_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task IfMatch_Wildcard_OnPatch_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } #endregion @@ -309,67 +309,67 @@ await _container.CreateItemAsync( public class ETagIfNoneMatchEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task IfNoneMatch_Wildcard_OnRead_WhenItemDoesNotExist_Returns404() - { - var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task IfNoneMatch_StaleETag_OnRead_Returns200() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - var oldETag = create.ETag; - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = oldETag }); - - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Name.Should().Be("Second"); - } - - [Fact] - public async Task IfNoneMatch_OnUpsert_IsIgnored() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Real Cosmos ignores IfNoneMatch on write operations - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task IfNoneMatch_OnReplace_IsIgnored() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task IfNoneMatch_Wildcard_OnRead_WhenItemDoesNotExist_Returns404() + { + var act = () => _container.ReadItemAsync("nonexistent", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task IfNoneMatch_StaleETag_OnRead_Returns200() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + var oldETag = create.ETag; + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = oldETag }); + + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Name.Should().Be("Second"); + } + + [Fact] + public async Task IfNoneMatch_OnUpsert_IsIgnored() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Real Cosmos ignores IfNoneMatch on write operations + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task IfNoneMatch_OnReplace_IsIgnored() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } #endregion @@ -378,61 +378,61 @@ await _container.CreateItemAsync( public class ETagLifecycleTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task CreateDeleteRecreate_GetsNewETag() - { - var create1 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, - new PartitionKey("pk1")); - var etag1 = create1.ETag; - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var create2 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, - new PartitionKey("pk1")); - var etag2 = create2.ETag; - - etag1.Should().NotBe(etag2); - } - - [Fact] - public async Task Upsert_WithIfMatch_WhenItemDoesNotExist_CreatesItem() - { - // If-Match is "applicable only on PUT and DELETE" per REST API docs. - // Upsert uses POST, so If-Match is ignored on the insert path. - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "new", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Delete_WithIfMatch_NonExistentItem_Returns404_Not412() - { - var act = () => _container.DeleteItemAsync( - "nonexistent", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Replace_WithIfMatch_NonExistentItem_Returns404_Not412() - { - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Test" }, - "nonexistent", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task CreateDeleteRecreate_GetsNewETag() + { + var create1 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }, + new PartitionKey("pk1")); + var etag1 = create1.ETag; + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var create2 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Second" }, + new PartitionKey("pk1")); + var etag2 = create2.ETag; + + etag1.Should().NotBe(etag2); + } + + [Fact] + public async Task Upsert_WithIfMatch_WhenItemDoesNotExist_CreatesItem() + { + // If-Match is "applicable only on PUT and DELETE" per REST API docs. + // Upsert uses POST, so If-Match is ignored on the insert path. + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "new", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Delete_WithIfMatch_NonExistentItem_Returns404_Not412() + { + var act = () => _container.DeleteItemAsync( + "nonexistent", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Replace_WithIfMatch_NonExistentItem_Returns404_Not412() + { + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Test" }, + "nonexistent", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } #endregion @@ -441,97 +441,97 @@ public async Task Replace_WithIfMatch_NonExistentItem_Returns404_Not412() public class ETagStreamTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task StreamRead_IfNoneMatch_Wildcard_WhenExists_Returns304() - { - await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), - new PartitionKey("pk1")); - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task StreamRead_IfNoneMatch_StaleETag_Returns200() - { - var createResp = await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"First\"}"), - new PartitionKey("pk1")); - var oldETag = createResp.Headers["ETag"]; - - await _container.UpsertItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Second\"}"), - new PartitionKey("pk1")); - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = oldETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task StreamReplace_IfMatch_Wildcard_Succeeds() - { - await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Original\"}"), - new PartitionKey("pk1")); - - var response = await _container.ReplaceItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Replaced\"}"), - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task StreamDelete_IfMatch_Wildcard_Succeeds() - { - await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), - new PartitionKey("pk1")); - - var response = await _container.DeleteItemStreamAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task StreamPatch_WithCurrentETag_Succeeds() - { - var createResp = await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), - new PartitionKey("pk1")); - var currentETag = createResp.Headers["ETag"]; - - var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = currentETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task StreamDelete_Response_HasNoETagHeader() - { - await _container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), - new PartitionKey("pk1")); - - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - response.Headers["ETag"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task StreamRead_IfNoneMatch_Wildcard_WhenExists_Returns304() + { + await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), + new PartitionKey("pk1")); + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task StreamRead_IfNoneMatch_StaleETag_Returns200() + { + var createResp = await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"First\"}"), + new PartitionKey("pk1")); + var oldETag = createResp.Headers["ETag"]; + + await _container.UpsertItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Second\"}"), + new PartitionKey("pk1")); + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = oldETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamReplace_IfMatch_Wildcard_Succeeds() + { + await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Original\"}"), + new PartitionKey("pk1")); + + var response = await _container.ReplaceItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Replaced\"}"), + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamDelete_IfMatch_Wildcard_Succeeds() + { + await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), + new PartitionKey("pk1")); + + var response = await _container.DeleteItemStreamAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task StreamPatch_WithCurrentETag_Succeeds() + { + var createResp = await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), + new PartitionKey("pk1")); + var currentETag = createResp.Headers["ETag"]; + + var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = currentETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamDelete_Response_HasNoETagHeader() + { + await _container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Test\"}"), + new PartitionKey("pk1")); + + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + response.Headers["ETag"].Should().BeNull(); + } } #endregion @@ -540,92 +540,92 @@ await _container.CreateItemStreamAsync( public class ETagBatchStreamTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task BatchStream_Replace_WithStaleETag_FailsBatch() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var oldETag = create.ETag; - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchReplaced\"}"), - new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task BatchStream_Upsert_WithStaleETag_FailsBatch() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var oldETag = create.ETag; - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream( - ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchUpserted\"}"), - new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Batch_Delete_WithIfMatch_StaleETag_FailsBatch() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var oldETag = create.ETag; - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Batch_Delete_WithIfMatch_CurrentETag_Succeeds() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = create.ETag }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - // Confirm item is deleted - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await readAct.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task BatchStream_Replace_WithStaleETag_FailsBatch() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var oldETag = create.ETag; + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchReplaced\"}"), + new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task BatchStream_Upsert_WithStaleETag_FailsBatch() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var oldETag = create.ETag; + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream( + ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"BatchUpserted\"}"), + new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Batch_Delete_WithIfMatch_StaleETag_FailsBatch() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var oldETag = create.ETag; + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Changed" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = oldETag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Batch_Delete_WithIfMatch_CurrentETag_Succeeds() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = create.ETag }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + // Confirm item is deleted + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await readAct.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } #endregion @@ -634,21 +634,21 @@ public async Task Batch_Delete_WithIfMatch_CurrentETag_Succeeds() public class ETagStreamReadCurrentTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task StreamRead_IfNoneMatch_CurrentETag_Returns304() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public async Task StreamRead_IfNoneMatch_CurrentETag_Returns304() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - var response = await _container.ReadItemStreamAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); + var response = await _container.ReadItemStreamAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - } + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } } #endregion @@ -657,52 +657,52 @@ public async Task StreamRead_IfNoneMatch_CurrentETag_Returns304() public class ETagBodyMatchTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task DocumentBody_ETag_MatchesResponseETag_AfterUpsert() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var upsertResponse = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_etag"]!.Value().Should().Be(upsertResponse.ETag); - } - - [Fact] - public async Task DocumentBody_ETag_MatchesResponseETag_AfterReplace() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var replaceResponse = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_etag"]!.Value().Should().Be(replaceResponse.ETag); - } - - [Fact] - public async Task DocumentBody_ETag_MatchesResponseETag_AfterPatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var patchResponse = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_etag"]!.Value().Should().Be(patchResponse.ETag); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task DocumentBody_ETag_MatchesResponseETag_AfterUpsert() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var upsertResponse = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_etag"]!.Value().Should().Be(upsertResponse.ETag); + } + + [Fact] + public async Task DocumentBody_ETag_MatchesResponseETag_AfterReplace() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var replaceResponse = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_etag"]!.Value().Should().Be(replaceResponse.ETag); + } + + [Fact] + public async Task DocumentBody_ETag_MatchesResponseETag_AfterPatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var patchResponse = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_etag"]!.Value().Should().Be(patchResponse.ETag); + } } #endregion @@ -711,20 +711,20 @@ await _container.CreateItemAsync( public class ETagWildcardUpsertTests { - [Fact] - public async Task IfMatch_Wildcard_OnUpsert_WhenItemDoesNotExist_CreatesItem() - { - // If-Match is "applicable only on PUT and DELETE" per REST API docs. - // Upsert uses POST, so If-Match (including wildcard) is ignored on the insert path. - var container = new InMemoryContainer("test", "/partitionKey"); - - var response = await container.UpsertItemAsync( - new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "*" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task IfMatch_Wildcard_OnUpsert_WhenItemDoesNotExist_CreatesItem() + { + // If-Match is "applicable only on PUT and DELETE" per REST API docs. + // Upsert uses POST, so If-Match (including wildcard) is ignored on the insert path. + var container = new InMemoryContainer("test", "/partitionKey"); + + var response = await container.UpsertItemAsync( + new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "*" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } #endregion @@ -733,22 +733,22 @@ public async Task IfMatch_Wildcard_OnUpsert_WhenItemDoesNotExist_CreatesItem() public class ETagIfNoneMatchCreateTests { - [Fact] - public async Task IfNoneMatch_Wildcard_OnCreate_WhenItemAlreadyExists_Returns409() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, - new PartitionKey("pk1")); - - var act = () => container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + [Fact] + public async Task IfNoneMatch_Wildcard_OnCreate_WhenItemAlreadyExists_Returns409() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Existing" }, + new PartitionKey("pk1")); + + var act = () => container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } #endregion @@ -757,31 +757,31 @@ await container.CreateItemAsync( public class ETagStreamCreateTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task StreamCreate_Response_HasETagHeader() - { - var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; - var response = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - response.Headers["ETag"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamCreate_ResponseETag_ChangesFromPriorItem() - { - var json1 = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"first\"}"; - var response1 = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json1)), new PartitionKey("pk1")); - - var json2 = "{\"id\":\"2\",\"partitionKey\":\"pk1\",\"name\":\"second\"}"; - var response2 = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json2)), new PartitionKey("pk1")); - - response2.Headers["ETag"].Should().NotBe(response1.Headers["ETag"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task StreamCreate_Response_HasETagHeader() + { + var json = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; + var response = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + response.Headers["ETag"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamCreate_ResponseETag_ChangesFromPriorItem() + { + var json1 = "{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"first\"}"; + var response1 = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json1)), new PartitionKey("pk1")); + + var json2 = "{\"id\":\"2\",\"partitionKey\":\"pk1\",\"name\":\"second\"}"; + var response2 = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json2)), new PartitionKey("pk1")); + + response2.Headers["ETag"].Should().NotBe(response1.Headers["ETag"]); + } } #endregion @@ -790,27 +790,27 @@ public async Task StreamCreate_ResponseETag_ChangesFromPriorItem() public class ETagQueryTests { - [Fact] - public async Task ETag_InSqlQueryResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var create1 = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - var create2 = await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); - - var query = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c._etag FROM c ORDER BY c.id")); - var results = new List(); - while (query.HasMoreResults) - results.AddRange(await query.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["_etag"]!.Value().Should().Be(create1.ETag); - results[1]["_etag"]!.Value().Should().Be(create2.ETag); - } + [Fact] + public async Task ETag_InSqlQueryResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var create1 = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + var create2 = await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); + + var query = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c._etag FROM c ORDER BY c.id")); + var results = new List(); + while (query.HasMoreResults) + results.AddRange(await query.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["_etag"]!.Value().Should().Be(create1.ETag); + results[1]["_etag"]!.Value().Should().Be(create2.ETag); + } } #endregion @@ -819,23 +819,23 @@ public async Task ETag_InSqlQueryResults() public class ETagPersistenceTests { - [Fact] - public async Task ETag_PreservedThroughExportImport() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var state = container.ExportState(); - container.ClearItems(); - - container.ImportState(state); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - // After import, body _etag exists but may differ from original (re-enrichment) - read.Resource["_etag"]!.Value().Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task ETag_PreservedThroughExportImport() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var state = container.ExportState(); + container.ClearItems(); + + container.ImportState(state); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + // After import, body _etag exists but may differ from original (re-enrichment) + read.Resource["_etag"]!.Value().Should().NotBeNullOrEmpty(); + } } #endregion @@ -844,37 +844,37 @@ await container.CreateItemAsync( public class ETagPatchAllTypesTests { - [Fact] - public async Task Patch_AllOperationTypes_GenerateNewETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Test", value = 10, tags = new[] { "a" } }), - new PartitionKey("pk1")); - var etags = new List { create.ETag }; - - var r1 = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Updated") }); - etags.Add(r1.ETag); - - var r2 = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 1) }); - etags.Add(r2.ETag); - - var r3 = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Add("/tags/-", "b") }); - etags.Add(r3.ETag); - - var r4 = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Remove("/tags/0") }); - etags.Add(r4.ETag); - - var r5 = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Replace("/name", "Final") }); - etags.Add(r5.ETag); - - etags.Should().OnlyHaveUniqueItems(); - } + [Fact] + public async Task Patch_AllOperationTypes_GenerateNewETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var create = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Test", value = 10, tags = new[] { "a" } }), + new PartitionKey("pk1")); + var etags = new List { create.ETag }; + + var r1 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Updated") }); + etags.Add(r1.ETag); + + var r2 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 1) }); + etags.Add(r2.ETag); + + var r3 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Add("/tags/-", "b") }); + etags.Add(r3.ETag); + + var r4 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Remove("/tags/0") }); + etags.Add(r4.ETag); + + var r5 = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Final") }); + etags.Add(r5.ETag); + + etags.Should().OnlyHaveUniqueItems(); + } } #endregion @@ -883,29 +883,29 @@ public async Task Patch_AllOperationTypes_GenerateNewETag() public class ETagStreamNonExistentTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task StreamDelete_IfMatch_NonExistentItem_Returns404() - { - var response = await _container.DeleteItemStreamAsync( - "missing", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StreamReplace_IfMatch_NonExistentItem_Returns404() - { - var json = "{\"id\":\"missing\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; - var response = await _container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - "missing", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task StreamDelete_IfMatch_NonExistentItem_Returns404() + { + var response = await _container.DeleteItemStreamAsync( + "missing", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamReplace_IfMatch_NonExistentItem_Returns404() + { + var json = "{\"id\":\"missing\",\"partitionKey\":\"pk1\",\"name\":\"test\"}"; + var response = await _container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + "missing", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } #endregion @@ -914,26 +914,26 @@ public async Task StreamReplace_IfMatch_NonExistentItem_Returns404() public class ETagRapidWriteTests { - [Fact] - public async Task MultipleRapidWrites_EachGetsUniqueETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V0" }, - new PartitionKey("pk1")); - - var etags = new List { create.ETag }; - for (var i = 1; i <= 10; i++) - { - var r = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, - new PartitionKey("pk1")); - etags.Add(r.ETag); - } - - etags.Should().OnlyHaveUniqueItems(); - etags.Should().HaveCount(11); - } + [Fact] + public async Task MultipleRapidWrites_EachGetsUniqueETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var create = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V0" }, + new PartitionKey("pk1")); + + var etags = new List { create.ETag }; + for (var i = 1; i <= 10; i++) + { + var r = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"V{i}" }, + new PartitionKey("pk1")); + etags.Add(r.ETag); + } + + etags.Should().OnlyHaveUniqueItems(); + etags.Should().HaveCount(11); + } } #endregion @@ -942,22 +942,22 @@ public async Task MultipleRapidWrites_EachGetsUniqueETag() public class ETagBatchReadTests { - [Fact] - public async Task Batch_ReadItem_Response_HasEmptyETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - // Batch read currently returns empty ETag (known gap) - response[0].ETag.Should().BeEmpty(); - } + [Fact] + public async Task Batch_ReadItem_Response_HasEmptyETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + // Batch read currently returns empty ETag (known gap) + response[0].ETag.Should().BeEmpty(); + } } #endregion diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EmptyQueryZeroPagesBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EmptyQueryZeroPagesBugTests.cs index 1ddcb0e..f0ab584 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EmptyQueryZeroPagesBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EmptyQueryZeroPagesBugTests.cs @@ -12,101 +12,101 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class EmptyQueryZeroPagesBugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task QueryIterator_EmptyContainer_ShouldReturnAtLeastOnePage() - { - // Query an empty container - no items have been inserted - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - - // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match - iterator.HasMoreResults.Should().BeTrue( - "real Cosmos DB always returns at least one page, even when empty"); - - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0, "the page should be empty since no documents exist"); - } - - [Fact] - public async Task QueryIterator_NonexistentPartition_ShouldReturnAtLeastOnePage() - { - // Insert an item in one partition - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // Query a different, nonexistent partition - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'nonexistent'")); - - // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match - iterator.HasMoreResults.Should().BeTrue( - "real Cosmos DB always returns at least one page, even when no documents match"); - - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); - } - - [Fact] - public async Task QueryIterator_WithQueryRequestOptions_EmptyResult_ShouldReturnAtLeastOnePage() - { - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'nonexistent'"), - requestOptions: new QueryRequestOptions { MaxItemCount = 10 }); - - // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match - iterator.HasMoreResults.Should().BeTrue( - "real Cosmos DB always returns at least one page, even when empty with MaxItemCount set"); - - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); - } - - [Fact] - public async Task QueryIterator_StringQuery_EmptyResult_ShouldReturnAtLeastOnePage() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.partitionKey = 'nonexistent'"); - - // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match - iterator.HasMoreResults.Should().BeTrue( - "real Cosmos DB always returns at least one page, even when using string query overload"); - - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); - } - - [Fact] - public async Task QueryIterator_EmptyResult_SecondReadShouldHaveNoMoreResults() - { - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - - // First page should be available - iterator.HasMoreResults.Should().BeTrue(); - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0); - - // After reading the empty page, no more results - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task QueryIterator_WithItems_ThenEmptyQuery_ShouldReturnOnePage() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), - new PartitionKey("pk1")); - - // Query that matches nothing - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'ZZZ'")); - - iterator.HasMoreResults.Should().BeTrue(); - var page = await iterator.ReadNextAsync(); - page.Count.Should().Be(0); - iterator.HasMoreResults.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task QueryIterator_EmptyContainer_ShouldReturnAtLeastOnePage() + { + // Query an empty container - no items have been inserted + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + + // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match + iterator.HasMoreResults.Should().BeTrue( + "real Cosmos DB always returns at least one page, even when empty"); + + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0, "the page should be empty since no documents exist"); + } + + [Fact] + public async Task QueryIterator_NonexistentPartition_ShouldReturnAtLeastOnePage() + { + // Insert an item in one partition + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // Query a different, nonexistent partition + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'nonexistent'")); + + // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match + iterator.HasMoreResults.Should().BeTrue( + "real Cosmos DB always returns at least one page, even when no documents match"); + + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); + } + + [Fact] + public async Task QueryIterator_WithQueryRequestOptions_EmptyResult_ShouldReturnAtLeastOnePage() + { + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'nonexistent'"), + requestOptions: new QueryRequestOptions { MaxItemCount = 10 }); + + // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match + iterator.HasMoreResults.Should().BeTrue( + "real Cosmos DB always returns at least one page, even when empty with MaxItemCount set"); + + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); + } + + [Fact] + public async Task QueryIterator_StringQuery_EmptyResult_ShouldReturnAtLeastOnePage() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.partitionKey = 'nonexistent'"); + + // Real Cosmos DB: HasMoreResults is true on the first check, even if no documents match + iterator.HasMoreResults.Should().BeTrue( + "real Cosmos DB always returns at least one page, even when using string query overload"); + + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0, "the page should be empty since no documents match the filter"); + } + + [Fact] + public async Task QueryIterator_EmptyResult_SecondReadShouldHaveNoMoreResults() + { + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + + // First page should be available + iterator.HasMoreResults.Should().BeTrue(); + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0); + + // After reading the empty page, no more results + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task QueryIterator_WithItems_ThenEmptyQuery_ShouldReturnOnePage() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), + new PartitionKey("pk1")); + + // Query that matches nothing + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'ZZZ'")); + + iterator.HasMoreResults.Should().BeTrue(); + var page = await iterator.ReadNextAsync(); + page.Count.Should().Be(0); + iterator.HasMoreResults.Should().BeFalse(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EtagExcludedPathsBugReproduction.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EtagExcludedPathsBugReproduction.cs index 83a8121..dd5f219 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EtagExcludedPathsBugReproduction.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/EtagExcludedPathsBugReproduction.cs @@ -13,62 +13,62 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class EtagExcludedPathsBugReproduction { - [Fact] - public async Task ContainerProperties_ShouldAutoAddEtagToExcludedPaths() - { - var client = new InMemoryCosmosClient(); - var database = (await client.CreateDatabaseIfNotExistsAsync("etag-index-db")).Database; + [Fact] + public async Task ContainerProperties_ShouldAutoAddEtagToExcludedPaths() + { + var client = new InMemoryCosmosClient(); + var database = (await client.CreateDatabaseIfNotExistsAsync("etag-index-db")).Database; - var containerProperties = new ContainerProperties("test-container", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - ExcludedPaths = { new ExcludedPath { Path = "/body/*" }, new ExcludedPath { Path = "/metaData/*" } } - } - }; - await database.CreateContainerIfNotExistsAsync(containerProperties); + var containerProperties = new ContainerProperties("test-container", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + ExcludedPaths = { new ExcludedPath { Path = "/body/*" }, new ExcludedPath { Path = "/metaData/*" } } + } + }; + await database.CreateContainerIfNotExistsAsync(containerProperties); - var container = database.GetContainer("test-container"); - var response = await container.ReadContainerAsync(); - var excludedPaths = response.Resource.IndexingPolicy.ExcludedPaths.Select(p => p.Path).ToList(); + var container = database.GetContainer("test-container"); + var response = await container.ReadContainerAsync(); + var excludedPaths = response.Resource.IndexingPolicy.ExcludedPaths.Select(p => p.Path).ToList(); - // Real Cosmos auto-adds /_etag/? to excluded paths - excludedPaths.Should().Contain("/\"_etag\"/?", - "Cosmos DB auto-adds /_etag/? to ExcludedPaths when a custom IndexingPolicy is provided"); - } + // Real Cosmos auto-adds /_etag/? to excluded paths + excludedPaths.Should().Contain("/\"_etag\"/?", + "Cosmos DB auto-adds /_etag/? to ExcludedPaths when a custom IndexingPolicy is provided"); + } - [Fact] - public void DefaultIndexingPolicy_ShouldHaveEtagExcluded() - { - var container = new InMemoryContainer("default-test", "/partitionKey"); + [Fact] + public void DefaultIndexingPolicy_ShouldHaveEtagExcluded() + { + var container = new InMemoryContainer("default-test", "/partitionKey"); - var excludedPaths = container.IndexingPolicy.ExcludedPaths.Select(p => p.Path).ToList(); + var excludedPaths = container.IndexingPolicy.ExcludedPaths.Select(p => p.Path).ToList(); - excludedPaths.Should().Contain("/\"_etag\"/?", - "Default IndexingPolicy should include /_etag/? in ExcludedPaths like real Cosmos DB"); - } + excludedPaths.Should().Contain("/\"_etag\"/?", + "Default IndexingPolicy should include /_etag/? in ExcludedPaths like real Cosmos DB"); + } - [Fact] - public void CustomIndexingPolicy_ShouldNotDuplicateEtag() - { - var containerProps = new ContainerProperties("no-dup-test", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - ExcludedPaths = - { - new ExcludedPath { Path = "/\"_etag\"/?" }, - new ExcludedPath { Path = "/body/*" } - } - } - }; - var container = new InMemoryContainer(containerProps); + [Fact] + public void CustomIndexingPolicy_ShouldNotDuplicateEtag() + { + var containerProps = new ContainerProperties("no-dup-test", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + ExcludedPaths = + { + new ExcludedPath { Path = "/\"_etag\"/?" }, + new ExcludedPath { Path = "/body/*" } + } + } + }; + var container = new InMemoryContainer(containerProps); - var etagPaths = container.IndexingPolicy.ExcludedPaths - .Where(p => p.Path == "/\"_etag\"/?") - .ToList(); + var etagPaths = container.IndexingPolicy.ExcludedPaths + .Where(p => p.Path == "/\"_etag\"/?") + .ToList(); - etagPaths.Should().ContainSingle( - "/_etag/? should not be duplicated when already present in user-specified ExcludedPaths"); - } + etagPaths.Should().ContainSingle( + "/_etag/? should not be duplicated when already present in user-specified ExcludedPaths"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ExtendedArrayFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ExtendedArrayFunctionTests.cs index b282241..030ba02 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ExtendedArrayFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ExtendedArrayFunctionTests.cs @@ -7,2117 +7,2120 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ExtendedArrayFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["a", "b", "c"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Tags = ["b", "c", "d"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, Tags = ["x", "y", "z"] }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40, Tags = [] }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - private async Task SeedTypeDiverseItems() - { - await SeedItems(); - - // id=5: numeric tags [1, 2, 3] - await _container.CreateItemAsync( - JObject.FromObject(new { id = "5", partitionKey = "pk1", tags = new[] { 1, 2, 3 } }), - new PartitionKey("pk1")); - - // id=6: mixed types [1, "a", true, null] - var mixed = new JObject { ["id"] = "6", ["partitionKey"] = "pk1", ["tags"] = new JArray(1, "a", true, JValue.CreateNull()) }; - await _container.CreateItemAsync(mixed, new PartitionKey("pk1")); - - // id=7: nested array property - await _container.CreateItemAsync( - JObject.FromObject(new { id = "7", partitionKey = "pk1", nested = new { items = new[] { "p", "q" } } }), - new PartitionKey("pk1")); - - // id=8: no tags property at all - await _container.CreateItemAsync( - JObject.FromObject(new { id = "8", partitionKey = "pk1", name = "NoTags" }), - new PartitionKey("pk1")); - - // id=9: tags is a string scalar, not an array - await _container.CreateItemAsync( - JObject.FromObject(new { id = "9", partitionKey = "pk1", tags = "not-an-array" }), - new PartitionKey("pk1")); - - // id=10: tags with duplicates ["a", "a", "b"] - await _container.CreateItemAsync( - JObject.FromObject(new { id = "10", partitionKey = "pk1", tags = new[] { "a", "a", "b" } }), - new PartitionKey("pk1")); - - // id=11: two array properties for both-from-document tests - var twoArrays = new JObject { ["id"] = "11", ["partitionKey"] = "pk1", ["tags"] = new JArray("a", "b"), ["otherTags"] = new JArray("b", "c") }; - await _container.CreateItemAsync(twoArrays, new PartitionKey("pk1")); - - // id=12: object array for OBJ tests - var objArray = new JObject - { - ["id"] = "12", ["partitionKey"] = "pk1", - ["tags"] = new JArray( - new JObject { ["name"] = "x" }, - new JObject { ["name"] = "y" }) - }; - await _container.CreateItemAsync(objArray, new PartitionKey("pk1")); - - // id=13: float array for FL tests - await _container.CreateItemAsync( - JObject.FromObject(new { id = "13", partitionKey = "pk1", tags = new[] { 1.5, 2.5, 3.5 } }), - new PartitionKey("pk1")); - - // id=14: array-of-array for NA tests - var nestedArrays = new JObject - { - ["id"] = "14", ["partitionKey"] = "pk1", - ["tags"] = new JArray(new JArray(1, 2), new JArray(3, 4)) - }; - await _container.CreateItemAsync(nestedArrays, new PartitionKey("pk1")); - - // id=15: bool+string mix for TS tests - var boolStringMix = new JObject - { - ["id"] = "15", ["partitionKey"] = "pk1", - ["tags"] = new JArray(true, "true", false, "false") - }; - await _container.CreateItemAsync(boolStringMix, new PartitionKey("pk1")); - } - - // ── ARRAY_CONTAINS_ANY ────────────────────────────────────────────────── - - [Fact] - public async Task ArrayContainsAny_WithMatchingElement_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a', 'z'])"); - - var results = await QueryAll(query); - - // Item 1 has "a", Item 3 has "z" - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); - } - - [Fact] - public async Task ArrayContainsAny_WithNoMatchingElement_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['q', 'r'])"); - - var results = await QueryAll(query); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayContainsAny_WithEmptySearchArray_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, [])"); - - var results = await QueryAll(query); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayContainsAny_WithEmptySourceArray_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) AND c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayContainsAny_WithNumericElements_ReturnsTrue() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [2, 4]) FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAny_WithMixedTypes_MatchesSameTypeOnly() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — searching for ["a"] should match the string "a" - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAny_NumberDoesNotMatchString_TypeSensitive() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — searching for string "1" should NOT match number 1 - // Real Cosmos DB is type-sensitive: number ≠ string - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['1']) FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task ArrayContainsAny_BoolDoesNotMatchString_TypeSensitive() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — searching for string "true" should NOT match boolean true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['true']) FROM c WHERE c.id = '6'"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["a", "b", "c"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Tags = ["b", "c", "d"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, Tags = ["x", "y", "z"] }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40, Tags = [] }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + private async Task SeedTypeDiverseItems() + { + await SeedItems(); + + // id=5: numeric tags [1, 2, 3] + await _container.CreateItemAsync( + JObject.FromObject(new { id = "5", partitionKey = "pk1", tags = new[] { 1, 2, 3 } }), + new PartitionKey("pk1")); + + // id=6: mixed types [1, "a", true, null] + var mixed = new JObject { ["id"] = "6", ["partitionKey"] = "pk1", ["tags"] = new JArray(1, "a", true, JValue.CreateNull()) }; + await _container.CreateItemAsync(mixed, new PartitionKey("pk1")); + + // id=7: nested array property + await _container.CreateItemAsync( + JObject.FromObject(new { id = "7", partitionKey = "pk1", nested = new { items = new[] { "p", "q" } } }), + new PartitionKey("pk1")); + + // id=8: no tags property at all + await _container.CreateItemAsync( + JObject.FromObject(new { id = "8", partitionKey = "pk1", name = "NoTags" }), + new PartitionKey("pk1")); + + // id=9: tags is a string scalar, not an array + await _container.CreateItemAsync( + JObject.FromObject(new { id = "9", partitionKey = "pk1", tags = "not-an-array" }), + new PartitionKey("pk1")); + + // id=10: tags with duplicates ["a", "a", "b"] + await _container.CreateItemAsync( + JObject.FromObject(new { id = "10", partitionKey = "pk1", tags = new[] { "a", "a", "b" } }), + new PartitionKey("pk1")); + + // id=11: two array properties for both-from-document tests + var twoArrays = new JObject { ["id"] = "11", ["partitionKey"] = "pk1", ["tags"] = new JArray("a", "b"), ["otherTags"] = new JArray("b", "c") }; + await _container.CreateItemAsync(twoArrays, new PartitionKey("pk1")); + + // id=12: object array for OBJ tests + var objArray = new JObject + { + ["id"] = "12", + ["partitionKey"] = "pk1", + ["tags"] = new JArray( + new JObject { ["name"] = "x" }, + new JObject { ["name"] = "y" }) + }; + await _container.CreateItemAsync(objArray, new PartitionKey("pk1")); + + // id=13: float array for FL tests + await _container.CreateItemAsync( + JObject.FromObject(new { id = "13", partitionKey = "pk1", tags = new[] { 1.5, 2.5, 3.5 } }), + new PartitionKey("pk1")); + + // id=14: array-of-array for NA tests + var nestedArrays = new JObject + { + ["id"] = "14", + ["partitionKey"] = "pk1", + ["tags"] = new JArray(new JArray(1, 2), new JArray(3, 4)) + }; + await _container.CreateItemAsync(nestedArrays, new PartitionKey("pk1")); + + // id=15: bool+string mix for TS tests + var boolStringMix = new JObject + { + ["id"] = "15", + ["partitionKey"] = "pk1", + ["tags"] = new JArray(true, "true", false, "false") + }; + await _container.CreateItemAsync(boolStringMix, new PartitionKey("pk1")); + } + + // ── ARRAY_CONTAINS_ANY ────────────────────────────────────────────────── + + [Fact] + public async Task ArrayContainsAny_WithMatchingElement_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a', 'z'])"); + + var results = await QueryAll(query); + + // Item 1 has "a", Item 3 has "z" + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); + } + + [Fact] + public async Task ArrayContainsAny_WithNoMatchingElement_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['q', 'r'])"); + + var results = await QueryAll(query); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayContainsAny_WithEmptySearchArray_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, [])"); + + var results = await QueryAll(query); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayContainsAny_WithEmptySourceArray_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) AND c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayContainsAny_WithNumericElements_ReturnsTrue() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [2, 4]) FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAny_WithMixedTypes_MatchesSameTypeOnly() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — searching for ["a"] should match the string "a" + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAny_NumberDoesNotMatchString_TypeSensitive() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — searching for string "1" should NOT match number 1 + // Real Cosmos DB is type-sensitive: number ≠ string + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['1']) FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task ArrayContainsAny_BoolDoesNotMatchString_TypeSensitive() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — searching for string "true" should NOT match boolean true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['true']) FROM c WHERE c.id = '6'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAny_WithNullElement_MatchesNull() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — searching for [null] should match - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [null]) FROM c WHERE c.id = '6'"); + [Fact] + public async Task ArrayContainsAny_WithNullElement_MatchesNull() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — searching for [null] should match + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [null]) FROM c WHERE c.id = '6'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_NonExistentProperty_ReturnsFalse() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.nonExistent, ['a']) FROM c WHERE c.id = '8'"); + [Fact] + public async Task ArrayContainsAny_NonExistentProperty_ReturnsFalse() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.nonExistent, ['a']) FROM c WHERE c.id = '8'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAny_NonArrayProperty_ReturnsFalse() - { - await SeedTypeDiverseItems(); - // Item 9 has tags = "not-an-array" (string scalar) - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '9'"); + [Fact] + public async Task ArrayContainsAny_NonArrayProperty_ReturnsFalse() + { + await SeedTypeDiverseItems(); + // Item 9 has tags = "not-an-array" (string scalar) + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '9'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAny_InProjection_ReturnsBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_CONTAINS_ANY(c.tags, ['a']) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_InProjection_ReturnsBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_CONTAINS_ANY(c.tags, ['a']) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().BeTrue(); - } + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_WithDuplicatesInSource_StillMatches() - { - await SeedTypeDiverseItems(); - // Item 10 has ["a", "a", "b"] - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '10'"); + [Fact] + public async Task ArrayContainsAny_WithDuplicatesInSource_StillMatches() + { + await SeedTypeDiverseItems(); + // Item 10 has ["a", "a", "b"] + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '10'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_WithDuplicatesInSearch_StillMatches() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a', 'a']) FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_WithDuplicatesInSearch_StillMatches() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a', 'a']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_BothArraysFromDocument_Works() - { - await SeedTypeDiverseItems(); - // Item 11 has tags=["a","b"] and otherTags=["b","c"] — overlap is "b" - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); + [Fact] + public async Task ArrayContainsAny_BothArraysFromDocument_Works() + { + await SeedTypeDiverseItems(); + // Item 11 has tags=["a","b"] and otherTags=["b","c"] — overlap is "b" + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_NestedPropertyPath_Works() - { - await SeedTypeDiverseItems(); - // Item 7 has nested.items = ["p", "q"] - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.nested.items, ['p']) FROM c WHERE c.id = '7'"); + [Fact] + public async Task ArrayContainsAny_NestedPropertyPath_Works() + { + await SeedTypeDiverseItems(); + // Item 7 has nested.items = ["p", "q"] + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.nested.items, ['p']) FROM c WHERE c.id = '7'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_SingleElementMatch_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_SingleElementMatch_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_VariadicForm_Works() - { - await SeedItems(); - // Real Cosmos DB syntax: ARRAY_CONTAINS_ANY(array, val1, val2, ...) - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'a', 'z') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_VariadicForm_Works() + { + await SeedItems(); + // Real Cosmos DB syntax: ARRAY_CONTAINS_ANY(array, val1, val2, ...) + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'a', 'z') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_VariadicForm_SingleArg_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'a') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_VariadicForm_SingleArg_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'a') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAny_VariadicForm_NoMatch_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'q', 'r') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAny_VariadicForm_NoMatch_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, 'q', 'r') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - // ── ARRAY_CONTAINS_ALL ────────────────────────────────────────────────── + // ── ARRAY_CONTAINS_ALL ────────────────────────────────────────────────── - [Fact] - public async Task ArrayContainsAll_WhenAllPresent_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b'])"); + [Fact] + public async Task ArrayContainsAll_WhenAllPresent_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b'])"); - var results = await QueryAll(query); + var results = await QueryAll(query); - // Only Item 1 has both "a" and "b" - results.Should().HaveCount(1); - results[0].Id.Should().Be("1"); - } + // Only Item 1 has both "a" and "b" + results.Should().HaveCount(1); + results[0].Id.Should().Be("1"); + } - [Fact] - public async Task ArrayContainsAll_WhenSomeMissing_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a', 'd'])"); + [Fact] + public async Task ArrayContainsAll_WhenSomeMissing_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a', 'd'])"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - [Fact] - public async Task ArrayContainsAll_WithEmptySearchArray_ReturnsAll() - { - await SeedItems(); - // In Cosmos DB, ARRAY_CONTAINS_ALL with an empty search array returns true for all items - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, [])"); + [Fact] + public async Task ArrayContainsAll_WithEmptySearchArray_ReturnsAll() + { + await SeedItems(); + // In Cosmos DB, ARRAY_CONTAINS_ALL with an empty search array returns true for all items + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, [])"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(4); - } + results.Should().HaveCount(4); + } - [Fact] - public async Task ArrayContainsAll_SourceEqualsSearch_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c']) FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_SourceEqualsSearch_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_SourceIsSupersetOfSearch_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b']) FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_SourceIsSupersetOfSearch_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_EmptySourceWithNonEmptySearch_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '4'"); + [Fact] + public async Task ArrayContainsAll_EmptySourceWithNonEmptySearch_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '4'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAll_WithNumericElements_ReturnsTrue() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [1, 3]) FROM c WHERE c.id = '5'"); + [Fact] + public async Task ArrayContainsAll_WithNumericElements_ReturnsTrue() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [1, 3]) FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_NumberDoesNotMatchString_TypeSensitive() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — searching for strings "1","2" should NOT match - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['1', '2']) FROM c WHERE c.id = '5'"); + [Fact] + public async Task ArrayContainsAll_NumberDoesNotMatchString_TypeSensitive() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — searching for strings "1","2" should NOT match + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['1', '2']) FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAll_WithNullElement_MatchesNull() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — searching for [null] should match - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [null]) FROM c WHERE c.id = '6'"); + [Fact] + public async Task ArrayContainsAll_WithNullElement_MatchesNull() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — searching for [null] should match + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [null]) FROM c WHERE c.id = '6'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_NonExistentProperty_ReturnsFalse() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.nonExistent, ['a']) FROM c WHERE c.id = '8'"); + [Fact] + public async Task ArrayContainsAll_NonExistentProperty_ReturnsFalse() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.nonExistent, ['a']) FROM c WHERE c.id = '8'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAll_NonArrayProperty_ReturnsFalse() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '9'"); + [Fact] + public async Task ArrayContainsAll_NonArrayProperty_ReturnsFalse() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '9'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAll_DuplicatesInSearch_StillWorks() - { - await SeedItems(); - // Source has "a", search has "a" twice — should still be true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'a']) FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_DuplicatesInSearch_StillWorks() + { + await SeedItems(); + // Source has "a", search has "a" twice — should still be true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'a']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_InProjection_ReturnsBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_CONTAINS_ALL(c.tags, ['a']) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_InProjection_ReturnsBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_CONTAINS_ALL(c.tags, ['a']) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().BeTrue(); - } + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_BothEmptyArrays_ReturnsTrue() - { - await SeedItems(); - // Empty search against empty source → vacuously true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, []) FROM c WHERE c.id = '4'"); + [Fact] + public async Task ArrayContainsAll_BothEmptyArrays_ReturnsTrue() + { + await SeedItems(); + // Empty search against empty source → vacuously true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, []) FROM c WHERE c.id = '4'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_VariadicForm_Works() - { - await SeedItems(); - // Real Cosmos DB syntax: ARRAY_CONTAINS_ALL(array, val1, val2, ...) - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, 'a', 'b', 'c') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_VariadicForm_Works() + { + await SeedItems(); + // Real Cosmos DB syntax: ARRAY_CONTAINS_ALL(array, val1, val2, ...) + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, 'a', 'b', 'c') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task ArrayContainsAll_VariadicForm_SingleMissing_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, 'a', 'q') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayContainsAll_VariadicForm_SingleMissing_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, 'a', 'q') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeFalse(); - } + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task ArrayContainsAll_NestedPropertyPath_Works() - { - await SeedTypeDiverseItems(); - // Item 7 has nested.items = ["p", "q"] - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.nested.items, ['p', 'q']) FROM c WHERE c.id = '7'"); + [Fact] + public async Task ArrayContainsAll_NestedPropertyPath_Works() + { + await SeedTypeDiverseItems(); + // Item 7 has nested.items = ["p", "q"] + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.nested.items, ['p', 'q']) FROM c WHERE c.id = '7'"); - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── SetIntersect ──────────────────────────────────────────────────────── + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── SetIntersect ──────────────────────────────────────────────────────── - [Fact] - public async Task SetIntersect_ReturnsCommonElements() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['b', 'c', 'z']) AS common FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var common = (JArray)results[0]["common"]!; - common.Select(t => t.ToString()).Should().BeEquivalentTo(["b", "c"]); - } - - [Fact] - public async Task SetIntersect_WithNoOverlap_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['q', 'r']) AS common FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var common = (JArray)results[0]["common"]!; - common.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_WithEmptySourceArray_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS common FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var common = (JArray)results[0]["common"]!; - common.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_BothEmpty_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, []) AS common FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_EmptySecondArray_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, []) AS common FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_CompleteOverlap_ReturnsSameElements() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, c.tags) AS common FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - [Fact] - public async Task SetIntersect_DuplicatesInInput_DedupedInResult() - { - await SeedTypeDiverseItems(); - // Item 10 has ["a", "a", "b"] — intersect with ["a","b"] should give ["a","b"] (deduped) - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a', 'b']) AS common FROM c WHERE c.id = '10'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Select(t => t.ToString()).Should().Equal("a", "b"); - } - - [Fact] - public async Task SetIntersect_WithNumericElements_Works() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — intersect with [2, 4] → [2] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [2, 4]) AS common FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().ContainSingle(); - common[0].Value().Should().Be(2); - } - - [Fact] - public async Task SetIntersect_TypeSensitive_NumberDoesNotMatchString() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — intersect with ["1","2"] → [] (number ≠ string) - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['1', '2']) AS common FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_WithNullElements_Works() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — intersect with [null, "a"] → [null is tricky, "a" should match] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [null, 'a']) AS common FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().HaveCount(2); - } - - [Fact] - public async Task SetIntersect_NestedInArrayLength_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetIntersect(c.tags, ['a', 'b', 'x'])) AS count FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["count"]!.Value().Should().Be(2); - } - - [Fact] - public async Task SetIntersect_BothFromDocument_Works() - { - await SeedTypeDiverseItems(); - // Item 11 has tags=["a","b"] and otherTags=["b","c"] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, c.otherTags) AS common FROM c WHERE c.id = '11'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Select(t => t.ToString()).Should().Equal("b"); - } - - [Fact] - public async Task SetIntersect_WithMixedTypes_OnlyMatchesSameType() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — intersect with [1, "b", true] → [1, true] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [1, 'b', true]) AS common FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().HaveCount(2); - } - - // ── SetUnion ──────────────────────────────────────────────────────────── - - [Fact] - public async Task SetUnion_ReturnsCombinedDistinctElements() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['b', 'd', 'e']) AS combined FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c", "d", "e"]); - } - - [Fact] - public async Task SetUnion_WithEmptySourceArray_ReturnsSecondArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a', 'b']) AS combined FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b"]); - } - - [Fact] - public async Task SetUnion_WithIdenticalArrays_ReturnsOriginal() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, c.tags) AS combined FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - [Fact] - public async Task SetUnion_BothEmpty_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, []) AS combined FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Should().BeEmpty(); - } - - [Fact] - public async Task SetUnion_EmptySecondArray_ReturnsFirstArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, []) AS combined FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - [Fact] - public async Task SetUnion_DuplicatesWithinSingleArray_Deduped() - { - await SeedTypeDiverseItems(); - // Item 10 has ["a", "a", "b"] — union with ["c"] → ["a", "b", "c"] - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['c']) AS combined FROM c WHERE c.id = '10'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().Equal("a", "b", "c"); - } - - [Fact] - public async Task SetUnion_WithNumericElements_Works() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — union with [3, 4] → [1, 2, 3, 4] - var query = new QueryDefinition("SELECT SetUnion(c.tags, [3, 4]) AS combined FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.Value()).Should().Equal(1, 2, 3, 4); - } - - [Fact] - public async Task SetUnion_TypeSensitive_NumberAndStringBothKept() - { - await SeedTypeDiverseItems(); - // Item 5 has [1, 2, 3] — union with ["1","2"] → [1, 2, 3, "1", "2"] (type-sensitive, no dedup) - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['1', '2']) AS combined FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Should().HaveCount(5); - } - - [Fact] - public async Task SetUnion_OrderPreserved_FirstThenSecond() - { - await SeedItems(); - // Item 1 has ["a","b","c"] — union with ["d","e"] → order should be a,b,c,d,e - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['d', 'e']) AS combined FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().Equal("a", "b", "c", "d", "e"); - } - - [Fact] - public async Task SetUnion_WithNullElements_Works() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — union with ["b", null] → null deduped - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['b', null]) AS combined FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - // Should have [1, "a", true, null, "b"] — 5 elements (null deduped) - combined.Should().HaveCount(5); - } - - [Fact] - public async Task SetUnion_NestedInArrayLength_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetUnion(c.tags, ['x', 'y'])) AS count FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["count"]!.Value().Should().Be(5); - } - - [Fact] - public async Task SetUnion_BothFromDocument_Works() - { - await SeedTypeDiverseItems(); - // Item 11 has tags=["a","b"] and otherTags=["b","c"] - var query = new QueryDefinition("SELECT SetUnion(c.tags, c.otherTags) AS combined FROM c WHERE c.id = '11'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Select(t => t.ToString()).Should().Equal("a", "b", "c"); - } - - [Fact] - public async Task SetUnion_WithMixedTypes_AllKept() - { - await SeedTypeDiverseItems(); - // Item 6 has [1, "a", true, null] — union with [2, "b", false] → all 7 kept - var query = new QueryDefinition("SELECT SetUnion(c.tags, [2, 'b', false]) AS combined FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Should().HaveCount(7); - } - - // ── VALUE expressions with array functions ────────────────────────────── - - [Fact] - public async Task ArrayContainsAny_AsValueExpression_ReturnsBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAll_AsValueExpression_ReturnsBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c']) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── Cross-cutting / Integration ───────────────────────────────────────── - - [Fact] - public async Task ExtendedArrayFunctions_CombinedInWhereClause_Works() - { - await SeedItems(); - // Item 1 has ["a","b","c"] — matches both conditions - var query = new QueryDefinition( - "SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) AND ARRAY_CONTAINS_ALL(c.tags, ['a', 'b'])"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Id.Should().Be("1"); - } - - [Fact] - public async Task SetIntersect_ResultUsedInWhere_WithArrayLength() - { - await SeedItems(); - // Items with at least 1 overlap with ["a","b"] — item 1 has a,b; item 2 has b - var query = new QueryDefinition( - "SELECT * FROM c WHERE ARRAY_LENGTH(SetIntersect(c.tags, ['a', 'b'])) > 0"); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); - } - - // ── Divergent behavior tests ──────────────────────────────────────────── - - // DIVERGENT: SetIntersect returns empty array [] instead of undefined when - // the source property doesn't exist. Real Cosmos DB would return undefined - // (omit the property from the result). - [Fact] - public async Task SetIntersect_NonExistentProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - // Expected real Cosmos behavior: property "common" would not appear in result - var query = new QueryDefinition("SELECT SetIntersect(c.nonExistent, ['a']) AS common FROM c WHERE c.id = '8'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["common"].Should().BeNull(); // property should be absent - } - - // Sister test: emulator now matches real Cosmos behavior - [Fact] - public async Task SetIntersect_NonExistentProperty_EmulatorAlsoReturnsUndefined() - { - // The emulator now correctly returns undefined (property absent) when input - // array property doesn't exist, matching real Cosmos DB behavior. - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.nonExistent, ['a']) AS common FROM c WHERE c.id = '8'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["common"].Should().BeNull(); // property absent - } - - [Fact] - public async Task SetUnion_NonExistentProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetUnion(c.nonExistent, ['a']) AS combined FROM c WHERE c.id = '8'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["combined"].Should().BeNull(); - } - - // Sister test: emulator now matches real Cosmos behavior - [Fact] - public async Task SetUnion_NonExistentProperty_EmulatorAlsoReturnsUndefined() - { - // The emulator now correctly returns undefined (property absent) when input - // array property doesn't exist, matching real Cosmos DB behavior. - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetUnion(c.nonExistent, ['a']) AS combined FROM c WHERE c.id = '8'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["combined"].Should().BeNull(); // property absent - } - - // ── SetDifference ─────────────────────────────────────────────────────── - - [Fact] - public async Task SetDifference_ReturnsElementsInFirstNotInSecond() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['b', 'c', 'z']) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a"); - } - - [Fact] - public async Task SetDifference_WithNoOverlap_ReturnsFirstArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['q', 'r']) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a", "b", "c"); - } - - [Fact] - public async Task SetDifference_WithCompleteOverlap_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a', 'b', 'c']) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().BeEmpty(); - } - - [Fact] - public async Task SetDifference_WithEmptyFirstArray_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().BeEmpty(); - } - - [Fact] - public async Task SetDifference_WithEmptySecondArray_ReturnsFirstArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, []) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a", "b", "c"); - } - - [Fact] - public async Task SetDifference_BothEmpty_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, []) AS diff FROM c WHERE c.id = '4'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().BeEmpty(); - } - - [Fact] - public async Task SetDifference_DuplicatesInSource_DedupedInResult() - { - await SeedTypeDiverseItems(); - // Item 10 has ["a","a","b"] — minus ["b"] → ["a"] (deduped) - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['b']) AS diff FROM c WHERE c.id = '10'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a"); - } - - [Fact] - public async Task SetDifference_WithNumericElements_Works() - { - await SeedTypeDiverseItems(); - // Item 5 has [1,2,3] — minus [2,4] → [1,3] - var query = new QueryDefinition("SELECT SetDifference(c.tags, [2, 4]) AS diff FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.Value()).Should().Equal(1, 3); - } - - [Fact] - public async Task SetDifference_TypeSensitive_NumberDoesNotMatchString() - { - await SeedTypeDiverseItems(); - // Item 5 has [1,2,3] — minus ["1","2"] → [1,2,3] (number ≠ string) - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['1', '2']) AS diff FROM c WHERE c.id = '5'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.Value()).Should().Equal(1, 2, 3); - } - - [Fact] - public async Task SetDifference_WithNullElements_Works() - { - await SeedTypeDiverseItems(); - // Item 6 has [1,"a",true,null] — minus [null,"a"] → [1, true] - var query = new QueryDefinition("SELECT SetDifference(c.tags, [null, 'a']) AS diff FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().HaveCount(2); - diff[0].Value().Should().Be(1); - diff[1].Value().Should().BeTrue(); - } - - [Fact] - public async Task SetDifference_WithMixedTypes_OnlyRemovesSameType() - { - await SeedTypeDiverseItems(); - // Item 6 has [1,"a",true,null] — minus [1,"b",false] → ["a",true,null] - var query = new QueryDefinition("SELECT SetDifference(c.tags, [1, 'b', false]) AS diff FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().HaveCount(3); - } - - [Fact] - public async Task SetDifference_IsNotCommutative() - { - await SeedTypeDiverseItems(); - // Item 11 has tags=["a","b"], otherTags=["b","c"] - var query1 = new QueryDefinition("SELECT SetDifference(c.tags, c.otherTags) AS diff FROM c WHERE c.id = '11'"); - var query2 = new QueryDefinition("SELECT SetDifference(c.otherTags, c.tags) AS diff FROM c WHERE c.id = '11'"); - - var results1 = await QueryAll(query1); - var results2 = await QueryAll(query2); - - ((JArray)results1[0]["diff"]!).Select(t => t.ToString()).Should().Equal("a"); - ((JArray)results2[0]["diff"]!).Select(t => t.ToString()).Should().Equal("c"); - } - - [Fact] - public async Task SetDifference_IdenticalArrays_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, c.tags) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().BeEmpty(); - } - - [Fact] - public async Task SetDifference_NestedInArrayLength_Works() - { - await SeedItems(); - // a,b,c minus b,c = a → length 1 - var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetDifference(c.tags, ['b', 'c'])) AS count FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["count"]!.Value().Should().Be(1); - } - - [Fact] - public async Task SetDifference_BothFromDocument_Works() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"] minus otherTags=["b","c"] = ["a"] - var query = new QueryDefinition("SELECT SetDifference(c.tags, c.otherTags) AS diff FROM c WHERE c.id = '11'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a"); - } - - [Fact] - public async Task SetDifference_OrderPreserved_FromFirstArray() - { - await SeedItems(); - // Item 3: tags=["x","y","z"] minus ["y"] → ["x","z"] (order preserved) - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['y']) AS diff FROM c WHERE c.id = '3'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("x", "z"); - } - - [Fact] - public async Task SetDifference_NestedPropertyPath_Works() - { - await SeedTypeDiverseItems(); - // Item 7: nested.items=["p","q"] minus ["q"] = ["p"] - var query = new QueryDefinition("SELECT SetDifference(c.nested.items, ['q']) AS diff FROM c WHERE c.id = '7'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("p"); - } - - [Fact] - public async Task SetDifference_NonExistentProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetDifference(c.nonExistent, ['a']) AS diff FROM c WHERE c.id = '8'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["diff"].Should().BeNull(); // property absent = undefined - } - - [Fact] - public async Task SetDifference_InWhereClause_WithArrayLength() - { - await SeedItems(); - // Items with diff length > 0 after removing ["b","c","d"] - // Item 1: ["a","b","c"] minus ["b","c","d"] = ["a"] (length 1 > 0) ✓ - // Item 2: ["b","c","d"] minus ["b","c","d"] = [] (length 0) ✗ - // Item 3: ["x","y","z"] minus ["b","c","d"] = ["x","y","z"] (length 3 > 0) ✓ - // Item 4: [] minus ["b","c","d"] = [] (length 0) ✗ - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_LENGTH(SetDifference(c.tags, ['b', 'c', 'd'])) > 0"); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); - } - - // ── Parameterized Queries ─────────────────────────────────────────────── - - [Fact] - public async Task ArrayContainsAny_WithParameterizedSearchValues_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, @searchValues)") - .WithParameter("@searchValues", new JArray("a", "z")); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); - } - - [Fact] - public async Task ArrayContainsAll_WithParameterizedSearchValues_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, @searchValues)") - .WithParameter("@searchValues", new JArray("a", "b")); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Id.Should().Be("1"); - } - - [Fact] - public async Task SetIntersect_WithParameterizedArray_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetIntersect(c.tags, @otherArray) AS common FROM c WHERE c.id = '1'") - .WithParameter("@otherArray", new JArray("b", "c", "z")); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Select(t => t.ToString()).Should().BeEquivalentTo(["b", "c"]); - } - - [Fact] - public async Task SetDifference_WithParameterizedArray_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, @otherArray) AS diff FROM c WHERE c.id = '1'") - .WithParameter("@otherArray", new JArray("b", "c", "z")); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.ToString()).Should().Equal("a"); - } - - // ── Object Elements in Arrays ─────────────────────────────────────────── - - [Fact] - public async Task SetIntersect_WithObjectElements_MatchesDeepEqual() - { - await SeedTypeDiverseItems(); - // Item 12: tags=[{name:"x"},{name:"y"}] — intersect with [{name:"x"}] → [{name:"x"}] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [{\"name\":\"x\"}]) AS common FROM c WHERE c.id = '12'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().ContainSingle(); - common[0]["name"]!.Value().Should().Be("x"); - } - - [Fact] - public async Task SetUnion_WithObjectElements_DeduplicatesDeepEqual() - { - await SeedTypeDiverseItems(); - // Item 12: tags=[{name:"x"},{name:"y"}] — union with [{name:"x"},{name:"z"}] - // → [{name:"x"},{name:"y"},{name:"z"}] (dedup {name:"x"}) - var query = new QueryDefinition("SELECT SetUnion(c.tags, [{\"name\":\"x\"},{\"name\":\"z\"}]) AS combined FROM c WHERE c.id = '12'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var combined = (JArray)results[0]["combined"]!; - combined.Should().HaveCount(3); - } - - [Fact] - public async Task ArrayContainsAny_WithObjectElementInVariadicForm_Works() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, {\"name\":\"x\"}) FROM c WHERE c.id = '12'"); - var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAny_WithObjectElement_EmulatorArrayForm() - { - await SeedTypeDiverseItems(); - // Using array form with object element - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [{\"name\":\"x\"}]) FROM c WHERE c.id = '12'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task SetDifference_WithObjectElements_Works() - { - await SeedTypeDiverseItems(); - // Item 12: tags=[{name:"x"},{name:"y"}] — minus [{name:"x"}] → [{name:"y"}] - var query = new QueryDefinition("SELECT SetDifference(c.tags, [{\"name\":\"x\"}]) AS diff FROM c WHERE c.id = '12'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().ContainSingle(); - diff[0]["name"]!.Value().Should().Be("y"); - } - - // ── NOT Operator Negation ─────────────────────────────────────────────── - - [Fact] - public async Task ArrayContainsAny_WithNotOperator_ReturnsInverse() - { - await SeedItems(); - // Items 1 has "a" → excluded. Items 2,3,4 → returned. - var query = new QueryDefinition("SELECT * FROM c WHERE NOT ARRAY_CONTAINS_ANY(c.tags, ['a'])"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Select(r => r.Id).Should().BeEquivalentTo(["2", "3", "4"]); - } - - [Fact] - public async Task ArrayContainsAll_WithNotOperator_ReturnsInverse() - { - await SeedItems(); - // Only item 1 has all of ["a","b","c"] → excluded. Items 2,3,4 → returned. - var query = new QueryDefinition("SELECT * FROM c WHERE NOT ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c'])"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Select(r => r.Id).Should().BeEquivalentTo(["2", "3", "4"]); - } - - // ── Literal Arrays in SQL ─────────────────────────────────────────────── - - [Fact] - public async Task SetIntersect_WithLiteralArrays_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetIntersect([1, 2, 3, 4], [3, 4, 5, 6]) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().Equal(3, 4); - } - - [Fact] - public async Task SetUnion_WithLiteralArrays_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetUnion([1, 2, 3, 4], [3, 4, 5, 6]) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().Equal(1, 2, 3, 4, 5, 6); - } - - [Fact] - public async Task SetDifference_WithLiteralArrays_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetDifference([1, 2, 3], [1, 2, 6, 7]) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().Equal(3); - } - - [Fact] - public async Task ArrayContainsAny_WithLiteralFirstArray_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY([1, true, '3', [1,2,3]], 1, true) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } + [Fact] + public async Task SetIntersect_ReturnsCommonElements() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['b', 'c', 'z']) AS common FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var common = (JArray)results[0]["common"]!; + common.Select(t => t.ToString()).Should().BeEquivalentTo(["b", "c"]); + } + + [Fact] + public async Task SetIntersect_WithNoOverlap_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['q', 'r']) AS common FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var common = (JArray)results[0]["common"]!; + common.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_WithEmptySourceArray_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS common FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var common = (JArray)results[0]["common"]!; + common.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_BothEmpty_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, []) AS common FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_EmptySecondArray_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, []) AS common FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_CompleteOverlap_ReturnsSameElements() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, c.tags) AS common FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + [Fact] + public async Task SetIntersect_DuplicatesInInput_DedupedInResult() + { + await SeedTypeDiverseItems(); + // Item 10 has ["a", "a", "b"] — intersect with ["a","b"] should give ["a","b"] (deduped) + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a', 'b']) AS common FROM c WHERE c.id = '10'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Select(t => t.ToString()).Should().Equal("a", "b"); + } + + [Fact] + public async Task SetIntersect_WithNumericElements_Works() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — intersect with [2, 4] → [2] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [2, 4]) AS common FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().ContainSingle(); + common[0].Value().Should().Be(2); + } + + [Fact] + public async Task SetIntersect_TypeSensitive_NumberDoesNotMatchString() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — intersect with ["1","2"] → [] (number ≠ string) + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['1', '2']) AS common FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_WithNullElements_Works() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — intersect with [null, "a"] → [null is tricky, "a" should match] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [null, 'a']) AS common FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().HaveCount(2); + } + + [Fact] + public async Task SetIntersect_NestedInArrayLength_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetIntersect(c.tags, ['a', 'b', 'x'])) AS count FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["count"]!.Value().Should().Be(2); + } + + [Fact] + public async Task SetIntersect_BothFromDocument_Works() + { + await SeedTypeDiverseItems(); + // Item 11 has tags=["a","b"] and otherTags=["b","c"] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, c.otherTags) AS common FROM c WHERE c.id = '11'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Select(t => t.ToString()).Should().Equal("b"); + } + + [Fact] + public async Task SetIntersect_WithMixedTypes_OnlyMatchesSameType() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — intersect with [1, "b", true] → [1, true] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [1, 'b', true]) AS common FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().HaveCount(2); + } + + // ── SetUnion ──────────────────────────────────────────────────────────── + + [Fact] + public async Task SetUnion_ReturnsCombinedDistinctElements() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['b', 'd', 'e']) AS combined FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c", "d", "e"]); + } + + [Fact] + public async Task SetUnion_WithEmptySourceArray_ReturnsSecondArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a', 'b']) AS combined FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b"]); + } + + [Fact] + public async Task SetUnion_WithIdenticalArrays_ReturnsOriginal() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, c.tags) AS combined FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + [Fact] + public async Task SetUnion_BothEmpty_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, []) AS combined FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Should().BeEmpty(); + } + + [Fact] + public async Task SetUnion_EmptySecondArray_ReturnsFirstArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, []) AS combined FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + [Fact] + public async Task SetUnion_DuplicatesWithinSingleArray_Deduped() + { + await SeedTypeDiverseItems(); + // Item 10 has ["a", "a", "b"] — union with ["c"] → ["a", "b", "c"] + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['c']) AS combined FROM c WHERE c.id = '10'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().Equal("a", "b", "c"); + } + + [Fact] + public async Task SetUnion_WithNumericElements_Works() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — union with [3, 4] → [1, 2, 3, 4] + var query = new QueryDefinition("SELECT SetUnion(c.tags, [3, 4]) AS combined FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.Value()).Should().Equal(1, 2, 3, 4); + } + + [Fact] + public async Task SetUnion_TypeSensitive_NumberAndStringBothKept() + { + await SeedTypeDiverseItems(); + // Item 5 has [1, 2, 3] — union with ["1","2"] → [1, 2, 3, "1", "2"] (type-sensitive, no dedup) + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['1', '2']) AS combined FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Should().HaveCount(5); + } + + [Fact] + public async Task SetUnion_OrderPreserved_FirstThenSecond() + { + await SeedItems(); + // Item 1 has ["a","b","c"] — union with ["d","e"] → order should be a,b,c,d,e + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['d', 'e']) AS combined FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().Equal("a", "b", "c", "d", "e"); + } + + [Fact] + public async Task SetUnion_WithNullElements_Works() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — union with ["b", null] → null deduped + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['b', null]) AS combined FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + // Should have [1, "a", true, null, "b"] — 5 elements (null deduped) + combined.Should().HaveCount(5); + } + + [Fact] + public async Task SetUnion_NestedInArrayLength_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetUnion(c.tags, ['x', 'y'])) AS count FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["count"]!.Value().Should().Be(5); + } + + [Fact] + public async Task SetUnion_BothFromDocument_Works() + { + await SeedTypeDiverseItems(); + // Item 11 has tags=["a","b"] and otherTags=["b","c"] + var query = new QueryDefinition("SELECT SetUnion(c.tags, c.otherTags) AS combined FROM c WHERE c.id = '11'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Select(t => t.ToString()).Should().Equal("a", "b", "c"); + } + + [Fact] + public async Task SetUnion_WithMixedTypes_AllKept() + { + await SeedTypeDiverseItems(); + // Item 6 has [1, "a", true, null] — union with [2, "b", false] → all 7 kept + var query = new QueryDefinition("SELECT SetUnion(c.tags, [2, 'b', false]) AS combined FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Should().HaveCount(7); + } + + // ── VALUE expressions with array functions ────────────────────────────── + + [Fact] + public async Task ArrayContainsAny_AsValueExpression_ReturnsBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAll_AsValueExpression_ReturnsBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c']) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── Cross-cutting / Integration ───────────────────────────────────────── + + [Fact] + public async Task ExtendedArrayFunctions_CombinedInWhereClause_Works() + { + await SeedItems(); + // Item 1 has ["a","b","c"] — matches both conditions + var query = new QueryDefinition( + "SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) AND ARRAY_CONTAINS_ALL(c.tags, ['a', 'b'])"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Id.Should().Be("1"); + } + + [Fact] + public async Task SetIntersect_ResultUsedInWhere_WithArrayLength() + { + await SeedItems(); + // Items with at least 1 overlap with ["a","b"] — item 1 has a,b; item 2 has b + var query = new QueryDefinition( + "SELECT * FROM c WHERE ARRAY_LENGTH(SetIntersect(c.tags, ['a', 'b'])) > 0"); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); + } + + // ── Divergent behavior tests ──────────────────────────────────────────── + + // DIVERGENT: SetIntersect returns empty array [] instead of undefined when + // the source property doesn't exist. Real Cosmos DB would return undefined + // (omit the property from the result). + [Fact] + public async Task SetIntersect_NonExistentProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + // Expected real Cosmos behavior: property "common" would not appear in result + var query = new QueryDefinition("SELECT SetIntersect(c.nonExistent, ['a']) AS common FROM c WHERE c.id = '8'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["common"].Should().BeNull(); // property should be absent + } + + // Sister test: emulator now matches real Cosmos behavior + [Fact] + public async Task SetIntersect_NonExistentProperty_EmulatorAlsoReturnsUndefined() + { + // The emulator now correctly returns undefined (property absent) when input + // array property doesn't exist, matching real Cosmos DB behavior. + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.nonExistent, ['a']) AS common FROM c WHERE c.id = '8'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["common"].Should().BeNull(); // property absent + } + + [Fact] + public async Task SetUnion_NonExistentProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetUnion(c.nonExistent, ['a']) AS combined FROM c WHERE c.id = '8'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["combined"].Should().BeNull(); + } + + // Sister test: emulator now matches real Cosmos behavior + [Fact] + public async Task SetUnion_NonExistentProperty_EmulatorAlsoReturnsUndefined() + { + // The emulator now correctly returns undefined (property absent) when input + // array property doesn't exist, matching real Cosmos DB behavior. + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetUnion(c.nonExistent, ['a']) AS combined FROM c WHERE c.id = '8'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["combined"].Should().BeNull(); // property absent + } + + // ── SetDifference ─────────────────────────────────────────────────────── + + [Fact] + public async Task SetDifference_ReturnsElementsInFirstNotInSecond() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['b', 'c', 'z']) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a"); + } + + [Fact] + public async Task SetDifference_WithNoOverlap_ReturnsFirstArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['q', 'r']) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a", "b", "c"); + } + + [Fact] + public async Task SetDifference_WithCompleteOverlap_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a', 'b', 'c']) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().BeEmpty(); + } + + [Fact] + public async Task SetDifference_WithEmptyFirstArray_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().BeEmpty(); + } + + [Fact] + public async Task SetDifference_WithEmptySecondArray_ReturnsFirstArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, []) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a", "b", "c"); + } + + [Fact] + public async Task SetDifference_BothEmpty_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, []) AS diff FROM c WHERE c.id = '4'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().BeEmpty(); + } + + [Fact] + public async Task SetDifference_DuplicatesInSource_DedupedInResult() + { + await SeedTypeDiverseItems(); + // Item 10 has ["a","a","b"] — minus ["b"] → ["a"] (deduped) + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['b']) AS diff FROM c WHERE c.id = '10'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a"); + } + + [Fact] + public async Task SetDifference_WithNumericElements_Works() + { + await SeedTypeDiverseItems(); + // Item 5 has [1,2,3] — minus [2,4] → [1,3] + var query = new QueryDefinition("SELECT SetDifference(c.tags, [2, 4]) AS diff FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.Value()).Should().Equal(1, 3); + } + + [Fact] + public async Task SetDifference_TypeSensitive_NumberDoesNotMatchString() + { + await SeedTypeDiverseItems(); + // Item 5 has [1,2,3] — minus ["1","2"] → [1,2,3] (number ≠ string) + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['1', '2']) AS diff FROM c WHERE c.id = '5'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.Value()).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task SetDifference_WithNullElements_Works() + { + await SeedTypeDiverseItems(); + // Item 6 has [1,"a",true,null] — minus [null,"a"] → [1, true] + var query = new QueryDefinition("SELECT SetDifference(c.tags, [null, 'a']) AS diff FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().HaveCount(2); + diff[0].Value().Should().Be(1); + diff[1].Value().Should().BeTrue(); + } + + [Fact] + public async Task SetDifference_WithMixedTypes_OnlyRemovesSameType() + { + await SeedTypeDiverseItems(); + // Item 6 has [1,"a",true,null] — minus [1,"b",false] → ["a",true,null] + var query = new QueryDefinition("SELECT SetDifference(c.tags, [1, 'b', false]) AS diff FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().HaveCount(3); + } + + [Fact] + public async Task SetDifference_IsNotCommutative() + { + await SeedTypeDiverseItems(); + // Item 11 has tags=["a","b"], otherTags=["b","c"] + var query1 = new QueryDefinition("SELECT SetDifference(c.tags, c.otherTags) AS diff FROM c WHERE c.id = '11'"); + var query2 = new QueryDefinition("SELECT SetDifference(c.otherTags, c.tags) AS diff FROM c WHERE c.id = '11'"); + + var results1 = await QueryAll(query1); + var results2 = await QueryAll(query2); + + ((JArray)results1[0]["diff"]!).Select(t => t.ToString()).Should().Equal("a"); + ((JArray)results2[0]["diff"]!).Select(t => t.ToString()).Should().Equal("c"); + } + + [Fact] + public async Task SetDifference_IdenticalArrays_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, c.tags) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().BeEmpty(); + } + + [Fact] + public async Task SetDifference_NestedInArrayLength_Works() + { + await SeedItems(); + // a,b,c minus b,c = a → length 1 + var query = new QueryDefinition("SELECT ARRAY_LENGTH(SetDifference(c.tags, ['b', 'c'])) AS count FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["count"]!.Value().Should().Be(1); + } + + [Fact] + public async Task SetDifference_BothFromDocument_Works() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"] minus otherTags=["b","c"] = ["a"] + var query = new QueryDefinition("SELECT SetDifference(c.tags, c.otherTags) AS diff FROM c WHERE c.id = '11'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a"); + } + + [Fact] + public async Task SetDifference_OrderPreserved_FromFirstArray() + { + await SeedItems(); + // Item 3: tags=["x","y","z"] minus ["y"] → ["x","z"] (order preserved) + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['y']) AS diff FROM c WHERE c.id = '3'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("x", "z"); + } + + [Fact] + public async Task SetDifference_NestedPropertyPath_Works() + { + await SeedTypeDiverseItems(); + // Item 7: nested.items=["p","q"] minus ["q"] = ["p"] + var query = new QueryDefinition("SELECT SetDifference(c.nested.items, ['q']) AS diff FROM c WHERE c.id = '7'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("p"); + } + + [Fact] + public async Task SetDifference_NonExistentProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetDifference(c.nonExistent, ['a']) AS diff FROM c WHERE c.id = '8'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["diff"].Should().BeNull(); // property absent = undefined + } + + [Fact] + public async Task SetDifference_InWhereClause_WithArrayLength() + { + await SeedItems(); + // Items with diff length > 0 after removing ["b","c","d"] + // Item 1: ["a","b","c"] minus ["b","c","d"] = ["a"] (length 1 > 0) ✓ + // Item 2: ["b","c","d"] minus ["b","c","d"] = [] (length 0) ✗ + // Item 3: ["x","y","z"] minus ["b","c","d"] = ["x","y","z"] (length 3 > 0) ✓ + // Item 4: [] minus ["b","c","d"] = [] (length 0) ✗ + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_LENGTH(SetDifference(c.tags, ['b', 'c', 'd'])) > 0"); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); + } + + // ── Parameterized Queries ─────────────────────────────────────────────── + + [Fact] + public async Task ArrayContainsAny_WithParameterizedSearchValues_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, @searchValues)") + .WithParameter("@searchValues", new JArray("a", "z")); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); + } + + [Fact] + public async Task ArrayContainsAll_WithParameterizedSearchValues_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, @searchValues)") + .WithParameter("@searchValues", new JArray("a", "b")); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Id.Should().Be("1"); + } + + [Fact] + public async Task SetIntersect_WithParameterizedArray_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetIntersect(c.tags, @otherArray) AS common FROM c WHERE c.id = '1'") + .WithParameter("@otherArray", new JArray("b", "c", "z")); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Select(t => t.ToString()).Should().BeEquivalentTo(["b", "c"]); + } + + [Fact] + public async Task SetDifference_WithParameterizedArray_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, @otherArray) AS diff FROM c WHERE c.id = '1'") + .WithParameter("@otherArray", new JArray("b", "c", "z")); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.ToString()).Should().Equal("a"); + } + + // ── Object Elements in Arrays ─────────────────────────────────────────── + + [Fact] + public async Task SetIntersect_WithObjectElements_MatchesDeepEqual() + { + await SeedTypeDiverseItems(); + // Item 12: tags=[{name:"x"},{name:"y"}] — intersect with [{name:"x"}] → [{name:"x"}] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [{\"name\":\"x\"}]) AS common FROM c WHERE c.id = '12'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().ContainSingle(); + common[0]["name"]!.Value().Should().Be("x"); + } + + [Fact] + public async Task SetUnion_WithObjectElements_DeduplicatesDeepEqual() + { + await SeedTypeDiverseItems(); + // Item 12: tags=[{name:"x"},{name:"y"}] — union with [{name:"x"},{name:"z"}] + // → [{name:"x"},{name:"y"},{name:"z"}] (dedup {name:"x"}) + var query = new QueryDefinition("SELECT SetUnion(c.tags, [{\"name\":\"x\"},{\"name\":\"z\"}]) AS combined FROM c WHERE c.id = '12'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var combined = (JArray)results[0]["combined"]!; + combined.Should().HaveCount(3); + } + + [Fact] + public async Task ArrayContainsAny_WithObjectElementInVariadicForm_Works() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, {\"name\":\"x\"}) FROM c WHERE c.id = '12'"); + var results = await QueryAll(query); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAny_WithObjectElement_EmulatorArrayForm() + { + await SeedTypeDiverseItems(); + // Using array form with object element + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [{\"name\":\"x\"}]) FROM c WHERE c.id = '12'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task SetDifference_WithObjectElements_Works() + { + await SeedTypeDiverseItems(); + // Item 12: tags=[{name:"x"},{name:"y"}] — minus [{name:"x"}] → [{name:"y"}] + var query = new QueryDefinition("SELECT SetDifference(c.tags, [{\"name\":\"x\"}]) AS diff FROM c WHERE c.id = '12'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().ContainSingle(); + diff[0]["name"]!.Value().Should().Be("y"); + } + + // ── NOT Operator Negation ─────────────────────────────────────────────── + + [Fact] + public async Task ArrayContainsAny_WithNotOperator_ReturnsInverse() + { + await SeedItems(); + // Items 1 has "a" → excluded. Items 2,3,4 → returned. + var query = new QueryDefinition("SELECT * FROM c WHERE NOT ARRAY_CONTAINS_ANY(c.tags, ['a'])"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Select(r => r.Id).Should().BeEquivalentTo(["2", "3", "4"]); + } + + [Fact] + public async Task ArrayContainsAll_WithNotOperator_ReturnsInverse() + { + await SeedItems(); + // Only item 1 has all of ["a","b","c"] → excluded. Items 2,3,4 → returned. + var query = new QueryDefinition("SELECT * FROM c WHERE NOT ARRAY_CONTAINS_ALL(c.tags, ['a', 'b', 'c'])"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Select(r => r.Id).Should().BeEquivalentTo(["2", "3", "4"]); + } + + // ── Literal Arrays in SQL ─────────────────────────────────────────────── + + [Fact] + public async Task SetIntersect_WithLiteralArrays_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetIntersect([1, 2, 3, 4], [3, 4, 5, 6]) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().Equal(3, 4); + } + + [Fact] + public async Task SetUnion_WithLiteralArrays_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetUnion([1, 2, 3, 4], [3, 4, 5, 6]) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().Equal(1, 2, 3, 4, 5, 6); + } + + [Fact] + public async Task SetDifference_WithLiteralArrays_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetDifference([1, 2, 3], [1, 2, 6, 7]) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().Equal(3); + } + + [Fact] + public async Task ArrayContainsAny_WithLiteralFirstArray_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY([1, true, '3', [1,2,3]], 1, true) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } - // ── Chained Set Operations ────────────────────────────────────────────── + // ── Chained Set Operations ────────────────────────────────────────────── - [Fact] - public async Task SetUnion_OfSetIntersect_ChainedOperations() - { - await SeedItems(); - // tags=["a","b","c"], intersect(tags, ["a","b"]) = ["a","b"], union(["a","b"], ["x","y"]) = ["a","b","x","y"] - var query = new QueryDefinition("SELECT SetUnion(SetIntersect(c.tags, ['a', 'b']), ['x', 'y']) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var result = (JArray)results[0]["result"]!; - result.Select(t => t.ToString()).Should().Equal("a", "b", "x", "y"); - } - - [Fact] - public async Task SetDifference_OfSetUnion_ChainedOperations() - { - await SeedItems(); - // tags=["a","b","c"], union(tags, ["d"]) = ["a","b","c","d"], diff(["a","b","c","d"], ["a","b"]) = ["c","d"] - var query = new QueryDefinition("SELECT SetDifference(SetUnion(c.tags, ['d']), ['a', 'b']) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var result = (JArray)results[0]["result"]!; - result.Select(t => t.ToString()).Should().Equal("c", "d"); - } - - // ── Float/Decimal Elements ────────────────────────────────────────────── + [Fact] + public async Task SetUnion_OfSetIntersect_ChainedOperations() + { + await SeedItems(); + // tags=["a","b","c"], intersect(tags, ["a","b"]) = ["a","b"], union(["a","b"], ["x","y"]) = ["a","b","x","y"] + var query = new QueryDefinition("SELECT SetUnion(SetIntersect(c.tags, ['a', 'b']), ['x', 'y']) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var result = (JArray)results[0]["result"]!; + result.Select(t => t.ToString()).Should().Equal("a", "b", "x", "y"); + } + + [Fact] + public async Task SetDifference_OfSetUnion_ChainedOperations() + { + await SeedItems(); + // tags=["a","b","c"], union(tags, ["d"]) = ["a","b","c","d"], diff(["a","b","c","d"], ["a","b"]) = ["c","d"] + var query = new QueryDefinition("SELECT SetDifference(SetUnion(c.tags, ['d']), ['a', 'b']) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var result = (JArray)results[0]["result"]!; + result.Select(t => t.ToString()).Should().Equal("c", "d"); + } + + // ── Float/Decimal Elements ────────────────────────────────────────────── - [Fact] - public async Task SetIntersect_WithFloatElements_Works() - { - await SeedTypeDiverseItems(); - // Item 13: tags=[1.5, 2.5, 3.5] — intersect with [2.5, 4.5] → [2.5] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [2.5, 4.5]) AS common FROM c WHERE c.id = '13'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().ContainSingle(); - common[0].Value().Should().Be(2.5); - } + [Fact] + public async Task SetIntersect_WithFloatElements_Works() + { + await SeedTypeDiverseItems(); + // Item 13: tags=[1.5, 2.5, 3.5] — intersect with [2.5, 4.5] → [2.5] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [2.5, 4.5]) AS common FROM c WHERE c.id = '13'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().ContainSingle(); + common[0].Value().Should().Be(2.5); + } - [Fact] - public async Task SetDifference_WithFloatElements_Works() - { - await SeedTypeDiverseItems(); - // Item 13: tags=[1.5, 2.5, 3.5] — minus [2.5] → [1.5, 3.5] - var query = new QueryDefinition("SELECT SetDifference(c.tags, [2.5]) AS diff FROM c WHERE c.id = '13'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Select(t => t.Value()).Should().Equal(1.5, 3.5); - } - - [Fact] - public async Task SetUnion_FloatAndIntegerDistinct() - { - await SeedTypeDiverseItems(); - // Union of [1] and [1.0] — in JSON.NET, 1 is Integer, 1.0 is Float → both kept - var query = new QueryDefinition("SELECT VALUE SetUnion([1], [1.0]) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0].Should().HaveCount(2); - } - - [Fact] - public async Task ArrayContainsAny_WithFloatElement_Works() - { - await SeedTypeDiverseItems(); - // Item 13: tags=[1.5, 2.5, 3.5] — contains 2.5? - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [2.5]) FROM c WHERE c.id = '13'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── Additional Type-Sensitivity ───────────────────────────────────────── - - [Fact] - public async Task ArrayContainsAll_BoolDoesNotMatchString_TypeSensitive() - { - await SeedTypeDiverseItems(); - // Item 6 has [1,"a",true,null] — search for ["true"] should NOT match boolean true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['true']) FROM c WHERE c.id = '6'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task SetIntersect_BoolDoesNotMatchString_TypeSensitive() - { - await SeedTypeDiverseItems(); - // Item 15: tags=[true,"true",false,"false"] — intersect with ["true"] → ["true"] only - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['true']) AS common FROM c WHERE c.id = '15'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().ContainSingle(); - common[0].Value().Should().Be("true"); - } - - [Fact] - public async Task SetDifference_TypeSensitive_BoolVsString() - { - await SeedTypeDiverseItems(); - // Item 15: tags=[true,"true",false,"false"] — minus ["true"] → [true, false, "false"] - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['true']) AS diff FROM c WHERE c.id = '15'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - var diff = (JArray)results[0]["diff"]!; - diff.Should().HaveCount(3); - } - - // ── Combined with OR ──────────────────────────────────────────────────── - - [Fact] - public async Task ArrayContainsAny_CombinedWithOr_Works() - { - await SeedItems(); - // Item 1 has "a" (matched by ARRAY_CONTAINS_ANY), Item 3 matched by id = '3' - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) OR c.id = '3'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); - } - - // ── Nested Array Elements ─────────────────────────────────────────────── - - [Fact] - public async Task SetIntersect_WithNestedArrayElements_Works() - { - await SeedTypeDiverseItems(); - // Item 14: tags=[[1,2],[3,4]] — intersect with [[1,2]] → [[1,2]] - var query = new QueryDefinition("SELECT SetIntersect(c.tags, [[1,2]]) AS common FROM c WHERE c.id = '14'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - var common = (JArray)results[0]["common"]!; - common.Should().ContainSingle(); - } - - [Fact] - public async Task ArrayContainsAny_WithNestedArrayElement_VariadicForm() - { - await SeedItems(); - // Docs example: ARRAY_CONTAINS_ANY([1, [1,2,3]], [1,2,3]) — variadic form, [1,2,3] is the value to search - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY([1, [1,2,3]], [1,2,3]) FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── Cross-Cutting Operations ──────────────────────────────────────────── - - [Fact] - public async Task SetOperations_CombinedInSingleProjection() - { - await SeedItems(); - // Item 1: tags=["a","b","c"] - var query = new QueryDefinition( - "SELECT SetIntersect(c.tags, ['a','b']) AS common, SetUnion(c.tags, ['x']) AS combined, SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - ((JArray)results[0]["common"]!).Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b"]); - ((JArray)results[0]["combined"]!).Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c", "x"]); - ((JArray)results[0]["diff"]!).Select(t => t.ToString()).Should().Equal("b", "c"); - } - - [Fact] - public async Task ArrayContainsAny_InOrderBy_WithCount() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.id, ARRAY_LENGTH(SetIntersect(c.tags, ['a','b','c'])) AS overlap FROM c ORDER BY ARRAY_LENGTH(SetIntersect(c.tags, ['a','b','c'])) DESC"); - - var results = await QueryAll(query); - - results.Should().HaveCountGreaterThanOrEqualTo(4); - // Item 1: overlap 3, Item 2: overlap 2, Items 3,4: overlap 0 - results[0]["id"]!.Value().Should().Be("1"); - results[0]["overlap"]!.Value().Should().Be(3); - } - - [Fact] - public async Task SetDifference_ResultUsedInArrayContainsAny() - { - await SeedItems(); - // tags=["a","b","c"], diff(tags, ["a"]) = ["b","c"], contains_any(["b","c"], "b") = true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(SetDifference(c.tags, ['a']), 'b') FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── Divergent Behavior: Document Array Arg (BUG-3) ────────────────────── - - [Fact(Skip = "In real Cosmos DB, ARRAY_CONTAINS_ANY's second argument is a single scalar expression. If c.otherTags is an array, real Cosmos checks if c.tags contains the value as a nested array ELEMENT (false). The emulator instead iterates otherTags and checks if ANY element matches (true). This is an intentional emulator convenience divergence.")] - public async Task ArrayContainsAny_DocumentArrayArg_RealCosmosTreatsSingleValue() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"], otherTags=["b","c"] - // Real Cosmos: does tags contain the VALUE ["b","c"]? No → false - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); - var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task ArrayContainsAny_DocumentArrayArg_EmulatorIteratesElements() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"], otherTags=["b","c"] - // Emulator: iterates otherTags elements, finds "b" in tags → true - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── Divergent Behavior: Non-Array Scalar Property (BUG-5) ─────────────── - - [Fact] - public async Task SetIntersect_NonArrayScalarProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - // Item 9: tags="not-an-array" - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS common FROM c WHERE c.id = '9'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["common"].Should().BeNull(); // undefined in real Cosmos - } - - [Fact] - public async Task SetUnion_NonArrayScalarProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS combined FROM c WHERE c.id = '9'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["combined"].Should().BeNull(); - } - - [Fact] - public async Task SetDifference_NonArrayScalarProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '9'"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["diff"].Should().BeNull(); - } - - // ════════════════════════════════════════════ - // Deep Dive: Additional Edge Cases - // ════════════════════════════════════════════ - - #region ARRAY_CONTAINS_ANY — Additional Edge Cases - - [Fact] - public async Task ArrayContainsAny_WhereSourceIsNull_ReturnsFalse() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "null1", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a'])"); - var results = await QueryAll(query); - results.Should().NotContain("null1"); - } - - [Fact] - public async Task ArrayContainsAny_CaseSensitive_NoMatch() - { - await SeedItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "cs1", partitionKey = "pk1", tags = new[] { "ABC" } }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['abc'])"); - var results = await QueryAll(query); - results.Should().NotContain("cs1"); - } - - [Fact] - public async Task ArrayContainsAny_WithNestedObjectInArray_MatchesDeepEqual() - { - await SeedTypeDiverseItems(); - // Item 12 has tags = [{name:"x"},{name:"y"}] - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, [{\"name\":\"x\"}])"); - var results = await QueryAll(query); - results.Should().Contain("12"); - } - - [Fact] - public async Task ArrayContainsAny_InSubquery_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE EXISTS(SELECT VALUE 1 FROM t IN c.tags WHERE t = 'a') AND ARRAY_CONTAINS_ANY(c.tags, ['b'])"); - var results = await QueryAll(query); - results.Should().Contain("1"); // tags=["a","b","c"] - } - - [Fact] - public async Task ArrayContainsAny_WithGroupBy_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.partitionKey AS pk, COUNT(1) AS cnt FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) GROUP BY c.partitionKey"); - var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(1); // Only id=1 has tag "a" - } - - [Fact] - public async Task ArrayContainsAny_WithSingleArgument_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().BeFalse(); - } - - #endregion - - #region ARRAY_CONTAINS_ALL — Additional Edge Cases - - [Fact] - public async Task ArrayContainsAll_SingleElementSearch_Present_ReturnsTrue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAll_SingleElementSearch_Missing_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['z']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().BeFalse(); - } - - [Fact] - public async Task ArrayContainsAll_WhereSourceIsNull_ReturnsFalse() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "null2", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a'])"); - var results = await QueryAll(query); - results.Should().NotContain("null2"); - } - - [Fact] - public async Task ArrayContainsAll_CaseSensitive_NoMatch() - { - await SeedItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "cs2", partitionKey = "pk1", tags = new[] { "ABC" } }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['abc']) FROM c WHERE c.id = 'cs2'"); - var results = await QueryAll(query); - results.Single().Should().BeFalse(); - } - - [Fact] - public async Task ArrayContainsAll_WithNestedObjectInArray_MatchesDeepEqual() - { - await SeedTypeDiverseItems(); - // Item 12 has tags = [{name:"x"},{name:"y"}] - var query = new QueryDefinition( - "SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [{\"name\":\"x\"}, {\"name\":\"y\"}]) FROM c WHERE c.id = '12'"); - var results = await QueryAll(query); - results.Single().Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAll_CombinedWithArrayContainsAny_InWhere() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['b','c']) AND ARRAY_CONTAINS_ANY(c.tags, ['a'])"); - var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().Be("1"); - } - - [Fact] - public async Task ArrayContainsAll_WithSingleArgument_ReturnsFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().BeFalse(); - } - - #endregion - - #region SetIntersect — Additional Edge Cases - - [Fact] - public async Task SetIntersect_DuplicatesInBothArrays_DedupedCorrectly() - { - await SeedTypeDiverseItems(); - // Item 10 has tags=["a","a","b"] - var query = new QueryDefinition( - "SELECT VALUE SetIntersect(c.tags, ['a','a','b','b']) FROM c WHERE c.id = '10'"); - var results = await QueryAll(query); - var arr = results.Single(); - arr.Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b" }); - } - - [Fact] - public async Task SetIntersect_SingleElementArrays_Match() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetIntersect(['a'], ['a']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().HaveCount(1); - results.Single()[0]!.Value().Should().Be("a"); - } - - [Fact] - public async Task SetIntersect_SingleElementArrays_NoMatch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetIntersect(['a'], ['b']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_WithNullProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "nullsi", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsi'"); - var results = await QueryAll(query); - results.Single()["result"].Should().BeNull(); - } - - [Fact] - public async Task SetIntersect_ChainedThreeSets() - { - await SeedItems(); - // Item 1: ["a","b","c"], Item 2: ["b","c","d"] - // SetIntersect(["a","b","c"], ["b","c","d"]) = ["b","c"] - // SetIntersect(["b","c"], ["c"]) = ["c"] - var query = new QueryDefinition( - "SELECT VALUE SetIntersect(SetIntersect(['a','b','c'], ['b','c','d']), ['c']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().HaveCount(1); - results.Single()[0]!.Value().Should().Be("c"); - } - - [Fact] - public async Task SetIntersect_WithParameterBothArgs_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetIntersect(@a, @b) FROM c WHERE c.id = '1'") - .WithParameter("@a", new JArray("a", "b", "c")) - .WithParameter("@b", new JArray("b", "c", "d")); - var results = await QueryAll(query); - results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "b", "c" }); - } - - #endregion - - #region SetUnion — Additional Edge Cases - - [Fact] - public async Task SetUnion_WithNullProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "nullsu", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsu'"); - var results = await QueryAll(query); - results.Single()["result"].Should().BeNull(); - } - - [Fact] - public async Task SetUnion_ChainedThreeSets() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE SetUnion(SetUnion(['a'], ['b']), ['c']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b", "c" }); - } - - [Fact] - public async Task SetUnion_CaseSensitiveStrings() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE SetUnion(['ABC'], ['abc']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().HaveCount(2); - } - - [Fact] - public async Task SetUnion_ResultInWhere_FiltersByLength() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"], otherTags=["b","c"] → union=["a","b","c"] (3 elements) - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_LENGTH(SetUnion(c.tags, c.otherTags)) >= 3"); - var results = await QueryAll(query); - results.Should().Contain("11"); - } - - [Fact] - public async Task SetUnion_WithParameterBothArgs_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetUnion(@a, @b) FROM c WHERE c.id = '1'") - .WithParameter("@a", new JArray("a")) - .WithParameter("@b", new JArray("b")); - var results = await QueryAll(query); - results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b" }); - } - - #endregion - - #region SetDifference — Additional Edge Cases - - [Fact] - public async Task SetDifference_WithNullProperty_ReturnsUndefined() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "nullsd", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsd'"); - var results = await QueryAll(query); - results.Single()["result"].Should().BeNull(); - } - - [Fact] - public async Task SetDifference_ChainedThreeSets() - { - await SeedItems(); - // ["a","b","c"] \ ["b"] = ["a","c"], then ["a","c"] \ ["c"] = ["a"] - var query = new QueryDefinition( - "SELECT VALUE SetDifference(SetDifference(['a','b','c'], ['b']), ['c']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().HaveCount(1); - results.Single()[0]!.Value().Should().Be("a"); - } - - [Fact] - public async Task SetDifference_CaseSensitiveStrings() - { - await SeedItems(); - // ["ABC","abc"] \ ["abc"] → ["ABC"] - var query = new QueryDefinition( - "SELECT VALUE SetDifference(['ABC','abc'], ['abc']) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single().Should().HaveCount(1); - results.Single()[0]!.Value().Should().Be("ABC"); - } - - [Fact] - public async Task SetDifference_WithParameterBothArgs_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE SetDifference(@a, @b) FROM c WHERE c.id = '1'") - .WithParameter("@a", new JArray("a", "b", "c")) - .WithParameter("@b", new JArray("b")); - var results = await QueryAll(query); - results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "c" }); - } - - [Fact] - public async Task SetDifference_ResultInSelectProjection() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Single()["diff"]!.ToObject().Should().BeEquivalentTo(new[] { "b", "c" }); - } - - #endregion - - #region Cross-Cutting Composition - - [Fact] - public async Task ArrayContainsAny_OnSetIntersectResult_Works() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"], otherTags=["b","c"] → intersect=["b"] - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(SetIntersect(c.tags, c.otherTags), ['b'])"); - var results = await QueryAll(query); - results.Should().Contain("11"); - } - - [Fact] - public async Task ArrayContainsAll_OnSetUnionResult_Works() - { - await SeedTypeDiverseItems(); - // Item 11: tags=["a","b"], otherTags=["b","c"] → union=["a","b","c"] - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(SetUnion(c.tags, c.otherTags), ['a','b','c'])"); - var results = await QueryAll(query); - results.Should().Contain("11"); - } - - [Fact] - public async Task SetDifference_InArrayLength_InOrderBy() - { - await SeedItems(); - // Item 1: ["a","b","c"]\["a"] = ["b","c"] (len=2), Item 2: ["b","c","d"]\["a"] = ["b","c","d"] (len=3) - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE ARRAY_LENGTH(c.tags) > 0 ORDER BY ARRAY_LENGTH(SetDifference(c.tags, ['a']))"); - var results = await QueryAll(query); - results.Should().HaveCountGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task AllFiveFunctions_InSingleQuery_Works() - { - await SeedTypeDiverseItems(); - var query = new QueryDefinition(@" + [Fact] + public async Task SetDifference_WithFloatElements_Works() + { + await SeedTypeDiverseItems(); + // Item 13: tags=[1.5, 2.5, 3.5] — minus [2.5] → [1.5, 3.5] + var query = new QueryDefinition("SELECT SetDifference(c.tags, [2.5]) AS diff FROM c WHERE c.id = '13'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Select(t => t.Value()).Should().Equal(1.5, 3.5); + } + + [Fact] + public async Task SetUnion_FloatAndIntegerDistinct() + { + await SeedTypeDiverseItems(); + // Union of [1] and [1.0] — in JSON.NET, 1 is Integer, 1.0 is Float → both kept + var query = new QueryDefinition("SELECT VALUE SetUnion([1], [1.0]) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0].Should().HaveCount(2); + } + + [Fact] + public async Task ArrayContainsAny_WithFloatElement_Works() + { + await SeedTypeDiverseItems(); + // Item 13: tags=[1.5, 2.5, 3.5] — contains 2.5? + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, [2.5]) FROM c WHERE c.id = '13'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── Additional Type-Sensitivity ───────────────────────────────────────── + + [Fact] + public async Task ArrayContainsAll_BoolDoesNotMatchString_TypeSensitive() + { + await SeedTypeDiverseItems(); + // Item 6 has [1,"a",true,null] — search for ["true"] should NOT match boolean true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['true']) FROM c WHERE c.id = '6'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task SetIntersect_BoolDoesNotMatchString_TypeSensitive() + { + await SeedTypeDiverseItems(); + // Item 15: tags=[true,"true",false,"false"] — intersect with ["true"] → ["true"] only + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['true']) AS common FROM c WHERE c.id = '15'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().ContainSingle(); + common[0].Value().Should().Be("true"); + } + + [Fact] + public async Task SetDifference_TypeSensitive_BoolVsString() + { + await SeedTypeDiverseItems(); + // Item 15: tags=[true,"true",false,"false"] — minus ["true"] → [true, false, "false"] + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['true']) AS diff FROM c WHERE c.id = '15'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + var diff = (JArray)results[0]["diff"]!; + diff.Should().HaveCount(3); + } + + // ── Combined with OR ──────────────────────────────────────────────────── + + [Fact] + public async Task ArrayContainsAny_CombinedWithOr_Works() + { + await SeedItems(); + // Item 1 has "a" (matched by ARRAY_CONTAINS_ANY), Item 3 matched by id = '3' + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) OR c.id = '3'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "3"]); + } + + // ── Nested Array Elements ─────────────────────────────────────────────── + + [Fact] + public async Task SetIntersect_WithNestedArrayElements_Works() + { + await SeedTypeDiverseItems(); + // Item 14: tags=[[1,2],[3,4]] — intersect with [[1,2]] → [[1,2]] + var query = new QueryDefinition("SELECT SetIntersect(c.tags, [[1,2]]) AS common FROM c WHERE c.id = '14'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + var common = (JArray)results[0]["common"]!; + common.Should().ContainSingle(); + } + + [Fact] + public async Task ArrayContainsAny_WithNestedArrayElement_VariadicForm() + { + await SeedItems(); + // Docs example: ARRAY_CONTAINS_ANY([1, [1,2,3]], [1,2,3]) — variadic form, [1,2,3] is the value to search + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY([1, [1,2,3]], [1,2,3]) FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── Cross-Cutting Operations ──────────────────────────────────────────── + + [Fact] + public async Task SetOperations_CombinedInSingleProjection() + { + await SeedItems(); + // Item 1: tags=["a","b","c"] + var query = new QueryDefinition( + "SELECT SetIntersect(c.tags, ['a','b']) AS common, SetUnion(c.tags, ['x']) AS combined, SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + ((JArray)results[0]["common"]!).Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b"]); + ((JArray)results[0]["combined"]!).Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c", "x"]); + ((JArray)results[0]["diff"]!).Select(t => t.ToString()).Should().Equal("b", "c"); + } + + [Fact] + public async Task ArrayContainsAny_InOrderBy_WithCount() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.id, ARRAY_LENGTH(SetIntersect(c.tags, ['a','b','c'])) AS overlap FROM c ORDER BY ARRAY_LENGTH(SetIntersect(c.tags, ['a','b','c'])) DESC"); + + var results = await QueryAll(query); + + results.Should().HaveCountGreaterThanOrEqualTo(4); + // Item 1: overlap 3, Item 2: overlap 2, Items 3,4: overlap 0 + results[0]["id"]!.Value().Should().Be("1"); + results[0]["overlap"]!.Value().Should().Be(3); + } + + [Fact] + public async Task SetDifference_ResultUsedInArrayContainsAny() + { + await SeedItems(); + // tags=["a","b","c"], diff(tags, ["a"]) = ["b","c"], contains_any(["b","c"], "b") = true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(SetDifference(c.tags, ['a']), 'b') FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── Divergent Behavior: Document Array Arg (BUG-3) ────────────────────── + + [Fact(Skip = "In real Cosmos DB, ARRAY_CONTAINS_ANY's second argument is a single scalar expression. If c.otherTags is an array, real Cosmos checks if c.tags contains the value as a nested array ELEMENT (false). The emulator instead iterates otherTags and checks if ANY element matches (true). This is an intentional emulator convenience divergence.")] + public async Task ArrayContainsAny_DocumentArrayArg_RealCosmosTreatsSingleValue() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"], otherTags=["b","c"] + // Real Cosmos: does tags contain the VALUE ["b","c"]? No → false + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); + var results = await QueryAll(query); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task ArrayContainsAny_DocumentArrayArg_EmulatorIteratesElements() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"], otherTags=["b","c"] + // Emulator: iterates otherTags elements, finds "b" in tags → true + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, c.otherTags) FROM c WHERE c.id = '11'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── Divergent Behavior: Non-Array Scalar Property (BUG-5) ─────────────── + + [Fact] + public async Task SetIntersect_NonArrayScalarProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + // Item 9: tags="not-an-array" + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS common FROM c WHERE c.id = '9'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["common"].Should().BeNull(); // undefined in real Cosmos + } + + [Fact] + public async Task SetUnion_NonArrayScalarProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS combined FROM c WHERE c.id = '9'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["combined"].Should().BeNull(); + } + + [Fact] + public async Task SetDifference_NonArrayScalarProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '9'"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["diff"].Should().BeNull(); + } + + // ════════════════════════════════════════════ + // Deep Dive: Additional Edge Cases + // ════════════════════════════════════════════ + + #region ARRAY_CONTAINS_ANY — Additional Edge Cases + + [Fact] + public async Task ArrayContainsAny_WhereSourceIsNull_ReturnsFalse() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "null1", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a'])"); + var results = await QueryAll(query); + results.Should().NotContain("null1"); + } + + [Fact] + public async Task ArrayContainsAny_CaseSensitive_NoMatch() + { + await SeedItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "cs1", partitionKey = "pk1", tags = new[] { "ABC" } }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['abc'])"); + var results = await QueryAll(query); + results.Should().NotContain("cs1"); + } + + [Fact] + public async Task ArrayContainsAny_WithNestedObjectInArray_MatchesDeepEqual() + { + await SeedTypeDiverseItems(); + // Item 12 has tags = [{name:"x"},{name:"y"}] + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, [{\"name\":\"x\"}])"); + var results = await QueryAll(query); + results.Should().Contain("12"); + } + + [Fact] + public async Task ArrayContainsAny_InSubquery_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE EXISTS(SELECT VALUE 1 FROM t IN c.tags WHERE t = 'a') AND ARRAY_CONTAINS_ANY(c.tags, ['b'])"); + var results = await QueryAll(query); + results.Should().Contain("1"); // tags=["a","b","c"] + } + + [Fact] + public async Task ArrayContainsAny_WithGroupBy_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.partitionKey AS pk, COUNT(1) AS cnt FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['a']) GROUP BY c.partitionKey"); + var results = await QueryAll(query); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(1); // Only id=1 has tag "a" + } + + [Fact] + public async Task ArrayContainsAny_WithSingleArgument_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().BeFalse(); + } + + #endregion + + #region ARRAY_CONTAINS_ALL — Additional Edge Cases + + [Fact] + public async Task ArrayContainsAll_SingleElementSearch_Present_ReturnsTrue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['a']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAll_SingleElementSearch_Missing_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['z']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().BeFalse(); + } + + [Fact] + public async Task ArrayContainsAll_WhereSourceIsNull_ReturnsFalse() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "null2", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['a'])"); + var results = await QueryAll(query); + results.Should().NotContain("null2"); + } + + [Fact] + public async Task ArrayContainsAll_CaseSensitive_NoMatch() + { + await SeedItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "cs2", partitionKey = "pk1", tags = new[] { "ABC" } }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ['abc']) FROM c WHERE c.id = 'cs2'"); + var results = await QueryAll(query); + results.Single().Should().BeFalse(); + } + + [Fact] + public async Task ArrayContainsAll_WithNestedObjectInArray_MatchesDeepEqual() + { + await SeedTypeDiverseItems(); + // Item 12 has tags = [{name:"x"},{name:"y"}] + var query = new QueryDefinition( + "SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, [{\"name\":\"x\"}, {\"name\":\"y\"}]) FROM c WHERE c.id = '12'"); + var results = await QueryAll(query); + results.Single().Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAll_CombinedWithArrayContainsAny_InWhere() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ['b','c']) AND ARRAY_CONTAINS_ANY(c.tags, ['a'])"); + var results = await QueryAll(query); + results.Should().ContainSingle().Which.Should().Be("1"); + } + + [Fact] + public async Task ArrayContainsAll_WithSingleArgument_ReturnsFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().BeFalse(); + } + + #endregion + + #region SetIntersect — Additional Edge Cases + + [Fact] + public async Task SetIntersect_DuplicatesInBothArrays_DedupedCorrectly() + { + await SeedTypeDiverseItems(); + // Item 10 has tags=["a","a","b"] + var query = new QueryDefinition( + "SELECT VALUE SetIntersect(c.tags, ['a','a','b','b']) FROM c WHERE c.id = '10'"); + var results = await QueryAll(query); + var arr = results.Single(); + arr.Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + [Fact] + public async Task SetIntersect_SingleElementArrays_Match() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetIntersect(['a'], ['a']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().HaveCount(1); + results.Single()[0]!.Value().Should().Be("a"); + } + + [Fact] + public async Task SetIntersect_SingleElementArrays_NoMatch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetIntersect(['a'], ['b']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_WithNullProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "nullsi", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsi'"); + var results = await QueryAll(query); + results.Single()["result"].Should().BeNull(); + } + + [Fact] + public async Task SetIntersect_ChainedThreeSets() + { + await SeedItems(); + // Item 1: ["a","b","c"], Item 2: ["b","c","d"] + // SetIntersect(["a","b","c"], ["b","c","d"]) = ["b","c"] + // SetIntersect(["b","c"], ["c"]) = ["c"] + var query = new QueryDefinition( + "SELECT VALUE SetIntersect(SetIntersect(['a','b','c'], ['b','c','d']), ['c']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().HaveCount(1); + results.Single()[0]!.Value().Should().Be("c"); + } + + [Fact] + public async Task SetIntersect_WithParameterBothArgs_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetIntersect(@a, @b) FROM c WHERE c.id = '1'") + .WithParameter("@a", new JArray("a", "b", "c")) + .WithParameter("@b", new JArray("b", "c", "d")); + var results = await QueryAll(query); + results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "b", "c" }); + } + + #endregion + + #region SetUnion — Additional Edge Cases + + [Fact] + public async Task SetUnion_WithNullProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "nullsu", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsu'"); + var results = await QueryAll(query); + results.Single()["result"].Should().BeNull(); + } + + [Fact] + public async Task SetUnion_ChainedThreeSets() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE SetUnion(SetUnion(['a'], ['b']), ['c']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b", "c" }); + } + + [Fact] + public async Task SetUnion_CaseSensitiveStrings() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE SetUnion(['ABC'], ['abc']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().HaveCount(2); + } + + [Fact] + public async Task SetUnion_ResultInWhere_FiltersByLength() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"], otherTags=["b","c"] → union=["a","b","c"] (3 elements) + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_LENGTH(SetUnion(c.tags, c.otherTags)) >= 3"); + var results = await QueryAll(query); + results.Should().Contain("11"); + } + + [Fact] + public async Task SetUnion_WithParameterBothArgs_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetUnion(@a, @b) FROM c WHERE c.id = '1'") + .WithParameter("@a", new JArray("a")) + .WithParameter("@b", new JArray("b")); + var results = await QueryAll(query); + results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + #endregion + + #region SetDifference — Additional Edge Cases + + [Fact] + public async Task SetDifference_WithNullProperty_ReturnsUndefined() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "nullsd", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT SetDifference(c.tags, ['a']) AS result FROM c WHERE c.id = 'nullsd'"); + var results = await QueryAll(query); + results.Single()["result"].Should().BeNull(); + } + + [Fact] + public async Task SetDifference_ChainedThreeSets() + { + await SeedItems(); + // ["a","b","c"] \ ["b"] = ["a","c"], then ["a","c"] \ ["c"] = ["a"] + var query = new QueryDefinition( + "SELECT VALUE SetDifference(SetDifference(['a','b','c'], ['b']), ['c']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().HaveCount(1); + results.Single()[0]!.Value().Should().Be("a"); + } + + [Fact] + public async Task SetDifference_CaseSensitiveStrings() + { + await SeedItems(); + // ["ABC","abc"] \ ["abc"] → ["ABC"] + var query = new QueryDefinition( + "SELECT VALUE SetDifference(['ABC','abc'], ['abc']) FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single().Should().HaveCount(1); + results.Single()[0]!.Value().Should().Be("ABC"); + } + + [Fact] + public async Task SetDifference_WithParameterBothArgs_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE SetDifference(@a, @b) FROM c WHERE c.id = '1'") + .WithParameter("@a", new JArray("a", "b", "c")) + .WithParameter("@b", new JArray("b")); + var results = await QueryAll(query); + results.Single().Select(t => t.Value()).Should().BeEquivalentTo(new[] { "a", "c" }); + } + + [Fact] + public async Task SetDifference_ResultInSelectProjection() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Single()["diff"]!.ToObject().Should().BeEquivalentTo(new[] { "b", "c" }); + } + + #endregion + + #region Cross-Cutting Composition + + [Fact] + public async Task ArrayContainsAny_OnSetIntersectResult_Works() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"], otherTags=["b","c"] → intersect=["b"] + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(SetIntersect(c.tags, c.otherTags), ['b'])"); + var results = await QueryAll(query); + results.Should().Contain("11"); + } + + [Fact] + public async Task ArrayContainsAll_OnSetUnionResult_Works() + { + await SeedTypeDiverseItems(); + // Item 11: tags=["a","b"], otherTags=["b","c"] → union=["a","b","c"] + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ALL(SetUnion(c.tags, c.otherTags), ['a','b','c'])"); + var results = await QueryAll(query); + results.Should().Contain("11"); + } + + [Fact] + public async Task SetDifference_InArrayLength_InOrderBy() + { + await SeedItems(); + // Item 1: ["a","b","c"]\["a"] = ["b","c"] (len=2), Item 2: ["b","c","d"]\["a"] = ["b","c","d"] (len=3) + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE ARRAY_LENGTH(c.tags) > 0 ORDER BY ARRAY_LENGTH(SetDifference(c.tags, ['a']))"); + var results = await QueryAll(query); + results.Should().HaveCountGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task AllFiveFunctions_InSingleQuery_Works() + { + await SeedTypeDiverseItems(); + var query = new QueryDefinition(@" SELECT ARRAY_CONTAINS_ANY(c.tags, ['a']) AS hasAny, ARRAY_CONTAINS_ALL(c.tags, ['a','b']) AS hasAll, @@ -2125,97 +2128,97 @@ public async Task AllFiveFunctions_InSingleQuery_Works() SetUnion(c.tags, ['z']) AS unioned, SetDifference(c.tags, ['a']) AS diff FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - var r = results.Single(); - r["hasAny"]!.Value().Should().BeTrue(); - r["hasAll"]!.Value().Should().BeTrue(); - r["inter"]!.ToObject().Should().BeEquivalentTo(new[] { "a", "b" }); - r["diff"]!.ToObject().Should().BeEquivalentTo(new[] { "b", "c" }); - } - - [Fact] - public async Task ExtendedArrayFunctions_WithDistinct_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT DISTINCT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c"); - var results = await QueryAll(query); - results.Should().Contain(true); - results.Should().Contain(false); - } - - [Fact] - public async Task ExtendedArrayFunctions_WithTop_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT TOP 2 VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['b'])"); - var results = await QueryAll(query); - results.Should().HaveCount(2); - } - - [Fact] - public async Task ExtendedArrayFunctions_WithOffsetLimit_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['b']) ORDER BY c.id OFFSET 0 LIMIT 1"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - } - - #endregion - - #region Null vs Undefined Semantics - - [Fact] - public async Task SetIntersect_ExplicitNullVsMissing_BothUndefined() - { - await SeedTypeDiverseItems(); - // Item 8 has no tags property (missing), add item with explicit null - await _container.CreateItemAsync( - JObject.FromObject(new { id = "nullcheck", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var queryMissing = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS r FROM c WHERE c.id = '8'"); - var queryNull = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS r FROM c WHERE c.id = 'nullcheck'"); - - var resMissing = await QueryAll(queryMissing); - var resNull = await QueryAll(queryNull); - - resMissing.Single()["r"].Should().BeNull(); // undefined → excluded from JSON - resNull.Single()["r"].Should().BeNull(); - } - - [Fact] - public async Task SetUnion_ExplicitNullVsMissing_BothUndefined() - { - await SeedTypeDiverseItems(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "nullcheck2", partitionKey = "pk1", tags = (object?)null }), - new PartitionKey("pk1")); - - var queryMissing = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS r FROM c WHERE c.id = '8'"); - var queryNull = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS r FROM c WHERE c.id = 'nullcheck2'"); - - var resMissing = await QueryAll(queryMissing); - var resNull = await QueryAll(queryNull); - - resMissing.Single()["r"].Should().BeNull(); - resNull.Single()["r"].Should().BeNull(); - } - - #endregion - - private async Task> QueryAll(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } + var results = await QueryAll(query); + var r = results.Single(); + r["hasAny"]!.Value().Should().BeTrue(); + r["hasAll"]!.Value().Should().BeTrue(); + r["inter"]!.ToObject().Should().BeEquivalentTo(new[] { "a", "b" }); + r["diff"]!.ToObject().Should().BeEquivalentTo(new[] { "b", "c" }); + } + + [Fact] + public async Task ExtendedArrayFunctions_WithDistinct_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT DISTINCT VALUE ARRAY_CONTAINS_ANY(c.tags, ['a']) FROM c"); + var results = await QueryAll(query); + results.Should().Contain(true); + results.Should().Contain(false); + } + + [Fact] + public async Task ExtendedArrayFunctions_WithTop_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT TOP 2 VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['b'])"); + var results = await QueryAll(query); + results.Should().HaveCount(2); + } + + [Fact] + public async Task ExtendedArrayFunctions_WithOffsetLimit_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ['b']) ORDER BY c.id OFFSET 0 LIMIT 1"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + } + + #endregion + + #region Null vs Undefined Semantics + + [Fact] + public async Task SetIntersect_ExplicitNullVsMissing_BothUndefined() + { + await SeedTypeDiverseItems(); + // Item 8 has no tags property (missing), add item with explicit null + await _container.CreateItemAsync( + JObject.FromObject(new { id = "nullcheck", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var queryMissing = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS r FROM c WHERE c.id = '8'"); + var queryNull = new QueryDefinition("SELECT SetIntersect(c.tags, ['a']) AS r FROM c WHERE c.id = 'nullcheck'"); + + var resMissing = await QueryAll(queryMissing); + var resNull = await QueryAll(queryNull); + + resMissing.Single()["r"].Should().BeNull(); // undefined → excluded from JSON + resNull.Single()["r"].Should().BeNull(); + } + + [Fact] + public async Task SetUnion_ExplicitNullVsMissing_BothUndefined() + { + await SeedTypeDiverseItems(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "nullcheck2", partitionKey = "pk1", tags = (object?)null }), + new PartitionKey("pk1")); + + var queryMissing = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS r FROM c WHERE c.id = '8'"); + var queryNull = new QueryDefinition("SELECT SetUnion(c.tags, ['a']) AS r FROM c WHERE c.id = 'nullcheck2'"); + + var resMissing = await QueryAll(queryMissing); + var resNull = await QueryAll(queryNull); + + resMissing.Single()["r"].Should().BeNull(); + resNull.Single()["r"].Should().BeNull(); + } + + #endregion + + private async Task> QueryAll(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerAdvancedFeatureTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerAdvancedFeatureTests.cs index c21cf93..c6c745d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerAdvancedFeatureTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerAdvancedFeatureTests.cs @@ -16,306 +16,312 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerAdvancedFeatureTests : IDisposable { - private readonly InMemoryContainer _inMemoryContainer; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public FakeCosmosHandlerAdvancedFeatureTests() - { - _inMemoryContainer = new InMemoryContainer("test-advanced", "/partitionKey"); - _handler = new FakeCosmosHandler(_inMemoryContainer); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("db", "test-advanced"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - private async Task> DrainQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - private async Task> DrainQuery(QueryDefinition queryDef) - { - var iterator = _container.GetItemQueryIterator(queryDef); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // UDF through handler — register on backing container, query via SDK - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task UDF_RegisteredOnBackingContainer_UsableInQueryThroughHandler() - { - _inMemoryContainer.RegisterUdf("doubleIt", args => (double)args[0] * 2); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 5, - Nested = new NestedObject { Score = 7.0 } }, - new PartitionKey("pk1")); - - var results = await DrainQuery( - "SELECT c.name, udf.doubleIt(c.nested.score) AS doubled FROM c"); - - results.Should().HaveCount(1); - results[0]["doubled"]!.Value().Should().Be(14); - } - - [Fact] - public async Task UDF_InWhereClause_FiltersCorrectly() - { - _inMemoryContainer.RegisterUdf("isLongName", args => args[0]?.ToString()?.Length > 3); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bo" }, - new PartitionKey("pk1")); - - var results = await DrainQuery( - "SELECT * FROM c WHERE udf.isLongName(c.name)"); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Stored Procedures — register on backing container, execute via Scripts - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task StoredProc_RegisteredOnBackingContainer_ExecutableThroughHandler() - { - _inMemoryContainer.RegisterStoredProcedure("echo", - (pk, args) => JsonConvert.SerializeObject(new { echo = args[0]?.ToString() })); - - var response = await _inMemoryContainer.Scripts.ExecuteStoredProcedureAsync( - "echo", new PartitionKey("pk1"), new dynamic[] { "hello" }); - - var parsed = JObject.Parse(response.Resource); - parsed["echo"]!.Value().Should().Be("hello"); - } - - [Fact] - public async Task StoredProc_CanAccessContainerData() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - _inMemoryContainer.RegisterStoredProcedure("countItems", - (pk, args) => - { - // Use query to count items in the container - var iterator = _inMemoryContainer.GetItemQueryIterator("SELECT * FROM c"); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - items.AddRange(page); - } - return items.Count.ToString(); - }); - - var response = await _inMemoryContainer.Scripts.ExecuteStoredProcedureAsync( - "countItems", new PartitionKey("pk1"), new dynamic[] { }); - - response.Resource.Should().Be("2"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Computed Properties through handler queries - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ComputedProperty_AppearsInQueryResults() - { - var props = new ContainerProperties("test-computed", "/partitionKey"); - props.ComputedProperties.Add(new ComputedProperty - { Name = "cp_upper", Query = "SELECT VALUE UPPER(c.name) FROM c" }); - var container = new InMemoryContainer(props); - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - var sdkContainer = client.GetContainer("db", "test-computed"); - - await sdkContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "alice" }, - new PartitionKey("pk1")); - - var iterator = sdkContainer.GetItemQueryIterator( - "SELECT c.name, c.cp_upper FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["cp_upper"]!.Value().Should().Be("ALICE"); - } - - [Fact] - public async Task ComputedProperty_NotVisibleInPointRead() - { - var props = new ContainerProperties("test-computed2", "/partitionKey"); - props.ComputedProperties.Add(new ComputedProperty - { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" }); - var container = new InMemoryContainer(props); - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - var sdkContainer = client.GetContainer("db", "test-computed2"); - - await sdkContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var response = await sdkContainer.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.ContainsKey("cp_lower").Should().BeFalse(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Container management via backing container - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Container_ClearItems_ThenQueryThroughHandler_ReturnsEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - _inMemoryContainer.ClearItems(); - - var results = await DrainQuery("SELECT * FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Container_MultiContainerRouter_IndependentData() - { - var container1 = new InMemoryContainer("container1", "/partitionKey"); - var container2 = new InMemoryContainer("container2", "/partitionKey"); - var handler1 = new FakeCosmosHandler(container1); - var handler2 = new FakeCosmosHandler(container2); - - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["container1"] = handler1, - ["container2"] = handler2, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) - }); - - var c1 = client.GetContainer("db", "container1"); - var c2 = client.GetContainer("db", "container2"); - - await c1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "InC1" }, - new PartitionKey("pk1")); - await c2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "InC2" }, - new PartitionKey("pk1")); - - var read1 = await c1.ReadItemAsync("1", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("InC1"); - - var read2 = await c2.ReadItemAsync("1", new PartitionKey("pk1")); - read2.Resource.Name.Should().Be("InC2"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Unique Key Policy - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task UniqueKeyPolicy_EnforcedThroughHandler() - { - var props = new ContainerProperties("test-unique", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(props); - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - var sdkContainer = client.GetContainer("db", "test-unique"); - - await sdkContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - // Duplicate name in same partition should fail - var act = () => sdkContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _inMemoryContainer; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public FakeCosmosHandlerAdvancedFeatureTests() + { + _inMemoryContainer = new InMemoryContainer("test-advanced", "/partitionKey"); + _handler = new FakeCosmosHandler(_inMemoryContainer); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("db", "test-advanced"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + private async Task> DrainQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef) + { + var iterator = _container.GetItemQueryIterator(queryDef); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UDF through handler — register on backing container, query via SDK + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UDF_RegisteredOnBackingContainer_UsableInQueryThroughHandler() + { + _inMemoryContainer.RegisterUdf("doubleIt", args => (double)args[0] * 2); + + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Alice", + Value = 5, + Nested = new NestedObject { Score = 7.0 } + }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT c.name, udf.doubleIt(c.nested.score) AS doubled FROM c"); + + results.Should().HaveCount(1); + results[0]["doubled"]!.Value().Should().Be(14); + } + + [Fact] + public async Task UDF_InWhereClause_FiltersCorrectly() + { + _inMemoryContainer.RegisterUdf("isLongName", args => args[0]?.ToString()?.Length > 3); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bo" }, + new PartitionKey("pk1")); + + var results = await DrainQuery( + "SELECT * FROM c WHERE udf.isLongName(c.name)"); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Stored Procedures — register on backing container, execute via Scripts + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StoredProc_RegisteredOnBackingContainer_ExecutableThroughHandler() + { + _inMemoryContainer.RegisterStoredProcedure("echo", + (pk, args) => JsonConvert.SerializeObject(new { echo = args[0]?.ToString() })); + + var response = await _inMemoryContainer.Scripts.ExecuteStoredProcedureAsync( + "echo", new PartitionKey("pk1"), new dynamic[] { "hello" }); + + var parsed = JObject.Parse(response.Resource); + parsed["echo"]!.Value().Should().Be("hello"); + } + + [Fact] + public async Task StoredProc_CanAccessContainerData() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + _inMemoryContainer.RegisterStoredProcedure("countItems", + (pk, args) => + { + // Use query to count items in the container + var iterator = _inMemoryContainer.GetItemQueryIterator("SELECT * FROM c"); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + items.AddRange(page); + } + return items.Count.ToString(); + }); + + var response = await _inMemoryContainer.Scripts.ExecuteStoredProcedureAsync( + "countItems", new PartitionKey("pk1"), new dynamic[] { }); + + response.Resource.Should().Be("2"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Computed Properties through handler queries + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ComputedProperty_AppearsInQueryResults() + { + var props = new ContainerProperties("test-computed", "/partitionKey"); + props.ComputedProperties.Add(new ComputedProperty + { Name = "cp_upper", Query = "SELECT VALUE UPPER(c.name) FROM c" }); + var container = new InMemoryContainer(props); + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + var sdkContainer = client.GetContainer("db", "test-computed"); + + await sdkContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "alice" }, + new PartitionKey("pk1")); + + var iterator = sdkContainer.GetItemQueryIterator( + "SELECT c.name, c.cp_upper FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["cp_upper"]!.Value().Should().Be("ALICE"); + } + + [Fact] + public async Task ComputedProperty_NotVisibleInPointRead() + { + var props = new ContainerProperties("test-computed2", "/partitionKey"); + props.ComputedProperties.Add(new ComputedProperty + { Name = "cp_lower", Query = "SELECT VALUE LOWER(c.name) FROM c" }); + var container = new InMemoryContainer(props); + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + var sdkContainer = client.GetContainer("db", "test-computed2"); + + await sdkContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var response = await sdkContainer.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.ContainsKey("cp_lower").Should().BeFalse(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Container management via backing container + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Container_ClearItems_ThenQueryThroughHandler_ReturnsEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + _inMemoryContainer.ClearItems(); + + var results = await DrainQuery("SELECT * FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Container_MultiContainerRouter_IndependentData() + { + var container1 = new InMemoryContainer("container1", "/partitionKey"); + var container2 = new InMemoryContainer("container2", "/partitionKey"); + var handler1 = new FakeCosmosHandler(container1); + var handler2 = new FakeCosmosHandler(container2); + + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["container1"] = handler1, + ["container2"] = handler2, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) + }); + + var c1 = client.GetContainer("db", "container1"); + var c2 = client.GetContainer("db", "container2"); + + await c1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "InC1" }, + new PartitionKey("pk1")); + await c2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "InC2" }, + new PartitionKey("pk1")); + + var read1 = await c1.ReadItemAsync("1", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("InC1"); + + var read2 = await c2.ReadItemAsync("1", new PartitionKey("pk1")); + read2.Resource.Name.Should().Be("InC2"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Unique Key Policy + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UniqueKeyPolicy_EnforcedThroughHandler() + { + var props = new ContainerProperties("test-unique", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(props); + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + var sdkContainer = client.GetContainer("db", "test-unique"); + + await sdkContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + // Duplicate name in same partition should fail + var act = () => sdkContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerChangeFeedTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerChangeFeedTests.cs index 5205448..4357a6a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerChangeFeedTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerChangeFeedTests.cs @@ -17,372 +17,372 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerChangeFeedTests : IDisposable { - private readonly InMemoryContainer _inMemoryContainer; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public FakeCosmosHandlerChangeFeedTests() - { - _inMemoryContainer = new InMemoryContainer("test-changefeed", "/partitionKey"); - _handler = new FakeCosmosHandler(_inMemoryContainer); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("db", "test-changefeed"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - private async Task> DrainIterator(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - private async Task> DrainChangeFeed(FeedIterator iterator, int maxPages = 10) - { - var results = new List(); - int pages = 0; - while (iterator.HasMoreResults && pages < maxPages) - { - try - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - pages++; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotModified) - { - break; - } - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // CRUD through handler -> change feed via backing container - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ChangeFeed_CreateThroughHandler_VisibleInBackingContainer() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(0); - var items = await DrainIterator(iterator); - items.Should().HaveCount(2); - } - - [Fact] - public async Task ChangeFeed_Incremental_TracksNewItemsAfterCheckpoint() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - var newItems = await DrainIterator(iterator); - newItems.Should().HaveCount(1); - newItems[0].Name.Should().Be("Bob"); - } - - [Fact] - public async Task ChangeFeed_UpsertThroughHandler_RecordsChange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "After" }, - new PartitionKey("pk1")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - var changes = await DrainIterator(iterator); - changes.Should().HaveCount(1); - changes[0].Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_DeleteThroughHandler_AdvancesCheckpoint() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, - new PartitionKey("pk1")); - - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var newCheckpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - newCheckpoint.Should().BeGreaterThan(checkpoint); - } - - [Fact] - public async Task ChangeFeed_PatchThroughHandler_RecordsUpdatedDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before", Value = 10 }, - new PartitionKey("pk1")); - - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "After")]); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - var changes = await DrainIterator(iterator); - changes.Should().HaveCount(1); - changes[0].Name.Should().Be("After"); - } - - [Fact] - public async Task ChangeFeed_ReplaceThroughHandler_RecordsFullDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "After", Value = 99 }, - "1", new PartitionKey("pk1")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - var changes = await DrainIterator(iterator); - changes.Should().HaveCount(1); - changes[0].Name.Should().Be("After"); - changes[0].Value.Should().Be(99); - } - - [Fact] - public async Task ChangeFeed_BatchThroughHandler_TracksAllOperations() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "Batch1" }); - batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "Batch2" }); - await batch.ExecuteAsync(); - - var iterator = _inMemoryContainer.GetChangeFeedIterator(0); - var changes = await DrainIterator(iterator); - changes.Should().HaveCount(2); - changes.Select(c => c.Name).Should().BeEquivalentTo("Batch1", "Batch2"); - } - - [Fact] - public async Task ChangeFeed_ManyItemsThroughHandler_AllTracked() - { - for (int i = 0; i < 20; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var iterator = _inMemoryContainer.GetChangeFeedIterator(0); - var allChanges = await DrainIterator(iterator); - allChanges.Should().HaveCount(20); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Change feed via backing container's SDK iterator - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ChangeFeed_BackingContainerSdkIterator_Beginning_ReturnsAllItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, - new PartitionKey("pk2")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = await DrainChangeFeed(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().Contain("Alice"); - results.Select(r => r.Name).Should().Contain("Bob"); - } - - [Fact] - public async Task ChangeFeed_BackingContainerSdkIterator_EmptyContainer_Returns304() - { - var iterator = _inMemoryContainer.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var results = await DrainChangeFeed(iterator); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_BackingContainerSdkIterator_WithPartitionKey_ScopesToPartition() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, - new PartitionKey("pk2")); - - var iterator = _inMemoryContainer.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(FeedRange.FromPartitionKey(new PartitionKey("pk1"))), - ChangeFeedMode.Incremental); - - var results = await DrainChangeFeed(iterator); - results.Should().OnlyContain(r => r.PartitionKey == "pk1"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Change feed processor — uses backing container - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ChangeFeed_Processor_ReceivesItemsCreatedThroughHandler() - { - var received = new List(); - var processor = _inMemoryContainer.GetChangeFeedProcessorBuilder( - "test-processor", - (context, changes, ct) => - { - received.AddRange(changes); - return Task.CompletedTask; - }) - .WithInMemoryLeaseContainer() - .WithInstanceName("instance1") - .WithStartTime(DateTime.MinValue.ToUniversalTime()) - .Build(); - - await processor.StartAsync(); - - // Create through the handler (HTTP pipeline) - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ProcessorItem" }, - new PartitionKey("pk1")); - - // Allow processor time to pick up the item - await Task.Delay(1000); - await processor.StopAsync(); - - received.Should().Contain(r => r.Name == "ProcessorItem"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // All Versions and Deletes (via checkpoint-based API) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ChangeFeed_AllVersions_ShowsCreateAndUpdate() - { - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "v1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "v1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - var changes = new List(); - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - changes.AddRange(page); - } - - // Should see both the create and the update as separate entries - changes.Should().HaveCount(2); - changes[0]["name"]!.Value().Should().Be("Original"); - changes[1]["name"]!.Value().Should().Be("Updated"); - } - - [Fact] - public async Task ChangeFeed_AllVersions_ShowsDeleteTombstone() - { - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "WillBeDeleted" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("d1", new PartitionKey("pk1")); - - var changes = new List(); - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - changes.AddRange(page); - } - - changes.Should().HaveCount(2); - changes[0]["name"]!.Value().Should().Be("WillBeDeleted"); - changes[1]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_AllVersions_MultipleOps_OrderIsPreserved() - { - var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); - - await _container.CreateItemAsync( - new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V1" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V2" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V3" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("m1", new PartitionKey("pk1")); - - var changes = new List(); - var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - changes.AddRange(page); - } - - changes.Should().HaveCount(4); - changes[0]["name"]!.Value().Should().Be("V1"); - changes[1]["name"]!.Value().Should().Be("V2"); - changes[2]["name"]!.Value().Should().Be("V3"); - changes[3]["_deleted"]!.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _inMemoryContainer; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public FakeCosmosHandlerChangeFeedTests() + { + _inMemoryContainer = new InMemoryContainer("test-changefeed", "/partitionKey"); + _handler = new FakeCosmosHandler(_inMemoryContainer); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("db", "test-changefeed"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + private async Task> DrainIterator(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainChangeFeed(FeedIterator iterator, int maxPages = 10) + { + var results = new List(); + int pages = 0; + while (iterator.HasMoreResults && pages < maxPages) + { + try + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + pages++; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotModified) + { + break; + } + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CRUD through handler -> change feed via backing container + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangeFeed_CreateThroughHandler_VisibleInBackingContainer() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(0); + var items = await DrainIterator(iterator); + items.Should().HaveCount(2); + } + + [Fact] + public async Task ChangeFeed_Incremental_TracksNewItemsAfterCheckpoint() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + var newItems = await DrainIterator(iterator); + newItems.Should().HaveCount(1); + newItems[0].Name.Should().Be("Bob"); + } + + [Fact] + public async Task ChangeFeed_UpsertThroughHandler_RecordsChange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "After" }, + new PartitionKey("pk1")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + var changes = await DrainIterator(iterator); + changes.Should().HaveCount(1); + changes[0].Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_DeleteThroughHandler_AdvancesCheckpoint() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ToDelete" }, + new PartitionKey("pk1")); + + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var newCheckpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + newCheckpoint.Should().BeGreaterThan(checkpoint); + } + + [Fact] + public async Task ChangeFeed_PatchThroughHandler_RecordsUpdatedDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before", Value = 10 }, + new PartitionKey("pk1")); + + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "After")]); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + var changes = await DrainIterator(iterator); + changes.Should().HaveCount(1); + changes[0].Name.Should().Be("After"); + } + + [Fact] + public async Task ChangeFeed_ReplaceThroughHandler_RecordsFullDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "After", Value = 99 }, + "1", new PartitionKey("pk1")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + var changes = await DrainIterator(iterator); + changes.Should().HaveCount(1); + changes[0].Name.Should().Be("After"); + changes[0].Value.Should().Be(99); + } + + [Fact] + public async Task ChangeFeed_BatchThroughHandler_TracksAllOperations() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk1", Name = "Batch1" }); + batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk1", Name = "Batch2" }); + await batch.ExecuteAsync(); + + var iterator = _inMemoryContainer.GetChangeFeedIterator(0); + var changes = await DrainIterator(iterator); + changes.Should().HaveCount(2); + changes.Select(c => c.Name).Should().BeEquivalentTo("Batch1", "Batch2"); + } + + [Fact] + public async Task ChangeFeed_ManyItemsThroughHandler_AllTracked() + { + for (int i = 0; i < 20; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var iterator = _inMemoryContainer.GetChangeFeedIterator(0); + var allChanges = await DrainIterator(iterator); + allChanges.Should().HaveCount(20); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Change feed via backing container's SDK iterator + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangeFeed_BackingContainerSdkIterator_Beginning_ReturnsAllItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, + new PartitionKey("pk2")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = await DrainChangeFeed(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().Contain("Alice"); + results.Select(r => r.Name).Should().Contain("Bob"); + } + + [Fact] + public async Task ChangeFeed_BackingContainerSdkIterator_EmptyContainer_Returns304() + { + var iterator = _inMemoryContainer.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var results = await DrainChangeFeed(iterator); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_BackingContainerSdkIterator_WithPartitionKey_ScopesToPartition() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, + new PartitionKey("pk2")); + + var iterator = _inMemoryContainer.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(FeedRange.FromPartitionKey(new PartitionKey("pk1"))), + ChangeFeedMode.Incremental); + + var results = await DrainChangeFeed(iterator); + results.Should().OnlyContain(r => r.PartitionKey == "pk1"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Change feed processor — uses backing container + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangeFeed_Processor_ReceivesItemsCreatedThroughHandler() + { + var received = new List(); + var processor = _inMemoryContainer.GetChangeFeedProcessorBuilder( + "test-processor", + (context, changes, ct) => + { + received.AddRange(changes); + return Task.CompletedTask; + }) + .WithInMemoryLeaseContainer() + .WithInstanceName("instance1") + .WithStartTime(DateTime.MinValue.ToUniversalTime()) + .Build(); + + await processor.StartAsync(); + + // Create through the handler (HTTP pipeline) + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "ProcessorItem" }, + new PartitionKey("pk1")); + + // Allow processor time to pick up the item + await Task.Delay(1000); + await processor.StopAsync(); + + received.Should().Contain(r => r.Name == "ProcessorItem"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // All Versions and Deletes (via checkpoint-based API) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangeFeed_AllVersions_ShowsCreateAndUpdate() + { + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "v1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "v1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var changes = new List(); + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + changes.AddRange(page); + } + + // Should see both the create and the update as separate entries + changes.Should().HaveCount(2); + changes[0]["name"]!.Value().Should().Be("Original"); + changes[1]["name"]!.Value().Should().Be("Updated"); + } + + [Fact] + public async Task ChangeFeed_AllVersions_ShowsDeleteTombstone() + { + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "d1", PartitionKey = "pk1", Name = "WillBeDeleted" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("d1", new PartitionKey("pk1")); + + var changes = new List(); + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + changes.AddRange(page); + } + + changes.Should().HaveCount(2); + changes[0]["name"]!.Value().Should().Be("WillBeDeleted"); + changes[1]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_AllVersions_MultipleOps_OrderIsPreserved() + { + var checkpoint = _inMemoryContainer.GetChangeFeedCheckpoint(); + + await _container.CreateItemAsync( + new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V1" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V2" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "m1", PartitionKey = "pk1", Name = "V3" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("m1", new PartitionKey("pk1")); + + var changes = new List(); + var iterator = _inMemoryContainer.GetChangeFeedIterator(checkpoint); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + changes.AddRange(page); + } + + changes.Should().HaveCount(4); + changes[0]["name"]!.Value().Should().Be("V1"); + changes[1]["name"]!.Value().Should().Be("V2"); + changes[2]["name"]!.Value().Should().Be("V3"); + changes[3]["_deleted"]!.Value().Should().BeTrue(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerDatabaseRoutesBugReproduction.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerDatabaseRoutesBugReproduction.cs index f55d277..a395c10 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerDatabaseRoutesBugReproduction.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerDatabaseRoutesBugReproduction.cs @@ -25,132 +25,132 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerDatabaseRoutesBugReproduction { - [Fact] - public async Task FakeCosmosHandler_ShouldSupportCreateDatabaseIfNotExistsAsync() - { - var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - var act = async () => await client.CreateDatabaseIfNotExistsAsync("test-db"); - await act.Should().NotThrowAsync( - "FakeCosmosHandler should support CreateDatabaseIfNotExistsAsync since nearly all real projects need it"); - } - - [Fact] - public async Task FakeCosmosHandler_ShouldSupportCreateContainerIfNotExistsAsync() - { - var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - // Projects typically create a database first, then a container - var act = async () => - { - var database = client.GetDatabase("test-db"); - await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); - }; - await act.Should().NotThrowAsync( - "FakeCosmosHandler should support CreateContainerIfNotExistsAsync since nearly all real projects need it"); - } - - [Fact] - public async Task FakeCosmosHandler_ShouldSupportReadDatabaseAsync() - { - var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - var database = client.GetDatabase("test-db"); - var act = async () => await database.ReadAsync(); - await act.Should().NotThrowAsync( - "FakeCosmosHandler should support ReadDatabaseAsync"); - } - - [Fact] - public async Task FakeCosmosHandler_ShouldSupportDeleteDatabaseAsync() - { - var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - var database = client.GetDatabase("test-db"); - var act = async () => await database.DeleteAsync(); - await act.Should().NotThrowAsync( - "FakeCosmosHandler should support DeleteDatabaseAsync"); - } - - [Fact] - public async Task FakeCosmosHandler_ShouldSupportFullSetupFlow() - { - // This is the most common real-world pattern: - // 1. Create database if not exists - // 2. Create container if not exists - // 3. Use the container - var inMemoryContainer = new InMemoryContainer("my-container", "/partitionKey"); - using var handler = new FakeCosmosHandler(inMemoryContainer); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - // Step 1: Create database - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - dbResponse.StatusCode.Should().BeOneOf( - System.Net.HttpStatusCode.Created, - System.Net.HttpStatusCode.OK); - - // Step 2: Create container - var database = client.GetDatabase("test-db"); - var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); - containerResponse.StatusCode.Should().BeOneOf( - System.Net.HttpStatusCode.Created, - System.Net.HttpStatusCode.OK); - - // Step 3: Use the container - var container = client.GetContainer("test-db", "my-container"); - await container.CreateItemAsync( - new { id = "1", partitionKey = "pk1", name = "test" }, - new PartitionKey("pk1")); - - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - ((string)readResponse.Resource.name).Should().Be("test"); - } + [Fact] + public async Task FakeCosmosHandler_ShouldSupportCreateDatabaseIfNotExistsAsync() + { + var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + var act = async () => await client.CreateDatabaseIfNotExistsAsync("test-db"); + await act.Should().NotThrowAsync( + "FakeCosmosHandler should support CreateDatabaseIfNotExistsAsync since nearly all real projects need it"); + } + + [Fact] + public async Task FakeCosmosHandler_ShouldSupportCreateContainerIfNotExistsAsync() + { + var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + // Projects typically create a database first, then a container + var act = async () => + { + var database = client.GetDatabase("test-db"); + await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); + }; + await act.Should().NotThrowAsync( + "FakeCosmosHandler should support CreateContainerIfNotExistsAsync since nearly all real projects need it"); + } + + [Fact] + public async Task FakeCosmosHandler_ShouldSupportReadDatabaseAsync() + { + var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + var database = client.GetDatabase("test-db"); + var act = async () => await database.ReadAsync(); + await act.Should().NotThrowAsync( + "FakeCosmosHandler should support ReadDatabaseAsync"); + } + + [Fact] + public async Task FakeCosmosHandler_ShouldSupportDeleteDatabaseAsync() + { + var inMemoryContainer = new InMemoryContainer("test-container", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + var database = client.GetDatabase("test-db"); + var act = async () => await database.DeleteAsync(); + await act.Should().NotThrowAsync( + "FakeCosmosHandler should support DeleteDatabaseAsync"); + } + + [Fact] + public async Task FakeCosmosHandler_ShouldSupportFullSetupFlow() + { + // This is the most common real-world pattern: + // 1. Create database if not exists + // 2. Create container if not exists + // 3. Use the container + var inMemoryContainer = new InMemoryContainer("my-container", "/partitionKey"); + using var handler = new FakeCosmosHandler(inMemoryContainer); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + // Step 1: Create database + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + dbResponse.StatusCode.Should().BeOneOf( + System.Net.HttpStatusCode.Created, + System.Net.HttpStatusCode.OK); + + // Step 2: Create container + var database = client.GetDatabase("test-db"); + var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); + containerResponse.StatusCode.Should().BeOneOf( + System.Net.HttpStatusCode.Created, + System.Net.HttpStatusCode.OK); + + // Step 3: Use the container + var container = client.GetContainer("test-db", "my-container"); + await container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", name = "test" }, + new PartitionKey("pk1")); + + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + ((string)readResponse.Resource.name).Should().Be("test"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerHierarchicalPkTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerHierarchicalPkTests.cs index ca44a71..369bf20 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerHierarchicalPkTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerHierarchicalPkTests.cs @@ -1,10 +1,10 @@ using System.Net; +using AwesomeAssertions; using CosmosDB.InMemoryEmulator; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Newtonsoft.Json; using Xunit; -using AwesomeAssertions; namespace CosmosDB.InMemoryEmulator.Tests; @@ -15,441 +15,441 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerHierarchicalPkTests { - public class HierDoc - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("region")] public string Region { get; set; } = ""; - [JsonProperty("tenant")] public string Tenant { get; set; } = ""; - [JsonProperty("category")] public string Category { get; set; } = ""; - [JsonProperty("data")] public string Data { get; set; } = ""; - [JsonProperty("count")] public int Count { get; set; } - } - - private static (CosmosClient client, Container container, InMemoryContainer backing) CreateTwoPathSetup() - { - var backing = new InMemoryContainer("hier2", new[] { "/region", "/tenant" }); - var handler = new FakeCosmosHandler(backing); - var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - return (client, client.GetContainer("db", "hier2"), backing); - } - - private static (CosmosClient client, Container container, InMemoryContainer backing) CreateThreePathSetup() - { - var backing = new InMemoryContainer("hier3", new[] { "/region", "/tenant", "/category" }); - var handler = new FakeCosmosHandler(backing); - var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - return (client, client.GetContainer("db", "hier3"), backing); - } - - private static PartitionKey PK2(string a, string b) => - new PartitionKeyBuilder().Add(a).Add(b).Build(); - - private static PartitionKey PK3(string a, string b, string c) => - new PartitionKeyBuilder().Add(a).Add(b).Add(c).Build(); - - private static PartitionKey Prefix1(string a) => - new PartitionKeyBuilder().Add(a).Build(); - - private static PartitionKey Prefix2(string a, string b) => - new PartitionKeyBuilder().Add(a).Add(b).Build(); - - private static async Task> DrainIterator(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - #region 3.1 Collection Metadata Correctness - - [Fact] - public async Task CollectionMetadata_SinglePathPK_ReturnsHash() - { - var backing = new InMemoryContainer("single", "/partitionKey"); - var handler = new FakeCosmosHandler(backing); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - var container = client.GetContainer("db", "single"); - - // Verify single-path PK works (Hash kind) — basic CRUD proves metadata is correct - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us" }, new PartitionKey("us")); - var read = await container.ReadItemAsync("1", new PartitionKey("us")); - read.Resource.Id.Should().Be("1"); - } - - [Fact] - public async Task CollectionMetadata_MultiPathPK_ReturnsMultiHash() - { - var (client, container, _) = CreateTwoPathSetup(); - - // If metadata returns "Hash" instead of "MultiHash", this prefix query would throw ArgumentException - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); - - var prefixPk = Prefix1("us"); - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - } - - [Fact] - public async Task CollectionMetadata_ThreePathPK_ReturnsMultiHash() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "eu", Tenant = "t1", Category = "cat1" }, PK3("eu", "t1", "cat1")); - - var prefixPk = Prefix1("eu"); - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - } - - #endregion - - #region 3.2 Prefix Partition Key Queries via FakeCosmosHandler - - [Fact] - public async Task FakeHandler_TwoPathPK_PrefixQuery_OneComponent_ReturnsMatches() - { - var (client, container, _) = CreateTwoPathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); - await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1" }, PK2("eu", "t1")); - - // Prefix PK queries via FakeCosmosHandler require a WHERE clause because - // the SDK routes prefix queries using partition key ranges, not the PK header. - var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") - .WithParameter("@r", "us"); - var iterator = container.GetItemQueryIterator( - query, - requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("us") }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); - } - - [Fact] - public async Task FakeHandler_TwoPathPK_FullQuery_ReturnsExactMatch() - { - var (client, container, _) = CreateTwoPathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = PK2("us", "t1") }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Id.Should().Be("1"); - } - - [Fact] - public async Task FakeHandler_ThreePathPK_PrefixQuery_OneComponent_ReturnsMatches() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2", Category = "c2" }, PK3("us", "t2", "c2")); - await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1", Category = "c1" }, PK3("eu", "t1", "c1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") - .WithParameter("@r", "us"); - var iterator = container.GetItemQueryIterator( - query, - requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("us") }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); - } - - [Fact] - public async Task FakeHandler_ThreePathPK_PrefixQuery_TwoComponents_ReturnsMatches() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t1", Category = "c2" }, PK3("us", "t1", "c2")); - await container.CreateItemAsync(new HierDoc { Id = "3", Region = "us", Tenant = "t2", Category = "c1" }, PK3("us", "t2", "c1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r AND c.tenant = @t") - .WithParameter("@r", "us") - .WithParameter("@t", "t1"); - var iterator = container.GetItemQueryIterator( - query, - requestOptions: new QueryRequestOptions { PartitionKey = Prefix2("us", "t1") }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); - } - - [Fact] - public async Task FakeHandler_ThreePathPK_FullQuery_ReturnsExactMatch() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t1", Category = "c2" }, PK3("us", "t1", "c2")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = PK3("us", "t1", "c1") }); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Id.Should().Be("1"); - } - - [Fact] - public async Task FakeHandler_PrefixQuery_NoMatches_ReturnsEmpty() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") - .WithParameter("@r", "nonexistent"); - var iterator = container.GetItemQueryIterator( - query, - requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("nonexistent") }); - - var results = await DrainIterator(iterator); - results.Should().BeEmpty(); - } - - #endregion - - #region 3.3 CRUD Operations via FakeCosmosHandler with Hierarchical PKs - - [Fact] - public async Task FakeHandler_HierarchicalPK_Create_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - var response = await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "hello" }, - PK3("us", "t1", "c1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Data.Should().Be("hello"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_Read_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "test" }, - PK3("us", "t1", "c1")); - - var response = await container.ReadItemAsync("1", PK3("us", "t1", "c1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Data.Should().Be("test"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_Upsert_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "v1" }, - PK3("us", "t1", "c1")); - - var response = await container.UpsertItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "v2" }, - PK3("us", "t1", "c1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Data.Should().Be("v2"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_Delete_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, - PK3("us", "t1", "c1")); - - var response = await container.DeleteItemAsync("1", PK3("us", "t1", "c1")); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var ex = await Assert.ThrowsAsync( - () => container.ReadItemAsync("1", PK3("us", "t1", "c1"))); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_Patch_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "old" }, - PK3("us", "t1", "c1")); - - var response = await container.PatchItemAsync("1", PK3("us", "t1", "c1"), - new[] { PatchOperation.Set("/data", "patched") }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Data.Should().Be("patched"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_Replace_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "old" }, - PK3("us", "t1", "c1")); - - var response = await container.ReplaceItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "replaced" }, - "1", PK3("us", "t1", "c1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Data.Should().Be("replaced"); - } - - #endregion - - #region 3.4 LINQ Queries via FakeCosmosHandler with Hierarchical PKs - - [Fact] - public async Task FakeHandler_HierarchicalPK_LinqQuery_WithPartitionKey_Works() - { - var (client, container, _) = CreateThreePathSetup(); - - await container.CreateItemAsync( - new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "match" }, - PK3("us", "t1", "c1")); - await container.CreateItemAsync( - new HierDoc { Id = "2", Region = "eu", Tenant = "t1", Category = "c1", Data = "nomatch" }, - PK3("eu", "t1", "c1")); - - var queryable = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = PK3("us", "t1", "c1") }); - var iterator = queryable.Where(d => d.Data == "match").ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(1); - results[0].Id.Should().Be("1"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_LinqQuery_CrossPartition_Works() - { - var (client, container, _) = CreateTwoPathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Data = "a" }, PK2("us", "t1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "eu", Tenant = "t2", Data = "b" }, PK2("eu", "t2")); - - var queryable = container.GetItemLinqQueryable(); - var iterator = queryable.OrderBy(d => d.Id).ToFeedIterator(); - - var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - } - - #endregion - - #region 3.5 Change Feed with Hierarchical PKs via FakeCosmosHandler - - [Fact] - public async Task FakeHandler_HierarchicalPK_ChangeFeed_ByPartitionKey_Works() - { - var (_, container, backing) = CreateTwoPathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); - await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1" }, PK2("eu", "t1")); - - // Read change feed for specific partition key via backing container - // (change feed with hierarchical PKs through FakeCosmosHandler uses range-based routing) - var changeFeedIterator = backing.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(FeedRange.FromPartitionKey(PK2("us", "t1"))), - ChangeFeedMode.Incremental); - - var changes = new List(); - while (changeFeedIterator.HasMoreResults) - { - var response = await changeFeedIterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(response); - } - - changes.Should().HaveCount(1); - changes[0].Id.Should().Be("1"); - } - - [Fact] - public async Task FakeHandler_HierarchicalPK_ChangeFeed_AllPartitions_Works() - { - var (_, container, backing) = CreateTwoPathSetup(); - - await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); - await container.CreateItemAsync(new HierDoc { Id = "2", Region = "eu", Tenant = "t2" }, PK2("eu", "t2")); - - // Read change feed via backing container - var changeFeedIterator = backing.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - - var allChanges = new List(); - while (changeFeedIterator.HasMoreResults) - { - var response = await changeFeedIterator.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - allChanges.AddRange(response); - } - - allChanges.Should().HaveCount(2); - allChanges.Select(c => c.Id).Should().BeEquivalentTo(new[] { "1", "2" }); - } - - #endregion + public class HierDoc + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("region")] public string Region { get; set; } = ""; + [JsonProperty("tenant")] public string Tenant { get; set; } = ""; + [JsonProperty("category")] public string Category { get; set; } = ""; + [JsonProperty("data")] public string Data { get; set; } = ""; + [JsonProperty("count")] public int Count { get; set; } + } + + private static (CosmosClient client, Container container, InMemoryContainer backing) CreateTwoPathSetup() + { + var backing = new InMemoryContainer("hier2", new[] { "/region", "/tenant" }); + var handler = new FakeCosmosHandler(backing); + var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + return (client, client.GetContainer("db", "hier2"), backing); + } + + private static (CosmosClient client, Container container, InMemoryContainer backing) CreateThreePathSetup() + { + var backing = new InMemoryContainer("hier3", new[] { "/region", "/tenant", "/category" }); + var handler = new FakeCosmosHandler(backing); + var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + return (client, client.GetContainer("db", "hier3"), backing); + } + + private static PartitionKey PK2(string a, string b) => + new PartitionKeyBuilder().Add(a).Add(b).Build(); + + private static PartitionKey PK3(string a, string b, string c) => + new PartitionKeyBuilder().Add(a).Add(b).Add(c).Build(); + + private static PartitionKey Prefix1(string a) => + new PartitionKeyBuilder().Add(a).Build(); + + private static PartitionKey Prefix2(string a, string b) => + new PartitionKeyBuilder().Add(a).Add(b).Build(); + + private static async Task> DrainIterator(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + #region 3.1 Collection Metadata Correctness + + [Fact] + public async Task CollectionMetadata_SinglePathPK_ReturnsHash() + { + var backing = new InMemoryContainer("single", "/partitionKey"); + var handler = new FakeCosmosHandler(backing); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + var container = client.GetContainer("db", "single"); + + // Verify single-path PK works (Hash kind) — basic CRUD proves metadata is correct + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us" }, new PartitionKey("us")); + var read = await container.ReadItemAsync("1", new PartitionKey("us")); + read.Resource.Id.Should().Be("1"); + } + + [Fact] + public async Task CollectionMetadata_MultiPathPK_ReturnsMultiHash() + { + var (client, container, _) = CreateTwoPathSetup(); + + // If metadata returns "Hash" instead of "MultiHash", this prefix query would throw ArgumentException + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); + + var prefixPk = Prefix1("us"); + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + } + + [Fact] + public async Task CollectionMetadata_ThreePathPK_ReturnsMultiHash() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "eu", Tenant = "t1", Category = "cat1" }, PK3("eu", "t1", "cat1")); + + var prefixPk = Prefix1("eu"); + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + } + + #endregion + + #region 3.2 Prefix Partition Key Queries via FakeCosmosHandler + + [Fact] + public async Task FakeHandler_TwoPathPK_PrefixQuery_OneComponent_ReturnsMatches() + { + var (client, container, _) = CreateTwoPathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); + await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1" }, PK2("eu", "t1")); + + // Prefix PK queries via FakeCosmosHandler require a WHERE clause because + // the SDK routes prefix queries using partition key ranges, not the PK header. + var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") + .WithParameter("@r", "us"); + var iterator = container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("us") }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); + } + + [Fact] + public async Task FakeHandler_TwoPathPK_FullQuery_ReturnsExactMatch() + { + var (client, container, _) = CreateTwoPathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = PK2("us", "t1") }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Id.Should().Be("1"); + } + + [Fact] + public async Task FakeHandler_ThreePathPK_PrefixQuery_OneComponent_ReturnsMatches() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2", Category = "c2" }, PK3("us", "t2", "c2")); + await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1", Category = "c1" }, PK3("eu", "t1", "c1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") + .WithParameter("@r", "us"); + var iterator = container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("us") }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); + } + + [Fact] + public async Task FakeHandler_ThreePathPK_PrefixQuery_TwoComponents_ReturnsMatches() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t1", Category = "c2" }, PK3("us", "t1", "c2")); + await container.CreateItemAsync(new HierDoc { Id = "3", Region = "us", Tenant = "t2", Category = "c1" }, PK3("us", "t2", "c1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r AND c.tenant = @t") + .WithParameter("@r", "us") + .WithParameter("@t", "t1"); + var iterator = container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = Prefix2("us", "t1") }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(new[] { "1", "2" }); + } + + [Fact] + public async Task FakeHandler_ThreePathPK_FullQuery_ReturnsExactMatch() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t1", Category = "c2" }, PK3("us", "t1", "c2")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = PK3("us", "t1", "c1") }); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Id.Should().Be("1"); + } + + [Fact] + public async Task FakeHandler_PrefixQuery_NoMatches_ReturnsEmpty() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, PK3("us", "t1", "c1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.region = @r") + .WithParameter("@r", "nonexistent"); + var iterator = container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = Prefix1("nonexistent") }); + + var results = await DrainIterator(iterator); + results.Should().BeEmpty(); + } + + #endregion + + #region 3.3 CRUD Operations via FakeCosmosHandler with Hierarchical PKs + + [Fact] + public async Task FakeHandler_HierarchicalPK_Create_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + var response = await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "hello" }, + PK3("us", "t1", "c1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Data.Should().Be("hello"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_Read_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "test" }, + PK3("us", "t1", "c1")); + + var response = await container.ReadItemAsync("1", PK3("us", "t1", "c1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Data.Should().Be("test"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_Upsert_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "v1" }, + PK3("us", "t1", "c1")); + + var response = await container.UpsertItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "v2" }, + PK3("us", "t1", "c1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Data.Should().Be("v2"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_Delete_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1" }, + PK3("us", "t1", "c1")); + + var response = await container.DeleteItemAsync("1", PK3("us", "t1", "c1")); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var ex = await Assert.ThrowsAsync( + () => container.ReadItemAsync("1", PK3("us", "t1", "c1"))); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_Patch_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "old" }, + PK3("us", "t1", "c1")); + + var response = await container.PatchItemAsync("1", PK3("us", "t1", "c1"), + new[] { PatchOperation.Set("/data", "patched") }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Data.Should().Be("patched"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_Replace_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "old" }, + PK3("us", "t1", "c1")); + + var response = await container.ReplaceItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "replaced" }, + "1", PK3("us", "t1", "c1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Data.Should().Be("replaced"); + } + + #endregion + + #region 3.4 LINQ Queries via FakeCosmosHandler with Hierarchical PKs + + [Fact] + public async Task FakeHandler_HierarchicalPK_LinqQuery_WithPartitionKey_Works() + { + var (client, container, _) = CreateThreePathSetup(); + + await container.CreateItemAsync( + new HierDoc { Id = "1", Region = "us", Tenant = "t1", Category = "c1", Data = "match" }, + PK3("us", "t1", "c1")); + await container.CreateItemAsync( + new HierDoc { Id = "2", Region = "eu", Tenant = "t1", Category = "c1", Data = "nomatch" }, + PK3("eu", "t1", "c1")); + + var queryable = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = PK3("us", "t1", "c1") }); + var iterator = queryable.Where(d => d.Data == "match").ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(1); + results[0].Id.Should().Be("1"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_LinqQuery_CrossPartition_Works() + { + var (client, container, _) = CreateTwoPathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1", Data = "a" }, PK2("us", "t1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "eu", Tenant = "t2", Data = "b" }, PK2("eu", "t2")); + + var queryable = container.GetItemLinqQueryable(); + var iterator = queryable.OrderBy(d => d.Id).ToFeedIterator(); + + var results = await DrainIterator(iterator); + results.Should().HaveCount(2); + } + + #endregion + + #region 3.5 Change Feed with Hierarchical PKs via FakeCosmosHandler + + [Fact] + public async Task FakeHandler_HierarchicalPK_ChangeFeed_ByPartitionKey_Works() + { + var (_, container, backing) = CreateTwoPathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "us", Tenant = "t2" }, PK2("us", "t2")); + await container.CreateItemAsync(new HierDoc { Id = "3", Region = "eu", Tenant = "t1" }, PK2("eu", "t1")); + + // Read change feed for specific partition key via backing container + // (change feed with hierarchical PKs through FakeCosmosHandler uses range-based routing) + var changeFeedIterator = backing.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(FeedRange.FromPartitionKey(PK2("us", "t1"))), + ChangeFeedMode.Incremental); + + var changes = new List(); + while (changeFeedIterator.HasMoreResults) + { + var response = await changeFeedIterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(response); + } + + changes.Should().HaveCount(1); + changes[0].Id.Should().Be("1"); + } + + [Fact] + public async Task FakeHandler_HierarchicalPK_ChangeFeed_AllPartitions_Works() + { + var (_, container, backing) = CreateTwoPathSetup(); + + await container.CreateItemAsync(new HierDoc { Id = "1", Region = "us", Tenant = "t1" }, PK2("us", "t1")); + await container.CreateItemAsync(new HierDoc { Id = "2", Region = "eu", Tenant = "t2" }, PK2("eu", "t2")); + + // Read change feed via backing container + var changeFeedIterator = backing.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + + var allChanges = new List(); + while (changeFeedIterator.HasMoreResults) + { + var response = await changeFeedIterator.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + allChanges.AddRange(response); + } + + allChanges.Should().HaveCount(2); + allChanges.Select(c => c.Id).Should().BeEquivalentTo(new[] { "1", "2" }); + } + + #endregion } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerReadManyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerReadManyTests.cs index 2dfedbc..54a73d3 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerReadManyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerReadManyTests.cs @@ -14,174 +14,174 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerReadManyTests : IDisposable { - private readonly InMemoryContainer _inMemoryContainer; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public FakeCosmosHandlerReadManyTests() - { - _inMemoryContainer = new InMemoryContainer("test-readmany", "/partitionKey"); - _handler = new FakeCosmosHandler(_inMemoryContainer); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("db", "test-readmany"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - private async Task SeedItems() - { - var docs = new[] - { - new TestDocument { Id = "1", PartitionKey = "pkA", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pkA", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pkB", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "4", PartitionKey = "pkB", Name = "Diana", Value = 40 }, - new TestDocument { Id = "5", PartitionKey = "pkC", Name = "Eve", Value = 50 }, - }; - foreach (var doc in docs) - await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - [Fact] - public async Task ReadMany_MultipleItems_ReturnsAll() - { - await SeedItems(); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - ("3", new PartitionKey("pkB")), - ("5", new PartitionKey("pkC")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(3); - response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); - } - - [Fact] - public async Task ReadMany_SingleItem_ReturnsOne() - { - await SeedItems(); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("2", new PartitionKey("pkA")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(1); - response.Resource.First().Name.Should().Be("Bob"); - } - - [Fact] - public async Task ReadMany_MissingItem_SkipsSilently() - { - await SeedItems(); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - ("nonexistent", new PartitionKey("pkA")), - ("3", new PartitionKey("pkB")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(2); - response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task ReadMany_AcrossPartitionKeys_ReturnsAll() - { - await SeedItems(); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - ("4", new PartitionKey("pkB")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(2); - response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Diana"); - } - - [Fact] - public async Task ReadMany_EmptyList_ReturnsEmpty() - { - await SeedItems(); - - var response = await _handler.BackingContainer.ReadManyItemsAsync( - new List<(string id, PartitionKey pk)>()); - response.Resource.Should().BeEmpty(); - } - - [Fact] - public async Task ReadMany_AfterDeleteThroughHandler_ReflectsDeletion() - { - await SeedItems(); - - await _container.DeleteItemAsync("1", new PartitionKey("pkA")); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - ("2", new PartitionKey("pkA")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(1); - response.Resource.First().Name.Should().Be("Bob"); - } - - [Fact] - public async Task ReadMany_AfterUpsertThroughHandler_ReflectsUpdate() - { - await SeedItems(); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pkA", Name = "Updated", Value = 999 }, - new PartitionKey("pkA")); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); - response.Resource.Should().HaveCount(1); - response.Resource.First().Name.Should().Be("Updated"); - response.Resource.First().Value.Should().Be(999); - } - - [Fact] - public async Task ReadMany_Stream_ReturnsSuccessStatus() - { - await SeedItems(); - - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pkA")), - ("3", new PartitionKey("pkB")), - }; - - var response = await _handler.BackingContainer.ReadManyItemsStreamAsync(itemsToRead); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - } + private readonly InMemoryContainer _inMemoryContainer; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public FakeCosmosHandlerReadManyTests() + { + _inMemoryContainer = new InMemoryContainer("test-readmany", "/partitionKey"); + _handler = new FakeCosmosHandler(_inMemoryContainer); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("db", "test-readmany"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + private async Task SeedItems() + { + var docs = new[] + { + new TestDocument { Id = "1", PartitionKey = "pkA", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pkA", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pkB", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "4", PartitionKey = "pkB", Name = "Diana", Value = 40 }, + new TestDocument { Id = "5", PartitionKey = "pkC", Name = "Eve", Value = 50 }, + }; + foreach (var doc in docs) + await _container.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + [Fact] + public async Task ReadMany_MultipleItems_ReturnsAll() + { + await SeedItems(); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + ("3", new PartitionKey("pkB")), + ("5", new PartitionKey("pkC")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(3); + response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); + } + + [Fact] + public async Task ReadMany_SingleItem_ReturnsOne() + { + await SeedItems(); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("2", new PartitionKey("pkA")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(1); + response.Resource.First().Name.Should().Be("Bob"); + } + + [Fact] + public async Task ReadMany_MissingItem_SkipsSilently() + { + await SeedItems(); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + ("nonexistent", new PartitionKey("pkA")), + ("3", new PartitionKey("pkB")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(2); + response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task ReadMany_AcrossPartitionKeys_ReturnsAll() + { + await SeedItems(); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + ("4", new PartitionKey("pkB")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(2); + response.Resource.Select(r => r.Name).Should().BeEquivalentTo("Alice", "Diana"); + } + + [Fact] + public async Task ReadMany_EmptyList_ReturnsEmpty() + { + await SeedItems(); + + var response = await _handler.BackingContainer.ReadManyItemsAsync( + new List<(string id, PartitionKey pk)>()); + response.Resource.Should().BeEmpty(); + } + + [Fact] + public async Task ReadMany_AfterDeleteThroughHandler_ReflectsDeletion() + { + await SeedItems(); + + await _container.DeleteItemAsync("1", new PartitionKey("pkA")); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + ("2", new PartitionKey("pkA")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(1); + response.Resource.First().Name.Should().Be("Bob"); + } + + [Fact] + public async Task ReadMany_AfterUpsertThroughHandler_ReflectsUpdate() + { + await SeedItems(); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pkA", Name = "Updated", Value = 999 }, + new PartitionKey("pkA")); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsAsync(itemsToRead); + response.Resource.Should().HaveCount(1); + response.Resource.First().Name.Should().Be("Updated"); + response.Resource.First().Value.Should().Be(999); + } + + [Fact] + public async Task ReadMany_Stream_ReturnsSuccessStatus() + { + await SeedItems(); + + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pkA")), + ("3", new PartitionKey("pkB")), + }; + + var response = await _handler.BackingContainer.ReadManyItemsStreamAsync(itemsToRead); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerTests.cs index 2fcf41a..b061e8d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FakeCosmosHandlerTests.cs @@ -1,9 +1,9 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -14,1398 +14,1398 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FakeCosmosHandlerGapTests { - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task Handler_ReadFeed_ReturnsAllDocuments() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // ReadFeed via SDK — uses read feed endpoint - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(5); - } + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task Handler_ReadFeed_ReturnsAllDocuments() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // ReadFeed via SDK — uses read feed endpoint + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(5); + } } public class FakeCosmosHandlerGapTests4 { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task Handler_AccountMetadata_ReturnsValidResponse() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - json["id"].Should().NotBeNull(); - json["writableLocations"].Should().NotBeNull(); - json["readableLocations"].Should().NotBeNull(); - } - - [Fact] - public async Task Handler_Query_PartitionKeyRange_FiltersToRange() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - - using var httpClient = new HttpClient(handler); - - // Query with a specific partition key range ID - var request = new HttpRequestMessage(HttpMethod.Post, - "https://localhost:9999/dbs/db/colls/test/docs"); - request.Headers.Add("x-ms-documentdb-partitionkeyrangeid", "0"); - request.Headers.Add("x-ms-documentdb-query", "True"); - request.Headers.Add("x-ms-documentdb-isquery", "True"); - request.Content = new StringContent( - """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); - - var response = await httpClient.SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var body = await response.Content.ReadAsStringAsync(); - var result = JObject.Parse(body); - var documents = result["Documents"]!.ToObject(); - - // Only items whose PK hashes to range 0 should be returned - documents!.Count.Should().BeLessThan(20); - documents.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task Handler_CacheEviction_StaleEntries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - CacheTtl = TimeSpan.FromMilliseconds(200) - }); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // First query — caches the result - var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iter1.HasMoreResults) await iter1.ReadNextAsync(); - - // Wait for cache TTL to expire - await Task.Delay(300); - - // Add a new item - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - - // Second query — stale cache should be evicted, returns fresh results - var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iter2.HasMoreResults) - { - var page = await iter2.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_CacheEviction_ExceedsMaxEntries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - CacheMaxEntries = 2 - }); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Execute 3 different queries to exceed max cache entries - // Note: 'value' is a reserved word in Cosmos SDK, use bracket notation - var queries = new[] - { - "SELECT * FROM c WHERE c.id = '1'", - "SELECT * FROM c WHERE c[\"value\"] = 1", - "SELECT * FROM c WHERE c.name = 'Test'" - }; - - foreach (var query in queries) - { - var iter = cosmosContainer.GetItemQueryIterator(query); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - } - - // All queries should still work — LRU eviction shouldn't break anything - var finalIter = cosmosContainer.GetItemQueryIterator(queries[2]); - var results = new List(); - while (finalIter.HasMoreResults) - { - var page = await finalIter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Handler_CollectionMetadata_ReturnsContainerProperties() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - json["id"]!.ToString().Should().Be("test"); - json["partitionKey"].Should().NotBeNull(); - } - - [Fact] - public async Task Handler_MurmurHash_DistributesEvenly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 100 items with diverse partition keys - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"tenant-{i}", Name = $"Item{i}" }, - new PartitionKey($"tenant-{i}")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - using var httpClient = new HttpClient(handler); - - var rangeCounts = new int[4]; - for (var rangeId = 0; rangeId < 4; rangeId++) - { - var request = new HttpRequestMessage(HttpMethod.Post, - "https://localhost:9999/dbs/db/colls/test/docs"); - request.Headers.Add("x-ms-documentdb-partitionkeyrangeid", rangeId.ToString()); - request.Headers.Add("x-ms-documentdb-query", "True"); - request.Headers.Add("x-ms-documentdb-isquery", "True"); - request.Content = new StringContent( - """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); - - var response = await httpClient.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var result = JObject.Parse(body); - rangeCounts[rangeId] = result["Documents"]!.ToObject()!.Count; - } - - // All ranges should have at least some items (rough distribution) - rangeCounts.Sum().Should().Be(100); - // Each range should have at least 5 items (with 100 items across 4 ranges) - foreach (var count in rangeCounts) - count.Should().BeGreaterThan(5); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task Handler_AccountMetadata_ReturnsValidResponse() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + json["id"].Should().NotBeNull(); + json["writableLocations"].Should().NotBeNull(); + json["readableLocations"].Should().NotBeNull(); + } + + [Fact] + public async Task Handler_Query_PartitionKeyRange_FiltersToRange() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + + using var httpClient = new HttpClient(handler); + + // Query with a specific partition key range ID + var request = new HttpRequestMessage(HttpMethod.Post, + "https://localhost:9999/dbs/db/colls/test/docs"); + request.Headers.Add("x-ms-documentdb-partitionkeyrangeid", "0"); + request.Headers.Add("x-ms-documentdb-query", "True"); + request.Headers.Add("x-ms-documentdb-isquery", "True"); + request.Content = new StringContent( + """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); + + var response = await httpClient.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(body); + var documents = result["Documents"]!.ToObject(); + + // Only items whose PK hashes to range 0 should be returned + documents!.Count.Should().BeLessThan(20); + documents.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Handler_CacheEviction_StaleEntries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + CacheTtl = TimeSpan.FromMilliseconds(200) + }); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // First query — caches the result + var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iter1.HasMoreResults) await iter1.ReadNextAsync(); + + // Wait for cache TTL to expire + await Task.Delay(300); + + // Add a new item + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + + // Second query — stale cache should be evicted, returns fresh results + var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iter2.HasMoreResults) + { + var page = await iter2.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_CacheEviction_ExceedsMaxEntries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 1 }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + CacheMaxEntries = 2 + }); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Execute 3 different queries to exceed max cache entries + // Note: 'value' is a reserved word in Cosmos SDK, use bracket notation + var queries = new[] + { + "SELECT * FROM c WHERE c.id = '1'", + "SELECT * FROM c WHERE c[\"value\"] = 1", + "SELECT * FROM c WHERE c.name = 'Test'" + }; + + foreach (var query in queries) + { + var iter = cosmosContainer.GetItemQueryIterator(query); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + } + + // All queries should still work — LRU eviction shouldn't break anything + var finalIter = cosmosContainer.GetItemQueryIterator(queries[2]); + var results = new List(); + while (finalIter.HasMoreResults) + { + var page = await finalIter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Handler_CollectionMetadata_ReturnsContainerProperties() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + json["id"]!.ToString().Should().Be("test"); + json["partitionKey"].Should().NotBeNull(); + } + + [Fact] + public async Task Handler_MurmurHash_DistributesEvenly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 100 items with diverse partition keys + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"tenant-{i}", Name = $"Item{i}" }, + new PartitionKey($"tenant-{i}")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + using var httpClient = new HttpClient(handler); + + var rangeCounts = new int[4]; + for (var rangeId = 0; rangeId < 4; rangeId++) + { + var request = new HttpRequestMessage(HttpMethod.Post, + "https://localhost:9999/dbs/db/colls/test/docs"); + request.Headers.Add("x-ms-documentdb-partitionkeyrangeid", rangeId.ToString()); + request.Headers.Add("x-ms-documentdb-query", "True"); + request.Headers.Add("x-ms-documentdb-isquery", "True"); + request.Content = new StringContent( + """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); + + var response = await httpClient.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(body); + rangeCounts[rangeId] = result["Documents"]!.ToObject()!.Count; + } + + // All ranges should have at least some items (rough distribution) + rangeCounts.Sum().Should().Be(100); + // Each range should have at least 5 items (with 100 items across 4 ranges) + foreach (var count in rangeCounts) + count.Should().BeGreaterThan(5); + } } public class FakeCosmosHandlerTests { - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task Handler_BasicQuery_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_OrderByQuery_ReturnsCorrectOrder() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .OrderBy(doc => doc.Name).ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].Name.Should().Be("Alice"); - results[1].Name.Should().Be("Bob"); - results[2].Name.Should().Be("Charlie"); - } - - [Fact] - public async Task Handler_Pagination_ContinuationTokenRoundtrip() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var allItems = new List(); - var pageCount = 0; - var iterator = cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - pageCount++; - } - - allItems.Should().HaveCount(5); - pageCount.Should().BeGreaterThan(1); - } - - [Fact] - public async Task Handler_PartitionKeyRanges_ReturnsConfiguredCount() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - - handler.RequestLog.Should().BeEmpty(); // No requests yet - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Trigger a query to force SDK to request pkranges - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - handler.RequestLog.Should().Contain(entry => entry.Contains("/pkranges")); - } - - [Fact] - public async Task Handler_PartitionKeyRanges_IfNoneMatch_Returns304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Make two queries — second should use cached pkranges - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator1 = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator1.HasMoreResults) await iterator1.ReadNextAsync(); - - var iterator2 = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator2.HasMoreResults) await iterator2.ReadNextAsync(); - - // Both queries should have triggered pkranges request - handler.RequestLog.Count(entry => entry.Contains("/pkranges")).Should().BeGreaterThanOrEqualTo(1); - } - - [Fact] - public async Task Handler_QueryLog_RecordsQueries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Use explicit SQL query rather than LINQ — the SDK may optimize LINQ - // differently and not always route through the query endpoint - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - - handler.QueryLog.Should().NotBeEmpty(); - } - - [Fact] - public async Task Handler_RequestLog_RecordsRequests() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - - handler.RequestLog.Should().NotBeEmpty(); - handler.RequestLog.Should().Contain(entry => entry.StartsWith("GET") || entry.StartsWith("POST")); - } - - [Fact] - public async Task Handler_FilteredQuery_ReturnsCorrectResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .Where(doc => doc.Value > 15) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_VerifySdkCompatibility_DoesNotThrow() - { - var act = FakeCosmosHandler.VerifySdkCompatibilityAsync; - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task Handler_MultiContainer_RouterDispatchesCorrectly() - { - var container1 = new InMemoryContainer("container1", "/partitionKey"); - var container2 = new InMemoryContainer("container2", "/partitionKey"); - - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FromContainer1" }, - new PartitionKey("pk1")); - await container2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FromContainer2" }, - new PartitionKey("pk1")); - - using var handler1 = new FakeCosmosHandler(container1); - using var handler2 = new FakeCosmosHandler(container2); - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["container1"] = handler1, - ["container2"] = handler2, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c1 = client.GetContainer("db", "container1"); - var c2 = client.GetContainer("db", "container2"); - - var results1 = new List(); - var iter1 = c1.GetItemLinqQueryable().ToFeedIterator(); - while (iter1.HasMoreResults) { var page = await iter1.ReadNextAsync(); results1.AddRange(page); } - - var results2 = new List(); - var iter2 = c2.GetItemLinqQueryable().ToFeedIterator(); - while (iter2.HasMoreResults) { var page = await iter2.ReadNextAsync(); results2.AddRange(page); } - - results1.Should().ContainSingle().Which.Name.Should().Be("FromContainer1"); - results2.Should().ContainSingle().Which.Name.Should().Be("FromContainer2"); - } - - [Fact] - public async Task Handler_CrossPartition_WithMultipleRanges_AllDataReturned() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i % 5}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i % 5}")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(20); - } - - [Fact] - public async Task Handler_Router_UnregisteredContainer_ThrowsDescriptiveError() - { - var orders = new InMemoryContainer("orders", "/customerId"); - var customers = new InMemoryContainer("customers", "/id"); - - using var handler1 = new FakeCosmosHandler(orders); - using var handler2 = new FakeCosmosHandler(customers); - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["orders"] = handler1, - ["customers"] = handler2, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var unknown = client.GetContainer("db", "unknown-container"); - - var act = () => unknown.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Handler_CountAsync_ReturnsCorrectCount() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var count = await cosmosContainer.GetItemLinqQueryable().CountAsync(); - - count.Resource.Should().Be(2); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C1. Parameterized Query - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ParameterizedQuery_ReturnsCorrectResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Alice"); - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator(queryDef); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C2. TOP Query - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_TopQuery_ReturnsLimitedResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .Take(3) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C3. OFFSET/LIMIT Query - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_OffsetLimitQuery_ReturnsPaginatedSlice() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .Skip(2).Take(3) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C4. DISTINCT Query - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_DistinctQuery_ReturnsUniqueValues() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator( - "SELECT DISTINCT VALUE c.name FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Should().Contain("Alice"); - results.Should().Contain("Bob"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C5. ORDER BY Descending - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_OrderByDescending_ReturnsReverseOrder() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .OrderByDescending(doc => doc.Name).ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].Name.Should().Be("Charlie"); - results[1].Name.Should().Be("Bob"); - results[2].Name.Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C6. Multi-field ORDER BY - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_OrderBy_MultipleFields_ReturnsCorrectOrder() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 5 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable() - .OrderBy(doc => doc.Name).ThenBy(doc => doc.Value) - .ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(10); - results[1].Name.Should().Be("Alice"); - results[1].Value.Should().Be(20); - results[2].Name.Should().Be("Bob"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C7. SUM Aggregate - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_SumAggregate_ReturnsCorrectSum() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE SUM(c[\"value\"]) FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Should().Be(60); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C8. MIN/MAX Aggregates - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_MinMaxAggregate_ReturnsCorrectValues() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 50 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var minResults = new List(); - var minIter = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE MIN(c[\"value\"]) FROM c"); - while (minIter.HasMoreResults) - { - var page = await minIter.ReadNextAsync(); - minResults.AddRange(page); - } - - var maxResults = new List(); - var maxIter = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE MAX(c[\"value\"]) FROM c"); - while (maxIter.HasMoreResults) - { - var page = await maxIter.ReadNextAsync(); - maxResults.AddRange(page); - } - - minResults.Should().ContainSingle().Which.Should().Be(10); - maxResults.Should().ContainSingle().Which.Should().Be(50); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C9. AVG Aggregate - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_AvgAggregate_ReturnsCorrectAverage() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE AVG(c[\"value\"]) FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Should().Be(20.0); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C10. Empty Container Query - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_EmptyContainer_QueryReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C11. GROUP BY Query - // - // The SDK's GroupByQueryPipelineStage on non-Windows platforms expects a - // specific rewritten query format where each document is wrapped in a - // structure with "groupByItems" + "payload" (similar to ORDER BY wrapping). - // The handler's query plan returns groupByExpressions and - // groupByAliasToAggregateType, but the rewritten query is the original SQL, - // which the SDK's GroupByQueryPipelineStage then fails to parse because it - // tries to extract JSON paths from SDK-internal field names containing - // curly braces (e.g. {"item": c.name}). - // - // Fixing this requires implementing GROUP BY document wrapping in - // FakeCosmosHandler.HandleQueryAsync (analogous to the ORDER BY wrapping), - // which is non-trivial because: - // 1. The SDK expects partial aggregates per group key per partition range - // 2. The rewritten query format is undocumented SDK internals - // 3. AVG needs {sum,count} per group, not a final value - // - // GROUP BY works correctly via InMemoryContainer.GetItemQueryIterator() - // directly — this limitation only applies to the FakeCosmosHandler HTTP - // path. Using InMemoryCosmosClient or UseInMemoryCosmosDB() DI bypasses - // this entirely. - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_GroupByQuery_ReturnsGroupedResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator( - "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name"); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Should().Contain(r => r["name"]!.ToString() == "Alice" && r["cnt"]!.Value() == 2); - results.Should().Contain(r => r["name"]!.ToString() == "Bob" && r["cnt"]!.Value() == 1); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // C12. Query with Partition Key Filter - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_QueryWithPartitionKeyFilter_ReturnsFilteredResults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pkA", Name = "FromA" }, new PartitionKey("pkA")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pkB", Name = "FromB" }, new PartitionKey("pkB")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pkA") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("FromA"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D4. Query response item count header - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_QueryResponse_ContainsItemCount() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var request = new HttpRequestMessage(HttpMethod.Post, - "https://localhost:9999/dbs/db/colls/test/docs"); - request.Headers.Add("x-ms-documentdb-query", "True"); - request.Headers.Add("x-ms-documentdb-isquery", "True"); - request.Content = new StringContent( - """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); - - var response = await httpClient.SendAsync(request); - response.Headers.TryGetValues("x-ms-item-count", out var itemCountValues).Should().BeTrue(); - int.Parse(itemCountValues!.First()).Should().Be(2); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D5. Collection metadata detail - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CollectionMetadata_ContainsIndexingPolicy() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); - - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - json["indexingPolicy"].Should().NotBeNull(); - json["indexingPolicy"]!["indexingMode"]!.ToString().Should().Be("consistent"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D6. Composite PK metadata - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CollectionMetadata_WithCompositePartitionKey_ReturnsMultiplePaths() - { - var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); - - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - var paths = json["partitionKey"]!["paths"]!.ToObject(); - paths.Should().HaveCount(2); - paths.Should().Contain("/tenantId"); - paths.Should().Contain("/userId"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // D7. Account metadata detail - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_AccountMetadata_ContainsQueryEngineConfiguration() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/"); - - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - json["queryEngineConfiguration"].Should().NotBeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // E1. Router with single container - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_Router_SingleContainer_WorksCorrectly() - { - var container = new InMemoryContainer("only", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Solo" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["only"] = handler, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c = client.GetContainer("db", "only"); - var results = new List(); - var iter = c.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) { var page = await iter.ReadNextAsync(); results.AddRange(page); } - - results.Should().ContainSingle().Which.Name.Should().Be("Solo"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // E2. Full CRUD through router - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_Router_CrudThroughRouter_WorksEndToEnd() - { - var container = new InMemoryContainer("crud-routed", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["crud-routed"] = handler, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c = client.GetContainer("db", "crud-routed"); - var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Created" }; - - // Create - var createResp = await c.CreateItemAsync(doc, new PartitionKey("pk1")); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - // Read - var readResp = await c.ReadItemAsync("r1", new PartitionKey("pk1")); - readResp.Resource.Name.Should().Be("Created"); - - // Replace - doc.Name = "Replaced"; - var replaceResp = await c.ReplaceItemAsync(doc, "r1", new PartitionKey("pk1")); - replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResp.Resource.Name.Should().Be("Replaced"); - - // Delete - var deleteResp = await c.DeleteItemAsync("r1", new PartitionKey("pk1")); - deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify gone - var act = () => c.ReadItemAsync("r1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // E3. Per-container fault injection via router - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_Router_FaultInjection_PerContainer() - { - var containerOk = new InMemoryContainer("healthy", "/partitionKey"); - var containerFail = new InMemoryContainer("faulty", "/partitionKey"); - - await containerOk.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Ok" }, - new PartitionKey("pk1")); - await containerFail.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fail" }, - new PartitionKey("pk1")); - - using var handlerOk = new FakeCosmosHandler(containerOk); - using var handlerFail = new FakeCosmosHandler(containerFail); - - // Only the faulty container injects faults - handlerFail.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["healthy"] = handlerOk, - ["faulty"] = handlerFail, - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - // Healthy container works - var healthy = client.GetContainer("db", "healthy"); - var readOk = await healthy.ReadItemAsync("1", new PartitionKey("pk1")); - readOk.Resource.Name.Should().Be("Ok"); - - // Faulty container fails - var faulty = client.GetContainer("db", "faulty"); - var act = () => faulty.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // F1. Abandoned iteration cache eviction - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_AbandonedIteration_CacheIsEventuallyEvicted() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - CacheTtl = TimeSpan.FromMilliseconds(100) - }); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Start paginated query but only read the first page - var iterator = cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) - .ToFeedIterator(); - if (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - // Deliberately abandon the iterator - - // Wait for cache TTL - await Task.Delay(200); - - // A fresh query should still work correctly (cache was evicted, no corruption) - var results = new List(); - var freshIterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (freshIterator.HasMoreResults) - { - var page = await freshIterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // F2. Concurrent queries don't interfere - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_ConcurrentQueries_DoNotInterfere() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var task1 = Task.Run(async () => - { - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - return results; - }); - - var task2 = Task.Run(async () => - { - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c WHERE c[\"value\"] > 15"); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - return results; - }); - - var results1 = await task1; - var results2 = await task2; - - results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); - results2.Should().HaveCount(2); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // G1. Dispose clears cache - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_Dispose_ClearsQueryCache() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Run a paginated query to populate cache - var iterator = cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) - .ToFeedIterator(); - if (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - // Dispose should not throw - handler.Dispose(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // G2. Double dispose safety - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Handler_DoubleDispose_DoesNotThrow() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var handler = new FakeCosmosHandler(container); - - handler.Dispose(); - var act = () => handler.Dispose(); - - act.Should().NotThrow(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // H1. Unrecognised route returns 404 - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_UnrecognisedRoute_Returns404WithMessage() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/something/weird"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - var body = await response.Content.ReadAsStringAsync(); - body.Should().Contain("FakeCosmosHandler"); - body.Should().Contain("unrecognised route"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B1. Numeric Partition Key - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_WithNumericPartitionKey_Succeeds() - { - var container = new InMemoryContainer("numericpk", "/value"); - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var c = client.GetContainer("db", "numericpk"); - - var doc = new { id = "n1", value = 42, name = "NumericPK" }; - var createResponse = await c.CreateItemAsync(doc, new PartitionKey(42)); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await c.ReadItemAsync("n1", new PartitionKey(42)); - readResponse.Resource["name"]!.ToString().Should().Be("NumericPK"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // B2. Boolean Partition Key - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Handler_CreateItem_WithBooleanPartitionKey_Succeeds() - { - var container = new InMemoryContainer("boolpk", "/isActive"); - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var c = client.GetContainer("db", "boolpk"); - - var doc = new { id = "b1", isActive = true, name = "BoolPK" }; - var createResponse = await c.CreateItemAsync(doc, new PartitionKey(true)); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await c.ReadItemAsync("b1", new PartitionKey(true)); - readResponse.Resource["name"]!.ToString().Should().Be("BoolPK"); - } + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task Handler_BasicQuery_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_OrderByQuery_ReturnsCorrectOrder() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .OrderBy(doc => doc.Name).ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].Name.Should().Be("Alice"); + results[1].Name.Should().Be("Bob"); + results[2].Name.Should().Be("Charlie"); + } + + [Fact] + public async Task Handler_Pagination_ContinuationTokenRoundtrip() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var allItems = new List(); + var pageCount = 0; + var iterator = cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + pageCount++; + } + + allItems.Should().HaveCount(5); + pageCount.Should().BeGreaterThan(1); + } + + [Fact] + public async Task Handler_PartitionKeyRanges_ReturnsConfiguredCount() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + + handler.RequestLog.Should().BeEmpty(); // No requests yet + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Trigger a query to force SDK to request pkranges + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + handler.RequestLog.Should().Contain(entry => entry.Contains("/pkranges")); + } + + [Fact] + public async Task Handler_PartitionKeyRanges_IfNoneMatch_Returns304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Make two queries — second should use cached pkranges + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator1 = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator1.HasMoreResults) await iterator1.ReadNextAsync(); + + var iterator2 = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator2.HasMoreResults) await iterator2.ReadNextAsync(); + + // Both queries should have triggered pkranges request + handler.RequestLog.Count(entry => entry.Contains("/pkranges")).Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task Handler_QueryLog_RecordsQueries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Use explicit SQL query rather than LINQ — the SDK may optimize LINQ + // differently and not always route through the query endpoint + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + + handler.QueryLog.Should().NotBeEmpty(); + } + + [Fact] + public async Task Handler_RequestLog_RecordsRequests() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + + handler.RequestLog.Should().NotBeEmpty(); + handler.RequestLog.Should().Contain(entry => entry.StartsWith("GET") || entry.StartsWith("POST")); + } + + [Fact] + public async Task Handler_FilteredQuery_ReturnsCorrectResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .Where(doc => doc.Value > 15) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_VerifySdkCompatibility_DoesNotThrow() + { + var act = FakeCosmosHandler.VerifySdkCompatibilityAsync; + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Handler_MultiContainer_RouterDispatchesCorrectly() + { + var container1 = new InMemoryContainer("container1", "/partitionKey"); + var container2 = new InMemoryContainer("container2", "/partitionKey"); + + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FromContainer1" }, + new PartitionKey("pk1")); + await container2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "FromContainer2" }, + new PartitionKey("pk1")); + + using var handler1 = new FakeCosmosHandler(container1); + using var handler2 = new FakeCosmosHandler(container2); + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["container1"] = handler1, + ["container2"] = handler2, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c1 = client.GetContainer("db", "container1"); + var c2 = client.GetContainer("db", "container2"); + + var results1 = new List(); + var iter1 = c1.GetItemLinqQueryable().ToFeedIterator(); + while (iter1.HasMoreResults) { var page = await iter1.ReadNextAsync(); results1.AddRange(page); } + + var results2 = new List(); + var iter2 = c2.GetItemLinqQueryable().ToFeedIterator(); + while (iter2.HasMoreResults) { var page = await iter2.ReadNextAsync(); results2.AddRange(page); } + + results1.Should().ContainSingle().Which.Name.Should().Be("FromContainer1"); + results2.Should().ContainSingle().Which.Name.Should().Be("FromContainer2"); + } + + [Fact] + public async Task Handler_CrossPartition_WithMultipleRanges_AllDataReturned() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i % 5}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i % 5}")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(20); + } + + [Fact] + public async Task Handler_Router_UnregisteredContainer_ThrowsDescriptiveError() + { + var orders = new InMemoryContainer("orders", "/customerId"); + var customers = new InMemoryContainer("customers", "/id"); + + using var handler1 = new FakeCosmosHandler(orders); + using var handler2 = new FakeCosmosHandler(customers); + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["orders"] = handler1, + ["customers"] = handler2, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var unknown = client.GetContainer("db", "unknown-container"); + + var act = () => unknown.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Handler_CountAsync_ReturnsCorrectCount() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var count = await cosmosContainer.GetItemLinqQueryable().CountAsync(); + + count.Resource.Should().Be(2); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C1. Parameterized Query + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ParameterizedQuery_ReturnsCorrectResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Alice"); + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator(queryDef); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C2. TOP Query + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_TopQuery_ReturnsLimitedResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .Take(3) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C3. OFFSET/LIMIT Query + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_OffsetLimitQuery_ReturnsPaginatedSlice() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .Skip(2).Take(3) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C4. DISTINCT Query + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_DistinctQuery_ReturnsUniqueValues() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator( + "SELECT DISTINCT VALUE c.name FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Should().Contain("Alice"); + results.Should().Contain("Bob"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C5. ORDER BY Descending + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_OrderByDescending_ReturnsReverseOrder() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .OrderByDescending(doc => doc.Name).ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].Name.Should().Be("Charlie"); + results[1].Name.Should().Be("Bob"); + results[2].Name.Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C6. Multi-field ORDER BY + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_OrderBy_MultipleFields_ReturnsCorrectOrder() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 5 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable() + .OrderBy(doc => doc.Name).ThenBy(doc => doc.Value) + .ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(10); + results[1].Name.Should().Be("Alice"); + results[1].Value.Should().Be(20); + results[2].Name.Should().Be("Bob"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C7. SUM Aggregate + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_SumAggregate_ReturnsCorrectSum() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE SUM(c[\"value\"]) FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Should().Be(60); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C8. MIN/MAX Aggregates + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_MinMaxAggregate_ReturnsCorrectValues() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 50 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var minResults = new List(); + var minIter = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE MIN(c[\"value\"]) FROM c"); + while (minIter.HasMoreResults) + { + var page = await minIter.ReadNextAsync(); + minResults.AddRange(page); + } + + var maxResults = new List(); + var maxIter = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE MAX(c[\"value\"]) FROM c"); + while (maxIter.HasMoreResults) + { + var page = await maxIter.ReadNextAsync(); + maxResults.AddRange(page); + } + + minResults.Should().ContainSingle().Which.Should().Be(10); + maxResults.Should().ContainSingle().Which.Should().Be(50); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C9. AVG Aggregate + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_AvgAggregate_ReturnsCorrectAverage() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE AVG(c[\"value\"]) FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Should().Be(20.0); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C10. Empty Container Query + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_EmptyContainer_QueryReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C11. GROUP BY Query + // + // The SDK's GroupByQueryPipelineStage on non-Windows platforms expects a + // specific rewritten query format where each document is wrapped in a + // structure with "groupByItems" + "payload" (similar to ORDER BY wrapping). + // The handler's query plan returns groupByExpressions and + // groupByAliasToAggregateType, but the rewritten query is the original SQL, + // which the SDK's GroupByQueryPipelineStage then fails to parse because it + // tries to extract JSON paths from SDK-internal field names containing + // curly braces (e.g. {"item": c.name}). + // + // Fixing this requires implementing GROUP BY document wrapping in + // FakeCosmosHandler.HandleQueryAsync (analogous to the ORDER BY wrapping), + // which is non-trivial because: + // 1. The SDK expects partial aggregates per group key per partition range + // 2. The rewritten query format is undocumented SDK internals + // 3. AVG needs {sum,count} per group, not a final value + // + // GROUP BY works correctly via InMemoryContainer.GetItemQueryIterator() + // directly — this limitation only applies to the FakeCosmosHandler HTTP + // path. Using InMemoryCosmosClient or UseInMemoryCosmosDB() DI bypasses + // this entirely. + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_GroupByQuery_ReturnsGroupedResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator( + "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name"); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Should().Contain(r => r["name"]!.ToString() == "Alice" && r["cnt"]!.Value() == 2); + results.Should().Contain(r => r["name"]!.ToString() == "Bob" && r["cnt"]!.Value() == 1); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // C12. Query with Partition Key Filter + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_QueryWithPartitionKeyFilter_ReturnsFilteredResults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pkA", Name = "FromA" }, new PartitionKey("pkA")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pkB", Name = "FromB" }, new PartitionKey("pkB")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pkA") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("FromA"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D4. Query response item count header + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_QueryResponse_ContainsItemCount() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, + "https://localhost:9999/dbs/db/colls/test/docs"); + request.Headers.Add("x-ms-documentdb-query", "True"); + request.Headers.Add("x-ms-documentdb-isquery", "True"); + request.Content = new StringContent( + """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); + + var response = await httpClient.SendAsync(request); + response.Headers.TryGetValues("x-ms-item-count", out var itemCountValues).Should().BeTrue(); + int.Parse(itemCountValues!.First()).Should().Be(2); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D5. Collection metadata detail + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CollectionMetadata_ContainsIndexingPolicy() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); + + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + json["indexingPolicy"].Should().NotBeNull(); + json["indexingPolicy"]!["indexingMode"]!.ToString().Should().Be("consistent"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D6. Composite PK metadata + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CollectionMetadata_WithCompositePartitionKey_ReturnsMultiplePaths() + { + var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test"); + + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + var paths = json["partitionKey"]!["paths"]!.ToObject(); + paths.Should().HaveCount(2); + paths.Should().Contain("/tenantId"); + paths.Should().Contain("/userId"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // D7. Account metadata detail + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_AccountMetadata_ContainsQueryEngineConfiguration() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/"); + + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + json["queryEngineConfiguration"].Should().NotBeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // E1. Router with single container + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_Router_SingleContainer_WorksCorrectly() + { + var container = new InMemoryContainer("only", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Solo" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["only"] = handler, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c = client.GetContainer("db", "only"); + var results = new List(); + var iter = c.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) { var page = await iter.ReadNextAsync(); results.AddRange(page); } + + results.Should().ContainSingle().Which.Name.Should().Be("Solo"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // E2. Full CRUD through router + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_Router_CrudThroughRouter_WorksEndToEnd() + { + var container = new InMemoryContainer("crud-routed", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["crud-routed"] = handler, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c = client.GetContainer("db", "crud-routed"); + var doc = new TestDocument { Id = "r1", PartitionKey = "pk1", Name = "Created" }; + + // Create + var createResp = await c.CreateItemAsync(doc, new PartitionKey("pk1")); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + // Read + var readResp = await c.ReadItemAsync("r1", new PartitionKey("pk1")); + readResp.Resource.Name.Should().Be("Created"); + + // Replace + doc.Name = "Replaced"; + var replaceResp = await c.ReplaceItemAsync(doc, "r1", new PartitionKey("pk1")); + replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResp.Resource.Name.Should().Be("Replaced"); + + // Delete + var deleteResp = await c.DeleteItemAsync("r1", new PartitionKey("pk1")); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify gone + var act = () => c.ReadItemAsync("r1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // E3. Per-container fault injection via router + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_Router_FaultInjection_PerContainer() + { + var containerOk = new InMemoryContainer("healthy", "/partitionKey"); + var containerFail = new InMemoryContainer("faulty", "/partitionKey"); + + await containerOk.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Ok" }, + new PartitionKey("pk1")); + await containerFail.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fail" }, + new PartitionKey("pk1")); + + using var handlerOk = new FakeCosmosHandler(containerOk); + using var handlerFail = new FakeCosmosHandler(containerFail); + + // Only the faulty container injects faults + handlerFail.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["healthy"] = handlerOk, + ["faulty"] = handlerFail, + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + // Healthy container works + var healthy = client.GetContainer("db", "healthy"); + var readOk = await healthy.ReadItemAsync("1", new PartitionKey("pk1")); + readOk.Resource.Name.Should().Be("Ok"); + + // Faulty container fails + var faulty = client.GetContainer("db", "faulty"); + var act = () => faulty.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // F1. Abandoned iteration cache eviction + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_AbandonedIteration_CacheIsEventuallyEvicted() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + CacheTtl = TimeSpan.FromMilliseconds(100) + }); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Start paginated query but only read the first page + var iterator = cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) + .ToFeedIterator(); + if (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + // Deliberately abandon the iterator + + // Wait for cache TTL + await Task.Delay(200); + + // A fresh query should still work correctly (cache was evicted, no corruption) + var results = new List(); + var freshIterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (freshIterator.HasMoreResults) + { + var page = await freshIterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // F2. Concurrent queries don't interfere + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_ConcurrentQueries_DoNotInterfere() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var task1 = Task.Run(async () => + { + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + return results; + }); + + var task2 = Task.Run(async () => + { + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c WHERE c[\"value\"] > 15"); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + return results; + }); + + var results1 = await task1; + var results2 = await task2; + + results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); + results2.Should().HaveCount(2); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // G1. Dispose clears cache + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_Dispose_ClearsQueryCache() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Run a paginated query to populate cache + var iterator = cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) + .ToFeedIterator(); + if (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + // Dispose should not throw + handler.Dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // G2. Double dispose safety + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Handler_DoubleDispose_DoesNotThrow() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var handler = new FakeCosmosHandler(container); + + handler.Dispose(); + var act = () => handler.Dispose(); + + act.Should().NotThrow(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // H1. Unrecognised route returns 404 + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_UnrecognisedRoute_Returns404WithMessage() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/something/weird"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("FakeCosmosHandler"); + body.Should().Contain("unrecognised route"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B1. Numeric Partition Key + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_WithNumericPartitionKey_Succeeds() + { + var container = new InMemoryContainer("numericpk", "/value"); + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var c = client.GetContainer("db", "numericpk"); + + var doc = new { id = "n1", value = 42, name = "NumericPK" }; + var createResponse = await c.CreateItemAsync(doc, new PartitionKey(42)); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await c.ReadItemAsync("n1", new PartitionKey(42)); + readResponse.Resource["name"]!.ToString().Should().Be("NumericPK"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // B2. Boolean Partition Key + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Handler_CreateItem_WithBooleanPartitionKey_Succeeds() + { + var container = new InMemoryContainer("boolpk", "/isActive"); + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var c = client.GetContainer("db", "boolpk"); + + var doc = new { id = "b1", isActive = true, name = "BoolPK" }; + var createResponse = await c.CreateItemAsync(doc, new PartitionKey(true)); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await c.ReadItemAsync("b1", new PartitionKey(true)); + readResponse.Resource["name"]!.ToString().Should().Be("BoolPK"); + } } @@ -1415,576 +1415,576 @@ public async Task Handler_CreateItem_WithBooleanPartitionKey_Succeeds() /// public class FakeCosmosHandlerDeepDiveTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - private static async Task<(InMemoryContainer container, FakeCosmosHandler handler, CosmosClient client, Container cosmosContainer)> SetupWithData(int count = 5, string containerName = "deep-test") - { - var container = new InMemoryContainer(containerName, "/partitionKey"); - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, - new PartitionKey("pk1")); - var handler = new FakeCosmosHandler(container); - var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", containerName); - return (container, handler, client, cosmosContainer); - } - - // ── I: Query Pipeline Edge Cases ──────────────────────────────────────── - - [Fact] - public async Task Handler_OrderByWithWhere_ReturnsFilteredSortedResults() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c WHERE c[\"value\"] >= 20 ORDER BY c[\"value\"] DESC"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); // Items 2,3,4 (values 20,30,40) - results[0].Value.Should().Be(40); - results[2].Value.Should().Be(20); - } - - [Fact] - public async Task Handler_OrderByWithPagination_ContinuationWorksAcrossPages() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var allResults = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c[\"value\"] ASC", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - allResults.AddRange(page); - } - - allResults.Should().HaveCount(5); - allResults.Select(r => r.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task Handler_TopWithOrderBy_ReturnsTopNOrdered() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT TOP 3 * FROM c ORDER BY c[\"value\"] DESC"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0].Value.Should().Be(40); - results[1].Value.Should().Be(30); - results[2].Value.Should().Be(20); - } - - [Fact] - public async Task Handler_AggregateWithWhere_ReturnsFilteredAggregate() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE COUNT(1) FROM c WHERE c[\"value\"] > 15"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Items 2,3,4 have values 20,30,40 → count = 3 - results.Should().ContainSingle().Which.Should().Be(3); - } - - [Fact] - public async Task Handler_QueryWithNullPartitionKey_ReturnsNullPKItems() - { - var container = new InMemoryContainer("null-pk-query", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "n1", PartitionKey = null!, Name = "NullPK" }, - PartitionKey.Null); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "null-pk-query"); - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Handler_ReadFeed_WithPagination_PagesThroughAllDocuments() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var allResults = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - int pageCount = 0; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - allResults.AddRange(page); - pageCount++; - } - - allResults.Should().HaveCount(5); - pageCount.Should().BeGreaterThan(1); - } - - // ── III: Response Header & Metadata Fidelity ──────────────────────────── - - [Fact] - public async Task Handler_QueryResponse_ContainsRequestCharge() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(3); - using var _ = handler; using var __ = client; - - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task Handler_PkRanges_Response_ContainsETag() - { - var container = new InMemoryContainer("pk-etag", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/pk-etag/pkranges"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Headers.ETag.Should().NotBeNull(); - } - - [Fact] - public async Task Handler_PkRanges_Response_ContainsCorrectRangeStructure() - { - var container = new InMemoryContainer("pk-struct", "/partitionKey"); - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 2 }); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/pk-struct/pkranges"); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - var ranges = (JArray)json["PartitionKeyRanges"]!; - - ranges.Should().HaveCount(2); - var first = (JObject)ranges[0]; - first["id"].Should().NotBeNull(); - first["minInclusive"].Should().NotBeNull(); - first["maxExclusive"].Should().NotBeNull(); - } - - [Fact] - public async Task Handler_CollectionMetadata_ContainsGeospatialConfig() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/geo-test"); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - - json["geospatialConfig"].Should().NotBeNull(); - json["geospatialConfig"]!["type"]!.ToString().Should().Be("Geography"); - } - - [Fact] - public async Task Handler_CollectionMetadata_ContainsSelfLink() - { - var container = new InMemoryContainer("self-test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/self-test"); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - - json["_self"].Should().NotBeNull(); - json["_etag"].Should().NotBeNull(); - json["_ts"].Should().NotBeNull(); - } - - [Fact] - public async Task Handler_AccountMetadata_ContainsConsistencyPolicy() - { - var container = new InMemoryContainer("cons-test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - var response = await httpClient.GetAsync("https://localhost:9999/"); - var body = await response.Content.ReadAsStringAsync(); - var json = JObject.Parse(body); - - json["userConsistencyPolicy"].Should().NotBeNull(); - json["userConsistencyPolicy"]!["defaultConsistencyLevel"]!.ToString().Should().Be("Session"); - } - - // ── IV: Cache Edge Cases ──────────────────────────────────────────────── - - [Fact] - public async Task Handler_Cache_ConcurrentPaginatedQueries_IndependentContinuations() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(6); - using var _ = handler; using var __ = client; - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - - // Start two iterators before consuming either - var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c ORDER BY c.id ASC", requestOptions: opts); - var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c ORDER BY c.id DESC", requestOptions: opts); - - var results1 = new List(); - var results2 = new List(); - - // Interleave reads from both iterators - while (iter1.HasMoreResults || iter2.HasMoreResults) - { - if (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - if (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - } - - results1.Should().HaveCount(6); - results2.Should().HaveCount(6); - } - - [Fact] - public async Task Handler_Cache_SameQueryTwice_GetsIndependentCursors() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(4); - using var _ = handler; using var __ = client; - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - - var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - - var results1 = new List(); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - - var results2 = new List(); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - - results1.Should().HaveCount(4); - results2.Should().HaveCount(4); - } - - // ── V: Router Edge Cases ──────────────────────────────────────────────── - - [Fact] - public void Handler_Router_EmptyDictionary_ThrowsOnConstruction() - { - var act = () => FakeCosmosHandler.CreateRouter(new Dictionary()); - - act.Should().Throw() - .Which.Message.Should().Contain("At least one handler"); - } - - [Fact] - public void Handler_Router_Dispose_DisposesAllChildHandlers() - { - var container1 = new InMemoryContainer("c1", "/partitionKey"); - var container2 = new InMemoryContainer("c2", "/partitionKey"); - var handler1 = new FakeCosmosHandler(container1); - var handler2 = new FakeCosmosHandler(container2); - - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["c1"] = handler1, - ["c2"] = handler2, - }); - - // Dispose should complete without error, including multiple calls - router.Dispose(); - router.Dispose(); // idempotent - } - - [Fact] - public async Task Handler_Router_PkRangesRequest_RoutesThroughCorrectHandler() - { - var container1 = new InMemoryContainer("route-c1", "/partitionKey"); - var container2 = new InMemoryContainer("route-c2", "/partitionKey"); - using var handler1 = new FakeCosmosHandler(container1, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 2 }); - using var handler2 = new FakeCosmosHandler(container2, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); - - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["route-c1"] = handler1, - ["route-c2"] = handler2, - }); - using var httpClient = new HttpClient(router); - - var response1 = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/route-c1/pkranges"); - var json1 = JObject.Parse(await response1.Content.ReadAsStringAsync()); - ((JArray)json1["PartitionKeyRanges"]!).Should().HaveCount(2); - - var response2 = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/route-c2/pkranges"); - var json2 = JObject.Parse(await response2.Content.ReadAsStringAsync()); - ((JArray)json2["PartitionKeyRanges"]!).Should().HaveCount(4); - } - - // ── VI: Fault Injection Edge Cases ────────────────────────────────────── - - [Fact] - public async Task Handler_FaultInjector_IncludesMetadata_BlocksAccountRequest() - { - var container = new InMemoryContainer("fi-meta", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); - handler.FaultInjectorIncludesMetadata = true; - - using var httpClient = new HttpClient(handler); - var response = await httpClient.GetAsync("https://localhost:9999/"); - - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - } - - [Fact] - public async Task Handler_FaultInjector_ExcludesMetadata_AllowsAccountRequest() - { - var container = new InMemoryContainer("fi-nometa", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); - handler.FaultInjectorIncludesMetadata = false; - - using var httpClient = new HttpClient(handler); - var response = await httpClient.GetAsync("https://localhost:9999/"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Handler_FaultInjector_QueryRequest_ReturnsInjectedFault() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(1); - using var _ = handler; using var __ = client; - - handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { - Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } - }; - handler.FaultInjectorIncludesMetadata = false; - - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var act = async () => { while (iter.HasMoreResults) await iter.ReadNextAsync(); }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Handler_FaultInjector_SelectiveByMethod_OnlyBlocksReads() - { - var container = new InMemoryContainer("fi-selective", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "fi-selective"); - - handler.FaultInjector = req => - req.Method == HttpMethod.Get - ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - : null!; - handler.FaultInjectorIncludesMetadata = false; - - // POST (create) should succeed - var doc = new TestDocument { Id = "sel1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // GET (read) should fail - var act = () => cosmosContainer.ReadItemAsync("sel1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - // ── VII: Continuation Token Edge Cases ────────────────────────────────── - - [Fact] - public async Task Handler_ContinuationToken_IsNonEmptyWhenMorePages() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var firstPage = await iter.ReadNextAsync(); - - // The continuation token should be non-null when more pages exist - firstPage.ContinuationToken.Should().NotBeNullOrEmpty(); - iter.HasMoreResults.Should().BeTrue(); - } - - // ── IX: Error Path Coverage ───────────────────────────────────────────── - - [Fact] - public async Task Handler_Query_UnparsableSql_FallsBackToDirectExecution() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(3); - using var _ = handler; using var __ = client; - - // A query that the parser can handle — verifies fallback doesn't break things - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - // ── Query Plan Validation (Indirect) ──────────────────────────────────── - - [Fact] - public async Task Handler_QueryPlan_OrderBy_WorksEndToEnd() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - // If the query plan for ORDER BY is wrong, the SDK would reject the response - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(5); - results.Select(r => r.Name).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task Handler_QueryPlan_Distinct_WorksEndToEnd() - { - var container = new InMemoryContainer("distinct-test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "distinct-test"); - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.name FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_QueryPlan_OffsetLimit_WorksEndToEnd() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.id OFFSET 1 LIMIT 2"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_QueryPlan_SelectValue_WorksEndToEnd() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(3); - using var _ = handler; using var __ = client; - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT VALUE c.name FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Handler_QueryPlan_Aggregates_WorkEndToEnd() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(5); - using var _ = handler; using var __ = client; - - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT VALUE SUM(c[\"value\"]) FROM c"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // 0+10+20+30+40 = 100 - results.Should().ContainSingle().Which.Should().Be(100); - } - - [Fact] - public async Task Handler_QueryPlan_BasicSelect_WorksEndToEnd() - { - var (_, handler, client, cosmosContainer) = await SetupWithData(3); - using var _ = handler; using var __ = client; - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Handler_DistinctWithOrderBy_ReturnsOrderedDistinctValues() - { - var container = new InMemoryContainer("do-test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "do-test"); - - var results = new List(); - var iter = cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.name FROM c ORDER BY c.name ASC"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Handler_MultipleAggregates_ReturnsAllValues() - { - var container = new InMemoryContainer("multi-agg", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "multi-agg"); - - // Use c["value"] bracket notation because 'value' is a reserved keyword - // in the SDK's ServiceInterop query plan generator. - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT COUNT(1) as cnt, SUM(c[\"value\"]) as total FROM c"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(5); - results[0]["total"]!.Value().Should().Be(100); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + private static async Task<(InMemoryContainer container, FakeCosmosHandler handler, CosmosClient client, Container cosmosContainer)> SetupWithData(int count = 5, string containerName = "deep-test") + { + var container = new InMemoryContainer(containerName, "/partitionKey"); + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, + new PartitionKey("pk1")); + var handler = new FakeCosmosHandler(container); + var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", containerName); + return (container, handler, client, cosmosContainer); + } + + // ── I: Query Pipeline Edge Cases ──────────────────────────────────────── + + [Fact] + public async Task Handler_OrderByWithWhere_ReturnsFilteredSortedResults() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c WHERE c[\"value\"] >= 20 ORDER BY c[\"value\"] DESC"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); // Items 2,3,4 (values 20,30,40) + results[0].Value.Should().Be(40); + results[2].Value.Should().Be(20); + } + + [Fact] + public async Task Handler_OrderByWithPagination_ContinuationWorksAcrossPages() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var allResults = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c[\"value\"] ASC", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + allResults.AddRange(page); + } + + allResults.Should().HaveCount(5); + allResults.Select(r => r.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task Handler_TopWithOrderBy_ReturnsTopNOrdered() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT TOP 3 * FROM c ORDER BY c[\"value\"] DESC"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0].Value.Should().Be(40); + results[1].Value.Should().Be(30); + results[2].Value.Should().Be(20); + } + + [Fact] + public async Task Handler_AggregateWithWhere_ReturnsFilteredAggregate() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE COUNT(1) FROM c WHERE c[\"value\"] > 15"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Items 2,3,4 have values 20,30,40 → count = 3 + results.Should().ContainSingle().Which.Should().Be(3); + } + + [Fact] + public async Task Handler_QueryWithNullPartitionKey_ReturnsNullPKItems() + { + var container = new InMemoryContainer("null-pk-query", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "n1", PartitionKey = null!, Name = "NullPK" }, + PartitionKey.Null); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "null-pk-query"); + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Handler_ReadFeed_WithPagination_PagesThroughAllDocuments() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var allResults = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + int pageCount = 0; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + allResults.AddRange(page); + pageCount++; + } + + allResults.Should().HaveCount(5); + pageCount.Should().BeGreaterThan(1); + } + + // ── III: Response Header & Metadata Fidelity ──────────────────────────── + + [Fact] + public async Task Handler_QueryResponse_ContainsRequestCharge() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(3); + using var _ = handler; using var __ = client; + + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Handler_PkRanges_Response_ContainsETag() + { + var container = new InMemoryContainer("pk-etag", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/pk-etag/pkranges"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.ETag.Should().NotBeNull(); + } + + [Fact] + public async Task Handler_PkRanges_Response_ContainsCorrectRangeStructure() + { + var container = new InMemoryContainer("pk-struct", "/partitionKey"); + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 2 }); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/pk-struct/pkranges"); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + var ranges = (JArray)json["PartitionKeyRanges"]!; + + ranges.Should().HaveCount(2); + var first = (JObject)ranges[0]; + first["id"].Should().NotBeNull(); + first["minInclusive"].Should().NotBeNull(); + first["maxExclusive"].Should().NotBeNull(); + } + + [Fact] + public async Task Handler_CollectionMetadata_ContainsGeospatialConfig() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/geo-test"); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + + json["geospatialConfig"].Should().NotBeNull(); + json["geospatialConfig"]!["type"]!.ToString().Should().Be("Geography"); + } + + [Fact] + public async Task Handler_CollectionMetadata_ContainsSelfLink() + { + var container = new InMemoryContainer("self-test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/self-test"); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + + json["_self"].Should().NotBeNull(); + json["_etag"].Should().NotBeNull(); + json["_ts"].Should().NotBeNull(); + } + + [Fact] + public async Task Handler_AccountMetadata_ContainsConsistencyPolicy() + { + var container = new InMemoryContainer("cons-test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetAsync("https://localhost:9999/"); + var body = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(body); + + json["userConsistencyPolicy"].Should().NotBeNull(); + json["userConsistencyPolicy"]!["defaultConsistencyLevel"]!.ToString().Should().Be("Session"); + } + + // ── IV: Cache Edge Cases ──────────────────────────────────────────────── + + [Fact] + public async Task Handler_Cache_ConcurrentPaginatedQueries_IndependentContinuations() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(6); + using var _ = handler; using var __ = client; + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + + // Start two iterators before consuming either + var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c ORDER BY c.id ASC", requestOptions: opts); + var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c ORDER BY c.id DESC", requestOptions: opts); + + var results1 = new List(); + var results2 = new List(); + + // Interleave reads from both iterators + while (iter1.HasMoreResults || iter2.HasMoreResults) + { + if (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + if (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + } + + results1.Should().HaveCount(6); + results2.Should().HaveCount(6); + } + + [Fact] + public async Task Handler_Cache_SameQueryTwice_GetsIndependentCursors() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(4); + using var _ = handler; using var __ = client; + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + + var iter1 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var iter2 = cosmosContainer.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + + var results1 = new List(); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + + var results2 = new List(); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + + results1.Should().HaveCount(4); + results2.Should().HaveCount(4); + } + + // ── V: Router Edge Cases ──────────────────────────────────────────────── + + [Fact] + public void Handler_Router_EmptyDictionary_ThrowsOnConstruction() + { + var act = () => FakeCosmosHandler.CreateRouter(new Dictionary()); + + act.Should().Throw() + .Which.Message.Should().Contain("At least one handler"); + } + + [Fact] + public void Handler_Router_Dispose_DisposesAllChildHandlers() + { + var container1 = new InMemoryContainer("c1", "/partitionKey"); + var container2 = new InMemoryContainer("c2", "/partitionKey"); + var handler1 = new FakeCosmosHandler(container1); + var handler2 = new FakeCosmosHandler(container2); + + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["c1"] = handler1, + ["c2"] = handler2, + }); + + // Dispose should complete without error, including multiple calls + router.Dispose(); + router.Dispose(); // idempotent + } + + [Fact] + public async Task Handler_Router_PkRangesRequest_RoutesThroughCorrectHandler() + { + var container1 = new InMemoryContainer("route-c1", "/partitionKey"); + var container2 = new InMemoryContainer("route-c2", "/partitionKey"); + using var handler1 = new FakeCosmosHandler(container1, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 2 }); + using var handler2 = new FakeCosmosHandler(container2, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); + + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["route-c1"] = handler1, + ["route-c2"] = handler2, + }); + using var httpClient = new HttpClient(router); + + var response1 = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/route-c1/pkranges"); + var json1 = JObject.Parse(await response1.Content.ReadAsStringAsync()); + ((JArray)json1["PartitionKeyRanges"]!).Should().HaveCount(2); + + var response2 = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/route-c2/pkranges"); + var json2 = JObject.Parse(await response2.Content.ReadAsStringAsync()); + ((JArray)json2["PartitionKeyRanges"]!).Should().HaveCount(4); + } + + // ── VI: Fault Injection Edge Cases ────────────────────────────────────── + + [Fact] + public async Task Handler_FaultInjector_IncludesMetadata_BlocksAccountRequest() + { + var container = new InMemoryContainer("fi-meta", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); + handler.FaultInjectorIncludesMetadata = true; + + using var httpClient = new HttpClient(handler); + var response = await httpClient.GetAsync("https://localhost:9999/"); + + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + + [Fact] + public async Task Handler_FaultInjector_ExcludesMetadata_AllowsAccountRequest() + { + var container = new InMemoryContainer("fi-nometa", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); + handler.FaultInjectorIncludesMetadata = false; + + using var httpClient = new HttpClient(handler); + var response = await httpClient.GetAsync("https://localhost:9999/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Handler_FaultInjector_QueryRequest_ReturnsInjectedFault() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(1); + using var _ = handler; using var __ = client; + + handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { + Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromMilliseconds(1)) } + }; + handler.FaultInjectorIncludesMetadata = false; + + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var act = async () => { while (iter.HasMoreResults) await iter.ReadNextAsync(); }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handler_FaultInjector_SelectiveByMethod_OnlyBlocksReads() + { + var container = new InMemoryContainer("fi-selective", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "fi-selective"); + + handler.FaultInjector = req => + req.Method == HttpMethod.Get + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : null!; + handler.FaultInjectorIncludesMetadata = false; + + // POST (create) should succeed + var doc = new TestDocument { Id = "sel1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // GET (read) should fail + var act = () => cosmosContainer.ReadItemAsync("sel1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + // ── VII: Continuation Token Edge Cases ────────────────────────────────── + + [Fact] + public async Task Handler_ContinuationToken_IsNonEmptyWhenMorePages() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var firstPage = await iter.ReadNextAsync(); + + // The continuation token should be non-null when more pages exist + firstPage.ContinuationToken.Should().NotBeNullOrEmpty(); + iter.HasMoreResults.Should().BeTrue(); + } + + // ── IX: Error Path Coverage ───────────────────────────────────────────── + + [Fact] + public async Task Handler_Query_UnparsableSql_FallsBackToDirectExecution() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(3); + using var _ = handler; using var __ = client; + + // A query that the parser can handle — verifies fallback doesn't break things + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + // ── Query Plan Validation (Indirect) ──────────────────────────────────── + + [Fact] + public async Task Handler_QueryPlan_OrderBy_WorksEndToEnd() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + // If the query plan for ORDER BY is wrong, the SDK would reject the response + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(5); + results.Select(r => r.Name).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task Handler_QueryPlan_Distinct_WorksEndToEnd() + { + var container = new InMemoryContainer("distinct-test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 20 }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "distinct-test"); + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.name FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_QueryPlan_OffsetLimit_WorksEndToEnd() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.id OFFSET 1 LIMIT 2"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_QueryPlan_SelectValue_WorksEndToEnd() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(3); + using var _ = handler; using var __ = client; + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT VALUE c.name FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Handler_QueryPlan_Aggregates_WorkEndToEnd() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(5); + using var _ = handler; using var __ = client; + + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT VALUE SUM(c[\"value\"]) FROM c"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // 0+10+20+30+40 = 100 + results.Should().ContainSingle().Which.Should().Be(100); + } + + [Fact] + public async Task Handler_QueryPlan_BasicSelect_WorksEndToEnd() + { + var (_, handler, client, cosmosContainer) = await SetupWithData(3); + using var _ = handler; using var __ = client; + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Handler_DistinctWithOrderBy_ReturnsOrderedDistinctValues() + { + var container = new InMemoryContainer("do-test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "do-test"); + + var results = new List(); + var iter = cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.name FROM c ORDER BY c.name ASC"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Handler_MultipleAggregates_ReturnsAllValues() + { + var container = new InMemoryContainer("multi-agg", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "multi-agg"); + + // Use c["value"] bracket notation because 'value' is a reserved keyword + // in the SDK's ServiceInterop query plan generator. + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT COUNT(1) as cnt, SUM(c[\"value\"]) as total FROM c"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(5); + results[0]["total"]!.Value().Should().Be(100); + } } @@ -1992,447 +1992,447 @@ await container.CreateItemAsync( public class FakeCosmosHandlerCoverageTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - private static async Task<(InMemoryContainer container, FakeCosmosHandler handler, CosmosClient client, Container cosmosContainer)> - SetupWithData(int count = 5) - { - var container = new InMemoryContainer("coverage-test", "/partitionKey"); - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, - new PartitionKey("pk1")); - var handler = new FakeCosmosHandler(container); - var client = CreateClient(handler); - return (container, handler, client, client.GetContainer("db", "coverage-test")); - } - - // ── Section A: CRUD via Handler ── - - [Fact] - public async Task Handler_Upsert_ViaSDK_CreatesNewItem() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var response = await cosmosContainer.UpsertItemAsync( - new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); - } - } - - [Fact] - public async Task Handler_Upsert_ViaSDK_ReplacesExistingItem() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - await cosmosContainer.UpsertItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Updated"); - } - } - - [Fact] - public async Task Handler_Create_DuplicateId_Returns409Conflict() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var act = () => cosmosContainer.CreateItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Dup" }, - new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - } - - [Fact] - public async Task Handler_Read_NonExistentItem_Returns404() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var act = () => cosmosContainer.ReadItemAsync("missing", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - [Fact] - public async Task Handler_Replace_NonExistentItem_Returns404() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var act = () => cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "missing", PartitionKey = "pk1", Name = "X" }, - "missing", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - [Fact] - public async Task Handler_Delete_NonExistentItem_Returns404() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var act = () => cosmosContainer.DeleteItemAsync("missing", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - [Fact] - public async Task Handler_Create_ResponseContainsETag() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var response = await cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - response.ETag.Should().NotBeNullOrEmpty(); - } - } - - [Fact] - public async Task Handler_Replace_WithIfMatchETag_Success() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); - var response = await cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Replaced" }, - "0", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = read.ETag }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - [Fact] - public async Task Handler_Replace_WithStaleIfMatchETag_Returns412() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); - var staleEtag = read.ETag; - await cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "V2" }, - "0", new PartitionKey("pk1")); - - var act = () => cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "0", PartitionKey = "pk1", Name = "V3" }, - "0", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = staleEtag }); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - } - - // ── Section B: Patch via Handler ── - - [Fact] - public async Task Handler_Patch_SetOperation_CreatesNewProperty() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var response = await cosmosContainer.PatchItemAsync( - "0", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/newProp", "hello") }); - response.Resource["newProp"]!.Value().Should().Be("hello"); - } - } - - [Fact] - public async Task Handler_Patch_ReplaceOperation_UpdatesExistingProperty() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var response = await cosmosContainer.PatchItemAsync( - "0", new PartitionKey("pk1"), - new[] { PatchOperation.Replace("/name", "Replaced") }); - response.Resource.Name.Should().Be("Replaced"); - } - } - - [Fact] - public async Task Handler_Patch_RemoveOperation_RemovesProperty() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var response = await cosmosContainer.PatchItemAsync( - "0", new PartitionKey("pk1"), - new[] { PatchOperation.Remove("/name") }); - response.Resource["name"].Should().BeNull(); - } - } - - [Fact] - public async Task Handler_Patch_IncrOperation_IncrementsNumericField() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var response = await cosmosContainer.PatchItemAsync( - "0", new PartitionKey("pk1"), - new[] { PatchOperation.Increment("/value", 5) }); - response.Resource.Value.Should().Be(5); // was 0 (i*10 for i=0) - } - } - - [Fact] - public async Task Handler_Patch_MultipleOperations_AppliedAtomically() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(1); - using (handler) - { - var response = await cosmosContainer.PatchItemAsync( - "0", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Multi"), PatchOperation.Increment("/value", 100) }); - response.Resource["name"]!.Value().Should().Be("Multi"); - response.Resource["value"]!.Value().Should().Be(100); - } - } - - [Fact] - public async Task Handler_Patch_NonExistentDocument_Returns404() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(0); - using (handler) - { - var act = () => cosmosContainer.PatchItemAsync( - "missing", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "X") }); - var ex = await act.Should().ThrowAsync(); - ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - // ── Section C: Query Pipeline ── - - [Fact] - public async Task Handler_Query_WithINOperator_ReturnsMatchingItems() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(3); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name IN ('Item0', 'Item2')"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Select(r => r.Name).Should().BeEquivalentTo(new[] { "Item0", "Item2" }); - } - } - - [Fact] - public async Task Handler_Query_WithBETWEEN_ReturnsRange() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(5); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c WHERE c[\"value\"] BETWEEN 10 AND 30"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(3); - } - } - - [Fact] - public async Task Handler_Query_SelectSpecificFields_ReturnsProjection() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(2); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT c.name FROM c ORDER BY c.id"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(2); - results[0]["name"]!.Value().Should().Be("Item0"); - } - } - - [Fact] - public async Task Handler_Query_EmptyResult_ReturnsEmptyArray() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(3); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c WHERE c.id = 'nonexistent'"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().BeEmpty(); - } - } - - // ── Section D: Pagination ── - - [Fact] - public async Task Handler_Pagination_SingleItemPages_DrainsFully() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(3); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.id", - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(3); - } - } - - [Fact] - public async Task Handler_Pagination_MaxItemCountLargerThanData_SinglePage() - { - var (_, handler, _, cosmosContainer) = await SetupWithData(3); - using (handler) - { - var iter = cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); - var pages = 0; - while (iter.HasMoreResults) { await iter.ReadNextAsync(); pages++; } - pages.Should().Be(1); - } - } - - // ── Section E: Router ── - - [Fact] - public async Task Handler_DifferentPartitionKeyPath_WorksCorrectly() - { - var container = new InMemoryContainer("c1", "/customPk"); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var c = client.GetContainer("db", "c1"); - - await c.CreateItemAsync(JObject.FromObject(new { id = "1", customPk = "a" }), new PartitionKey("a")); - var read = await c.ReadItemAsync("1", new PartitionKey("a")); - read.Resource["id"]!.Value().Should().Be("1"); - } - - // ── Section F: Fault Injection ── - - [Fact] - public async Task Handler_FaultInjector_NullReturn_PassesThrough() - { - var container = new InMemoryContainer("fi-test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => null; // null means pass through - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "fi-test"); - - var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task Handler_FaultInjector_Toggle_MidTest() - { - var container = new InMemoryContainer("fi-toggle", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "fi-toggle"); - - // Enable fault injection - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Disable fault injection - handler.FaultInjector = null; - var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } - - // ── Section G: Response Metadata ── - - [Fact] - public void Handler_BackingContainer_Accessible() - { - var container = new InMemoryContainer("backing-test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - handler.BackingContainer.Should().BeSameAs(container); - } - - [Fact] - public void Handler_DoubleDispose_DoesNotThrow() - { - var container = new InMemoryContainer("dd-test", "/partitionKey"); - var handler = new FakeCosmosHandler(container); - handler.Dispose(); - var act = () => handler.Dispose(); - act.Should().NotThrow(); - } - - // ── Section I: Composite PK ── - - [Fact] - public async Task Handler_CompositePartitionKey_CrudRoundTrip() - { - var container = new InMemoryContainer("composite-test", new[] { "/tenantId", "/region" }); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "composite-test"); - - var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Build(); - var doc = JObject.FromObject(new { id = "1", tenantId = "t1", region = "us-east", name = "Test" }); - await cosmosContainer.CreateItemAsync(doc, pk); - - var read = await cosmosContainer.ReadItemAsync("1", pk); - read.Resource["name"]!.Value().Should().Be("Test"); - - await cosmosContainer.DeleteItemAsync("1", pk); - var readAct = () => cosmosContainer.ReadItemAsync("1", pk); - await readAct.Should().ThrowAsync(); - } - - // ── Section J: Edge Cases ── - - [Fact] - public async Task Handler_LargeDocumentThroughHandler_Succeeds() - { - var container = new InMemoryContainer("large-doc", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "large-doc"); - - var largeValue = new string('x', 1_500_000); - var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", data = largeValue }); - await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); - - var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["data"]!.Value().Should().HaveLength(1_500_000); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + private static async Task<(InMemoryContainer container, FakeCosmosHandler handler, CosmosClient client, Container cosmosContainer)> + SetupWithData(int count = 5) + { + var container = new InMemoryContainer("coverage-test", "/partitionKey"); + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, + new PartitionKey("pk1")); + var handler = new FakeCosmosHandler(container); + var client = CreateClient(handler); + return (container, handler, client, client.GetContainer("db", "coverage-test")); + } + + // ── Section A: CRUD via Handler ── + + [Fact] + public async Task Handler_Upsert_ViaSDK_CreatesNewItem() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var response = await cosmosContainer.UpsertItemAsync( + new TestDocument { Id = "new", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created); + } + } + + [Fact] + public async Task Handler_Upsert_ViaSDK_ReplacesExistingItem() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + await cosmosContainer.UpsertItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Updated"); + } + } + + [Fact] + public async Task Handler_Create_DuplicateId_Returns409Conflict() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var act = () => cosmosContainer.CreateItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Dup" }, + new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + } + + [Fact] + public async Task Handler_Read_NonExistentItem_Returns404() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var act = () => cosmosContainer.ReadItemAsync("missing", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + [Fact] + public async Task Handler_Replace_NonExistentItem_Returns404() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var act = () => cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "missing", PartitionKey = "pk1", Name = "X" }, + "missing", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + [Fact] + public async Task Handler_Delete_NonExistentItem_Returns404() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var act = () => cosmosContainer.DeleteItemAsync("missing", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + [Fact] + public async Task Handler_Create_ResponseContainsETag() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var response = await cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + response.ETag.Should().NotBeNullOrEmpty(); + } + } + + [Fact] + public async Task Handler_Replace_WithIfMatchETag_Success() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); + var response = await cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "Replaced" }, + "0", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = read.ETag }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + } + + [Fact] + public async Task Handler_Replace_WithStaleIfMatchETag_Returns412() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var read = await cosmosContainer.ReadItemAsync("0", new PartitionKey("pk1")); + var staleEtag = read.ETag; + await cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "V2" }, + "0", new PartitionKey("pk1")); + + var act = () => cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "0", PartitionKey = "pk1", Name = "V3" }, + "0", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = staleEtag }); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + } + + // ── Section B: Patch via Handler ── + + [Fact] + public async Task Handler_Patch_SetOperation_CreatesNewProperty() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var response = await cosmosContainer.PatchItemAsync( + "0", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/newProp", "hello") }); + response.Resource["newProp"]!.Value().Should().Be("hello"); + } + } + + [Fact] + public async Task Handler_Patch_ReplaceOperation_UpdatesExistingProperty() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var response = await cosmosContainer.PatchItemAsync( + "0", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "Replaced") }); + response.Resource.Name.Should().Be("Replaced"); + } + } + + [Fact] + public async Task Handler_Patch_RemoveOperation_RemovesProperty() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var response = await cosmosContainer.PatchItemAsync( + "0", new PartitionKey("pk1"), + new[] { PatchOperation.Remove("/name") }); + response.Resource["name"].Should().BeNull(); + } + } + + [Fact] + public async Task Handler_Patch_IncrOperation_IncrementsNumericField() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var response = await cosmosContainer.PatchItemAsync( + "0", new PartitionKey("pk1"), + new[] { PatchOperation.Increment("/value", 5) }); + response.Resource.Value.Should().Be(5); // was 0 (i*10 for i=0) + } + } + + [Fact] + public async Task Handler_Patch_MultipleOperations_AppliedAtomically() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(1); + using (handler) + { + var response = await cosmosContainer.PatchItemAsync( + "0", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Multi"), PatchOperation.Increment("/value", 100) }); + response.Resource["name"]!.Value().Should().Be("Multi"); + response.Resource["value"]!.Value().Should().Be(100); + } + } + + [Fact] + public async Task Handler_Patch_NonExistentDocument_Returns404() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(0); + using (handler) + { + var act = () => cosmosContainer.PatchItemAsync( + "missing", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "X") }); + var ex = await act.Should().ThrowAsync(); + ex.And.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + // ── Section C: Query Pipeline ── + + [Fact] + public async Task Handler_Query_WithINOperator_ReturnsMatchingItems() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(3); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name IN ('Item0', 'Item2')"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Select(r => r.Name).Should().BeEquivalentTo(new[] { "Item0", "Item2" }); + } + } + + [Fact] + public async Task Handler_Query_WithBETWEEN_ReturnsRange() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(5); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c WHERE c[\"value\"] BETWEEN 10 AND 30"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(3); + } + } + + [Fact] + public async Task Handler_Query_SelectSpecificFields_ReturnsProjection() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(2); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT c.name FROM c ORDER BY c.id"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(2); + results[0]["name"]!.Value().Should().Be("Item0"); + } + } + + [Fact] + public async Task Handler_Query_EmptyResult_ReturnsEmptyArray() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(3); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c WHERE c.id = 'nonexistent'"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().BeEmpty(); + } + } + + // ── Section D: Pagination ── + + [Fact] + public async Task Handler_Pagination_SingleItemPages_DrainsFully() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(3); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.id", + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(3); + } + } + + [Fact] + public async Task Handler_Pagination_MaxItemCountLargerThanData_SinglePage() + { + var (_, handler, _, cosmosContainer) = await SetupWithData(3); + using (handler) + { + var iter = cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); + var pages = 0; + while (iter.HasMoreResults) { await iter.ReadNextAsync(); pages++; } + pages.Should().Be(1); + } + } + + // ── Section E: Router ── + + [Fact] + public async Task Handler_DifferentPartitionKeyPath_WorksCorrectly() + { + var container = new InMemoryContainer("c1", "/customPk"); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var c = client.GetContainer("db", "c1"); + + await c.CreateItemAsync(JObject.FromObject(new { id = "1", customPk = "a" }), new PartitionKey("a")); + var read = await c.ReadItemAsync("1", new PartitionKey("a")); + read.Resource["id"]!.Value().Should().Be("1"); + } + + // ── Section F: Fault Injection ── + + [Fact] + public async Task Handler_FaultInjector_NullReturn_PassesThrough() + { + var container = new InMemoryContainer("fi-test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => null; // null means pass through + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "fi-test"); + + var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task Handler_FaultInjector_Toggle_MidTest() + { + var container = new InMemoryContainer("fi-toggle", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "fi-toggle"); + + // Enable fault injection + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Disable fault injection + handler.FaultInjector = null; + var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } + + // ── Section G: Response Metadata ── + + [Fact] + public void Handler_BackingContainer_Accessible() + { + var container = new InMemoryContainer("backing-test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + handler.BackingContainer.Should().BeSameAs(container); + } + + [Fact] + public void Handler_DoubleDispose_DoesNotThrow() + { + var container = new InMemoryContainer("dd-test", "/partitionKey"); + var handler = new FakeCosmosHandler(container); + handler.Dispose(); + var act = () => handler.Dispose(); + act.Should().NotThrow(); + } + + // ── Section I: Composite PK ── + + [Fact] + public async Task Handler_CompositePartitionKey_CrudRoundTrip() + { + var container = new InMemoryContainer("composite-test", new[] { "/tenantId", "/region" }); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "composite-test"); + + var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Build(); + var doc = JObject.FromObject(new { id = "1", tenantId = "t1", region = "us-east", name = "Test" }); + await cosmosContainer.CreateItemAsync(doc, pk); + + var read = await cosmosContainer.ReadItemAsync("1", pk); + read.Resource["name"]!.Value().Should().Be("Test"); + + await cosmosContainer.DeleteItemAsync("1", pk); + var readAct = () => cosmosContainer.ReadItemAsync("1", pk); + await readAct.Should().ThrowAsync(); + } + + // ── Section J: Edge Cases ── + + [Fact] + public async Task Handler_LargeDocumentThroughHandler_Succeeds() + { + var container = new InMemoryContainer("large-doc", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "large-doc"); + + var largeValue = new string('x', 1_500_000); + var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", data = largeValue }); + await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); + + var read = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["data"]!.Value().Should().HaveLength(1_500_000); + } } #endregion diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionDeepDiveTests.cs index 57481cd..3ecbcd6 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionDeepDiveTests.cs @@ -1,8 +1,8 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -15,767 +15,767 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FaultInjectionDeepDiveStreamTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_OnUpsertItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - var response = await client.GetContainer("db", "test") - .UpsertItemStreamAsync(stream, new PartitionKey("pk1")); - - ((int)response.StatusCode).Should().Be(429); - } - - [Fact] - public async Task FaultInjection_429_OnPatchItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var response = await client.GetContainer("db", "test") - .PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - ((int)response.StatusCode).Should().Be(429); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_OnUpsertItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var response = await client.GetContainer("db", "test") + .UpsertItemStreamAsync(stream, new PartitionKey("pk1")); + + ((int)response.StatusCode).Should().Be(429); + } + + [Fact] + public async Task FaultInjection_429_OnPatchItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var response = await client.GetContainer("db", "test") + .PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + ((int)response.StatusCode).Should().Be(429); + } } public class FaultInjectionBatchTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact(Skip = "FakeCosmosHandler does not handle transactional batch routes (/docs with batch headers). " + - "Batch operations use a different HTTP endpoint pattern not in the handler's route table.")] - public async Task FaultInjection_429_OnTransactionalBatch() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var batch = cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new { id = "b1", partitionKey = "pk1" }); - - var response = await batch.ExecuteAsync(); - ((int)response.StatusCode).Should().Be(429); - } - - [Fact(Skip = "FakeCosmosHandler does not handle transactional batch routes (/docs with batch headers). " + - "Batch operations use a different HTTP endpoint pattern not in the handler's route table.")] - public async Task FaultInjection_503_OnTransactionalBatch() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var batch = cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new { id = "b2", partitionKey = "pk1" }); - - var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact(Skip = "FakeCosmosHandler does not handle transactional batch routes (/docs with batch headers). " + + "Batch operations use a different HTTP endpoint pattern not in the handler's route table.")] + public async Task FaultInjection_429_OnTransactionalBatch() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var batch = cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new { id = "b1", partitionKey = "pk1" }); + + var response = await batch.ExecuteAsync(); + ((int)response.StatusCode).Should().Be(429); + } + + [Fact(Skip = "FakeCosmosHandler does not handle transactional batch routes (/docs with batch headers). " + + "Batch operations use a different HTTP endpoint pattern not in the handler's route table.")] + public async Task FaultInjection_503_OnTransactionalBatch() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var batch = cosmosContainer.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new { id = "b2", partitionKey = "pk1" }); + + var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } } public class FaultInjectionLinqTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_OnLinqCountAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = () => cosmosContainer.GetItemLinqQueryable().CountAsync(); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_429_OnLinqWhereToFeedIterator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = async () => - { - var iterator = cosmosContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Test") - .ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_OnLinqCountAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = () => cosmosContainer.GetItemLinqQueryable().CountAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_429_OnLinqWhereToFeedIterator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = async () => + { + var iterator = cosmosContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Test") + .ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } } public class FaultInjectionReadManyTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact(Skip = "ReadManyItemsAsync hangs when fault injector returns 429. " + - "ReadMany uses internal query orchestration that doesn't respect " + - "MaxRetryAttemptsOnRateLimitedRequests=0, causing an infinite retry loop.")] - public async Task FaultInjection_429_OnReadManyItemsAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var act = () => cosmosContainer.ReadManyItemsAsync(items); - - // ReadMany uses query internally — should be affected by fault injection - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact(Skip = "ReadManyItemsAsync hangs when fault injector returns 429. " + + "ReadMany uses internal query orchestration that doesn't respect " + + "MaxRetryAttemptsOnRateLimitedRequests=0, causing an infinite retry loop.")] + public async Task FaultInjection_429_OnReadManyItemsAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var act = () => cosmosContainer.ReadManyItemsAsync(items); + + // ReadMany uses query internally — should be affected by fault injection + await act.Should().ThrowAsync(); + } } public class FaultInjectionQueryPlanTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_QueryPlanRequest_NotAffectedByDefault() - { - // The fault injector fires AFTER metadata routes but query plan detection - // happens AFTER fault injection in the current code flow. - // On Windows, query plan is computed locally via ServiceInterop, so query plan - // requests don't go through the handler. This test verifies that queries work - // even when the fault injector is set, because on Windows the query plan - // is computed locally and the data query is what gets faulted. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var dataRequestFaulted = false; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = request => - { - // Only fault non-query-plan POST requests (data queries) - if (request.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) - return null; // Let query plan through - dataRequestFaulted = true; - return new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = async () => - { - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - dataRequestFaulted.Should().BeTrue(); - } - - [Fact] - public async Task FaultInjection_QueryPlanRequest_AffectedWhenMetadataEnabled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") }, - FaultInjectorIncludesMetadata = true - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // With FaultInjectorIncludesMetadata=true, ALL requests (including metadata - // and query plan) are faulted. The SDK can't even initialize. - var act = async () => - { - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_QueryPlanRequest_NotAffectedByDefault() + { + // The fault injector fires AFTER metadata routes but query plan detection + // happens AFTER fault injection in the current code flow. + // On Windows, query plan is computed locally via ServiceInterop, so query plan + // requests don't go through the handler. This test verifies that queries work + // even when the fault injector is set, because on Windows the query plan + // is computed locally and the data query is what gets faulted. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var dataRequestFaulted = false; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = request => + { + // Only fault non-query-plan POST requests (data queries) + if (request.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) + return null; // Let query plan through + dataRequestFaulted = true; + return new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = async () => + { + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + dataRequestFaulted.Should().BeTrue(); + } + + [Fact] + public async Task FaultInjection_QueryPlanRequest_AffectedWhenMetadataEnabled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") }, + FaultInjectorIncludesMetadata = true + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // With FaultInjectorIncludesMetadata=true, ALL requests (including metadata + // and query plan) are faulted. The SDK can't even initialize. + var act = async () => + { + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } } public class FaultInjectionEdgeCaseTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_ContentReadByInjector_SubsequentHandlerStillWorks() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - - handler.FaultInjector = request => - { - // Intentionally read the content to inspect it — this consumes the stream - if (request.Content != null) - { - var body = request.Content.ReadAsStringAsync().Result; - // Decision: pass through (return null) - } - return null; - }; - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // This should succeed even though the fault injector read the content - var doc = new TestDocument { Id = "ci1", PartitionKey = "pk1", Name = "Content" }; - var response = await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Name.Should().Be("Content"); - } - - [Fact] - public async Task FaultInjection_CancellationToken_CancelledBeforeFault() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); // Cancel before the request - - var act = () => cosmosContainer.ReadItemAsync( - "1", new PartitionKey("pk1"), cancellationToken: cts.Token); - - // Either cancellation or fault takes precedence — document which - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_SequentialFaults_NoStateAccumulation() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Make 50 sequential faulted requests - for (var i = 0; i < 50; i++) - { - try - { - await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException) { } - } - - // Clear fault and verify normal operation - handler.FaultInjector = null; - var response = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Test"); - - // Request log should have accumulated entries - handler.RequestLog.Count.Should().BeGreaterThan(50); - } - - [Fact] - public async Task FaultInjection_FaultInjectorReturnsNull_RequestPassesThrough() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => null! // Always pass through - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var response = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task FaultInjection_ConcurrentDelegateSwap_ThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 20; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"cs-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }, - new PartitionKey("pk1")); - } - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var exceptions = new System.Collections.Concurrent.ConcurrentBag(); - var tasks = Enumerable.Range(0, 20).Select(async i => - { - try - { - // After a short delay, swap the injector to null - if (i == 10) - { - await Task.Delay(10); - handler.FaultInjector = null; - } - await cosmosContainer.ReadItemAsync($"cs-{i}", new PartitionKey("pk1")); - } - catch (CosmosException) { /* expected 429 */ } - catch (Exception ex) - { - exceptions.Add(ex); // unexpected exceptions - } - }); - - await Task.WhenAll(tasks); - - // No unexpected exceptions (NullReferenceException etc) - exceptions.Should().BeEmpty(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_ContentReadByInjector_SubsequentHandlerStillWorks() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + + handler.FaultInjector = request => + { + // Intentionally read the content to inspect it — this consumes the stream + if (request.Content != null) + { + var body = request.Content.ReadAsStringAsync().Result; + // Decision: pass through (return null) + } + return null; + }; + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // This should succeed even though the fault injector read the content + var doc = new TestDocument { Id = "ci1", PartitionKey = "pk1", Name = "Content" }; + var response = await cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Name.Should().Be("Content"); + } + + [Fact] + public async Task FaultInjection_CancellationToken_CancelledBeforeFault() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel before the request + + var act = () => cosmosContainer.ReadItemAsync( + "1", new PartitionKey("pk1"), cancellationToken: cts.Token); + + // Either cancellation or fault takes precedence — document which + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_SequentialFaults_NoStateAccumulation() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Make 50 sequential faulted requests + for (var i = 0; i < 50; i++) + { + try + { + await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException) { } + } + + // Clear fault and verify normal operation + handler.FaultInjector = null; + var response = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Test"); + + // Request log should have accumulated entries + handler.RequestLog.Count.Should().BeGreaterThan(50); + } + + [Fact] + public async Task FaultInjection_FaultInjectorReturnsNull_RequestPassesThrough() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => null! // Always pass through + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var response = await cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task FaultInjection_ConcurrentDelegateSwap_ThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 20; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"cs-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }, + new PartitionKey("pk1")); + } + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var exceptions = new System.Collections.Concurrent.ConcurrentBag(); + var tasks = Enumerable.Range(0, 20).Select(async i => + { + try + { + // After a short delay, swap the injector to null + if (i == 10) + { + await Task.Delay(10); + handler.FaultInjector = null; + } + await cosmosContainer.ReadItemAsync($"cs-{i}", new PartitionKey("pk1")); + } + catch (CosmosException) { /* expected 429 */ } + catch (Exception ex) + { + exceptions.Add(ex); // unexpected exceptions + } + }); + + await Task.WhenAll(tasks); + + // No unexpected exceptions (NullReferenceException etc) + exceptions.Should().BeEmpty(); + } } public class FaultInjectionDeepDiveRouterTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_Router_ThreeContainers_OnlyMiddleFaulted() - { - var c1 = new InMemoryContainer("c1", "/partitionKey"); - var c2 = new InMemoryContainer("c2", "/partitionKey"); - var c3 = new InMemoryContainer("c3", "/partitionKey"); - - await c1.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, new PartitionKey("pk1")); - await c2.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, new PartitionKey("pk1")); - await c3.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C3" }, new PartitionKey("pk1")); - - using var h1 = new FakeCosmosHandler(c1); - using var h2 = new FakeCosmosHandler(c2) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var h3 = new FakeCosmosHandler(c3); - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["c1"] = h1, - ["c2"] = h2, - ["c3"] = h3, - }); - - using var client = CreateClient(router); - - // C1 succeeds (db1) - var read1 = await client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("C1"); - - // C2 fails (db2 is faulted) - var act2 = () => client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); - await act2.Should().ThrowAsync(); - - // C3 succeeds (db3) - var read3 = await client.GetContainer("db", "c3").ReadItemAsync("1", new PartitionKey("pk1")); - read3.Resource.Name.Should().Be("C3"); - } - - [Fact] - public async Task FaultInjection_Router_DynamicFaultChange_AcrossContainers() - { - var c1 = new InMemoryContainer("c1", "/partitionKey"); - var c2 = new InMemoryContainer("c2", "/partitionKey"); - - await c1.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, new PartitionKey("pk1")); - await c2.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, new PartitionKey("pk1")); - - using var h1 = new FakeCosmosHandler(c1) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var h2 = new FakeCosmosHandler(c2); - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["c1"] = h1, - ["c2"] = h2, - }); - - using var client = CreateClient(router); - - // Initially: c1 faulted, c2 normal - var act1 = () => client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); - await act1.Should().ThrowAsync(); - - var read2 = await client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); - read2.Resource.Name.Should().Be("C2"); - - // Swap: disable c1 fault, enable c2 fault - h1.FaultInjector = null; - h2.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - - var read1 = await client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("C1"); - - var act2 = () => client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); - await act2.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_Router_FaultOnDefaultHandler() - { - var c1 = new InMemoryContainer("c1", "/partitionKey"); - using var h1 = new FakeCosmosHandler(c1) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) - { Content = new StringContent("{}") }, - FaultInjectorIncludesMetadata = true - }; - - var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["c1"] = h1, - }); - - using var client = CreateClient(router); - - // With FaultInjectorIncludesMetadata=true on the handler, SDK can't initialize - // because account metadata request gets faulted - var act = () => client.GetContainer("db", "c1") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_Router_ThreeContainers_OnlyMiddleFaulted() + { + var c1 = new InMemoryContainer("c1", "/partitionKey"); + var c2 = new InMemoryContainer("c2", "/partitionKey"); + var c3 = new InMemoryContainer("c3", "/partitionKey"); + + await c1.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, new PartitionKey("pk1")); + await c2.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, new PartitionKey("pk1")); + await c3.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C3" }, new PartitionKey("pk1")); + + using var h1 = new FakeCosmosHandler(c1); + using var h2 = new FakeCosmosHandler(c2) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var h3 = new FakeCosmosHandler(c3); + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["c1"] = h1, + ["c2"] = h2, + ["c3"] = h3, + }); + + using var client = CreateClient(router); + + // C1 succeeds (db1) + var read1 = await client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("C1"); + + // C2 fails (db2 is faulted) + var act2 = () => client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); + await act2.Should().ThrowAsync(); + + // C3 succeeds (db3) + var read3 = await client.GetContainer("db", "c3").ReadItemAsync("1", new PartitionKey("pk1")); + read3.Resource.Name.Should().Be("C3"); + } + + [Fact] + public async Task FaultInjection_Router_DynamicFaultChange_AcrossContainers() + { + var c1 = new InMemoryContainer("c1", "/partitionKey"); + var c2 = new InMemoryContainer("c2", "/partitionKey"); + + await c1.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, new PartitionKey("pk1")); + await c2.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, new PartitionKey("pk1")); + + using var h1 = new FakeCosmosHandler(c1) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var h2 = new FakeCosmosHandler(c2); + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["c1"] = h1, + ["c2"] = h2, + }); + + using var client = CreateClient(router); + + // Initially: c1 faulted, c2 normal + var act1 = () => client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); + await act1.Should().ThrowAsync(); + + var read2 = await client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); + read2.Resource.Name.Should().Be("C2"); + + // Swap: disable c1 fault, enable c2 fault + h1.FaultInjector = null; + h2.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + + var read1 = await client.GetContainer("db", "c1").ReadItemAsync("1", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("C1"); + + var act2 = () => client.GetContainer("db", "c2").ReadItemAsync("1", new PartitionKey("pk1")); + await act2.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_Router_FaultOnDefaultHandler() + { + var c1 = new InMemoryContainer("c1", "/partitionKey"); + using var h1 = new FakeCosmosHandler(c1) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { Content = new StringContent("{}") }, + FaultInjectorIncludesMetadata = true + }; + + var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["c1"] = h1, + }); + + using var client = CreateClient(router); + + // With FaultInjectorIncludesMetadata=true on the handler, SDK can't initialize + // because account metadata request gets faulted + var act = () => client.GetContainer("db", "c1") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } } public class FaultInjectionDeepDiveResponseTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_WithRetryAfterHeader_StandardFormat() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(1)); - return resp; - } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be((HttpStatusCode)429); - } - - [Fact] - public async Task FaultInjection_503_WithRetryAfterHeader() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-retry-after-ms", "100"); - return resp; - } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } - - [Fact] - public async Task FaultInjection_ResponseWithCustomHeaders_PreservedInException() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-substatus", "3200"); - return resp; - } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - ex.Which.SubStatusCode.Should().Be(3200); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_WithRetryAfterHeader_StandardFormat() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(1)); + return resp; + } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be((HttpStatusCode)429); + } + + [Fact] + public async Task FaultInjection_503_WithRetryAfterHeader() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-retry-after-ms", "100"); + return resp; + } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public async Task FaultInjection_ResponseWithCustomHeaders_PreservedInException() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-substatus", "3200"); + return resp; + } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = () => cosmosContainer.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + ex.Which.SubStatusCode.Should().Be(3200); + } } public class FaultInjectionChangeFeedDeepDiveTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact(Skip = "FakeCosmosHandler does not route change feed requests. Change feed uses " + - "special headers (a]4-im, If-None-Match) on GET /docs which the handler " + - "routes to ReadFeed instead of ChangeFeed.")] - public async Task FaultInjection_ChangeFeedIterator_AffectedByFault() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var iterator = cosmosContainer.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - var response = await iterator.ReadNextAsync(); - ((int)response.StatusCode).Should().Be(429); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact(Skip = "FakeCosmosHandler does not route change feed requests. Change feed uses " + + "special headers (a]4-im, If-None-Match) on GET /docs which the handler " + + "routes to ReadFeed instead of ChangeFeed.")] + public async Task FaultInjection_ChangeFeedIterator_AffectedByFault() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var iterator = cosmosContainer.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + var response = await iterator.ReadNextAsync(); + ((int)response.StatusCode).Should().Be(429); + } } public class FaultInjectionBulkTests { - [Fact] - public async Task FaultInjection_BulkExecution_EachOpCanBeFaulted() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - AllowBulkExecution = true, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var cosmosContainer = client.GetContainer("db", "test"); - - var tasks = Enumerable.Range(0, 10).Select(i => - { - var doc = new TestDocument { Id = $"bulk-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }; - return cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); - }).ToList(); - - var exceptions = new List(); - foreach (var task in tasks) - { - try { await task; } - catch (CosmosException ex) { exceptions.Add(ex); } - } - - // All ops should fail with 429 - exceptions.Count.Should().Be(10); - exceptions.Should().OnlyContain(e => e.StatusCode == (HttpStatusCode)429); - } + [Fact] + public async Task FaultInjection_BulkExecution_EachOpCanBeFaulted() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + AllowBulkExecution = true, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var cosmosContainer = client.GetContainer("db", "test"); + + var tasks = Enumerable.Range(0, 10).Select(i => + { + var doc = new TestDocument { Id = $"bulk-{i}", PartitionKey = "pk1", Name = $"Doc{i}" }; + return cosmosContainer.CreateItemAsync(doc, new PartitionKey("pk1")); + }).ToList(); + + var exceptions = new List(); + foreach (var task in tasks) + { + try { await task; } + catch (CosmosException ex) { exceptions.Add(ex); } + } + + // All ops should fail with 429 + exceptions.Count.Should().Be(10); + exceptions.Should().OnlyContain(e => e.StatusCode == (HttpStatusCode)429); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionTests.cs index c57ada7..6c677d5 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FaultInjectionTests.cs @@ -1,9 +1,9 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -11,269 +11,269 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FaultInjectionGapTests4 { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_Timeout_408() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)408) - { - Content = new StringContent("{}") - } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = async () => - { - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_SelectiveByPath_OnlyFailsWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = request => - { - // Only fail POST requests (writes/queries), allow GET requests (reads) - if (request.Method == HttpMethod.Post) - return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("{}") - }; - return null; // Allow through - } - }; - - using var httpClient = new HttpClient(handler); - - // GET should succeed (read feed) - var getResponse = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test/docs"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // POST should fail (query) - var postRequest = new HttpRequestMessage(HttpMethod.Post, - "https://localhost:9999/dbs/db/colls/test/docs"); - postRequest.Headers.Add("x-ms-documentdb-query", "True"); - postRequest.Headers.Add("x-ms-documentdb-isquery", "True"); - postRequest.Content = new StringContent( - """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); - - var postResponse = await httpClient.SendAsync(postRequest); - postResponse.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_Timeout_408() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)408) + { + Content = new StringContent("{}") + } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = async () => + { + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_SelectiveByPath_OnlyFailsWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = request => + { + // Only fail POST requests (writes/queries), allow GET requests (reads) + if (request.Method == HttpMethod.Post) + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("{}") + }; + return null; // Allow through + } + }; + + using var httpClient = new HttpClient(handler); + + // GET should succeed (read feed) + var getResponse = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test/docs"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // POST should fail (query) + var postRequest = new HttpRequestMessage(HttpMethod.Post, + "https://localhost:9999/dbs/db/colls/test/docs"); + postRequest.Headers.Add("x-ms-documentdb-query", "True"); + postRequest.Headers.Add("x-ms-documentdb-isquery", "True"); + postRequest.Content = new StringContent( + """{"query":"SELECT * FROM c"}""", Encoding.UTF8, "application/query+json"); + + var postResponse = await httpClient.SendAsync(postRequest); + postResponse.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } } public class FaultInjectionTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_ClientReceivesThrottle() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { - Content = new StringContent("""{"message":"Too many requests"}""", - Encoding.UTF8, "application/json") - }; - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = async () => - { - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_503_ClientReceivesServiceUnavailable() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("""{"message":"Service unavailable"}""", - Encoding.UTF8, "application/json") - }; - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var act = async () => - { - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_SkipsMetadata_ByDefault() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var queryCallCount = 0; - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = request => - { - // Only intercept data requests, not metadata - Interlocked.Increment(ref queryCallCount); - return new HttpResponseMessage((HttpStatusCode)429) - { - Content = new StringContent("""{"message":"throttled"}""", - Encoding.UTF8, "application/json") - }; - }; - // FaultInjectorIncludesMetadata defaults to false - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // The SDK should still be able to initialize (metadata requests bypass fault injector) - // but actual queries should fail - var act = async () => - { - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_IncludesMetadata_WhenEnabled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - handler.FaultInjectorIncludesMetadata = true; - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("""{"message":"unavailable"}""", - Encoding.UTF8, "application/json") - }; - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Even metadata routes should fail now - var act = async () => - { - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_Intermittent_EventuallySucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var callCount = 0; - using var handler = new FakeCosmosHandler(container); - handler.FaultInjector = _ => - { - var count = Interlocked.Increment(ref callCount); - if (count <= 2) - { - var response = new HttpResponseMessage((HttpStatusCode)429) - { - Content = new StringContent("""{"message":"throttled"}""", - Encoding.UTF8, "application/json") - }; - // Real Cosmos DB always includes x-ms-retry-after-ms on 429 responses. - // Without it the SDK uses its own backoff with random jitter, which can - // exceed MaxRetryWaitTimeOnRateLimitedRequests and cause flaky failures. - response.Headers.Add("x-ms-retry-after-ms", "10"); - return response; - } - - return null; // Allow normal processing after first 2 calls - }; - - // Use client with retries enabled - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 5, - MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) } - }); - - var cosmosContainer = client.GetContainer("db", "test"); - - var results = new List(); - var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_ClientReceivesThrottle() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { + Content = new StringContent("""{"message":"Too many requests"}""", + Encoding.UTF8, "application/json") + }; + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = async () => + { + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_503_ClientReceivesServiceUnavailable() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("""{"message":"Service unavailable"}""", + Encoding.UTF8, "application/json") + }; + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var act = async () => + { + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_SkipsMetadata_ByDefault() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var queryCallCount = 0; + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = request => + { + // Only intercept data requests, not metadata + Interlocked.Increment(ref queryCallCount); + return new HttpResponseMessage((HttpStatusCode)429) + { + Content = new StringContent("""{"message":"throttled"}""", + Encoding.UTF8, "application/json") + }; + }; + // FaultInjectorIncludesMetadata defaults to false + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // The SDK should still be able to initialize (metadata requests bypass fault injector) + // but actual queries should fail + var act = async () => + { + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_IncludesMetadata_WhenEnabled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + handler.FaultInjectorIncludesMetadata = true; + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("""{"message":"unavailable"}""", + Encoding.UTF8, "application/json") + }; + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Even metadata routes should fail now + var act = async () => + { + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_Intermittent_EventuallySucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var callCount = 0; + using var handler = new FakeCosmosHandler(container); + handler.FaultInjector = _ => + { + var count = Interlocked.Increment(ref callCount); + if (count <= 2) + { + var response = new HttpResponseMessage((HttpStatusCode)429) + { + Content = new StringContent("""{"message":"throttled"}""", + Encoding.UTF8, "application/json") + }; + // Real Cosmos DB always includes x-ms-retry-after-ms on 429 responses. + // Without it the SDK uses its own backoff with random jitter, which can + // exceed MaxRetryWaitTimeOnRateLimitedRequests and cause flaky failures. + response.Headers.Add("x-ms-retry-after-ms", "10"); + return response; + } + + return null; // Allow normal processing after first 2 calls + }; + + // Use client with retries enabled + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 5, + MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) } + }); + + var cosmosContainer = client.GetContainer("db", "test"); + + var results = new List(); + var iterator = cosmosContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } } @@ -283,124 +283,124 @@ await container.CreateItemAsync( public class FaultInjectionOperationTests { - private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = maxRetries, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_OnPointRead() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(ex => (int)ex.StatusCode == 429); - } - - [Fact] - public async Task FaultInjection_429_OnReplace() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - "1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_429_OnUpsert() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_429_OnDelete() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .DeleteItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_429_OnPatch() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Updated") }); - - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = maxRetries, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_OnPointRead() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(ex => (int)ex.StatusCode == 429); + } + + [Fact] + public async Task FaultInjection_429_OnReplace() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + "1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_429_OnUpsert() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_429_OnDelete() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .DeleteItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_429_OnPatch() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Updated") }); + + await act.Should().ThrowAsync(); + } } @@ -410,65 +410,65 @@ await container.CreateItemAsync( public class FaultInjectionStatusCodeTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Theory] - [InlineData(500, "InternalServerError")] - [InlineData(403, "Forbidden")] - [InlineData(400, "BadRequest")] - public async Task FaultInjection_VariousStatusCodes_ClientReceivesError(int statusCode, string description) - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)statusCode) - { Content = new StringContent($"{{\"message\":\"{description}\"}}") } - }; - using var client = CreateClient(handler); - - var act = async () => - { - var iterator = client.GetContainer("db", "test") - .GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_404_ViaFaultInjection_DistinctFromNatural404() - { - var container = new InMemoryContainer("test", "/partitionKey"); - // No items — reading "1" would naturally return 404 - // But we're injecting 404 at the handler level (different code path) - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.NotFound) - { Content = new StringContent("{\"message\":\"Injected 404\"}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(ex => ex.StatusCode == HttpStatusCode.NotFound); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Theory] + [InlineData(500, "InternalServerError")] + [InlineData(403, "Forbidden")] + [InlineData(400, "BadRequest")] + public async Task FaultInjection_VariousStatusCodes_ClientReceivesError(int statusCode, string description) + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)statusCode) + { Content = new StringContent($"{{\"message\":\"{description}\"}}") } + }; + using var client = CreateClient(handler); + + var act = async () => + { + var iterator = client.GetContainer("db", "test") + .GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_404_ViaFaultInjection_DistinctFromNatural404() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // No items — reading "1" would naturally return 404 + // But we're injecting 404 at the handler level (different code path) + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.NotFound) + { Content = new StringContent("{\"message\":\"Injected 404\"}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(ex => ex.StatusCode == HttpStatusCode.NotFound); + } } @@ -478,85 +478,85 @@ await act.Should().ThrowAsync() public class FaultInjectionDynamicTests { - private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = maxRetries, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_SetToNull_DisablesMidway() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Should fail with fault injector active - var act1 = async () => - { - var it = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (it.HasMoreResults) await it.ReadNextAsync(); - }; - await act1.Should().ThrowAsync(); - - // Disable fault injection - handler.FaultInjector = null; - - // Should succeed now - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().NotBeEmpty(); - } - - [Fact] - public async Task FaultInjection_CountBased_FailsFirstNThenSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var callCount = 0; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - if (Interlocked.Increment(ref callCount) <= 3) - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-retry-after-ms", "10"); - return resp; - } - return null; - } - }; - - using var client = CreateClient(handler, maxRetries: 5); - var cosmosContainer = client.GetContainer("db", "test"); - - // With retries, should eventually succeed after N failures - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().NotBeEmpty(); - callCount.Should().BeGreaterThan(3); - } + private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = maxRetries, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_SetToNull_DisablesMidway() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Should fail with fault injector active + var act1 = async () => + { + var it = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (it.HasMoreResults) await it.ReadNextAsync(); + }; + await act1.Should().ThrowAsync(); + + // Disable fault injection + handler.FaultInjector = null; + + // Should succeed now + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task FaultInjection_CountBased_FailsFirstNThenSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var callCount = 0; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + if (Interlocked.Increment(ref callCount) <= 3) + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-retry-after-ms", "10"); + return resp; + } + return null; + } + }; + + using var client = CreateClient(handler, maxRetries: 5); + var cosmosContainer = client.GetContainer("db", "test"); + + // With retries, should eventually succeed after N failures + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().NotBeEmpty(); + callCount.Should().BeGreaterThan(3); + } } @@ -566,139 +566,139 @@ await container.CreateItemAsync( public class FaultInjectionInfrastructureTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_DelegateThrows_ExceptionPropagates() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => throw new InvalidOperationException("injector crashed") - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .WithMessage("*injector crashed*"); - } - - [Fact] - public async Task FaultInjection_RequestLogRecords_FaultedRequests() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - try - { - var iterator = client.GetContainer("db", "test") - .GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - } - catch (CosmosException) { } - - handler.RequestLog.Should().NotBeEmpty("request log should capture faulted request paths"); - } - - [Fact] - public async Task FaultInjection_ConcurrentRequests_ThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - - var callCount = 0; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - Interlocked.Increment(ref callCount); - return new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - } - }; - - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - var tasks = Enumerable.Range(0, 20).Select(async _ => - { - try - { - var it = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (it.HasMoreResults) await it.ReadNextAsync(); - } - catch (CosmosException) { } - }); - - await Task.WhenAll(tasks); - callCount.Should().BeGreaterThan(0); - } - - [Fact] - public async Task FaultInjection_DirectContainerOps_BypassFaultInjection() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - - // Direct container operations bypass the handler entirely - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task FaultInjection_WithCreateRouter_IndependentPerContainer() - { - var container1 = new InMemoryContainer("c1", "/partitionKey"); - var container2 = new InMemoryContainer("c2", "/partitionKey"); - - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, - new PartitionKey("pk1")); - await container2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, - new PartitionKey("pk1")); - - using var handler1 = new FakeCosmosHandler(container1) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var handler2 = new FakeCosmosHandler(container2); - // handler2 has NO fault injector — should work normally - - // container1 is faulted, container2 is not - // Direct access to the non-faulted container should work - var read = await container2.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("C2"); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_DelegateThrows_ExceptionPropagates() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => throw new InvalidOperationException("injector crashed") + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .WithMessage("*injector crashed*"); + } + + [Fact] + public async Task FaultInjection_RequestLogRecords_FaultedRequests() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + try + { + var iterator = client.GetContainer("db", "test") + .GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + } + catch (CosmosException) { } + + handler.RequestLog.Should().NotBeEmpty("request log should capture faulted request paths"); + } + + [Fact] + public async Task FaultInjection_ConcurrentRequests_ThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + + var callCount = 0; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + Interlocked.Increment(ref callCount); + return new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + } + }; + + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + var tasks = Enumerable.Range(0, 20).Select(async _ => + { + try + { + var it = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (it.HasMoreResults) await it.ReadNextAsync(); + } + catch (CosmosException) { } + }); + + await Task.WhenAll(tasks); + callCount.Should().BeGreaterThan(0); + } + + [Fact] + public async Task FaultInjection_DirectContainerOps_BypassFaultInjection() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + + // Direct container operations bypass the handler entirely + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task FaultInjection_WithCreateRouter_IndependentPerContainer() + { + var container1 = new InMemoryContainer("c1", "/partitionKey"); + var container2 = new InMemoryContainer("c2", "/partitionKey"); + + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, + new PartitionKey("pk1")); + await container2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, + new PartitionKey("pk1")); + + using var handler1 = new FakeCosmosHandler(container1) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var handler2 = new FakeCosmosHandler(container2); + // handler2 has NO fault injector — should work normally + + // container1 is faulted, container2 is not + // Direct access to the non-faulted container should work + var read = await container2.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("C2"); + } } @@ -708,202 +708,202 @@ await container2.CreateItemAsync( public class FaultInjectionOperationDeepDiveTests { - private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = maxRetries, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - // ── A: Operation gaps ───────────────────────────────────────────────── - - [Fact] - public async Task FaultInjection_429_OnCreate() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(ex => (int)ex.StatusCode == 429); - } - - [Fact] - public async Task FaultInjection_429_OnQueryIterator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = async () => - { - var iter = client.GetContainer("db", "test") - .GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - }; - - await act.Should().ThrowAsync() - .Where(ex => (int)ex.StatusCode == 429); - } - - [Fact] - public async Task FaultInjection_429_OnReadFeed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var httpClient = new HttpClient(handler); - - handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - - var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test/docs"); - response.StatusCode.Should().Be((HttpStatusCode)429); - } - - [Fact] - public async Task FaultInjection_503_OnPointRead() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(ex => ex.StatusCode == HttpStatusCode.ServiceUnavailable); - } - - [Fact] - public async Task FaultInjection_SelectiveFault_ReadSucceeds_WriteFailsViaSdk() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = req => - req.Method == HttpMethod.Get ? null - : new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var c = client.GetContainer("db", "test"); - - // Read (GET) should succeed - var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - - // Write (POST) should fail - var act = () => c.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - // ── B: Additional HTTP status codes ───────────────────────────────────── - - [Theory] - [InlineData(401, "Unauthorized")] - [InlineData(409, "Conflict")] - [InlineData(412, "PreconditionFailed")] - [InlineData(413, "EntityTooLarge")] - public async Task FaultInjection_AdditionalStatusCodes_ClientReceivesError(int statusCode, string description) - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)statusCode) - { Content = new StringContent($"{{\"message\":\"{description}\"}}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(ex => (int)ex.StatusCode == statusCode); - } - - [Fact] - public async Task FaultInjection_410_Gone() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.Gone) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_449_RetryWith() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)449) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } + private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = maxRetries, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + // ── A: Operation gaps ───────────────────────────────────────────────── + + [Fact] + public async Task FaultInjection_429_OnCreate() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(ex => (int)ex.StatusCode == 429); + } + + [Fact] + public async Task FaultInjection_429_OnQueryIterator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = async () => + { + var iter = client.GetContainer("db", "test") + .GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + }; + + await act.Should().ThrowAsync() + .Where(ex => (int)ex.StatusCode == 429); + } + + [Fact] + public async Task FaultInjection_429_OnReadFeed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var httpClient = new HttpClient(handler); + + handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + + var response = await httpClient.GetAsync("https://localhost:9999/dbs/db/colls/test/docs"); + response.StatusCode.Should().Be((HttpStatusCode)429); + } + + [Fact] + public async Task FaultInjection_503_OnPointRead() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(ex => ex.StatusCode == HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public async Task FaultInjection_SelectiveFault_ReadSucceeds_WriteFailsViaSdk() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = req => + req.Method == HttpMethod.Get ? null + : new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var c = client.GetContainer("db", "test"); + + // Read (GET) should succeed + var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + + // Write (POST) should fail + var act = () => c.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + // ── B: Additional HTTP status codes ───────────────────────────────────── + + [Theory] + [InlineData(401, "Unauthorized")] + [InlineData(409, "Conflict")] + [InlineData(412, "PreconditionFailed")] + [InlineData(413, "EntityTooLarge")] + public async Task FaultInjection_AdditionalStatusCodes_ClientReceivesError(int statusCode, string description) + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)statusCode) + { Content = new StringContent($"{{\"message\":\"{description}\"}}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(ex => (int)ex.StatusCode == statusCode); + } + + [Fact] + public async Task FaultInjection_410_Gone() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.Gone) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_449_RetryWith() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)449) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } } @@ -913,217 +913,217 @@ await container.CreateItemAsync( public class FaultInjectionResponseFidelityTests { - private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = maxRetries, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_WithRetryAfterMs_SDKRespectsDelay() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var callCount = 0; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - if (Interlocked.Increment(ref callCount) <= 1) - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-retry-after-ms", "10"); - return resp; - } - return null; - } - }; - using var client = CreateClient(handler, maxRetries: 3); - var c = client.GetContainer("db", "test"); - - var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - callCount.Should().BeGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task FaultInjection_Response_WithEmptyContent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("") } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_Response_WithNullContent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)500) - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_Response_WithActivityId() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var activityId = Guid.NewGuid().ToString(); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-activity-id", activityId); - return resp; - } - }; - using var client = CreateClient(handler); - - try - { - await client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - ex.ActivityId.Should().Be(activityId); - return; - } - - throw new Xunit.Sdk.XunitException("Expected CosmosException"); - } - - [Fact] - public async Task FaultInjection_Response_WithRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-request-charge", "42.5"); - return resp; - } - }; - using var client = CreateClient(handler); - - try - { - await client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - ex.RequestCharge.Should().Be(42.5); - return; - } - - throw new Xunit.Sdk.XunitException("Expected CosmosException"); - } - - [Fact] - public async Task FaultInjection_Response_MissingContentType() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage((HttpStatusCode)429); - resp.Content = new ByteArrayContent(Array.Empty()); - resp.Content.Headers.ContentType = null; - return resp; - } - }; - using var client = CreateClient(handler); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_429_WithSubstatus_CollectionThrottle() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => - { - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-substatus", "3200"); - return resp; - } - }; - using var client = CreateClient(handler); - - try - { - await client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - ((int)ex.StatusCode).Should().Be(429); - return; - } - - throw new Xunit.Sdk.XunitException("Expected CosmosException"); - } + private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = maxRetries, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_WithRetryAfterMs_SDKRespectsDelay() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var callCount = 0; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + if (Interlocked.Increment(ref callCount) <= 1) + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-retry-after-ms", "10"); + return resp; + } + return null; + } + }; + using var client = CreateClient(handler, maxRetries: 3); + var c = client.GetContainer("db", "test"); + + var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + callCount.Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task FaultInjection_Response_WithEmptyContent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("") } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_Response_WithNullContent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)500) + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_Response_WithActivityId() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var activityId = Guid.NewGuid().ToString(); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-activity-id", activityId); + return resp; + } + }; + using var client = CreateClient(handler); + + try + { + await client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + ex.ActivityId.Should().Be(activityId); + return; + } + + throw new Xunit.Sdk.XunitException("Expected CosmosException"); + } + + [Fact] + public async Task FaultInjection_Response_WithRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-request-charge", "42.5"); + return resp; + } + }; + using var client = CreateClient(handler); + + try + { + await client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + ex.RequestCharge.Should().Be(42.5); + return; + } + + throw new Xunit.Sdk.XunitException("Expected CosmosException"); + } + + [Fact] + public async Task FaultInjection_Response_MissingContentType() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage((HttpStatusCode)429); + resp.Content = new ByteArrayContent(Array.Empty()); + resp.Content.Headers.ContentType = null; + return resp; + } + }; + using var client = CreateClient(handler); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_429_WithSubstatus_CollectionThrottle() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => + { + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-substatus", "3200"); + return resp; + } + }; + using var client = CreateClient(handler); + + try + { + await client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + ((int)ex.StatusCode).Should().Be(429); + return; + } + + throw new Xunit.Sdk.XunitException("Expected CosmosException"); + } } @@ -1133,114 +1133,114 @@ await client.GetContainer("db", "test") public class FaultInjectionDynamicDeepDiveTests { - private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = maxRetries, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_ToggleOnOff_DynamicBehavior() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var c = client.GetContainer("db", "test"); - - // Phase 1: No fault — succeeds - var read1 = await c.ReadItemAsync("1", new PartitionKey("pk1")); - read1.StatusCode.Should().Be(HttpStatusCode.OK); - - // Phase 2: Enable fault — fails - handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - - var act = () => c.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Phase 3: Disable — succeeds again - handler.FaultInjector = null; - var read3 = await c.ReadItemAsync("1", new PartitionKey("pk1")); - read3.StatusCode.Should().Be(HttpStatusCode.OK); - - // Phase 4: Re-enable — fails again - handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)503) - { Content = new StringContent("{}") }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_MethodBased_OnlyFailsWrites_ViaSdk() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = req => - req.Method == HttpMethod.Get ? null - : new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - var c = client.GetContainer("db", "test"); - - // GET (read) succeeds - var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - - // DELETE fails - var deleteAct = () => c.DeleteItemAsync("1", new PartitionKey("pk1")); - await deleteAct.Should().ThrowAsync(); - - // PATCH fails - var patchAct = () => c.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "X") }); - await patchAct.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_DocumentIdBased_OnlyFailsSpecificDoc() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "target-doc", PartitionKey = "pk1", Name = "Target" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "safe-doc", PartitionKey = "pk1", Name = "Safe" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = req => - req.RequestUri?.AbsolutePath.Contains("target-doc") == true - ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - : null - }; - using var client = CreateClient(handler); - var c = client.GetContainer("db", "test"); - - // Target doc fails - var act = () => c.ReadItemAsync("target-doc", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Safe doc succeeds - var read = await c.ReadItemAsync("safe-doc", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } + private static CosmosClient CreateClient(HttpMessageHandler handler, int maxRetries = 0) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = maxRetries, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_ToggleOnOff_DynamicBehavior() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var c = client.GetContainer("db", "test"); + + // Phase 1: No fault — succeeds + var read1 = await c.ReadItemAsync("1", new PartitionKey("pk1")); + read1.StatusCode.Should().Be(HttpStatusCode.OK); + + // Phase 2: Enable fault — fails + handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + + var act = () => c.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Phase 3: Disable — succeeds again + handler.FaultInjector = null; + var read3 = await c.ReadItemAsync("1", new PartitionKey("pk1")); + read3.StatusCode.Should().Be(HttpStatusCode.OK); + + // Phase 4: Re-enable — fails again + handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)503) + { Content = new StringContent("{}") }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_MethodBased_OnlyFailsWrites_ViaSdk() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = req => + req.Method == HttpMethod.Get ? null + : new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + var c = client.GetContainer("db", "test"); + + // GET (read) succeeds + var read = await c.ReadItemAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + + // DELETE fails + var deleteAct = () => c.DeleteItemAsync("1", new PartitionKey("pk1")); + await deleteAct.Should().ThrowAsync(); + + // PATCH fails + var patchAct = () => c.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "X") }); + await patchAct.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_DocumentIdBased_OnlyFailsSpecificDoc() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "target-doc", PartitionKey = "pk1", Name = "Target" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "safe-doc", PartitionKey = "pk1", Name = "Safe" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = req => + req.RequestUri?.AbsolutePath.Contains("target-doc") == true + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + : null + }; + using var client = CreateClient(handler); + var c = client.GetContainer("db", "test"); + + // Target doc fails + var act = () => c.ReadItemAsync("target-doc", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Safe doc succeeds + var read = await c.ReadItemAsync("safe-doc", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } } @@ -1250,119 +1250,119 @@ await container.CreateItemAsync( public class FaultInjectionInfrastructureDeepDiveTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_QueryLogNotPopulated_WhenFaulted() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - try - { - var iter = client.GetContainer("db", "test") - .GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - } - catch (CosmosException) { } - - // QueryLog populated at line 661 (inside query handler), but fault fires before that - handler.QueryLog.Should().BeEmpty("fault fires before query SQL is logged"); - } - - [Fact] - public async Task FaultInjection_RequestLog_CapturesMetadataRequests_EvenWhenFaulted() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container); - handler.FaultInjectorIncludesMetadata = true; - handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") }; - - using var httpClient = new HttpClient(handler); - var _ = await httpClient.GetAsync("https://localhost:9999/"); - - // RequestLog is written at line 161 BEFORE the fault check at line 163 - handler.RequestLog.Should().NotBeEmpty(); - } - - [Fact] - public async Task FaultInjection_EmptyDatabase_StillFaults() - { - var container = new InMemoryContainer("test", "/partitionKey"); - // No items at all - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var act = async () => - { - var iter = client.GetContainer("db", "test") - .GetItemQueryIterator("SELECT * FROM c"); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjection_WithCreateRouter_RoutingIsolation_ViaSdk() - { - var container1 = new InMemoryContainer("c1", "/partitionKey"); - var container2 = new InMemoryContainer("c2", "/partitionKey"); - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, - new PartitionKey("pk1")); - await container2.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, - new PartitionKey("pk1")); - - using var handler1 = new FakeCosmosHandler(container1) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var handler2 = new FakeCosmosHandler(container2); - - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["c1"] = handler1, - ["c2"] = handler2, - }); - using var client = CreateClient(router); - - // Container c1 is faulted — should fail - var act = () => client.GetContainer("db", "c1") - .ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Container c2 is not faulted — should succeed - var read = await client.GetContainer("db", "c2") - .ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("C2"); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_QueryLogNotPopulated_WhenFaulted() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + try + { + var iter = client.GetContainer("db", "test") + .GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + } + catch (CosmosException) { } + + // QueryLog populated at line 661 (inside query handler), but fault fires before that + handler.QueryLog.Should().BeEmpty("fault fires before query SQL is logged"); + } + + [Fact] + public async Task FaultInjection_RequestLog_CapturesMetadataRequests_EvenWhenFaulted() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container); + handler.FaultInjectorIncludesMetadata = true; + handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") }; + + using var httpClient = new HttpClient(handler); + var _ = await httpClient.GetAsync("https://localhost:9999/"); + + // RequestLog is written at line 161 BEFORE the fault check at line 163 + handler.RequestLog.Should().NotBeEmpty(); + } + + [Fact] + public async Task FaultInjection_EmptyDatabase_StillFaults() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // No items at all + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var act = async () => + { + var iter = client.GetContainer("db", "test") + .GetItemQueryIterator("SELECT * FROM c"); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjection_WithCreateRouter_RoutingIsolation_ViaSdk() + { + var container1 = new InMemoryContainer("c1", "/partitionKey"); + var container2 = new InMemoryContainer("c2", "/partitionKey"); + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C1" }, + new PartitionKey("pk1")); + await container2.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C2" }, + new PartitionKey("pk1")); + + using var handler1 = new FakeCosmosHandler(container1) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var handler2 = new FakeCosmosHandler(container2); + + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["c1"] = handler1, + ["c2"] = handler2, + }); + using var client = CreateClient(router); + + // Container c1 is faulted — should fail + var act = () => client.GetContainer("db", "c1") + .ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Container c2 is not faulted — should succeed + var read = await client.GetContainer("db", "c2") + .ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("C2"); + } } @@ -1372,92 +1372,92 @@ await container2.CreateItemAsync( public class FaultInjectionRetryTests { - [Fact] - public async Task FaultInjection_429_NoRetries_ExactlyOneDataCall() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var dataCallCount = 0; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = req => - { - // Only count non-metadata requests - if (req.RequestUri?.AbsolutePath.Contains("/docs") == true) - { - Interlocked.Increment(ref dataCallCount); - return new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - } - return null; - } - }; - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - try - { - await client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - } - catch (CosmosException) { } - - dataCallCount.Should().Be(1, "with 0 retries, exactly 1 data call should be made"); - } - - [Fact] - public async Task FaultInjection_429_MaxRetriesExhausted_StillThrows() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var callCount = 0; - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = req => - { - if (req.RequestUri?.AbsolutePath.Contains("/docs") == true) - { - Interlocked.Increment(ref callCount); - var resp = new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") }; - resp.Headers.Add("x-ms-retry-after-ms", "1"); - return resp; - } - return null; - } - }; - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 2, - MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var act = () => client.GetContainer("db", "test") - .ReadItemAsync("1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - callCount.Should().BeGreaterThanOrEqualTo(2, "SDK should retry at least 2 times"); - } + [Fact] + public async Task FaultInjection_429_NoRetries_ExactlyOneDataCall() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var dataCallCount = 0; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = req => + { + // Only count non-metadata requests + if (req.RequestUri?.AbsolutePath.Contains("/docs") == true) + { + Interlocked.Increment(ref dataCallCount); + return new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + } + return null; + } + }; + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + try + { + await client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + } + catch (CosmosException) { } + + dataCallCount.Should().Be(1, "with 0 retries, exactly 1 data call should be made"); + } + + [Fact] + public async Task FaultInjection_429_MaxRetriesExhausted_StillThrows() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var callCount = 0; + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = req => + { + if (req.RequestUri?.AbsolutePath.Contains("/docs") == true) + { + Interlocked.Increment(ref callCount); + var resp = new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") }; + resp.Headers.Add("x-ms-retry-after-ms", "1"); + return resp; + } + return null; + } + }; + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 2, + MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var act = () => client.GetContainer("db", "test") + .ReadItemAsync("1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + callCount.Should().BeGreaterThanOrEqualTo(2, "SDK should retry at least 2 times"); + } } @@ -1467,123 +1467,123 @@ await container.CreateItemAsync( public class FaultInjectionStreamTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public async Task FaultInjection_429_OnCreateItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - var response = await client.GetContainer("db", "test") - .CreateItemStreamAsync(stream, new PartitionKey("pk1")); - - ((int)response.StatusCode).Should().Be(429); - } - - [Fact] - public async Task FaultInjection_429_OnReadItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var response = await client.GetContainer("db", "test") - .ReadItemStreamAsync("1", new PartitionKey("pk1")); - - // Stream API returns status code instead of throwing - ((int)response.StatusCode).Should().Be(429); - } - - [Fact] - public async Task FaultInjection_429_OnReplaceItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var json = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - var response = await client.GetContainer("db", "test") - .ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1")); - - ((int)response.StatusCode).Should().Be(429); - } - - [Fact] - public async Task FaultInjection_429_OnDeleteItemStreamAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var response = await client.GetContainer("db", "test") - .DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - ((int)response.StatusCode).Should().Be(429); - } - - [Fact] - public async Task FaultInjection_503_OnGetItemQueryStreamIterator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - using var handler = new FakeCosmosHandler(container) - { - FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var iter = client.GetContainer("db", "test") - .GetItemQueryStreamIterator("SELECT * FROM c"); - - var response = await iter.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public async Task FaultInjection_429_OnCreateItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var response = await client.GetContainer("db", "test") + .CreateItemStreamAsync(stream, new PartitionKey("pk1")); + + ((int)response.StatusCode).Should().Be(429); + } + + [Fact] + public async Task FaultInjection_429_OnReadItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var response = await client.GetContainer("db", "test") + .ReadItemStreamAsync("1", new PartitionKey("pk1")); + + // Stream API returns status code instead of throwing + ((int)response.StatusCode).Should().Be(429); + } + + [Fact] + public async Task FaultInjection_429_OnReplaceItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var json = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var response = await client.GetContainer("db", "test") + .ReplaceItemStreamAsync(stream, "1", new PartitionKey("pk1")); + + ((int)response.StatusCode).Should().Be(429); + } + + [Fact] + public async Task FaultInjection_429_OnDeleteItemStreamAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var response = await client.GetContainer("db", "test") + .DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + ((int)response.StatusCode).Should().Be(429); + } + + [Fact] + public async Task FaultInjection_503_OnGetItemQueryStreamIterator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + using var handler = new FakeCosmosHandler(container) + { + FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var iter = client.GetContainer("db", "test") + .GetItemQueryStreamIterator("SELECT * FROM c"); + + var response = await iter.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeDeepDiveTests.cs index af36951..9771a1a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeDeepDiveTests.cs @@ -11,245 +11,245 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeAggregateDivergentTests { - private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) - { - var container = new InMemoryContainer("fr-agg", "/partitionKey") { FeedRangeCount = feedRangeCount }; - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}", value = i * 10 }), - new PartitionKey($"pk-{i}")); - return container; - } - - // 1.1 — Aggregate COUNT over FeedRange produces per-range counts - [Fact] - public async Task AggregateCOUNT_WithFeedRange_PerRangeSumsToTotal() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeSum = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) perRangeSum += item.Value(); - } - } - - // If working correctly, this would equal 50 - perRangeSum.Should().Be(50); - } - - // Sister test: verify per-range counts are non-trivially distributed - [Fact] - public async Task AggregateCOUNT_WithFeedRange_PerRangeCounts_DistributedAcrossRanges() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeCounts = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - var rangeCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) rangeCount += item.Value(); - } - perRangeCounts.Add(rangeCount); - } - - // Items are pre-filtered by FeedRange before aggregation, so multiple ranges should have counts. - var rangesWithCount = perRangeCounts.Count(c => c > 0); - rangesWithCount.Should().BeGreaterThan(1, "items should be distributed across multiple ranges"); - perRangeCounts.Sum().Should().Be(50); - } - - // 1.2 — Aggregate SUM - [Fact] - public async Task AggregateSUM_WithFeedRange_PerRangeSumsToTotal() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeSum = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE SUM(c.value) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) perRangeSum += item.Value(); - } - } - - perRangeSum.Should().Be(Enumerable.Range(0, 50).Sum(i => i * 10)); - } - - // Sister test: verify per-range sums add up - [Fact] - public async Task AggregateSUM_WithFeedRange_PerRangeSums_DistributedCorrectly() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var expectedTotal = Enumerable.Range(0, 50).Sum(i => i * 10); - var perRangeSums = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE SUM(c.value) FROM c")); - var rangeSum = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) rangeSum += item.Value(); - } - perRangeSums.Add(rangeSum); - } - - var rangesWithSum = perRangeSums.Count(s => s > 0); - rangesWithSum.Should().BeGreaterThan(1, "sums should be distributed across multiple ranges"); - perRangeSums.Sum().Should().Be(expectedTotal); - } - - // 1.3 — Aggregate AVG - [Fact] - public async Task AggregateAVG_WithFeedRange_EachRangeReturnsLocalAvg() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeAvgs = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE AVG(c.value) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) perRangeAvgs.Add(item.Value()); - } - } - - // Each range should return its own local AVG - perRangeAvgs.Should().HaveCountGreaterThan(1, "multiple ranges should return AVGs"); - } - - // Sister test: verify local AVGs are reasonable values - [Fact] - public async Task AggregateAVG_WithFeedRange_LocalAvgs_AreReasonableValues() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeAvgs = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE AVG(c.value) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) perRangeAvgs.Add(item.Value()); - } - } - - // All per-range AVGs should be within the global range [0, 490] - perRangeAvgs.Should().AllSatisfy(avg => avg.Should().BeInRange(0, 490)); - } - - // 1.4 — Aggregate MIN/MAX - [Fact] - public async Task AggregateMinMax_WithFeedRange_EachRangeReturnsLocalMin() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allMins = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE MIN(c.value) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allMins.Add(item.Value()); - } - } - - // Each range should return its local MIN - allMins.Should().HaveCountGreaterThan(1, "multiple ranges should return MIN values"); - } - - // Sister test: global MIN is 0 and appears somewhere in per-range results - [Fact] - public async Task AggregateMinMax_WithFeedRange_GlobalMinAppearsInOneRange() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allMins = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE MIN(c.value) FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allMins.Add(item.Value()); - } - } - - allMins.Should().Contain(0, "global MIN of 0 should appear in one of the ranges"); - } - - // 1.5 — VALUE keyword with FeedRange - [Fact] - public async Task VALUE_WithFeedRange_PerRangeSumsToTotal() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allNames = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE c.name FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allNames.Add(item.Value()!); - } - } - - allNames.Should().HaveCount(50, "all 50 names should be returned across ranges"); - } - - // Sister test: VALUE results distributed across ranges - [Fact] - public async Task VALUE_WithFeedRange_ScalarsDistributedAcrossRanges() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeCounts = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE c.name FROM c")); - var count = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - count += page.Count; - } - perRangeCounts.Add(count); - } - - // Items are pre-filtered by FeedRange, so results are distributed - var rangesWithResults = perRangeCounts.Count(c => c > 0); - rangesWithResults.Should().BeGreaterThan(1, "VALUE results should be distributed across ranges"); - perRangeCounts.Sum().Should().Be(50, "total count is still 50"); - } + private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) + { + var container = new InMemoryContainer("fr-agg", "/partitionKey") { FeedRangeCount = feedRangeCount }; + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}", value = i * 10 }), + new PartitionKey($"pk-{i}")); + return container; + } + + // 1.1 — Aggregate COUNT over FeedRange produces per-range counts + [Fact] + public async Task AggregateCOUNT_WithFeedRange_PerRangeSumsToTotal() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeSum = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) perRangeSum += item.Value(); + } + } + + // If working correctly, this would equal 50 + perRangeSum.Should().Be(50); + } + + // Sister test: verify per-range counts are non-trivially distributed + [Fact] + public async Task AggregateCOUNT_WithFeedRange_PerRangeCounts_DistributedAcrossRanges() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeCounts = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + var rangeCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) rangeCount += item.Value(); + } + perRangeCounts.Add(rangeCount); + } + + // Items are pre-filtered by FeedRange before aggregation, so multiple ranges should have counts. + var rangesWithCount = perRangeCounts.Count(c => c > 0); + rangesWithCount.Should().BeGreaterThan(1, "items should be distributed across multiple ranges"); + perRangeCounts.Sum().Should().Be(50); + } + + // 1.2 — Aggregate SUM + [Fact] + public async Task AggregateSUM_WithFeedRange_PerRangeSumsToTotal() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeSum = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE SUM(c.value) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) perRangeSum += item.Value(); + } + } + + perRangeSum.Should().Be(Enumerable.Range(0, 50).Sum(i => i * 10)); + } + + // Sister test: verify per-range sums add up + [Fact] + public async Task AggregateSUM_WithFeedRange_PerRangeSums_DistributedCorrectly() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var expectedTotal = Enumerable.Range(0, 50).Sum(i => i * 10); + var perRangeSums = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE SUM(c.value) FROM c")); + var rangeSum = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) rangeSum += item.Value(); + } + perRangeSums.Add(rangeSum); + } + + var rangesWithSum = perRangeSums.Count(s => s > 0); + rangesWithSum.Should().BeGreaterThan(1, "sums should be distributed across multiple ranges"); + perRangeSums.Sum().Should().Be(expectedTotal); + } + + // 1.3 — Aggregate AVG + [Fact] + public async Task AggregateAVG_WithFeedRange_EachRangeReturnsLocalAvg() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeAvgs = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE AVG(c.value) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) perRangeAvgs.Add(item.Value()); + } + } + + // Each range should return its own local AVG + perRangeAvgs.Should().HaveCountGreaterThan(1, "multiple ranges should return AVGs"); + } + + // Sister test: verify local AVGs are reasonable values + [Fact] + public async Task AggregateAVG_WithFeedRange_LocalAvgs_AreReasonableValues() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeAvgs = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE AVG(c.value) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) perRangeAvgs.Add(item.Value()); + } + } + + // All per-range AVGs should be within the global range [0, 490] + perRangeAvgs.Should().AllSatisfy(avg => avg.Should().BeInRange(0, 490)); + } + + // 1.4 — Aggregate MIN/MAX + [Fact] + public async Task AggregateMinMax_WithFeedRange_EachRangeReturnsLocalMin() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allMins = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE MIN(c.value) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allMins.Add(item.Value()); + } + } + + // Each range should return its local MIN + allMins.Should().HaveCountGreaterThan(1, "multiple ranges should return MIN values"); + } + + // Sister test: global MIN is 0 and appears somewhere in per-range results + [Fact] + public async Task AggregateMinMax_WithFeedRange_GlobalMinAppearsInOneRange() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allMins = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE MIN(c.value) FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allMins.Add(item.Value()); + } + } + + allMins.Should().Contain(0, "global MIN of 0 should appear in one of the ranges"); + } + + // 1.5 — VALUE keyword with FeedRange + [Fact] + public async Task VALUE_WithFeedRange_PerRangeSumsToTotal() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allNames = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE c.name FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allNames.Add(item.Value()!); + } + } + + allNames.Should().HaveCount(50, "all 50 names should be returned across ranges"); + } + + // Sister test: VALUE results distributed across ranges + [Fact] + public async Task VALUE_WithFeedRange_ScalarsDistributedAcrossRanges() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeCounts = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE c.name FROM c")); + var count = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + count += page.Count; + } + perRangeCounts.Add(count); + } + + // Items are pre-filtered by FeedRange, so results are distributed + var rangesWithResults = perRangeCounts.Count(c => c > 0); + rangesWithResults.Should().BeGreaterThan(1, "VALUE results should be distributed across ranges"); + perRangeCounts.Sum().Should().Be(50, "total count is still 50"); + } } // ═══════════════════════════════════════════════════════════ @@ -258,78 +258,78 @@ public async Task VALUE_WithFeedRange_ScalarsDistributedAcrossRanges() public class FeedRangeTtlTests { - // 2.1 — TTL-expired items excluded from FeedRange queries - [Fact] - public async Task TTLExpiredItems_FeedRangeQuery_ExcludesExpiredItems() - { - var container = new InMemoryContainer("fr-ttl", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), - new PartitionKey($"pk-{i}")); - - // Before TTL expiry — all items present across feed ranges - var beforeCount = 0; - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - beforeCount += page.Count; - } - } - beforeCount.Should().Be(20); - - // Wait for TTL to expire (1 second + buffer) - await Task.Delay(1500); - - // After TTL expiry — items should be excluded from FeedRange queries - var afterCount = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - afterCount += page.Count; - } - } - afterCount.Should().Be(0, "TTL-expired items should not appear in FeedRange queries"); - } - - // 2.2 — Change feed from Beginning should still show creation of TTL-expired items - [Fact] - public async Task TTLExpiredItems_FeedRangeChangeFeed_StillShowsCreation() - { - var container = new InMemoryContainer("fr-ttl-cf", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Wait for TTL expiry - await Task.Delay(1500); - - // Change feed from Beginning should still show all 10 creations - var totalFromChangeFeed = 0; - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; - totalFromChangeFeed += page.Count; - } - } - totalFromChangeFeed.Should().Be(10, "change feed shows historical creations even after TTL expiry"); - } + // 2.1 — TTL-expired items excluded from FeedRange queries + [Fact] + public async Task TTLExpiredItems_FeedRangeQuery_ExcludesExpiredItems() + { + var container = new InMemoryContainer("fr-ttl", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), + new PartitionKey($"pk-{i}")); + + // Before TTL expiry — all items present across feed ranges + var beforeCount = 0; + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + beforeCount += page.Count; + } + } + beforeCount.Should().Be(20); + + // Wait for TTL to expire (1 second + buffer) + await Task.Delay(1500); + + // After TTL expiry — items should be excluded from FeedRange queries + var afterCount = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + afterCount += page.Count; + } + } + afterCount.Should().Be(0, "TTL-expired items should not appear in FeedRange queries"); + } + + // 2.2 — Change feed from Beginning should still show creation of TTL-expired items + [Fact] + public async Task TTLExpiredItems_FeedRangeChangeFeed_StillShowsCreation() + { + var container = new InMemoryContainer("fr-ttl-cf", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Wait for TTL expiry + await Task.Delay(1500); + + // Change feed from Beginning should still show all 10 creations + var totalFromChangeFeed = 0; + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; + totalFromChangeFeed += page.Count; + } + } + totalFromChangeFeed.Should().Be(10, "change feed shows historical creations even after TTL expiry"); + } } // ═══════════════════════════════════════════════════════════ @@ -338,143 +338,143 @@ await container.CreateItemAsync( public class FeedRangeMutationTests { - // 3.1 — Patch doesn't change PK so item stays in same range - [Fact] - public async Task PatchItem_StaysInSameRange() - { - var container = new InMemoryContainer("fr-patch", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk-stable", name = "Before" }), - new PartitionKey("pk-stable")); - - // Find which range the item is in before patch - var ranges = await container.GetFeedRangesAsync(); - int? rangeBefore = null; - for (var i = 0; i < ranges.Count; i++) - { - var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) rangeBefore = i; - } - } - rangeBefore.Should().NotBeNull(); - - // Patch the item (non-PK field) - await container.PatchItemAsync("1", new PartitionKey("pk-stable"), - [PatchOperation.Set("/name", "After")]); - - // Verify item is still in the same range - int? rangeAfter = null; - for (var i = 0; i < ranges.Count; i++) - { - var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) rangeAfter = i; - } - } - rangeAfter.Should().Be(rangeBefore, "patching a non-PK field should not change FeedRange assignment"); - } - - // 3.2 — Patch change feed appears in correct range - [Fact] - public async Task PatchItem_ChangeFeed_UpdateAppearsInCorrectRange() - { - var container = new InMemoryContainer("fr-patch-cf", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk-cf", name = "Original" }), - new PartitionKey("pk-cf")); - - await container.PatchItemAsync("1", new PartitionKey("pk-cf"), - [PatchOperation.Set("/name", "Patched")]); - - // Read change feed from Beginning per range — the patched version should appear in exactly one range - var ranges = await container.GetFeedRangesAsync(); - var rangesWithPatchedItem = 0; - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; - if (page.Any(item => item["name"]?.Value() == "Patched")) - rangesWithPatchedItem++; - } - } - rangesWithPatchedItem.Should().Be(1, "patch update should appear in exactly one FeedRange change feed"); - } - - // 3.3 — DeleteAllByPartitionKey makes items disappear from range - [Fact] - public async Task DeleteAllByPartitionKey_FeedRange_RangeItemsDisappear() - { - var container = new InMemoryContainer("fr-delpk", "/partitionKey") { FeedRangeCount = 4 }; - - // Seed: 5 items with pk-a, 5 items with pk-b - for (var i = 0; i < 5; i++) - { - await container.CreateItemAsync( - JObject.FromObject(new { id = $"a{i}", partitionKey = "pk-a" }), - new PartitionKey("pk-a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = $"b{i}", partitionKey = "pk-b" }), - new PartitionKey("pk-b")); - } - - // Count total before delete - var ranges = await container.GetFeedRangesAsync(); - var totalBefore = await CountAllAcrossRanges(container, ranges); - totalBefore.Should().Be(10); - - // Delete all pk-a items - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk-a")); - - // Count total after delete — should be 5 - var totalAfter = await CountAllAcrossRanges(container, ranges); - totalAfter.Should().Be(5, "only pk-b items should remain after deleting pk-a"); - } - - // 3.4 — ClearItems makes all ranges empty - [Fact] - public async Task ClearItems_AllFeedRanges_ReturnEmpty() - { - var container = new InMemoryContainer("fr-clear", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - (await CountAllAcrossRanges(container, ranges)).Should().Be(20); - - container.ClearItems(); - - var totalAfter = await CountAllAcrossRanges(container, ranges); - totalAfter.Should().Be(0, "all FeedRange queries should return empty after ClearItems"); - } - - private static async Task CountAllAcrossRanges(InMemoryContainer container, IReadOnlyList ranges) - { - var total = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - total += page.Count; - } - } - return total; - } + // 3.1 — Patch doesn't change PK so item stays in same range + [Fact] + public async Task PatchItem_StaysInSameRange() + { + var container = new InMemoryContainer("fr-patch", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk-stable", name = "Before" }), + new PartitionKey("pk-stable")); + + // Find which range the item is in before patch + var ranges = await container.GetFeedRangesAsync(); + int? rangeBefore = null; + for (var i = 0; i < ranges.Count; i++) + { + var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) rangeBefore = i; + } + } + rangeBefore.Should().NotBeNull(); + + // Patch the item (non-PK field) + await container.PatchItemAsync("1", new PartitionKey("pk-stable"), + [PatchOperation.Set("/name", "After")]); + + // Verify item is still in the same range + int? rangeAfter = null; + for (var i = 0; i < ranges.Count; i++) + { + var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) rangeAfter = i; + } + } + rangeAfter.Should().Be(rangeBefore, "patching a non-PK field should not change FeedRange assignment"); + } + + // 3.2 — Patch change feed appears in correct range + [Fact] + public async Task PatchItem_ChangeFeed_UpdateAppearsInCorrectRange() + { + var container = new InMemoryContainer("fr-patch-cf", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk-cf", name = "Original" }), + new PartitionKey("pk-cf")); + + await container.PatchItemAsync("1", new PartitionKey("pk-cf"), + [PatchOperation.Set("/name", "Patched")]); + + // Read change feed from Beginning per range — the patched version should appear in exactly one range + var ranges = await container.GetFeedRangesAsync(); + var rangesWithPatchedItem = 0; + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; + if (page.Any(item => item["name"]?.Value() == "Patched")) + rangesWithPatchedItem++; + } + } + rangesWithPatchedItem.Should().Be(1, "patch update should appear in exactly one FeedRange change feed"); + } + + // 3.3 — DeleteAllByPartitionKey makes items disappear from range + [Fact] + public async Task DeleteAllByPartitionKey_FeedRange_RangeItemsDisappear() + { + var container = new InMemoryContainer("fr-delpk", "/partitionKey") { FeedRangeCount = 4 }; + + // Seed: 5 items with pk-a, 5 items with pk-b + for (var i = 0; i < 5; i++) + { + await container.CreateItemAsync( + JObject.FromObject(new { id = $"a{i}", partitionKey = "pk-a" }), + new PartitionKey("pk-a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = $"b{i}", partitionKey = "pk-b" }), + new PartitionKey("pk-b")); + } + + // Count total before delete + var ranges = await container.GetFeedRangesAsync(); + var totalBefore = await CountAllAcrossRanges(container, ranges); + totalBefore.Should().Be(10); + + // Delete all pk-a items + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk-a")); + + // Count total after delete — should be 5 + var totalAfter = await CountAllAcrossRanges(container, ranges); + totalAfter.Should().Be(5, "only pk-b items should remain after deleting pk-a"); + } + + // 3.4 — ClearItems makes all ranges empty + [Fact] + public async Task ClearItems_AllFeedRanges_ReturnEmpty() + { + var container = new InMemoryContainer("fr-clear", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + (await CountAllAcrossRanges(container, ranges)).Should().Be(20); + + container.ClearItems(); + + var totalAfter = await CountAllAcrossRanges(container, ranges); + totalAfter.Should().Be(0, "all FeedRange queries should return empty after ClearItems"); + } + + private static async Task CountAllAcrossRanges(InMemoryContainer container, IReadOnlyList ranges) + { + var total = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + total += page.Count; + } + } + return total; + } } // ═══════════════════════════════════════════════════════════ @@ -483,100 +483,100 @@ private static async Task CountAllAcrossRanges(InMemoryContainer container, public class FeedRangeStatePersistenceTests { - // 4.1 — Export/Import preserves FeedRange distribution - [Fact] - public async Task ExportImportState_FeedRangeDistribution_Preserved() - { - var container1 = new InMemoryContainer("fr-persist", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 50; i++) - await container1.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), - new PartitionKey($"pk-{i}")); - - // Capture per-range distribution from original container - var ranges1 = await container1.GetFeedRangesAsync(); - var dist1 = await GetPerRangeIds(container1, ranges1); - - // Export and import into new container with same config - var state = container1.ExportState(); - var container2 = new InMemoryContainer("fr-persist", "/partitionKey") { FeedRangeCount = 4 }; - container2.ImportState(state); - - // Verify same distribution - var ranges2 = await container2.GetFeedRangesAsync(); - var dist2 = await GetPerRangeIds(container2, ranges2); - - dist1.Count.Should().Be(dist2.Count); - for (var i = 0; i < dist1.Count; i++) - dist1[i].Should().BeEquivalentTo(dist2[i], $"range {i} should have the same items after import"); - } - - // 4.2 — After import, change feed is empty but query FeedRanges work - [Fact] - public async Task ExportImportState_ChangeFeed_NotPreserved_ButQueryWorks() - { - var container1 = new InMemoryContainer("fr-persist2", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container1.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var state = container1.ExportState(); - var container2 = new InMemoryContainer("fr-persist2", "/partitionKey") { FeedRangeCount = 4 }; - container2.ImportState(state); - - // Query works — items are present - var ranges = await container2.GetFeedRangesAsync(); - var queryCount = 0; - foreach (var range in ranges) - { - var iterator = container2.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - queryCount += page.Count; - } - } - queryCount.Should().Be(10, "query should return all imported items"); - - // Change feed from Beginning — may or may not have entries (import behavior) - // The key assertion is that queries work correctly - var changeFeedCount = 0; - foreach (var range in ranges) - { - var iterator = container2.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; - changeFeedCount += page.Count; - } - } - - // Change feed is populated by ImportState (it calls UpsertItemAsync internally) - // So it may or may not be empty — just verify it doesn't throw - changeFeedCount.Should().BeGreaterThanOrEqualTo(0); - } - - private static async Task>> GetPerRangeIds(InMemoryContainer container, IReadOnlyList ranges) - { - var result = new List>(); - foreach (var range in ranges) - { - var ids = new HashSet(); - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) ids.Add(item["id"]!.ToString()); - } - result.Add(ids); - } - return result; - } + // 4.1 — Export/Import preserves FeedRange distribution + [Fact] + public async Task ExportImportState_FeedRangeDistribution_Preserved() + { + var container1 = new InMemoryContainer("fr-persist", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 50; i++) + await container1.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), + new PartitionKey($"pk-{i}")); + + // Capture per-range distribution from original container + var ranges1 = await container1.GetFeedRangesAsync(); + var dist1 = await GetPerRangeIds(container1, ranges1); + + // Export and import into new container with same config + var state = container1.ExportState(); + var container2 = new InMemoryContainer("fr-persist", "/partitionKey") { FeedRangeCount = 4 }; + container2.ImportState(state); + + // Verify same distribution + var ranges2 = await container2.GetFeedRangesAsync(); + var dist2 = await GetPerRangeIds(container2, ranges2); + + dist1.Count.Should().Be(dist2.Count); + for (var i = 0; i < dist1.Count; i++) + dist1[i].Should().BeEquivalentTo(dist2[i], $"range {i} should have the same items after import"); + } + + // 4.2 — After import, change feed is empty but query FeedRanges work + [Fact] + public async Task ExportImportState_ChangeFeed_NotPreserved_ButQueryWorks() + { + var container1 = new InMemoryContainer("fr-persist2", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container1.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var state = container1.ExportState(); + var container2 = new InMemoryContainer("fr-persist2", "/partitionKey") { FeedRangeCount = 4 }; + container2.ImportState(state); + + // Query works — items are present + var ranges = await container2.GetFeedRangesAsync(); + var queryCount = 0; + foreach (var range in ranges) + { + var iterator = container2.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + queryCount += page.Count; + } + } + queryCount.Should().Be(10, "query should return all imported items"); + + // Change feed from Beginning — may or may not have entries (import behavior) + // The key assertion is that queries work correctly + var changeFeedCount = 0; + foreach (var range in ranges) + { + var iterator = container2.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; + changeFeedCount += page.Count; + } + } + + // Change feed is populated by ImportState (it calls UpsertItemAsync internally) + // So it may or may not be empty — just verify it doesn't throw + changeFeedCount.Should().BeGreaterThanOrEqualTo(0); + } + + private static async Task>> GetPerRangeIds(InMemoryContainer container, IReadOnlyList ranges) + { + var result = new List>(); + foreach (var range in ranges) + { + var ids = new HashSet(); + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) ids.Add(item["id"]!.ToString()); + } + result.Add(ids); + } + return result; + } } // ═══════════════════════════════════════════════════════════ @@ -585,75 +585,75 @@ private static async Task>> GetPerRangeIds(InMemoryContaine public class FeedRangeChangeFeedAdvancedDeepDiveTests { - // 5.1 — After delete, item disappears from the correct FeedRange - [Fact] - public async Task Delete_ItemDisappearsFromCorrectFeedRange() - { - var container = new InMemoryContainer("fr-cf-del", "/partitionKey") { FeedRangeCount = 4 }; - - // Create items across ranges - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Find which range item "5" is in - var ranges = await container.GetFeedRangesAsync(); - int? targetRange = null; - for (var i = 0; i < ranges.Count; i++) - { - var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '5'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) targetRange = i; - } - } - targetRange.Should().NotBeNull(); - - // Delete item "5" - await container.DeleteItemAsync("5", new PartitionKey("pk-5")); - - // Verify item is gone from its FeedRange - var iteratorAfter = container.GetItemQueryIterator(ranges[targetRange!.Value], - new QueryDefinition("SELECT * FROM c WHERE c.id = '5'")); - var afterResults = new List(); - while (iteratorAfter.HasMoreResults) - { - var page = await iteratorAfter.ReadNextAsync(); - afterResults.AddRange(page); - } - afterResults.Should().BeEmpty("deleted item should be gone from its FeedRange"); - - // Verify tombstone is in change feed (checkpoint-based, no FeedRange filter) - var allChanges = new List(); - var feedIterator = container.GetChangeFeedIterator(0); - while (feedIterator.HasMoreResults) - { - var page = await feedIterator.ReadNextAsync(); - allChanges.AddRange(page); - } - allChanges.Any(item => item["_deleted"] != null && item["_deleted"]!.Value() && item["id"] != null && item["id"]!.Value() == "5") - .Should().BeTrue("delete tombstone should be in change feed"); - } - - // 5.2 — ChangeFeedStartFrom.ContinuationAndFeedRange (known gap) - [Fact(Skip = "ChangeFeedStartFrom.ContinuationAndFeedRange may have incomplete support — ExtractFeedRangeFromStartFrom may not find FeedRange from this subtype")] - public async Task ChangeFeed_ContinuationAndFeedRange_Support() - { - // This test documents the known limitation per GAP 9 - var container = new InMemoryContainer("fr-cf-cont", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - // ChangeFeedStartFrom.ContinuationAndFeedRange would be used here - // but the constructor is likely internal/not public - await Task.CompletedTask; - } + // 5.1 — After delete, item disappears from the correct FeedRange + [Fact] + public async Task Delete_ItemDisappearsFromCorrectFeedRange() + { + var container = new InMemoryContainer("fr-cf-del", "/partitionKey") { FeedRangeCount = 4 }; + + // Create items across ranges + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Find which range item "5" is in + var ranges = await container.GetFeedRangesAsync(); + int? targetRange = null; + for (var i = 0; i < ranges.Count; i++) + { + var iterator = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '5'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) targetRange = i; + } + } + targetRange.Should().NotBeNull(); + + // Delete item "5" + await container.DeleteItemAsync("5", new PartitionKey("pk-5")); + + // Verify item is gone from its FeedRange + var iteratorAfter = container.GetItemQueryIterator(ranges[targetRange!.Value], + new QueryDefinition("SELECT * FROM c WHERE c.id = '5'")); + var afterResults = new List(); + while (iteratorAfter.HasMoreResults) + { + var page = await iteratorAfter.ReadNextAsync(); + afterResults.AddRange(page); + } + afterResults.Should().BeEmpty("deleted item should be gone from its FeedRange"); + + // Verify tombstone is in change feed (checkpoint-based, no FeedRange filter) + var allChanges = new List(); + var feedIterator = container.GetChangeFeedIterator(0); + while (feedIterator.HasMoreResults) + { + var page = await feedIterator.ReadNextAsync(); + allChanges.AddRange(page); + } + allChanges.Any(item => item["_deleted"] != null && item["_deleted"]!.Value() && item["id"] != null && item["id"]!.Value() == "5") + .Should().BeTrue("delete tombstone should be in change feed"); + } + + // 5.2 — ChangeFeedStartFrom.ContinuationAndFeedRange (known gap) + [Fact(Skip = "ChangeFeedStartFrom.ContinuationAndFeedRange may have incomplete support — ExtractFeedRangeFromStartFrom may not find FeedRange from this subtype")] + public async Task ChangeFeed_ContinuationAndFeedRange_Support() + { + // This test documents the known limitation per GAP 9 + var container = new InMemoryContainer("fr-cf-cont", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + // ChangeFeedStartFrom.ContinuationAndFeedRange would be used here + // but the constructor is likely internal/not public + await Task.CompletedTask; + } } // ═══════════════════════════════════════════════════════════ @@ -662,114 +662,114 @@ await container.CreateItemAsync( public class FeedRangeQueryInteractionDeepDiveTests { - private static async Task CreatePopulatedContainer() - { - var container = new InMemoryContainer("fr-qi", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", tags = new[] { $"tag-{i % 3}" }, sub = new { val = i } }), - new PartitionKey($"pk-{i}")); - return container; - } - - // 6.1 — Both FeedRange and PartitionKey RequestOption - [Fact] - public async Task QueryWithPartitionKeyOption_AndFeedRange_DoubleFilterBehavior() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - // Find which range pk-5 belongs to - int? targetRangeIndex = null; - for (var i = 0; i < ranges.Count; i++) - { - var iterator = container.GetItemQueryIterator(ranges[i], - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk-5'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) targetRangeIndex = i; - } - } - targetRangeIndex.Should().NotBeNull(); - - // Query with both FeedRange and PartitionKey — should still find the item - var iteratorBoth = container.GetItemQueryIterator( - ranges[targetRangeIndex!.Value], - new QueryDefinition("SELECT * FROM c"), - null, - new QueryRequestOptions { PartitionKey = new PartitionKey("pk-5") }); - var results = new List(); - while (iteratorBoth.HasMoreResults) - { - var page = await iteratorBoth.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(item => item["id"]!.Value() == "5"); - - // Query with FeedRange of a DIFFERENT range + PartitionKey — should return empty - var otherRange = (targetRangeIndex.Value + 1) % ranges.Count; - var iteratorMismatch = container.GetItemQueryIterator( - ranges[otherRange], - new QueryDefinition("SELECT * FROM c"), - null, - new QueryRequestOptions { PartitionKey = new PartitionKey("pk-5") }); - var mismatchResults = new List(); - while (iteratorMismatch.HasMoreResults) - { - var page = await iteratorMismatch.ReadNextAsync(); - mismatchResults.AddRange(page); - } - mismatchResults.Should().BeEmpty("FeedRange and PartitionKey filter to different subsets"); - } - - // 6.2 — Subquery EXISTS with FeedRange - [Fact] - public async Task SubqueryEXISTS_WithFeedRange_FiltersCorrectly() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - // Query using EXISTS subquery with FeedRange - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT * FROM c WHERE EXISTS (SELECT VALUE 1 FROM t IN c.tags WHERE t = 'tag-0')")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - // Items with tag-0 are those where i%3==0: 0, 3, 6, 9, 12, 15, 18, 21, 24, 27 - allIds.Should().HaveCount(10); - allIds.Should().BeEquivalentTo(Enumerable.Range(0, 30).Where(i => i % 3 == 0).Select(i => $"{i}")); - } - - // 6.3 — JOIN query with FeedRange - [Fact] - public async Task JoinQuery_WithFeedRange_FiltersCorrectly() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT c.id, t AS tag FROM c JOIN t IN c.tags")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - // Every item has 1 tag, so JOIN produces 30 rows with 30 unique ids - allIds.Should().HaveCount(30, "JOIN with FeedRange should cover all items across all ranges"); - } + private static async Task CreatePopulatedContainer() + { + var container = new InMemoryContainer("fr-qi", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", tags = new[] { $"tag-{i % 3}" }, sub = new { val = i } }), + new PartitionKey($"pk-{i}")); + return container; + } + + // 6.1 — Both FeedRange and PartitionKey RequestOption + [Fact] + public async Task QueryWithPartitionKeyOption_AndFeedRange_DoubleFilterBehavior() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + // Find which range pk-5 belongs to + int? targetRangeIndex = null; + for (var i = 0; i < ranges.Count; i++) + { + var iterator = container.GetItemQueryIterator(ranges[i], + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk-5'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) targetRangeIndex = i; + } + } + targetRangeIndex.Should().NotBeNull(); + + // Query with both FeedRange and PartitionKey — should still find the item + var iteratorBoth = container.GetItemQueryIterator( + ranges[targetRangeIndex!.Value], + new QueryDefinition("SELECT * FROM c"), + null, + new QueryRequestOptions { PartitionKey = new PartitionKey("pk-5") }); + var results = new List(); + while (iteratorBoth.HasMoreResults) + { + var page = await iteratorBoth.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(item => item["id"]!.Value() == "5"); + + // Query with FeedRange of a DIFFERENT range + PartitionKey — should return empty + var otherRange = (targetRangeIndex.Value + 1) % ranges.Count; + var iteratorMismatch = container.GetItemQueryIterator( + ranges[otherRange], + new QueryDefinition("SELECT * FROM c"), + null, + new QueryRequestOptions { PartitionKey = new PartitionKey("pk-5") }); + var mismatchResults = new List(); + while (iteratorMismatch.HasMoreResults) + { + var page = await iteratorMismatch.ReadNextAsync(); + mismatchResults.AddRange(page); + } + mismatchResults.Should().BeEmpty("FeedRange and PartitionKey filter to different subsets"); + } + + // 6.2 — Subquery EXISTS with FeedRange + [Fact] + public async Task SubqueryEXISTS_WithFeedRange_FiltersCorrectly() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + // Query using EXISTS subquery with FeedRange + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT * FROM c WHERE EXISTS (SELECT VALUE 1 FROM t IN c.tags WHERE t = 'tag-0')")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + // Items with tag-0 are those where i%3==0: 0, 3, 6, 9, 12, 15, 18, 21, 24, 27 + allIds.Should().HaveCount(10); + allIds.Should().BeEquivalentTo(Enumerable.Range(0, 30).Where(i => i % 3 == 0).Select(i => $"{i}")); + } + + // 6.3 — JOIN query with FeedRange + [Fact] + public async Task JoinQuery_WithFeedRange_FiltersCorrectly() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT c.id, t AS tag FROM c JOIN t IN c.tags")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + // Every item has 1 tag, so JOIN produces 30 rows with 30 unique ids + allIds.Should().HaveCount(30, "JOIN with FeedRange should cover all items across all ranges"); + } } // ═══════════════════════════════════════════════════════════ @@ -778,63 +778,63 @@ public async Task JoinQuery_WithFeedRange_FiltersCorrectly() public class FeedRangeHandlerSyncTests { - // 7.1 — Handler defaulting to PKRangeCount=1 while container has FeedRangeCount=4 - [Fact] - public async Task FakeCosmosHandler_DefaultPKRangeCount_DesyncWithContainerFeedRangeCount() - { - var container = new InMemoryContainer("fr-sync", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), - new PartitionKey($"pk-{i}")); - - // Container reports 4 FeedRanges - var containerRanges = await container.GetFeedRangesAsync(); - containerRanges.Should().HaveCount(4); - - // Handler defaults to PKRangeCount=1 unless set - var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("testdb", "fr-sync"); - - // Handler-based client: GetFeedRangesAsync uses handler's PKRangeCount (default=1) - var handlerRanges = await cosmosContainer.GetFeedRangesAsync(); - handlerRanges.Should().HaveCount(1, "handler defaults to 1 PKRange"); - - // But both paths return the same total items - var containerTotal = 0; - foreach (var range in containerRanges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containerTotal += page.Count; - } - } - - var handlerTotal = 0; - foreach (var range in handlerRanges) - { - var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - handlerTotal += page.Count; - } - } - - containerTotal.Should().Be(20); - handlerTotal.Should().Be(20); - } + // 7.1 — Handler defaulting to PKRangeCount=1 while container has FeedRangeCount=4 + [Fact] + public async Task FakeCosmosHandler_DefaultPKRangeCount_DesyncWithContainerFeedRangeCount() + { + var container = new InMemoryContainer("fr-sync", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), + new PartitionKey($"pk-{i}")); + + // Container reports 4 FeedRanges + var containerRanges = await container.GetFeedRangesAsync(); + containerRanges.Should().HaveCount(4); + + // Handler defaults to PKRangeCount=1 unless set + var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("testdb", "fr-sync"); + + // Handler-based client: GetFeedRangesAsync uses handler's PKRangeCount (default=1) + var handlerRanges = await cosmosContainer.GetFeedRangesAsync(); + handlerRanges.Should().HaveCount(1, "handler defaults to 1 PKRange"); + + // But both paths return the same total items + var containerTotal = 0; + foreach (var range in containerRanges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containerTotal += page.Count; + } + } + + var handlerTotal = 0; + foreach (var range in handlerRanges) + { + var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + handlerTotal += page.Count; + } + } + + containerTotal.Should().Be(20); + handlerTotal.Should().Be(20); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseDeepDiveTests.cs index a1ee85a..2fea2ca 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseDeepDiveTests.cs @@ -1,9 +1,9 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -14,153 +14,153 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeFromPartitionKeyTests { - private static async Task CreateTestContainer(int count = 20, int feedRangeCount = 4) - { - var container = new InMemoryContainer("fr-pk", "/partitionKey") { FeedRangeCount = feedRangeCount }; - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), - new PartitionKey($"pk-{i}")); - return container; - } - - [Fact] - public async Task FeedRange_FromPartitionKey_QueryReturnsOnlyThatPK() - { - var container = await CreateTestContainer(); - var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); - var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("5"); - } - - [Fact] - public async Task FeedRange_FromPartitionKey_StreamIterator_ScopedCorrectly() - { - var container = await CreateTestContainer(); - var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); - var iterator = container.GetItemQueryStreamIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var sr = new System.IO.StreamReader(response.Content); - var json = await sr.ReadToEndAsync(); - var doc = JObject.Parse(json); - var items = doc["Documents"]!.ToObject>()!; - allItems.AddRange(items); - } - allItems.Should().ContainSingle(); - allItems[0]["id"]!.Value().Should().Be("5"); - } - - [Fact] - public async Task FeedRange_FromPartitionKey_ChangeFeed_ScopedCorrectly() - { - var container = await CreateTestContainer(); - var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(feedRange), ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(page); - } - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("5"); - } - - [Fact] - public async Task FeedRange_FromPartitionKey_NumericPK_Works() - { - var container = new InMemoryContainer("fr-pk-num", "/value") { FeedRangeCount = 4 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", value = 100 }), new PartitionKey(100)); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", value = 200 }), new PartitionKey(200)); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", value = 300 }), new PartitionKey(300)); - - var feedRange = FeedRange.FromPartitionKey(new PartitionKey(200)); - var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task FeedRange_FromPartitionKey_BooleanPK_Works() - { - var container = new InMemoryContainer("fr-pk-bool", "/isActive") { FeedRangeCount = 2 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", isActive = true }), new PartitionKey(true)); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", isActive = false }), new PartitionKey(false)); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", isActive = true }), new PartitionKey(true)); - - var feedRange = FeedRange.FromPartitionKey(new PartitionKey(true)); - var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "3"]); - } - - [Fact] - public async Task FeedRange_FromPartitionKey_NullPK_Works() - { - var container = new InMemoryContainer("fr-pk-null", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk-a" }), new PartitionKey("pk-a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = (string?)null }), PartitionKey.Null); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk-b" }), new PartitionKey("pk-b")); - - var feedRange = FeedRange.FromPartitionKey(PartitionKey.Null); - var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - // Should return only null PK item - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - // Sister test: null PK FeedRange correctly filters to just null-PK items - [Fact] - public async Task FeedRange_FromPartitionKey_NullPK_DoesNotReturnOtherItems() - { - var container = new InMemoryContainer("fr-pk-null-sis", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk-a" }), new PartitionKey("pk-a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = (string?)null }), PartitionKey.Null); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk-b" }), new PartitionKey("pk-b")); - - var feedRange = FeedRange.FromPartitionKey(PartitionKey.Null); - var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - // Null PK FeedRange correctly filters — excludes non-null PK items - results.Should().ContainSingle(); - results.Should().NotContain(r => r["id"]!.Value() == "1"); - results.Should().NotContain(r => r["id"]!.Value() == "3"); - } + private static async Task CreateTestContainer(int count = 20, int feedRangeCount = 4) + { + var container = new InMemoryContainer("fr-pk", "/partitionKey") { FeedRangeCount = feedRangeCount }; + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), + new PartitionKey($"pk-{i}")); + return container; + } + + [Fact] + public async Task FeedRange_FromPartitionKey_QueryReturnsOnlyThatPK() + { + var container = await CreateTestContainer(); + var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); + var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("5"); + } + + [Fact] + public async Task FeedRange_FromPartitionKey_StreamIterator_ScopedCorrectly() + { + var container = await CreateTestContainer(); + var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); + var iterator = container.GetItemQueryStreamIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var sr = new System.IO.StreamReader(response.Content); + var json = await sr.ReadToEndAsync(); + var doc = JObject.Parse(json); + var items = doc["Documents"]!.ToObject>()!; + allItems.AddRange(items); + } + allItems.Should().ContainSingle(); + allItems[0]["id"]!.Value().Should().Be("5"); + } + + [Fact] + public async Task FeedRange_FromPartitionKey_ChangeFeed_ScopedCorrectly() + { + var container = await CreateTestContainer(); + var feedRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(feedRange), ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(page); + } + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("5"); + } + + [Fact] + public async Task FeedRange_FromPartitionKey_NumericPK_Works() + { + var container = new InMemoryContainer("fr-pk-num", "/value") { FeedRangeCount = 4 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", value = 100 }), new PartitionKey(100)); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", value = 200 }), new PartitionKey(200)); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", value = 300 }), new PartitionKey(300)); + + var feedRange = FeedRange.FromPartitionKey(new PartitionKey(200)); + var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task FeedRange_FromPartitionKey_BooleanPK_Works() + { + var container = new InMemoryContainer("fr-pk-bool", "/isActive") { FeedRangeCount = 2 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", isActive = true }), new PartitionKey(true)); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", isActive = false }), new PartitionKey(false)); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", isActive = true }), new PartitionKey(true)); + + var feedRange = FeedRange.FromPartitionKey(new PartitionKey(true)); + var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "3"]); + } + + [Fact] + public async Task FeedRange_FromPartitionKey_NullPK_Works() + { + var container = new InMemoryContainer("fr-pk-null", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk-a" }), new PartitionKey("pk-a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = (string?)null }), PartitionKey.Null); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk-b" }), new PartitionKey("pk-b")); + + var feedRange = FeedRange.FromPartitionKey(PartitionKey.Null); + var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + // Should return only null PK item + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + // Sister test: null PK FeedRange correctly filters to just null-PK items + [Fact] + public async Task FeedRange_FromPartitionKey_NullPK_DoesNotReturnOtherItems() + { + var container = new InMemoryContainer("fr-pk-null-sis", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk-a" }), new PartitionKey("pk-a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = (string?)null }), PartitionKey.Null); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk-b" }), new PartitionKey("pk-b")); + + var feedRange = FeedRange.FromPartitionKey(PartitionKey.Null); + var iterator = container.GetItemQueryIterator(feedRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + // Null PK FeedRange correctly filters — excludes non-null PK items + results.Should().ContainSingle(); + results.Should().NotContain(r => r["id"]!.Value() == "1"); + results.Should().NotContain(r => r["id"]!.Value() == "3"); + } } // ═══════════════════════════════════════════════════════════ @@ -169,66 +169,66 @@ public async Task FeedRange_FromPartitionKey_NullPK_DoesNotReturnOtherItems() public class FeedRangeAllVersionsAndDeletesTests { - private const string AllVersionsSkipReason = - "ChangeFeedMode.AllVersionsAndDeletes is internal in the Cosmos SDK (v3.58). " + - "The emulator supports all-versions semantics via GetChangeFeedIterator(long checkpoint)."; - - [Fact(Skip = AllVersionsSkipReason)] - public async Task ChangeFeed_AllVersionsAndDeletes_WithFeedRange_ShowsAllVersions() - { - await Task.CompletedTask; - } - - // Sister test: Via checkpoint-based approach (no FeedRange), all versions visible - [Fact] - public async Task ChangeFeed_AllVersions_ViaCheckpoint_ShowsAllVersions() - { - var container = new InMemoryContainer("fr-avd", "/partitionKey") { FeedRangeCount = 2 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v1" }), new PartitionKey("pk")); - await container.UpsertItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v2" }), new PartitionKey("pk")); - await container.UpsertItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v3" }), new PartitionKey("pk")); - - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(3, "all 3 versions should appear"); - results.Select(r => r["name"]!.Value()).Should().ContainInOrder("v1", "v2", "v3"); - } - - [Fact(Skip = AllVersionsSkipReason)] - public async Task ChangeFeed_AllVersionsAndDeletes_WithFeedRange_ShowsDeletes() - { - await Task.CompletedTask; - } - - // Sister test: Via checkpoint, deletes visible - [Fact] - public async Task ChangeFeed_AllVersions_ViaCheckpoint_ShowsDeletes() - { - var container = new InMemoryContainer("fr-avd-del", "/partitionKey") { FeedRangeCount = 2 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk" }), new PartitionKey("pk")); - await container.DeleteItemAsync("1", new PartitionKey("pk")); - - var iterator = container.GetChangeFeedIterator(0); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(2); - results[1]["_deleted"]!.Value().Should().BeTrue(); - } - - [Fact(Skip = AllVersionsSkipReason)] - public async Task ChangeFeed_AllVersionsAndDeletes_Delete_InCorrectRange_Only() - { - await Task.CompletedTask; - } + private const string AllVersionsSkipReason = + "ChangeFeedMode.AllVersionsAndDeletes is internal in the Cosmos SDK (v3.58). " + + "The emulator supports all-versions semantics via GetChangeFeedIterator(long checkpoint)."; + + [Fact(Skip = AllVersionsSkipReason)] + public async Task ChangeFeed_AllVersionsAndDeletes_WithFeedRange_ShowsAllVersions() + { + await Task.CompletedTask; + } + + // Sister test: Via checkpoint-based approach (no FeedRange), all versions visible + [Fact] + public async Task ChangeFeed_AllVersions_ViaCheckpoint_ShowsAllVersions() + { + var container = new InMemoryContainer("fr-avd", "/partitionKey") { FeedRangeCount = 2 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v1" }), new PartitionKey("pk")); + await container.UpsertItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v2" }), new PartitionKey("pk")); + await container.UpsertItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "v3" }), new PartitionKey("pk")); + + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(3, "all 3 versions should appear"); + results.Select(r => r["name"]!.Value()).Should().ContainInOrder("v1", "v2", "v3"); + } + + [Fact(Skip = AllVersionsSkipReason)] + public async Task ChangeFeed_AllVersionsAndDeletes_WithFeedRange_ShowsDeletes() + { + await Task.CompletedTask; + } + + // Sister test: Via checkpoint, deletes visible + [Fact] + public async Task ChangeFeed_AllVersions_ViaCheckpoint_ShowsDeletes() + { + var container = new InMemoryContainer("fr-avd-del", "/partitionKey") { FeedRangeCount = 2 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk" }), new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); + + var iterator = container.GetChangeFeedIterator(0); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(2); + results[1]["_deleted"]!.Value().Should().BeTrue(); + } + + [Fact(Skip = AllVersionsSkipReason)] + public async Task ChangeFeed_AllVersionsAndDeletes_Delete_InCorrectRange_Only() + { + await Task.CompletedTask; + } } // ═══════════════════════════════════════════════════════════ @@ -237,139 +237,139 @@ public async Task ChangeFeed_AllVersionsAndDeletes_Delete_InCorrectRange_Only() public class FeedRangeBoundaryMathTests { - [Fact] - public async Task ParseFeedRangeBoundaries_MaxFF_TreatedAsUintMax() - { - var container = new InMemoryContainer("fr-boundary", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var lastRange = JObject.Parse(ranges[^1].ToJsonString()); - lastRange["Range"]!["max"]!.ToString().Should().Be("FF"); - - // Items in last range should be queryable — "FF" must be parsed as uint.MaxValue - var iterator = container.GetItemQueryIterator(ranges[^1], new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().NotBeEmpty("last range (up to FF) should contain items"); - } - - [Fact] - public async Task IsHashInRange_ItemsDistributedAcrossAllRanges() - { - // With enough items, at least one should hash to range starting at min=0 - var container = new InMemoryContainer("fr-hash0", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 200; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - count += page.Count; - } - count.Should().BeGreaterThan(0, "with 200 items, every range should have at least one"); - } - } - - [Fact] - public async Task CustomRange_PartialBoundary_SubsetReturned() - { - var container = new InMemoryContainer("fr-partial", "/partitionKey") { FeedRangeCount = 1 }; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - // Create a custom FeedRange covering only the middle portion - var customRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); - var iterator = container.GetItemQueryIterator(customRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().NotBeEmpty("some items should hash into the 40-80 range"); - results.Count.Should().BeLessThan(100, "not all items should be in the partial range"); - } - - [Fact] - public async Task FeedRange_WithLeadingZeros_ParsesCorrectly() - { - var container = new InMemoryContainer("fr-zeros", "/partitionKey") { FeedRangeCount = 1 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - var lowRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"00000000\",\"max\":\"40000000\"}}"); - var iterator = container.GetItemQueryIterator(lowRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - // Should parse leading zeros correctly and return a subset - results.Count.Should().BeLessThan(50); - } - - [Fact] - public async Task FeedRange_WithLowercaseHex_ParsesCorrectly() - { - var container = new InMemoryContainer("fr-lower", "/partitionKey") { FeedRangeCount = 1 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - var lowerRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); - var upperRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); - - var lowerIterator = container.GetItemQueryIterator(lowerRange, new QueryDefinition("SELECT * FROM c")); - var upperIterator = container.GetItemQueryIterator(upperRange, new QueryDefinition("SELECT * FROM c")); - - var lowerResults = new List(); - while (lowerIterator.HasMoreResults) - { - var page = await lowerIterator.ReadNextAsync(); - foreach (var item in page) lowerResults.Add(item["id"]!.Value()!); - } - var upperResults = new List(); - while (upperIterator.HasMoreResults) - { - var page = await upperIterator.ReadNextAsync(); - foreach (var item in page) upperResults.Add(item["id"]!.Value()!); - } - lowerResults.Should().BeEquivalentTo(upperResults); - } - - [Fact] - public async Task IsHashInRange_HashEqualsMin_Included() - { - // Verify items at the boundary of a range are included (min is inclusive) - var container = new InMemoryContainer("fr-minbound", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); - - // All items should be found across all ranges (none lost) - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(100, "no items should be lost at range boundaries"); - } + [Fact] + public async Task ParseFeedRangeBoundaries_MaxFF_TreatedAsUintMax() + { + var container = new InMemoryContainer("fr-boundary", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var lastRange = JObject.Parse(ranges[^1].ToJsonString()); + lastRange["Range"]!["max"]!.ToString().Should().Be("FF"); + + // Items in last range should be queryable — "FF" must be parsed as uint.MaxValue + var iterator = container.GetItemQueryIterator(ranges[^1], new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().NotBeEmpty("last range (up to FF) should contain items"); + } + + [Fact] + public async Task IsHashInRange_ItemsDistributedAcrossAllRanges() + { + // With enough items, at least one should hash to range starting at min=0 + var container = new InMemoryContainer("fr-hash0", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 200; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + count += page.Count; + } + count.Should().BeGreaterThan(0, "with 200 items, every range should have at least one"); + } + } + + [Fact] + public async Task CustomRange_PartialBoundary_SubsetReturned() + { + var container = new InMemoryContainer("fr-partial", "/partitionKey") { FeedRangeCount = 1 }; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + // Create a custom FeedRange covering only the middle portion + var customRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); + var iterator = container.GetItemQueryIterator(customRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().NotBeEmpty("some items should hash into the 40-80 range"); + results.Count.Should().BeLessThan(100, "not all items should be in the partial range"); + } + + [Fact] + public async Task FeedRange_WithLeadingZeros_ParsesCorrectly() + { + var container = new InMemoryContainer("fr-zeros", "/partitionKey") { FeedRangeCount = 1 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + var lowRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"00000000\",\"max\":\"40000000\"}}"); + var iterator = container.GetItemQueryIterator(lowRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + // Should parse leading zeros correctly and return a subset + results.Count.Should().BeLessThan(50); + } + + [Fact] + public async Task FeedRange_WithLowercaseHex_ParsesCorrectly() + { + var container = new InMemoryContainer("fr-lower", "/partitionKey") { FeedRangeCount = 1 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + var lowerRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); + var upperRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"40000000\",\"max\":\"80000000\"}}"); + + var lowerIterator = container.GetItemQueryIterator(lowerRange, new QueryDefinition("SELECT * FROM c")); + var upperIterator = container.GetItemQueryIterator(upperRange, new QueryDefinition("SELECT * FROM c")); + + var lowerResults = new List(); + while (lowerIterator.HasMoreResults) + { + var page = await lowerIterator.ReadNextAsync(); + foreach (var item in page) lowerResults.Add(item["id"]!.Value()!); + } + var upperResults = new List(); + while (upperIterator.HasMoreResults) + { + var page = await upperIterator.ReadNextAsync(); + foreach (var item in page) upperResults.Add(item["id"]!.Value()!); + } + lowerResults.Should().BeEquivalentTo(upperResults); + } + + [Fact] + public async Task IsHashInRange_HashEqualsMin_Included() + { + // Verify items at the boundary of a range are included (min is inclusive) + var container = new InMemoryContainer("fr-minbound", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync(JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), new PartitionKey($"pk-{i}")); + + // All items should be found across all ranges (none lost) + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(100, "no items should be lost at range boundaries"); + } } // ═══════════════════════════════════════════════════════════ @@ -378,101 +378,101 @@ public async Task IsHashInRange_HashEqualsMin_Included() public class FeedRangePartitionKeyExtractionTests { - [Fact] - public async Task FeedRange_Query_NestedPartitionKeyPath_WorksCorrectly() - { - var container = new InMemoryContainer("fr-nested", "/nested/field") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", nested = new { field = $"val-{i}" } }), - new PartitionKey($"val-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(20, "all items with nested PK path found across ranges"); - } - - [Fact] - public async Task FeedRange_Query_NullPKValue_HashesConsistently() - { - var container = new InMemoryContainer("fr-nullpk", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = (string?)null }), - PartitionKey.Null); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "real-pk" }), - new PartitionKey("real-pk")); - - // Should not throw (BUG-1 from plan — null PK should be handled gracefully) - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(2, "both null and non-null PK items found"); - } - - [Fact] - public async Task FeedRange_ChangeFeed_NullPartitionKeyEntry_NoException() - { - var container = new InMemoryContainer("fr-nullcf", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = (string?)null }), - PartitionKey.Null); - - var ranges = await container.GetFeedRangesAsync(); - var totalItems = 0; - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - totalItems += page.Count; - } - } - totalItems.Should().Be(1, "null PK item should appear in exactly one range's change feed"); - } - - [Fact] - public async Task FeedRange_Query_MissingPKField_HashesConsistently() - { - var container = new InMemoryContainer("fr-missingpk", "/partitionKey") { FeedRangeCount = 4 }; - // Item without the partitionKey field - await container.CreateItemAsync( - JObject.FromObject(new { id = "nopk", name = "test" }), - PartitionKey.None); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(1, "item with missing PK field should be found in one range"); - } + [Fact] + public async Task FeedRange_Query_NestedPartitionKeyPath_WorksCorrectly() + { + var container = new InMemoryContainer("fr-nested", "/nested/field") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", nested = new { field = $"val-{i}" } }), + new PartitionKey($"val-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(20, "all items with nested PK path found across ranges"); + } + + [Fact] + public async Task FeedRange_Query_NullPKValue_HashesConsistently() + { + var container = new InMemoryContainer("fr-nullpk", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = (string?)null }), + PartitionKey.Null); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "real-pk" }), + new PartitionKey("real-pk")); + + // Should not throw (BUG-1 from plan — null PK should be handled gracefully) + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(2, "both null and non-null PK items found"); + } + + [Fact] + public async Task FeedRange_ChangeFeed_NullPartitionKeyEntry_NoException() + { + var container = new InMemoryContainer("fr-nullcf", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = (string?)null }), + PartitionKey.Null); + + var ranges = await container.GetFeedRangesAsync(); + var totalItems = 0; + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + totalItems += page.Count; + } + } + totalItems.Should().Be(1, "null PK item should appear in exactly one range's change feed"); + } + + [Fact] + public async Task FeedRange_Query_MissingPKField_HashesConsistently() + { + var container = new InMemoryContainer("fr-missingpk", "/partitionKey") { FeedRangeCount = 4 }; + // Item without the partitionKey field + await container.CreateItemAsync( + JObject.FromObject(new { id = "nopk", name = "test" }), + PartitionKey.None); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(1, "item with missing PK field should be found in one range"); + } } // ═══════════════════════════════════════════════════════════ @@ -481,132 +481,132 @@ await container.CreateItemAsync( public class FakeCosmosHandlerFeedRangeAdvancedTests { - [Fact] - public async Task FakeCosmosHandler_PartitionKeyRangeCount_One_NoFiltering() - { - var container = new InMemoryContainer("fr-handler1", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 1 }); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("db", "fr-handler1"); - var ranges = await cosmosContainer.GetFeedRangesAsync(); - ranges.Should().HaveCount(1, "PartitionKeyRangeCount=1 → 1 range"); - - var iterator = cosmosContainer.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(20, "single range should return all items"); - } - - [Fact] - public async Task FakeCosmosHandler_PartitionKeyRangeCount_Matched_AllItemsCovered() - { - var container = new InMemoryContainer("fr-handler4", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("db", "fr-handler4"); - var ranges = await cosmosContainer.GetFeedRangesAsync(); - ranges.Should().HaveCount(4); - - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(50, "all items covered across 4 ranges"); - } - - [Fact] - public async Task FakeCosmosHandler_PartitionKeyRangeCount_Mismatch_BothReturnAllItems() - { - var container = new InMemoryContainer("fr-handler-mm", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Handler has different range count than container - var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 8 }); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("db", "fr-handler-mm"); - - // SDK path: 8 ranges - var sdkRanges = await cosmosContainer.GetFeedRangesAsync(); - sdkRanges.Should().HaveCount(8); - - var sdkIds = new HashSet(); - foreach (var range in sdkRanges) - { - var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) sdkIds.Add(item["id"]!.Value()!); - } - } - - // Container path: 4 ranges - var containerRanges = await container.GetFeedRangesAsync(); - var containerIds = new HashSet(); - foreach (var range in containerRanges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) containerIds.Add(item["id"]!.Value()!); - } - } - - sdkIds.Should().HaveCount(30, "SDK path returns all items"); - containerIds.Should().HaveCount(30, "container path returns all items"); - sdkIds.Should().BeEquivalentTo(containerIds, "both paths return the same items"); - } + [Fact] + public async Task FakeCosmosHandler_PartitionKeyRangeCount_One_NoFiltering() + { + var container = new InMemoryContainer("fr-handler1", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 1 }); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("db", "fr-handler1"); + var ranges = await cosmosContainer.GetFeedRangesAsync(); + ranges.Should().HaveCount(1, "PartitionKeyRangeCount=1 → 1 range"); + + var iterator = cosmosContainer.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(20, "single range should return all items"); + } + + [Fact] + public async Task FakeCosmosHandler_PartitionKeyRangeCount_Matched_AllItemsCovered() + { + var container = new InMemoryContainer("fr-handler4", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("db", "fr-handler4"); + var ranges = await cosmosContainer.GetFeedRangesAsync(); + ranges.Should().HaveCount(4); + + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(50, "all items covered across 4 ranges"); + } + + [Fact] + public async Task FakeCosmosHandler_PartitionKeyRangeCount_Mismatch_BothReturnAllItems() + { + var container = new InMemoryContainer("fr-handler-mm", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Handler has different range count than container + var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 8 }); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("db", "fr-handler-mm"); + + // SDK path: 8 ranges + var sdkRanges = await cosmosContainer.GetFeedRangesAsync(); + sdkRanges.Should().HaveCount(8); + + var sdkIds = new HashSet(); + foreach (var range in sdkRanges) + { + var iterator = cosmosContainer.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) sdkIds.Add(item["id"]!.Value()!); + } + } + + // Container path: 4 ranges + var containerRanges = await container.GetFeedRangesAsync(); + var containerIds = new HashSet(); + foreach (var range in containerRanges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) containerIds.Add(item["id"]!.Value()!); + } + } + + sdkIds.Should().HaveCount(30, "SDK path returns all items"); + containerIds.Should().HaveCount(30, "container path returns all items"); + sdkIds.Should().BeEquivalentTo(containerIds, "both paths return the same items"); + } } // ═══════════════════════════════════════════════════════════ @@ -615,80 +615,80 @@ await container.CreateItemAsync( public class FeedRangeTTLAndDeleteTests { - [Fact] - public async Task FeedRange_Query_TTLExpiredItems_NotReturned() - { - var container = new InMemoryContainer("fr-ttl-m", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - await Task.Delay(1500); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - total += page.Count; - } - } - total.Should().Be(0, "all items TTL-expired, not returned in FeedRange queries"); - } - - [Fact] - public async Task FeedRange_Query_AfterDelete_ItemGone() - { - var container = new InMemoryContainer("fr-del-m", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - await container.DeleteItemAsync("5", new PartitionKey("pk-5")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(19); - allIds.Should().NotContain("5", "deleted item should not appear in any FeedRange"); - } - - [Fact] - public async Task FeedRange_ChangeFeed_DeletedItem_TombstoneViaCheckpoint() - { - var container = new InMemoryContainer("fr-del-cf-m", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("3", new PartitionKey("pk-3")); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - results[0]["_deleted"]!.Value().Should().BeTrue(); - results[0]["id"]!.Value().Should().Be("3"); - } + [Fact] + public async Task FeedRange_Query_TTLExpiredItems_NotReturned() + { + var container = new InMemoryContainer("fr-ttl-m", "/partitionKey") { FeedRangeCount = 4, DefaultTimeToLive = 1 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + await Task.Delay(1500); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + total += page.Count; + } + } + total.Should().Be(0, "all items TTL-expired, not returned in FeedRange queries"); + } + + [Fact] + public async Task FeedRange_Query_AfterDelete_ItemGone() + { + var container = new InMemoryContainer("fr-del-m", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + await container.DeleteItemAsync("5", new PartitionKey("pk-5")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(19); + allIds.Should().NotContain("5", "deleted item should not appear in any FeedRange"); + } + + [Fact] + public async Task FeedRange_ChangeFeed_DeletedItem_TombstoneViaCheckpoint() + { + var container = new InMemoryContainer("fr-del-cf-m", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("3", new PartitionKey("pk-3")); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + results[0]["_deleted"]!.Value().Should().BeTrue(); + results[0]["id"]!.Value().Should().Be("3"); + } } // ═══════════════════════════════════════════════════════════ @@ -697,60 +697,60 @@ await container.CreateItemAsync( public class FeedRangeExactBoundaryTests { - [Fact] - public async Task FeedRangeCount_Two_ExactBoundaryAt80000000() - { - var container = new InMemoryContainer("fr-2", "/partitionKey") { FeedRangeCount = 2 }; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(2); - - var r0 = JObject.Parse(ranges[0].ToJsonString()); - r0["Range"]!["min"]!.ToString().Should().Be(""); - r0["Range"]!["max"]!.ToString().Should().Be("80000000"); - - var r1 = JObject.Parse(ranges[1].ToJsonString()); - r1["Range"]!["min"]!.ToString().Should().Be("80000000"); - r1["Range"]!["max"]!.ToString().Should().Be("FF"); - } - - [Fact] - public async Task FeedRangeCount_Three_ExactBoundaries() - { - var container = new InMemoryContainer("fr-3", "/partitionKey") { FeedRangeCount = 3 }; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(3); - - var r0 = JObject.Parse(ranges[0].ToJsonString()); - var r1 = JObject.Parse(ranges[1].ToJsonString()); - var r2 = JObject.Parse(ranges[2].ToJsonString()); - - r0["Range"]!["min"]!.ToString().Should().Be(""); - r2["Range"]!["max"]!.ToString().Should().Be("FF"); - - // Step = 0x100000000 / 3 = 0x55555555 (integer division) - r0["Range"]!["max"]!.ToString().Should().Be(r1["Range"]!["min"]!.ToString()); - r1["Range"]!["max"]!.ToString().Should().Be(r2["Range"]!["min"]!.ToString()); - } - - [Fact] - public async Task FeedRangeCount_PowerOfTwo_AllBoundariesDivisible() - { - var container = new InMemoryContainer("fr-8", "/partitionKey") { FeedRangeCount = 8 }; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(8); - - // First starts at "", last ends at "FF" - JObject.Parse(ranges[0].ToJsonString())["Range"]!["min"]!.ToString().Should().Be(""); - JObject.Parse(ranges[^1].ToJsonString())["Range"]!["max"]!.ToString().Should().Be("FF"); - - // All ranges are contiguous - for (var i = 0; i < ranges.Count - 1; i++) - { - var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); - var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); - currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); - } - } + [Fact] + public async Task FeedRangeCount_Two_ExactBoundaryAt80000000() + { + var container = new InMemoryContainer("fr-2", "/partitionKey") { FeedRangeCount = 2 }; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(2); + + var r0 = JObject.Parse(ranges[0].ToJsonString()); + r0["Range"]!["min"]!.ToString().Should().Be(""); + r0["Range"]!["max"]!.ToString().Should().Be("80000000"); + + var r1 = JObject.Parse(ranges[1].ToJsonString()); + r1["Range"]!["min"]!.ToString().Should().Be("80000000"); + r1["Range"]!["max"]!.ToString().Should().Be("FF"); + } + + [Fact] + public async Task FeedRangeCount_Three_ExactBoundaries() + { + var container = new InMemoryContainer("fr-3", "/partitionKey") { FeedRangeCount = 3 }; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(3); + + var r0 = JObject.Parse(ranges[0].ToJsonString()); + var r1 = JObject.Parse(ranges[1].ToJsonString()); + var r2 = JObject.Parse(ranges[2].ToJsonString()); + + r0["Range"]!["min"]!.ToString().Should().Be(""); + r2["Range"]!["max"]!.ToString().Should().Be("FF"); + + // Step = 0x100000000 / 3 = 0x55555555 (integer division) + r0["Range"]!["max"]!.ToString().Should().Be(r1["Range"]!["min"]!.ToString()); + r1["Range"]!["max"]!.ToString().Should().Be(r2["Range"]!["min"]!.ToString()); + } + + [Fact] + public async Task FeedRangeCount_PowerOfTwo_AllBoundariesDivisible() + { + var container = new InMemoryContainer("fr-8", "/partitionKey") { FeedRangeCount = 8 }; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(8); + + // First starts at "", last ends at "FF" + JObject.Parse(ranges[0].ToJsonString())["Range"]!["min"]!.ToString().Should().Be(""); + JObject.Parse(ranges[^1].ToJsonString())["Range"]!["max"]!.ToString().Should().Be("FF"); + + // All ranges are contiguous + for (var i = 0; i < ranges.Count - 1; i++) + { + var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); + var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); + currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); + } + } } // ═══════════════════════════════════════════════════════════ @@ -759,87 +759,87 @@ public async Task FeedRangeCount_PowerOfTwo_AllBoundariesDivisible() public class FeedRangeContinuationTokenTests { - [Fact] - public async Task ContinuationToken_WithFeedRange_PaginatesWithinScope() - { - var container = new InMemoryContainer("fr-ct", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var range0 = ranges[0]; - - // Count items in range 0 - var iterator = container.GetItemQueryIterator(range0, new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); - var allIds = new List(); - var pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - pageCount++; - } - - allIds.Should().NotBeEmpty("range 0 should have items"); - allIds.Should().OnlyHaveUniqueItems("no duplicates across pages"); - if (allIds.Count > 3) pageCount.Should().BeGreaterThan(1, "should paginate when more than MaxItemCount items"); - } - - [Fact] - public async Task ContinuationToken_BeyondItems_WithFeedRange_ReturnsEmpty() - { - var container = new InMemoryContainer("fr-ct-beyond", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - // Use a large continuation token offset - var iterator = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c"), "99999"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().BeEmpty("continuation token beyond available items should return empty"); - } - - [Fact] - public async Task ContinuationToken_Zero_WithFeedRange_StartsFromBeginning() - { - var container = new InMemoryContainer("fr-ct-zero", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - // No continuation - var iter1 = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c")); - var ids1 = new List(); - while (iter1.HasMoreResults) - { - var page = await iter1.ReadNextAsync(); - foreach (var item in page) ids1.Add(item["id"]!.Value()!); - } - - // Continuation = "0" - var iter2 = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c"), "0"); - var ids2 = new List(); - while (iter2.HasMoreResults) - { - var page = await iter2.ReadNextAsync(); - foreach (var item in page) ids2.Add(item["id"]!.Value()!); - } - - ids1.Should().BeEquivalentTo(ids2, "cont token '0' should give same results as no token"); - } + [Fact] + public async Task ContinuationToken_WithFeedRange_PaginatesWithinScope() + { + var container = new InMemoryContainer("fr-ct", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var range0 = ranges[0]; + + // Count items in range 0 + var iterator = container.GetItemQueryIterator(range0, new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); + var allIds = new List(); + var pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + pageCount++; + } + + allIds.Should().NotBeEmpty("range 0 should have items"); + allIds.Should().OnlyHaveUniqueItems("no duplicates across pages"); + if (allIds.Count > 3) pageCount.Should().BeGreaterThan(1, "should paginate when more than MaxItemCount items"); + } + + [Fact] + public async Task ContinuationToken_BeyondItems_WithFeedRange_ReturnsEmpty() + { + var container = new InMemoryContainer("fr-ct-beyond", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + // Use a large continuation token offset + var iterator = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c"), "99999"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().BeEmpty("continuation token beyond available items should return empty"); + } + + [Fact] + public async Task ContinuationToken_Zero_WithFeedRange_StartsFromBeginning() + { + var container = new InMemoryContainer("fr-ct-zero", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + // No continuation + var iter1 = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c")); + var ids1 = new List(); + while (iter1.HasMoreResults) + { + var page = await iter1.ReadNextAsync(); + foreach (var item in page) ids1.Add(item["id"]!.Value()!); + } + + // Continuation = "0" + var iter2 = container.GetItemQueryIterator(ranges[0], new QueryDefinition("SELECT * FROM c"), "0"); + var ids2 = new List(); + while (iter2.HasMoreResults) + { + var page = await iter2.ReadNextAsync(); + foreach (var item in page) ids2.Add(item["id"]!.Value()!); + } + + ids1.Should().BeEquivalentTo(ids2, "cont token '0' should give same results as no token"); + } } // ═══════════════════════════════════════════════════════════ @@ -848,82 +848,82 @@ await container.CreateItemAsync( public class FeedRangeMutationEdgeCaseTests { - [Fact] - public async Task Patch_DoesNotChangePartitionKey_StaysInSameRange() - { - var container = new InMemoryContainer("fr-mut-patch", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Original{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - // Find range for item "5" - var rangeBefore = await FindRangeForItem(container, ranges, "5"); - - await container.PatchItemAsync("5", new PartitionKey("pk-5"), - [PatchOperation.Set("/name", "Patched")]); - - var rangeAfter = await FindRangeForItem(container, ranges, "5"); - rangeAfter.Should().Be(rangeBefore, "patch should not change FeedRange"); - } - - [Fact] - public async Task Replace_SameId_SamePK_StaysInSameRange() - { - var container = new InMemoryContainer("fr-mut-replace", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Original{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var rangeBefore = await FindRangeForItem(container, ranges, "7"); - - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "7", partitionKey = "pk-7", name = "Replaced" }), - "7", new PartitionKey("pk-7")); - - var rangeAfter = await FindRangeForItem(container, ranges, "7"); - rangeAfter.Should().Be(rangeBefore, "replace with same PK should stay in same range"); - } - - [Fact] - public async Task Upsert_NewItem_AppearsInExactlyOneRange() - { - var container = new InMemoryContainer("fr-mut-upsert", "/partitionKey") { FeedRangeCount = 4 }; - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "new1", partitionKey = "pk-new", name = "Upserted" }), - new PartitionKey("pk-new")); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItem = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.id = 'new1'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) rangesWithItem++; - } - } - rangesWithItem.Should().Be(1, "upserted item should appear in exactly one range"); - } - - private static async Task FindRangeForItem(InMemoryContainer container, IReadOnlyList ranges, string id) - { - for (var i = 0; i < ranges.Count; i++) - { - var iterator = container.GetItemQueryIterator(ranges[i], - new QueryDefinition("SELECT * FROM c WHERE c.id = @id").WithParameter("@id", id)); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Any()) return i; - } - } - return -1; - } + [Fact] + public async Task Patch_DoesNotChangePartitionKey_StaysInSameRange() + { + var container = new InMemoryContainer("fr-mut-patch", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Original{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + // Find range for item "5" + var rangeBefore = await FindRangeForItem(container, ranges, "5"); + + await container.PatchItemAsync("5", new PartitionKey("pk-5"), + [PatchOperation.Set("/name", "Patched")]); + + var rangeAfter = await FindRangeForItem(container, ranges, "5"); + rangeAfter.Should().Be(rangeBefore, "patch should not change FeedRange"); + } + + [Fact] + public async Task Replace_SameId_SamePK_StaysInSameRange() + { + var container = new InMemoryContainer("fr-mut-replace", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Original{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var rangeBefore = await FindRangeForItem(container, ranges, "7"); + + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "7", partitionKey = "pk-7", name = "Replaced" }), + "7", new PartitionKey("pk-7")); + + var rangeAfter = await FindRangeForItem(container, ranges, "7"); + rangeAfter.Should().Be(rangeBefore, "replace with same PK should stay in same range"); + } + + [Fact] + public async Task Upsert_NewItem_AppearsInExactlyOneRange() + { + var container = new InMemoryContainer("fr-mut-upsert", "/partitionKey") { FeedRangeCount = 4 }; + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "new1", partitionKey = "pk-new", name = "Upserted" }), + new PartitionKey("pk-new")); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItem = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.id = 'new1'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) rangesWithItem++; + } + } + rangesWithItem.Should().Be(1, "upserted item should appear in exactly one range"); + } + + private static async Task FindRangeForItem(InMemoryContainer container, IReadOnlyList ranges, string id) + { + for (var i = 0; i < ranges.Count; i++) + { + var iterator = container.GetItemQueryIterator(ranges[i], + new QueryDefinition("SELECT * FROM c WHERE c.id = @id").WithParameter("@id", id)); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Any()) return i; + } + } + return -1; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseTests.cs index 7e8d8ad..b766f6d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeEdgeCaseTests.cs @@ -12,117 +12,117 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeHashBoundaryTests { - [Theory] - [InlineData(3)] - [InlineData(5)] - [InlineData(7)] - public async Task FeedRanges_AreContiguous_WithOddCounts(int count) - { - // Odd counts must still produce contiguous, non-overlapping ranges - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = count }; - var ranges = await container.GetFeedRangesAsync(); - - ranges.Should().HaveCount(count); - - // First starts at "" - var firstJson = JObject.Parse(ranges[0].ToJsonString()); - firstJson["Range"]!["min"]!.ToString().Should().Be(""); - - // Last ends at "FF" - var lastJson = JObject.Parse(ranges[^1].ToJsonString()); - lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); - - // Each max == next min (contiguous) - for (var i = 0; i < ranges.Count - 1; i++) - { - var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); - var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); - currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); - } - } - - [Fact] - public async Task FeedRanges_AllItemsLandInExactlyOneRange() - { - // Each item must appear in exactly one range — no duplicates across ranges - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var itemToRangeCount = new Dictionary(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - { - itemToRangeCount.TryGetValue(item.Id, out var count); - itemToRangeCount[item.Id] = count + 1; - } - } - } - - // Every item should appear exactly once across all ranges - itemToRangeCount.Values.Should().AllSatisfy(c => c.Should().Be(1), "each item should land in exactly one range"); - itemToRangeCount.Should().HaveCount(50); - } - - [Fact] - public async Task FeedRange_SingleItem_AppearsInExactlyOneOf100Ranges() - { - // With FeedRangeCount=100, a single item should appear in exactly 1 range - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 100 }; - - await container.CreateItemAsync( - new TestDocument { Id = "solo", PartitionKey = "solo-pk", Name = "Solo" }, - new PartitionKey("solo-pk")); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItem = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.Count > 0) rangesWithItem++; - } - } - - rangesWithItem.Should().Be(1, "a single item should appear in exactly one of 100 ranges"); - } - - [Fact] - public async Task RangeBoundaryToHex_Produces8DigitHex() - { - // Verify that internal boundaries are 8-digit hex strings (not 2-digit) - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 3 }; - var ranges = await container.GetFeedRangesAsync(); - - // The middle boundaries (not "" or "FF") should be 8-digit hex - for (var i = 0; i < ranges.Count; i++) - { - var json = JObject.Parse(ranges[i].ToJsonString()); - var min = json["Range"]!["min"]!.ToString(); - var max = json["Range"]!["max"]!.ToString(); - - if (!string.IsNullOrEmpty(min) && min != "FF") - min.Length.Should().Be(8, $"range {i} min '{min}' should be 8-digit hex"); - - if (!string.IsNullOrEmpty(max) && max != "FF") - max.Length.Should().Be(8, $"range {i} max '{max}' should be 8-digit hex"); - } - } + [Theory] + [InlineData(3)] + [InlineData(5)] + [InlineData(7)] + public async Task FeedRanges_AreContiguous_WithOddCounts(int count) + { + // Odd counts must still produce contiguous, non-overlapping ranges + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = count }; + var ranges = await container.GetFeedRangesAsync(); + + ranges.Should().HaveCount(count); + + // First starts at "" + var firstJson = JObject.Parse(ranges[0].ToJsonString()); + firstJson["Range"]!["min"]!.ToString().Should().Be(""); + + // Last ends at "FF" + var lastJson = JObject.Parse(ranges[^1].ToJsonString()); + lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); + + // Each max == next min (contiguous) + for (var i = 0; i < ranges.Count - 1; i++) + { + var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); + var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); + currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); + } + } + + [Fact] + public async Task FeedRanges_AllItemsLandInExactlyOneRange() + { + // Each item must appear in exactly one range — no duplicates across ranges + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var itemToRangeCount = new Dictionary(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + { + itemToRangeCount.TryGetValue(item.Id, out var count); + itemToRangeCount[item.Id] = count + 1; + } + } + } + + // Every item should appear exactly once across all ranges + itemToRangeCount.Values.Should().AllSatisfy(c => c.Should().Be(1), "each item should land in exactly one range"); + itemToRangeCount.Should().HaveCount(50); + } + + [Fact] + public async Task FeedRange_SingleItem_AppearsInExactlyOneOf100Ranges() + { + // With FeedRangeCount=100, a single item should appear in exactly 1 range + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 100 }; + + await container.CreateItemAsync( + new TestDocument { Id = "solo", PartitionKey = "solo-pk", Name = "Solo" }, + new PartitionKey("solo-pk")); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItem = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.Count > 0) rangesWithItem++; + } + } + + rangesWithItem.Should().Be(1, "a single item should appear in exactly one of 100 ranges"); + } + + [Fact] + public async Task RangeBoundaryToHex_Produces8DigitHex() + { + // Verify that internal boundaries are 8-digit hex strings (not 2-digit) + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 3 }; + var ranges = await container.GetFeedRangesAsync(); + + // The middle boundaries (not "" or "FF") should be 8-digit hex + for (var i = 0; i < ranges.Count; i++) + { + var json = JObject.Parse(ranges[i].ToJsonString()); + var min = json["Range"]!["min"]!.ToString(); + var max = json["Range"]!["max"]!.ToString(); + + if (!string.IsNullOrEmpty(min) && min != "FF") + min.Length.Should().Be(8, $"range {i} min '{min}' should be 8-digit hex"); + + if (!string.IsNullOrEmpty(max) && max != "FF") + max.Length.Should().Be(8, $"range {i} max '{max}' should be 8-digit hex"); + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -131,100 +131,100 @@ public async Task RangeBoundaryToHex_Produces8DigitHex() public class FeedRangePartitionKeyEdgeCaseTests { - [Fact] - public async Task CompositePartitionKey_FeedRange_NoItemsLost() - { - // Items with composite PKs should still distribute across ranges with no loss - var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) - { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - var pk = new PartitionKeyBuilder().Add($"t{i % 4}").Add($"u{i}").Build(); - var doc = new JObject - { - ["id"] = $"doc-{i}", - ["tenantId"] = $"t{i % 4}", - ["userId"] = $"u{i}", - ["value"] = i - }; - await container.CreateItemAsync(doc, pk); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20, "all items with composite PKs must be found across FeedRanges"); - } - - [Fact] - public async Task NullPartitionKey_FeedRange_ItemNotLost() - { - // An item with PartitionKey.None should land in exactly one range - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - new TestDocument { Id = "null-pk-item", PartitionKey = null!, Name = "NullPK" }, - PartitionKey.None); - - var ranges = await container.GetFeedRangesAsync(); - var foundCount = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foundCount += page.Count; - } - } - - foundCount.Should().Be(1, "item with null PK should appear in exactly one range"); - } - - [Fact] - public async Task NumericPartitionKey_FeedRange_ConsistentHashing() - { - // Numeric partition keys should hash consistently across ranges - var container = new InMemoryContainer("test", "/category") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - var doc = new JObject { ["id"] = $"{i}", ["category"] = i * 100 }; - await container.CreateItemAsync(doc, new PartitionKey(i * 100)); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20, "all numeric-PK items must be found across ranges"); - } + [Fact] + public async Task CompositePartitionKey_FeedRange_NoItemsLost() + { + // Items with composite PKs should still distribute across ranges with no loss + var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) + { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + var pk = new PartitionKeyBuilder().Add($"t{i % 4}").Add($"u{i}").Build(); + var doc = new JObject + { + ["id"] = $"doc-{i}", + ["tenantId"] = $"t{i % 4}", + ["userId"] = $"u{i}", + ["value"] = i + }; + await container.CreateItemAsync(doc, pk); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20, "all items with composite PKs must be found across FeedRanges"); + } + + [Fact] + public async Task NullPartitionKey_FeedRange_ItemNotLost() + { + // An item with PartitionKey.None should land in exactly one range + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + new TestDocument { Id = "null-pk-item", PartitionKey = null!, Name = "NullPK" }, + PartitionKey.None); + + var ranges = await container.GetFeedRangesAsync(); + var foundCount = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foundCount += page.Count; + } + } + + foundCount.Should().Be(1, "item with null PK should appear in exactly one range"); + } + + [Fact] + public async Task NumericPartitionKey_FeedRange_ConsistentHashing() + { + // Numeric partition keys should hash consistently across ranges + var container = new InMemoryContainer("test", "/category") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + var doc = new JObject { ["id"] = $"{i}", ["category"] = i * 100 }; + await container.CreateItemAsync(doc, new PartitionKey(i * 100)); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20, "all numeric-PK items must be found across ranges"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -233,88 +233,88 @@ public async Task NumericPartitionKey_FeedRange_ConsistentHashing() public class FeedRangeParsingTests { - [Fact] - public async Task MalformedFeedRangeJson_ReturnsAllItems() - { - // If a FeedRange has broken JSON, graceful fallback should return all items - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Create a FeedRange with no Range key — just min/max at top level - var fakeRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"ZZZZ\",\"max\":\"YYYY\"}}"); - - var iterator = container.GetItemQueryIterator( - fakeRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Malformed hex values should cause ParseFeedRangeBoundaries to fail, - // falling back to returning all items - results.Should().HaveCount(10, - "malformed FeedRange should gracefully fall back to returning all items"); - } - - [Fact] - public async Task FeedRangeWithMissingRangeKey_ReturnsAllItems() - { - // A JSON with no "Range" key should return all items (graceful fallback) - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // FeedRange.FromJsonString requires a Range key, but we test parsing robustness - // by using a valid-looking range with empty boundaries - var fakeRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"FF\"}}"); - - var iterator = container.GetItemQueryIterator( - fakeRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Full range ["", "FF") should return all items - results.Should().HaveCount(10); - } - - [Fact] - public async Task Query_WithWhere_AndFeedRange_EmptyResult() - { - // WHERE that eliminates everything within a scoped range → empty - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - // Query with impossible WHERE on first range - var iterator = container.GetItemQueryIterator( - ranges[0], new QueryDefinition("SELECT * FROM c WHERE c.name = 'NonExistent'")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("WHERE that matches nothing should return empty even with FeedRange"); - } + [Fact] + public async Task MalformedFeedRangeJson_ReturnsAllItems() + { + // If a FeedRange has broken JSON, graceful fallback should return all items + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Create a FeedRange with no Range key — just min/max at top level + var fakeRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"ZZZZ\",\"max\":\"YYYY\"}}"); + + var iterator = container.GetItemQueryIterator( + fakeRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Malformed hex values should cause ParseFeedRangeBoundaries to fail, + // falling back to returning all items + results.Should().HaveCount(10, + "malformed FeedRange should gracefully fall back to returning all items"); + } + + [Fact] + public async Task FeedRangeWithMissingRangeKey_ReturnsAllItems() + { + // A JSON with no "Range" key should return all items (graceful fallback) + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // FeedRange.FromJsonString requires a Range key, but we test parsing robustness + // by using a valid-looking range with empty boundaries + var fakeRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"FF\"}}"); + + var iterator = container.GetItemQueryIterator( + fakeRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Full range ["", "FF") should return all items + results.Should().HaveCount(10); + } + + [Fact] + public async Task Query_WithWhere_AndFeedRange_EmptyResult() + { + // WHERE that eliminates everything within a scoped range → empty + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + // Query with impossible WHERE on first range + var iterator = container.GetItemQueryIterator( + ranges[0], new QueryDefinition("SELECT * FROM c WHERE c.name = 'NonExistent'")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("WHERE that matches nothing should return empty even with FeedRange"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -323,132 +323,132 @@ await container.CreateItemAsync( public class FeedRangeChangeFeedEdgeCaseTests { - [Fact] - public async Task ChangeFeed_NullPK_WithFeedRange_DoesNotThrow() - { - // BUG-2: FilterChangeFeedEntriesByFeedRange calls MurmurHash3(entry.PartitionKey) - // but PartitionKeyToString(PartitionKey.None) returns null, causing ArgumentNullException - // in Encoding.UTF8.GetBytes(null). The query path uses ExtractPartitionKeyValueFromJson - // which returns "" for missing PK fields — no crash there. The change feed path must - // similarly handle null PKs without throwing. - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - new TestDocument { Id = "null-pk-item", PartitionKey = null!, Name = "NullPK" }, - PartitionKey.None); - - var ranges = await container.GetFeedRangesAsync(); - var foundCount = 0; - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foundCount += page.Count; - } - } - - foundCount.Should().Be(1, "item with null PK should appear in exactly one range's change feed"); - } - - [Fact] - public async Task ChangeFeed_Time_WithFeedRange_FiltersBothTimeAndRange() - { - // ChangeFeedStartFrom.Time(dt, feedRange) should filter by both time AND range - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - // Create items in two batches with a time gap - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"E{i}" }, - new PartitionKey($"pk-{i}")); - - var midpoint = DateTimeOffset.UtcNow; - await Task.Delay(50); // ensure time separation - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"L{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allLateResults = new List(); - - // Get change feed from midpoint with each range - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(midpoint.UtcDateTime, range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allLateResults.AddRange(page); - } - } - - // Should get only the late items (20), not the early ones - allLateResults.Should().HaveCount(20, "Time filter should exclude early items"); - allLateResults.Select(r => r.Id).Should().OnlyContain(id => id.StartsWith("late-")); - } - - [Fact] - public async Task ChangeFeed_Beginning_WithoutFeedRange_ReturnsAllItems() - { - // ChangeFeedStartFrom.Beginning() with no FeedRange → should return all items - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(20, "Beginning() without FeedRange should return all items"); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_Pagination_AllItemsDelivered() - { - // Pagination (small page size) with FeedRange scoping should still deliver all items - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 5 }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) allIds.Add(item.Id); - } - } - - allIds.Should().HaveCount(30, "pagination + FeedRange should still deliver all items"); - } + [Fact] + public async Task ChangeFeed_NullPK_WithFeedRange_DoesNotThrow() + { + // BUG-2: FilterChangeFeedEntriesByFeedRange calls MurmurHash3(entry.PartitionKey) + // but PartitionKeyToString(PartitionKey.None) returns null, causing ArgumentNullException + // in Encoding.UTF8.GetBytes(null). The query path uses ExtractPartitionKeyValueFromJson + // which returns "" for missing PK fields — no crash there. The change feed path must + // similarly handle null PKs without throwing. + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + new TestDocument { Id = "null-pk-item", PartitionKey = null!, Name = "NullPK" }, + PartitionKey.None); + + var ranges = await container.GetFeedRangesAsync(); + var foundCount = 0; + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foundCount += page.Count; + } + } + + foundCount.Should().Be(1, "item with null PK should appear in exactly one range's change feed"); + } + + [Fact] + public async Task ChangeFeed_Time_WithFeedRange_FiltersBothTimeAndRange() + { + // ChangeFeedStartFrom.Time(dt, feedRange) should filter by both time AND range + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + // Create items in two batches with a time gap + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"E{i}" }, + new PartitionKey($"pk-{i}")); + + var midpoint = DateTimeOffset.UtcNow; + await Task.Delay(50); // ensure time separation + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"L{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allLateResults = new List(); + + // Get change feed from midpoint with each range + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(midpoint.UtcDateTime, range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allLateResults.AddRange(page); + } + } + + // Should get only the late items (20), not the early ones + allLateResults.Should().HaveCount(20, "Time filter should exclude early items"); + allLateResults.Select(r => r.Id).Should().OnlyContain(id => id.StartsWith("late-")); + } + + [Fact] + public async Task ChangeFeed_Beginning_WithoutFeedRange_ReturnsAllItems() + { + // ChangeFeedStartFrom.Beginning() with no FeedRange → should return all items + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(20, "Beginning() without FeedRange should return all items"); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_Pagination_AllItemsDelivered() + { + // Pagination (small page size) with FeedRange scoping should still deliver all items + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 5 }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) allIds.Add(item.Id); + } + } + + allIds.Should().HaveCount(30, "pagination + FeedRange should still deliver all items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -457,255 +457,255 @@ await container.CreateItemAsync( public class FakeCosmosHandlerConsistencyTests { - [Fact] - public async Task FakeCosmosHandler_PerRange_MatchesInMemoryContainer_PerRange() - { - // BUG-1: FakeCosmosHandler.FilterDocumentsByRange uses hash % rangeCount (modulo) - // while InMemoryContainer.FilterByFeedRange uses interval-based IsHashInRange. - // These are mathematically different: for hash=0x80000001 with 4 ranges, - // interval → range 2 (falls in [0x80000000, 0xC0000000)), modulo → range 1 (0x80000001 % 4 = 1). - // The per-range item assignments must match between both paths. - const int rangeCount = 4; - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; - var handler = new FakeCosmosHandler(container, - new FakeCosmosHandlerOptions { PartitionKeyRangeCount = rangeCount }); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Get per-range results from InMemoryContainer FeedRange path - var feedRanges = await container.GetFeedRangesAsync(); - var containerPerRange = new List>(); - foreach (var range in feedRanges) - { - var items = new HashSet(); - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) items.Add(item.Id); - } - containerPerRange.Add(items); - } - - // Get per-range results from SDK path (FakeCosmosHandler uses PKRanges) - using var client = new CosmosClient( - "https://localhost:8081", - "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", - new CosmosClientOptions - { - HttpClientFactory = () => new HttpClient(handler), - ConnectionMode = ConnectionMode.Gateway - }); - var cosmosContainer = client.GetDatabase("testdb").GetContainer("test"); - - // The SDK internally sends one query per PKRange (range id = "0","1","2","3"). - // We can verify via per-FeedRange queries through the SDK: - var sdkFeedRanges = await cosmosContainer.GetFeedRangesAsync(); - var sdkPerRange = new List>(); - foreach (var range in sdkFeedRanges) - { - var items = new HashSet(); - using var iter = cosmosContainer.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) items.Add(item.Id); - } - sdkPerRange.Add(items); - } - - // Per-range assignments must match — this will fail if modulo != interval - containerPerRange.Should().HaveCount(rangeCount); - sdkPerRange.Should().HaveCount(rangeCount); - - for (var i = 0; i < rangeCount; i++) - { - sdkPerRange[i].Should().BeEquivalentTo(containerPerRange[i], - $"range {i} items should match between SDK and InMemoryContainer paths"); - } - } - - [Fact] - public async Task FakeCosmosHandler_And_InMemoryContainer_ProduceConsistentRanges() - { - // The FakeCosmosHandler's GetPartitionKeyRanges and InMemoryContainer's - // GetFeedRangesAsync must produce the same boundary values - const int rangeCount = 4; - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; - var handler = new FakeCosmosHandler(container); - - // Get boundaries from InMemoryContainer - var feedRanges = await container.GetFeedRangesAsync(); - var containerBoundaries = new List<(string Min, string Max)>(); - foreach (var range in feedRanges) - { - var json = JObject.Parse(range.ToJsonString()); - containerBoundaries.Add(( - json["Range"]!["min"]!.ToString(), - json["Range"]!["max"]!.ToString())); - } - - // Get boundaries from FakeCosmosHandler via a real CosmosClient query - // We use the handler to create a client and read PKRanges - using var client = new CosmosClient( - "https://localhost:8081", - "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", - new CosmosClientOptions - { - HttpClientFactory = () => new HttpClient(handler), - ConnectionMode = ConnectionMode.Gateway - }); - - var db = client.GetDatabase("testdb"); - var cosmosContainer = db.GetContainer("test"); - - // Query all items using each FeedRange so: seed items, query through SDK - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Query through the real SDK path (which uses FakeCosmosHandler's PKRanges) - var sdkResults = new List(); - using var sdkIterator = cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - while (sdkIterator.HasMoreResults) - { - var page = await sdkIterator.ReadNextAsync(); - sdkResults.AddRange(page); - } - - // Query through InMemoryContainer's FeedRange path - var feedRangeResults = new List(); - foreach (var range in feedRanges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - feedRangeResults.AddRange(page); - } - } - - // Both paths should return the same items - sdkResults.Should().HaveCount(20, "SDK path should return all items"); - feedRangeResults.Should().HaveCount(20, "FeedRange path should return all items"); - sdkResults.Select(r => r.Id).OrderBy(x => x) - .Should().Equal(feedRangeResults.Select(r => r.Id).OrderBy(x => x), - "SDK and FeedRange paths must return identical items"); - } - - [Fact] - public void PartitionKeyHash_RangeBoundaryToHex_Boundaries() - { - // Verify edge cases in RangeBoundaryToHex - PartitionKeyHash.RangeBoundaryToHex(0).Should().Be("", "0 → empty string (range start)"); - PartitionKeyHash.RangeBoundaryToHex(-1).Should().Be("", "negative → empty string"); - PartitionKeyHash.RangeBoundaryToHex(0x1_0000_0000L).Should().Be("FF", "max uint32+1 → FF"); - PartitionKeyHash.RangeBoundaryToHex(0x2_0000_0000L).Should().Be("FF", "beyond max → FF"); - - // Mid-range values should be 8-digit hex - PartitionKeyHash.RangeBoundaryToHex(0x5555_5555L).Should().Be("55555555"); - PartitionKeyHash.RangeBoundaryToHex(0xAAAA_AAAAL).Should().Be("AAAAAAAA"); - PartitionKeyHash.RangeBoundaryToHex(1).Should().Be("00000001"); - } - - [Fact] - public void PartitionKeyHash_GetRangeIndex_EdgeCases() - { - // GetRangeIndex should return valid range for edge cases - PartitionKeyHash.GetRangeIndex("test", 1).Should().Be(0, "single range → always 0"); - PartitionKeyHash.GetRangeIndex("test", 0).Should().Be(0, "zero ranges → 0 (clamped)"); - - // Same key should always map to same range - var r1 = PartitionKeyHash.GetRangeIndex("consistent", 10); - var r2 = PartitionKeyHash.GetRangeIndex("consistent", 10); - r1.Should().Be(r2, "same key + same count → same range"); - - // Range index should be within bounds - for (var i = 0; i < 50; i++) - { - var idx = PartitionKeyHash.GetRangeIndex($"key-{i}", 7); - idx.Should().BeInRange(0, 6); - } - } - - [Fact] - public async Task FakeCosmosHandler_PKRangesUse8DigitHex() - { - // FakeCosmosHandler.GetPartitionKeyRanges must produce 8-digit hex boundaries - // matching InMemoryContainer's GetFeedRangesAsync, NOT 2-digit hex. - const int rangeCount = 3; - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; - var handler = new FakeCosmosHandler(container); - - using var client = new CosmosClient( - "https://localhost:8081", - "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", - new CosmosClientOptions - { - HttpClientFactory = () => new HttpClient(handler), - ConnectionMode = ConnectionMode.Gateway - }); - - // Seed one item so the pipeline runs and fetches PKRanges - await container.CreateItemAsync( - new TestDocument { Id = "seed", PartitionKey = "pk", Name = "S" }, - new PartitionKey("pk")); - - // Get the FeedRange boundaries from InMemoryContainer - var feedRanges = await container.GetFeedRangesAsync(); - var containerBoundaries = feedRanges.Select(r => - { - var json = JObject.Parse(r.ToJsonString()); - return (Min: json["Range"]!["min"]!.ToString(), Max: json["Range"]!["max"]!.ToString()); - }).ToList(); - - // The shortest internal boundary should be 8 chars (e.g. "55555555"), not 2 (e.g. "55") - // This test will be RED if FakeCosmosHandler still uses 2-digit hex - foreach (var (min, max) in containerBoundaries) - { - if (!string.IsNullOrEmpty(min) && min != "FF") - min.Length.Should().Be(8, $"InMemoryContainer boundary '{min}' should be 8-digit hex"); - } - - // Now verify the handler uses the SAME boundaries by querying pkranges - // We can't easily extract them from the handler directly, but we verify via - // the fact that querying each FeedRange through the SDK gives consistent results - // with querying through InMemoryContainer FeedRanges - var cosmosContainer = client.GetDatabase("testdb").GetContainer("test"); - - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Query through FeedRanges (InMemoryContainer path) - var feedRangeItemsPerRange = new List>(); - foreach (var range in feedRanges) - { - var items = new HashSet(); - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) items.Add(item.Id); - } - feedRangeItemsPerRange.Add(items); - } - - // Verify all items found and no overlap - var allFeedRangeItems = feedRangeItemsPerRange.SelectMany(s => s).ToList(); - allFeedRangeItems.Should().HaveCount(allFeedRangeItems.Distinct().Count(), - "items should not appear in multiple FeedRanges"); - } + [Fact] + public async Task FakeCosmosHandler_PerRange_MatchesInMemoryContainer_PerRange() + { + // BUG-1: FakeCosmosHandler.FilterDocumentsByRange uses hash % rangeCount (modulo) + // while InMemoryContainer.FilterByFeedRange uses interval-based IsHashInRange. + // These are mathematically different: for hash=0x80000001 with 4 ranges, + // interval → range 2 (falls in [0x80000000, 0xC0000000)), modulo → range 1 (0x80000001 % 4 = 1). + // The per-range item assignments must match between both paths. + const int rangeCount = 4; + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; + var handler = new FakeCosmosHandler(container, + new FakeCosmosHandlerOptions { PartitionKeyRangeCount = rangeCount }); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Get per-range results from InMemoryContainer FeedRange path + var feedRanges = await container.GetFeedRangesAsync(); + var containerPerRange = new List>(); + foreach (var range in feedRanges) + { + var items = new HashSet(); + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) items.Add(item.Id); + } + containerPerRange.Add(items); + } + + // Get per-range results from SDK path (FakeCosmosHandler uses PKRanges) + using var client = new CosmosClient( + "https://localhost:8081", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + new CosmosClientOptions + { + HttpClientFactory = () => new HttpClient(handler), + ConnectionMode = ConnectionMode.Gateway + }); + var cosmosContainer = client.GetDatabase("testdb").GetContainer("test"); + + // The SDK internally sends one query per PKRange (range id = "0","1","2","3"). + // We can verify via per-FeedRange queries through the SDK: + var sdkFeedRanges = await cosmosContainer.GetFeedRangesAsync(); + var sdkPerRange = new List>(); + foreach (var range in sdkFeedRanges) + { + var items = new HashSet(); + using var iter = cosmosContainer.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) items.Add(item.Id); + } + sdkPerRange.Add(items); + } + + // Per-range assignments must match — this will fail if modulo != interval + containerPerRange.Should().HaveCount(rangeCount); + sdkPerRange.Should().HaveCount(rangeCount); + + for (var i = 0; i < rangeCount; i++) + { + sdkPerRange[i].Should().BeEquivalentTo(containerPerRange[i], + $"range {i} items should match between SDK and InMemoryContainer paths"); + } + } + + [Fact] + public async Task FakeCosmosHandler_And_InMemoryContainer_ProduceConsistentRanges() + { + // The FakeCosmosHandler's GetPartitionKeyRanges and InMemoryContainer's + // GetFeedRangesAsync must produce the same boundary values + const int rangeCount = 4; + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; + var handler = new FakeCosmosHandler(container); + + // Get boundaries from InMemoryContainer + var feedRanges = await container.GetFeedRangesAsync(); + var containerBoundaries = new List<(string Min, string Max)>(); + foreach (var range in feedRanges) + { + var json = JObject.Parse(range.ToJsonString()); + containerBoundaries.Add(( + json["Range"]!["min"]!.ToString(), + json["Range"]!["max"]!.ToString())); + } + + // Get boundaries from FakeCosmosHandler via a real CosmosClient query + // We use the handler to create a client and read PKRanges + using var client = new CosmosClient( + "https://localhost:8081", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + new CosmosClientOptions + { + HttpClientFactory = () => new HttpClient(handler), + ConnectionMode = ConnectionMode.Gateway + }); + + var db = client.GetDatabase("testdb"); + var cosmosContainer = db.GetContainer("test"); + + // Query all items using each FeedRange so: seed items, query through SDK + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Query through the real SDK path (which uses FakeCosmosHandler's PKRanges) + var sdkResults = new List(); + using var sdkIterator = cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + while (sdkIterator.HasMoreResults) + { + var page = await sdkIterator.ReadNextAsync(); + sdkResults.AddRange(page); + } + + // Query through InMemoryContainer's FeedRange path + var feedRangeResults = new List(); + foreach (var range in feedRanges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + feedRangeResults.AddRange(page); + } + } + + // Both paths should return the same items + sdkResults.Should().HaveCount(20, "SDK path should return all items"); + feedRangeResults.Should().HaveCount(20, "FeedRange path should return all items"); + sdkResults.Select(r => r.Id).OrderBy(x => x) + .Should().Equal(feedRangeResults.Select(r => r.Id).OrderBy(x => x), + "SDK and FeedRange paths must return identical items"); + } + + [Fact] + public void PartitionKeyHash_RangeBoundaryToHex_Boundaries() + { + // Verify edge cases in RangeBoundaryToHex + PartitionKeyHash.RangeBoundaryToHex(0).Should().Be("", "0 → empty string (range start)"); + PartitionKeyHash.RangeBoundaryToHex(-1).Should().Be("", "negative → empty string"); + PartitionKeyHash.RangeBoundaryToHex(0x1_0000_0000L).Should().Be("FF", "max uint32+1 → FF"); + PartitionKeyHash.RangeBoundaryToHex(0x2_0000_0000L).Should().Be("FF", "beyond max → FF"); + + // Mid-range values should be 8-digit hex + PartitionKeyHash.RangeBoundaryToHex(0x5555_5555L).Should().Be("55555555"); + PartitionKeyHash.RangeBoundaryToHex(0xAAAA_AAAAL).Should().Be("AAAAAAAA"); + PartitionKeyHash.RangeBoundaryToHex(1).Should().Be("00000001"); + } + + [Fact] + public void PartitionKeyHash_GetRangeIndex_EdgeCases() + { + // GetRangeIndex should return valid range for edge cases + PartitionKeyHash.GetRangeIndex("test", 1).Should().Be(0, "single range → always 0"); + PartitionKeyHash.GetRangeIndex("test", 0).Should().Be(0, "zero ranges → 0 (clamped)"); + + // Same key should always map to same range + var r1 = PartitionKeyHash.GetRangeIndex("consistent", 10); + var r2 = PartitionKeyHash.GetRangeIndex("consistent", 10); + r1.Should().Be(r2, "same key + same count → same range"); + + // Range index should be within bounds + for (var i = 0; i < 50; i++) + { + var idx = PartitionKeyHash.GetRangeIndex($"key-{i}", 7); + idx.Should().BeInRange(0, 6); + } + } + + [Fact] + public async Task FakeCosmosHandler_PKRangesUse8DigitHex() + { + // FakeCosmosHandler.GetPartitionKeyRanges must produce 8-digit hex boundaries + // matching InMemoryContainer's GetFeedRangesAsync, NOT 2-digit hex. + const int rangeCount = 3; + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = rangeCount }; + var handler = new FakeCosmosHandler(container); + + using var client = new CosmosClient( + "https://localhost:8081", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + new CosmosClientOptions + { + HttpClientFactory = () => new HttpClient(handler), + ConnectionMode = ConnectionMode.Gateway + }); + + // Seed one item so the pipeline runs and fetches PKRanges + await container.CreateItemAsync( + new TestDocument { Id = "seed", PartitionKey = "pk", Name = "S" }, + new PartitionKey("pk")); + + // Get the FeedRange boundaries from InMemoryContainer + var feedRanges = await container.GetFeedRangesAsync(); + var containerBoundaries = feedRanges.Select(r => + { + var json = JObject.Parse(r.ToJsonString()); + return (Min: json["Range"]!["min"]!.ToString(), Max: json["Range"]!["max"]!.ToString()); + }).ToList(); + + // The shortest internal boundary should be 8 chars (e.g. "55555555"), not 2 (e.g. "55") + // This test will be RED if FakeCosmosHandler still uses 2-digit hex + foreach (var (min, max) in containerBoundaries) + { + if (!string.IsNullOrEmpty(min) && min != "FF") + min.Length.Should().Be(8, $"InMemoryContainer boundary '{min}' should be 8-digit hex"); + } + + // Now verify the handler uses the SAME boundaries by querying pkranges + // We can't easily extract them from the handler directly, but we verify via + // the fact that querying each FeedRange through the SDK gives consistent results + // with querying through InMemoryContainer FeedRanges + var cosmosContainer = client.GetDatabase("testdb").GetContainer("test"); + + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Query through FeedRanges (InMemoryContainer path) + var feedRangeItemsPerRange = new List>(); + foreach (var range in feedRanges) + { + var items = new HashSet(); + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) items.Add(item.Id); + } + feedRangeItemsPerRange.Add(items); + } + + // Verify all items found and no overlap + var allFeedRangeItems = feedRangeItemsPerRange.SelectMany(s => s).ToList(); + allFeedRangeItems.Should().HaveCount(allFeedRangeItems.Distinct().Count(), + "items should not appear in multiple FeedRanges"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -714,327 +714,327 @@ await container.CreateItemAsync( public class FeedRangeStreamAndConcurrencyTests { - [Fact] - public async Task GetItemQueryStreamIterator_WithFeedRange_MatchesTypedIterator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 3; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var feedRanges = await container.GetFeedRangesAsync(); - - foreach (var range in feedRanges) - { - // Typed iterator - var typedResults = new List(); - var typedIter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (typedIter.HasMoreResults) - { - var page = await typedIter.ReadNextAsync(); - typedResults.AddRange(page); - } - - // Stream iterator - var streamResults = new List(); - var streamIter = container.GetItemQueryStreamIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (streamIter.HasMoreResults) - { - var response = await streamIter.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var docs = JObject.Parse(json)["Documents"]!; - foreach (var doc in docs) - streamResults.Add(doc["id"]!.ToString()); - } - - typedResults.Select(r => r.Id).OrderBy(x => x) - .Should().Equal(streamResults.OrderBy(x => x), - "stream and typed iterators should return same items"); - } - } - - [Fact] - public async Task ConcurrentReads_AcrossDifferentFeedRanges_ThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var feedRanges = await container.GetFeedRangesAsync(); - var allIds = new System.Collections.Concurrent.ConcurrentBag(); - - await Task.WhenAll(feedRanges.Select(async range => - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item.Id); - } - })); - - allIds.Should().HaveCount(100, "concurrent reads should not lose items"); - allIds.Distinct().Should().HaveCount(100, "no duplicates from concurrent reads"); - } - - [Fact] - public async Task ItemsCreatedDuringIteration_SnapshotBehavior() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 2; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var feedRanges = await container.GetFeedRangesAsync(); - var iter = container.GetItemQueryIterator( - feedRanges[0], new QueryDefinition("SELECT * FROM c")); - - // Read first page - var firstPage = await iter.ReadNextAsync(); - var beforeCount = firstPage.Count; - - // Add more items while iterator is open - for (var i = 100; i < 110; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // The iterator should not throw (whether it includes new items is implementation-dependent) - beforeCount.Should().BeGreaterThan(0, "first page should have items"); - } - - [Fact] - public async Task ChangeFeed_AfterUpdates_ItemsStayInSameRange() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 3; - for (var i = 0; i < 15; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Original{i}" }, - new PartitionKey($"pk-{i}")); - - var feedRanges = await container.GetFeedRangesAsync(); - - // Record which range each item is in - var itemToRange = new Dictionary(); - for (var r = 0; r < feedRanges.Count; r++) - { - var iter = container.GetItemQueryIterator( - feedRanges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) itemToRange[item.Id] = r; - } - } - - // Update some items (same partition key = same range) - for (var i = 0; i < 5; i++) - await container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Updated{i}" }, - new PartitionKey($"pk-{i}")); - - // Verify updated items are still in the same ranges - for (var r = 0; r < feedRanges.Count; r++) - { - var iter = container.GetItemQueryIterator( - feedRanges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - { - if (itemToRange.ContainsKey(item.Id)) - itemToRange[item.Id].Should().Be(r, - $"item {item.Id} should still be in range {r} after update"); - } - } - } - } - - [Fact] - public async Task FeedRanges_AreContiguous_WithEvenCounts() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - var feedRanges = await container.GetFeedRangesAsync(); - - feedRanges.Should().HaveCount(4); - - // Parse feed ranges and verify contiguity - var boundaries = new List<(string min, string max)>(); - foreach (var range in feedRanges) - { - var rangeJson = JObject.Parse(range.ToJsonString()); - var epk = rangeJson["Range"]; - if (epk != null) - { - boundaries.Add((epk["min"]!.ToString(), epk["max"]!.ToString())); - } - } - - // First range starts at "" (empty), last range ends at "FF" - if (boundaries.Count > 0) - { - boundaries[0].min.Should().Be("", "first range should start at empty string"); - boundaries[^1].max.Should().Be("FF", "last range should end at FF"); - - // Each range's max should equal the next range's min (contiguity) - for (var i = 0; i < boundaries.Count - 1; i++) - { - boundaries[i].max.Should().Be(boundaries[i + 1].min, - $"range {i} max should equal range {i + 1} min"); - } - } - } - - [Fact] - public async Task FeedRanges_AreContiguous_WithLargeCounts() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 16; - var feedRanges = await container.GetFeedRangesAsync(); - - feedRanges.Should().HaveCount(16); - - // Verify all items distributed without loss - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var total = 0; - foreach (var range in feedRanges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - total += page.Count; - } - } - total.Should().Be(50, "all 50 items should be distributed across 16 ranges"); - } - - [Fact] - public async Task GetFeedRangesAsync_IsIdempotent() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 3; - - var ranges1 = await container.GetFeedRangesAsync(); - var ranges2 = await container.GetFeedRangesAsync(); - - ranges1.Should().HaveCount(ranges2.Count); - for (var i = 0; i < ranges1.Count; i++) - { - ranges1[i].ToJsonString().Should().Be(ranges2[i].ToJsonString(), - $"range {i} should be identical across calls"); - } - } - - [Fact] - public async Task FeedRange_JsonRoundTrip_PreservesBoundaries() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - var feedRanges = await container.GetFeedRangesAsync(); - - foreach (var range in feedRanges) - { - var json = range.ToJsonString(); - json.Should().NotBeNullOrEmpty(); - - // Verify the JSON is valid and contains Range fields - var parsed = JObject.Parse(json); - var rangeObj = parsed["Range"]; - if (rangeObj != null) - { - rangeObj["min"].Should().NotBeNull(); - rangeObj["max"].Should().NotBeNull(); - } - } - } - - [Fact] - public async Task GuidPartitionKey_FeedRange_Distribution() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - - // GUIDs should distribute roughly evenly - for (var i = 0; i < 100; i++) - { - var guid = Guid.NewGuid().ToString(); - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = guid, Name = $"N{i}" }, - new PartitionKey(guid)); - } - - var feedRanges = await container.GetFeedRangesAsync(); - var rangeCounts = new int[4]; - for (var r = 0; r < feedRanges.Count; r++) - { - var iter = container.GetItemQueryIterator( - feedRanges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - rangeCounts[r] += page.Count; - } - } - - // With 100 GUIDs across 4 ranges, each should have at least some items - rangeCounts.Sum().Should().Be(100, "all items should be accounted for"); - rangeCounts.Should().AllSatisfy(c => c.Should().BeGreaterThan(0), - "each range should have at least one GUID-keyed item"); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_OnlyReturnsItemsInRange() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 2; - - // Create items - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var feedRanges = await container.GetFeedRangesAsync(); - - var allItems = new List(); - foreach (var range in feedRanges) - { - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; - allItems.AddRange(page.Select(d => d.Id)); - } - } - - // Union of all feed range change feeds should cover all items - allItems.Distinct().Should().HaveCount(20, - "union of change feeds across all ranges should cover all items"); - } + [Fact] + public async Task GetItemQueryStreamIterator_WithFeedRange_MatchesTypedIterator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 3; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var feedRanges = await container.GetFeedRangesAsync(); + + foreach (var range in feedRanges) + { + // Typed iterator + var typedResults = new List(); + var typedIter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (typedIter.HasMoreResults) + { + var page = await typedIter.ReadNextAsync(); + typedResults.AddRange(page); + } + + // Stream iterator + var streamResults = new List(); + var streamIter = container.GetItemQueryStreamIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (streamIter.HasMoreResults) + { + var response = await streamIter.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var docs = JObject.Parse(json)["Documents"]!; + foreach (var doc in docs) + streamResults.Add(doc["id"]!.ToString()); + } + + typedResults.Select(r => r.Id).OrderBy(x => x) + .Should().Equal(streamResults.OrderBy(x => x), + "stream and typed iterators should return same items"); + } + } + + [Fact] + public async Task ConcurrentReads_AcrossDifferentFeedRanges_ThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var feedRanges = await container.GetFeedRangesAsync(); + var allIds = new System.Collections.Concurrent.ConcurrentBag(); + + await Task.WhenAll(feedRanges.Select(async range => + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item.Id); + } + })); + + allIds.Should().HaveCount(100, "concurrent reads should not lose items"); + allIds.Distinct().Should().HaveCount(100, "no duplicates from concurrent reads"); + } + + [Fact] + public async Task ItemsCreatedDuringIteration_SnapshotBehavior() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 2; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var feedRanges = await container.GetFeedRangesAsync(); + var iter = container.GetItemQueryIterator( + feedRanges[0], new QueryDefinition("SELECT * FROM c")); + + // Read first page + var firstPage = await iter.ReadNextAsync(); + var beforeCount = firstPage.Count; + + // Add more items while iterator is open + for (var i = 100; i < 110; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // The iterator should not throw (whether it includes new items is implementation-dependent) + beforeCount.Should().BeGreaterThan(0, "first page should have items"); + } + + [Fact] + public async Task ChangeFeed_AfterUpdates_ItemsStayInSameRange() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 3; + for (var i = 0; i < 15; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Original{i}" }, + new PartitionKey($"pk-{i}")); + + var feedRanges = await container.GetFeedRangesAsync(); + + // Record which range each item is in + var itemToRange = new Dictionary(); + for (var r = 0; r < feedRanges.Count; r++) + { + var iter = container.GetItemQueryIterator( + feedRanges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) itemToRange[item.Id] = r; + } + } + + // Update some items (same partition key = same range) + for (var i = 0; i < 5; i++) + await container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Updated{i}" }, + new PartitionKey($"pk-{i}")); + + // Verify updated items are still in the same ranges + for (var r = 0; r < feedRanges.Count; r++) + { + var iter = container.GetItemQueryIterator( + feedRanges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + { + if (itemToRange.ContainsKey(item.Id)) + itemToRange[item.Id].Should().Be(r, + $"item {item.Id} should still be in range {r} after update"); + } + } + } + } + + [Fact] + public async Task FeedRanges_AreContiguous_WithEvenCounts() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + var feedRanges = await container.GetFeedRangesAsync(); + + feedRanges.Should().HaveCount(4); + + // Parse feed ranges and verify contiguity + var boundaries = new List<(string min, string max)>(); + foreach (var range in feedRanges) + { + var rangeJson = JObject.Parse(range.ToJsonString()); + var epk = rangeJson["Range"]; + if (epk != null) + { + boundaries.Add((epk["min"]!.ToString(), epk["max"]!.ToString())); + } + } + + // First range starts at "" (empty), last range ends at "FF" + if (boundaries.Count > 0) + { + boundaries[0].min.Should().Be("", "first range should start at empty string"); + boundaries[^1].max.Should().Be("FF", "last range should end at FF"); + + // Each range's max should equal the next range's min (contiguity) + for (var i = 0; i < boundaries.Count - 1; i++) + { + boundaries[i].max.Should().Be(boundaries[i + 1].min, + $"range {i} max should equal range {i + 1} min"); + } + } + } + + [Fact] + public async Task FeedRanges_AreContiguous_WithLargeCounts() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 16; + var feedRanges = await container.GetFeedRangesAsync(); + + feedRanges.Should().HaveCount(16); + + // Verify all items distributed without loss + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var total = 0; + foreach (var range in feedRanges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + total += page.Count; + } + } + total.Should().Be(50, "all 50 items should be distributed across 16 ranges"); + } + + [Fact] + public async Task GetFeedRangesAsync_IsIdempotent() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 3; + + var ranges1 = await container.GetFeedRangesAsync(); + var ranges2 = await container.GetFeedRangesAsync(); + + ranges1.Should().HaveCount(ranges2.Count); + for (var i = 0; i < ranges1.Count; i++) + { + ranges1[i].ToJsonString().Should().Be(ranges2[i].ToJsonString(), + $"range {i} should be identical across calls"); + } + } + + [Fact] + public async Task FeedRange_JsonRoundTrip_PreservesBoundaries() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + var feedRanges = await container.GetFeedRangesAsync(); + + foreach (var range in feedRanges) + { + var json = range.ToJsonString(); + json.Should().NotBeNullOrEmpty(); + + // Verify the JSON is valid and contains Range fields + var parsed = JObject.Parse(json); + var rangeObj = parsed["Range"]; + if (rangeObj != null) + { + rangeObj["min"].Should().NotBeNull(); + rangeObj["max"].Should().NotBeNull(); + } + } + } + + [Fact] + public async Task GuidPartitionKey_FeedRange_Distribution() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + + // GUIDs should distribute roughly evenly + for (var i = 0; i < 100; i++) + { + var guid = Guid.NewGuid().ToString(); + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = guid, Name = $"N{i}" }, + new PartitionKey(guid)); + } + + var feedRanges = await container.GetFeedRangesAsync(); + var rangeCounts = new int[4]; + for (var r = 0; r < feedRanges.Count; r++) + { + var iter = container.GetItemQueryIterator( + feedRanges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + rangeCounts[r] += page.Count; + } + } + + // With 100 GUIDs across 4 ranges, each should have at least some items + rangeCounts.Sum().Should().Be(100, "all items should be accounted for"); + rangeCounts.Should().AllSatisfy(c => c.Should().BeGreaterThan(0), + "each range should have at least one GUID-keyed item"); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_OnlyReturnsItemsInRange() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 2; + + // Create items + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var feedRanges = await container.GetFeedRangesAsync(); + + var allItems = new List(); + foreach (var range in feedRanges) + { + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == System.Net.HttpStatusCode.NotModified) break; + allItems.AddRange(page.Select(d => d.Id)); + } + } + + // Union of all feed range change feeds should cover all items + allItems.Distinct().Should().HaveCount(20, + "union of change feeds across all ranges should cover all items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1043,70 +1043,70 @@ await container.CreateItemAsync( public class PartitionKeyHashUnitTests { - [Fact] - public void MurmurHash3_NullInput_ThrowsArgumentNullException() - { - // MurmurHash3 calls Encoding.UTF8.GetBytes(value) which throws on null input. - // Callers must null-check before invoking. - var act = () => PartitionKeyHash.MurmurHash3(null!); - act.Should().Throw(); - } - - [Fact] - public void MurmurHash3_EmptyString_ReturnsDeterministicHash() - { - var hash1 = PartitionKeyHash.MurmurHash3(""); - var hash2 = PartitionKeyHash.MurmurHash3(""); - hash1.Should().Be(hash2, "empty string should always produce the same hash"); - } - - [Fact] - public void MurmurHash3_KnownValues_DeterministicAcrossRuns() - { - // Record exact hash values as regression anchors — these must never change - var testHash = PartitionKeyHash.MurmurHash3("test"); - var helloHash = PartitionKeyHash.MurmurHash3("hello"); - var pkHash = PartitionKeyHash.MurmurHash3("partition-key-1"); - - // Same values on subsequent calls - PartitionKeyHash.MurmurHash3("test").Should().Be(testHash); - PartitionKeyHash.MurmurHash3("hello").Should().Be(helloHash); - PartitionKeyHash.MurmurHash3("partition-key-1").Should().Be(pkHash); - - // All three should be different from each other (astronomically unlikely to collide) - new[] { testHash, helloHash, pkHash }.Distinct().Should().HaveCount(3); - } - - [Fact] - public void GetRangeIndex_LargeRangeCount_StaysInBounds() - { - // Range index must stay within [0, rangeCount-1] even for large counts - var idx10k = PartitionKeyHash.GetRangeIndex("key", 10000); - idx10k.Should().BeInRange(0, 9999); - - // int.MaxValue should not overflow - var idxMax = PartitionKeyHash.GetRangeIndex("key", int.MaxValue); - idxMax.Should().BeInRange(0, int.MaxValue - 1); - - // Multiple keys should all stay in bounds - for (var i = 0; i < 50; i++) - { - var idx = PartitionKeyHash.GetRangeIndex($"key-{i}", 65536); - idx.Should().BeInRange(0, 65535); - } - } - - [Fact] - public void RangeBoundaryToHex_ExactValues_CornerCases() - { - PartitionKeyHash.RangeBoundaryToHex(1).Should().Be("00000001"); - PartitionKeyHash.RangeBoundaryToHex(0xFFFFFFFE).Should().Be("FFFFFFFE"); - PartitionKeyHash.RangeBoundaryToHex(0xFFFFFFFF).Should().Be("FFFFFFFF"); - // 0x1_0000_0000 is the max boundary sentinel → "FF" - PartitionKeyHash.RangeBoundaryToHex(0x1_0000_0000L).Should().Be("FF"); - // Mid-range - PartitionKeyHash.RangeBoundaryToHex(0x8000_0000L).Should().Be("80000000"); - } + [Fact] + public void MurmurHash3_NullInput_ThrowsArgumentNullException() + { + // MurmurHash3 calls Encoding.UTF8.GetBytes(value) which throws on null input. + // Callers must null-check before invoking. + var act = () => PartitionKeyHash.MurmurHash3(null!); + act.Should().Throw(); + } + + [Fact] + public void MurmurHash3_EmptyString_ReturnsDeterministicHash() + { + var hash1 = PartitionKeyHash.MurmurHash3(""); + var hash2 = PartitionKeyHash.MurmurHash3(""); + hash1.Should().Be(hash2, "empty string should always produce the same hash"); + } + + [Fact] + public void MurmurHash3_KnownValues_DeterministicAcrossRuns() + { + // Record exact hash values as regression anchors — these must never change + var testHash = PartitionKeyHash.MurmurHash3("test"); + var helloHash = PartitionKeyHash.MurmurHash3("hello"); + var pkHash = PartitionKeyHash.MurmurHash3("partition-key-1"); + + // Same values on subsequent calls + PartitionKeyHash.MurmurHash3("test").Should().Be(testHash); + PartitionKeyHash.MurmurHash3("hello").Should().Be(helloHash); + PartitionKeyHash.MurmurHash3("partition-key-1").Should().Be(pkHash); + + // All three should be different from each other (astronomically unlikely to collide) + new[] { testHash, helloHash, pkHash }.Distinct().Should().HaveCount(3); + } + + [Fact] + public void GetRangeIndex_LargeRangeCount_StaysInBounds() + { + // Range index must stay within [0, rangeCount-1] even for large counts + var idx10k = PartitionKeyHash.GetRangeIndex("key", 10000); + idx10k.Should().BeInRange(0, 9999); + + // int.MaxValue should not overflow + var idxMax = PartitionKeyHash.GetRangeIndex("key", int.MaxValue); + idxMax.Should().BeInRange(0, int.MaxValue - 1); + + // Multiple keys should all stay in bounds + for (var i = 0; i < 50; i++) + { + var idx = PartitionKeyHash.GetRangeIndex($"key-{i}", 65536); + idx.Should().BeInRange(0, 65535); + } + } + + [Fact] + public void RangeBoundaryToHex_ExactValues_CornerCases() + { + PartitionKeyHash.RangeBoundaryToHex(1).Should().Be("00000001"); + PartitionKeyHash.RangeBoundaryToHex(0xFFFFFFFE).Should().Be("FFFFFFFE"); + PartitionKeyHash.RangeBoundaryToHex(0xFFFFFFFF).Should().Be("FFFFFFFF"); + // 0x1_0000_0000 is the max boundary sentinel → "FF" + PartitionKeyHash.RangeBoundaryToHex(0x1_0000_0000L).Should().Be("FF"); + // Mid-range + PartitionKeyHash.RangeBoundaryToHex(0x8000_0000L).Should().Be("80000000"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1115,211 +1115,211 @@ public void RangeBoundaryToHex_ExactValues_CornerCases() public class FeedRangeBoundaryDeepDiveTests { - [Fact] - public async Task FeedRangeCount_One_SingleFullRange_AllItemsReturned() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - - // Single range should span ["", "FF") - var json = JObject.Parse(ranges[0].ToJsonString()); - json["Range"]!["min"]!.ToString().Should().Be(""); - json["Range"]!["max"]!.ToString().Should().Be("FF"); - - // Query via the single range → all 20 items - var results = new List(); - var iter = container.GetItemQueryIterator( - ranges[0], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(20); - } - - [Fact] - public async Task FeedRangeCount_VeryLarge_NoOverflow() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 65536 }; - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(65536); - - // Contiguous: first="" last="FF" - var first = JObject.Parse(ranges[0].ToJsonString()); - first["Range"]!["min"]!.ToString().Should().Be(""); - var last = JObject.Parse(ranges[^1].ToJsonString()); - last["Range"]!["max"]!.ToString().Should().Be("FF"); - - // Seed and verify no loss - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var total = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - total += page.Count; - } - } - - total.Should().Be(100, "all items should be distributed across 65536 ranges"); - } - - [Fact] - public void ExactBoundaryItem_LandsInCorrectRange() - { - // Verify that GetRangeIndex is consistent with IsHashInRange logic. - // With 4 ranges, boundaries are at 0, 0x40000000, 0x80000000, 0xC0000000. - // Find keys that hash to known ranges and verify consistency. - const int rangeCount = 4; - - // Test many keys — each should land in a valid range and be deterministic - var keyToRange = new Dictionary(); - for (var i = 0; i < 100; i++) - { - var key = $"boundary-test-{i}"; - var range = PartitionKeyHash.GetRangeIndex(key, rangeCount); - range.Should().BeInRange(0, rangeCount - 1); - keyToRange[key] = range; - } - - // Verify determinism: same key → same range - foreach (var (key, expectedRange) in keyToRange) - { - PartitionKeyHash.GetRangeIndex(key, rangeCount).Should().Be(expectedRange); - } - - // All 4 ranges should be populated (with 100 keys, statistically certain) - keyToRange.Values.Distinct().Should().HaveCount(4, "100 keys should cover all 4 ranges"); - } - - [Fact] - public async Task InvertedBoundaries_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Inverted: min > max → IsHashInRange always false - var inverted = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"40000000\"}}"); - var results = new List(); - var iter = container.GetItemQueryIterator( - inverted, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("inverted boundaries (min > max) should return 0 items"); - } - - [Fact] - public async Task IdenticalBoundaries_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // min == max → hash >= 0x55555555 && hash < 0x55555555 is always false - var identical = FeedRange.FromJsonString("{\"Range\":{\"min\":\"55555555\",\"max\":\"55555555\"}}"); - var results = new List(); - var iter = container.GetItemQueryIterator( - identical, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("identical boundaries (min == max) should return 0 items"); - } - - [Fact] - public async Task EmptyMinMaxBoth_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Both empty strings → min="" parses to 0, max="" fails hex parse → catch → (null,null) → all items - var emptyRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"\"}}"); - var results = new List(); - var iter = container.GetItemQueryIterator( - emptyRange, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10, "empty max boundary should fall back to returning all items"); - } - - [Fact] - public async Task MaxValueHash_LandsInLastRange() - { - // With 4 ranges, last range is [0xC0000000, FF=uint.MaxValue). - // Items whose PK hashes to >= 0xC0000000 should land in the last range. - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - // Seed many items and verify all items in last range have high hashes - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var lastRange = ranges[^1]; - var lastJson = JObject.Parse(lastRange.ToJsonString()); - lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); - - var lastRangeItems = new List(); - var iter = container.GetItemQueryIterator( - lastRange, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - lastRangeItems.AddRange(page); - } - - // Every item in the last range should have a hash in the expected interval - var lastMinStr = lastJson["Range"]!["min"]!.ToString(); - var lastMin = Convert.ToUInt32(lastMinStr, 16); - foreach (var item in lastRangeItems) - { - var hash = PartitionKeyHash.MurmurHash3(InMemoryContainer.JTokenToTypedKey(new Newtonsoft.Json.Linq.JValue(item.PartitionKey))); - hash.Should().BeGreaterThanOrEqualTo(lastMin, - $"item {item.Id} with PK '{item.PartitionKey}' should hash to the last range"); - } - - lastRangeItems.Should().NotBeEmpty("with 100 items, the last range should have some items"); - } + [Fact] + public async Task FeedRangeCount_One_SingleFullRange_AllItemsReturned() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + + // Single range should span ["", "FF") + var json = JObject.Parse(ranges[0].ToJsonString()); + json["Range"]!["min"]!.ToString().Should().Be(""); + json["Range"]!["max"]!.ToString().Should().Be("FF"); + + // Query via the single range → all 20 items + var results = new List(); + var iter = container.GetItemQueryIterator( + ranges[0], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(20); + } + + [Fact] + public async Task FeedRangeCount_VeryLarge_NoOverflow() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 65536 }; + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(65536); + + // Contiguous: first="" last="FF" + var first = JObject.Parse(ranges[0].ToJsonString()); + first["Range"]!["min"]!.ToString().Should().Be(""); + var last = JObject.Parse(ranges[^1].ToJsonString()); + last["Range"]!["max"]!.ToString().Should().Be("FF"); + + // Seed and verify no loss + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var total = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + total += page.Count; + } + } + + total.Should().Be(100, "all items should be distributed across 65536 ranges"); + } + + [Fact] + public void ExactBoundaryItem_LandsInCorrectRange() + { + // Verify that GetRangeIndex is consistent with IsHashInRange logic. + // With 4 ranges, boundaries are at 0, 0x40000000, 0x80000000, 0xC0000000. + // Find keys that hash to known ranges and verify consistency. + const int rangeCount = 4; + + // Test many keys — each should land in a valid range and be deterministic + var keyToRange = new Dictionary(); + for (var i = 0; i < 100; i++) + { + var key = $"boundary-test-{i}"; + var range = PartitionKeyHash.GetRangeIndex(key, rangeCount); + range.Should().BeInRange(0, rangeCount - 1); + keyToRange[key] = range; + } + + // Verify determinism: same key → same range + foreach (var (key, expectedRange) in keyToRange) + { + PartitionKeyHash.GetRangeIndex(key, rangeCount).Should().Be(expectedRange); + } + + // All 4 ranges should be populated (with 100 keys, statistically certain) + keyToRange.Values.Distinct().Should().HaveCount(4, "100 keys should cover all 4 ranges"); + } + + [Fact] + public async Task InvertedBoundaries_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Inverted: min > max → IsHashInRange always false + var inverted = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"40000000\"}}"); + var results = new List(); + var iter = container.GetItemQueryIterator( + inverted, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("inverted boundaries (min > max) should return 0 items"); + } + + [Fact] + public async Task IdenticalBoundaries_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // min == max → hash >= 0x55555555 && hash < 0x55555555 is always false + var identical = FeedRange.FromJsonString("{\"Range\":{\"min\":\"55555555\",\"max\":\"55555555\"}}"); + var results = new List(); + var iter = container.GetItemQueryIterator( + identical, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("identical boundaries (min == max) should return 0 items"); + } + + [Fact] + public async Task EmptyMinMaxBoth_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Both empty strings → min="" parses to 0, max="" fails hex parse → catch → (null,null) → all items + var emptyRange = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"\"}}"); + var results = new List(); + var iter = container.GetItemQueryIterator( + emptyRange, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10, "empty max boundary should fall back to returning all items"); + } + + [Fact] + public async Task MaxValueHash_LandsInLastRange() + { + // With 4 ranges, last range is [0xC0000000, FF=uint.MaxValue). + // Items whose PK hashes to >= 0xC0000000 should land in the last range. + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + // Seed many items and verify all items in last range have high hashes + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var lastRange = ranges[^1]; + var lastJson = JObject.Parse(lastRange.ToJsonString()); + lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); + + var lastRangeItems = new List(); + var iter = container.GetItemQueryIterator( + lastRange, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + lastRangeItems.AddRange(page); + } + + // Every item in the last range should have a hash in the expected interval + var lastMinStr = lastJson["Range"]!["min"]!.ToString(); + var lastMin = Convert.ToUInt32(lastMinStr, 16); + foreach (var item in lastRangeItems) + { + var hash = PartitionKeyHash.MurmurHash3(InMemoryContainer.JTokenToTypedKey(new Newtonsoft.Json.Linq.JValue(item.PartitionKey))); + hash.Should().BeGreaterThanOrEqualTo(lastMin, + $"item {item.Id} with PK '{item.PartitionKey}' should hash to the last range"); + } + + lastRangeItems.Should().NotBeEmpty("with 100 items, the last range should have some items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1328,182 +1328,184 @@ await container.CreateItemAsync( public class FeedRangePartitionKeyTypeDeepTests { - [Fact] - public async Task BooleanPartitionKey_MultiRange_ConsistentDistribution() - { - var container = new InMemoryContainer("test", "/isActive") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - var active = i % 2 == 0; - var doc = new JObject { ["id"] = $"{i}", ["isActive"] = active }; - await container.CreateItemAsync(doc, new PartitionKey(active)); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20, "all boolean PK items must be found across ranges"); - - // Same bool value should always map to same range - var trueRange = PartitionKeyHash.GetRangeIndex("True", 4); - var falseRange = PartitionKeyHash.GetRangeIndex("False", 4); - PartitionKeyHash.GetRangeIndex("True", 4).Should().Be(trueRange); - PartitionKeyHash.GetRangeIndex("False", 4).Should().Be(falseRange); - } - - [Fact] - public async Task DoublePK_FeedRange_ConsistentHashing() - { - var container = new InMemoryContainer("test", "/value") { FeedRangeCount = 4 }; - - var values = new[] { 3.14, -1.0, 0.0, 1e10, double.MinValue, double.MaxValue }; - for (var i = 0; i < values.Length; i++) - { - var doc = new JObject { ["id"] = $"{i}", ["value"] = values[i] }; - await container.CreateItemAsync(doc, new PartitionKey(values[i])); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(values.Length, "all double PK items must be found across ranges"); - } - - [Fact] - public async Task SpecialCharsPK_MultiRange_NoLoss() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - var specialKeys = new[] { "\n", "\t", "emoji🎉", "a\nb\tc", "spaces in key", "special!@#$%^&*()" }; - for (var i = 0; i < specialKeys.Length; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = specialKeys[i], Name = $"N{i}" }, - new PartitionKey(specialKeys[i])); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item.Id); - } - } - - allIds.Should().HaveCount(specialKeys.Length, "special character PKs must not be lost"); - } - - [Fact] - public async Task HierarchicalPK_ThreeLevel_FeedRange_NoLoss() - { - var container = new InMemoryContainer("test", - new List { "/tenantId", "/region", "/userId" }) { FeedRangeCount = 4 }; - - for (var i = 0; i < 30; i++) - { - var pk = new PartitionKeyBuilder() - .Add($"tenant-{i % 3}") - .Add($"region-{i % 2}") - .Add($"user-{i}") - .Build(); - var doc = new JObject - { - ["id"] = $"doc-{i}", - ["tenantId"] = $"tenant-{i % 3}", - ["region"] = $"region-{i % 2}", - ["userId"] = $"user-{i}" - }; - await container.CreateItemAsync(doc, pk); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(30, "3-level hierarchical PK items must all be found"); - } - - [Fact] - public async Task CompositePartitionKey_WithNullComponents_FeedRange_NoLoss() - { - var container = new InMemoryContainer("test", - new List { "/tenantId", "/userId" }) { FeedRangeCount = 4 }; - - // Mix of null and non-null components - var items = new[] - { - (id: "both-set", t: "t1", u: "u1"), - (id: "tenant-null", t: (string?)null, u: "u2"), - (id: "user-null", t: "t3", u: (string?)null), - (id: "both-null", t: (string?)null, u: (string?)null), - }; - - foreach (var (id, t, u) in items) - { - var pkBuilder = new PartitionKeyBuilder(); - if (t != null) pkBuilder.Add(t); else pkBuilder.AddNullValue(); - if (u != null) pkBuilder.Add(u); else pkBuilder.AddNullValue(); - var pk = pkBuilder.Build(); - - var doc = new JObject { ["id"] = id }; - if (t != null) doc["tenantId"] = t; - if (u != null) doc["userId"] = u; - await container.CreateItemAsync(doc, pk); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(items.Length, "composite PK items with null components must not be lost"); - } + [Fact] + public async Task BooleanPartitionKey_MultiRange_ConsistentDistribution() + { + var container = new InMemoryContainer("test", "/isActive") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + var active = i % 2 == 0; + var doc = new JObject { ["id"] = $"{i}", ["isActive"] = active }; + await container.CreateItemAsync(doc, new PartitionKey(active)); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20, "all boolean PK items must be found across ranges"); + + // Same bool value should always map to same range + var trueRange = PartitionKeyHash.GetRangeIndex("True", 4); + var falseRange = PartitionKeyHash.GetRangeIndex("False", 4); + PartitionKeyHash.GetRangeIndex("True", 4).Should().Be(trueRange); + PartitionKeyHash.GetRangeIndex("False", 4).Should().Be(falseRange); + } + + [Fact] + public async Task DoublePK_FeedRange_ConsistentHashing() + { + var container = new InMemoryContainer("test", "/value") { FeedRangeCount = 4 }; + + var values = new[] { 3.14, -1.0, 0.0, 1e10, double.MinValue, double.MaxValue }; + for (var i = 0; i < values.Length; i++) + { + var doc = new JObject { ["id"] = $"{i}", ["value"] = values[i] }; + await container.CreateItemAsync(doc, new PartitionKey(values[i])); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(values.Length, "all double PK items must be found across ranges"); + } + + [Fact] + public async Task SpecialCharsPK_MultiRange_NoLoss() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + var specialKeys = new[] { "\n", "\t", "emoji🎉", "a\nb\tc", "spaces in key", "special!@#$%^&*()" }; + for (var i = 0; i < specialKeys.Length; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = specialKeys[i], Name = $"N{i}" }, + new PartitionKey(specialKeys[i])); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item.Id); + } + } + + allIds.Should().HaveCount(specialKeys.Length, "special character PKs must not be lost"); + } + + [Fact] + public async Task HierarchicalPK_ThreeLevel_FeedRange_NoLoss() + { + var container = new InMemoryContainer("test", + new List { "/tenantId", "/region", "/userId" }) + { FeedRangeCount = 4 }; + + for (var i = 0; i < 30; i++) + { + var pk = new PartitionKeyBuilder() + .Add($"tenant-{i % 3}") + .Add($"region-{i % 2}") + .Add($"user-{i}") + .Build(); + var doc = new JObject + { + ["id"] = $"doc-{i}", + ["tenantId"] = $"tenant-{i % 3}", + ["region"] = $"region-{i % 2}", + ["userId"] = $"user-{i}" + }; + await container.CreateItemAsync(doc, pk); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(30, "3-level hierarchical PK items must all be found"); + } + + [Fact] + public async Task CompositePartitionKey_WithNullComponents_FeedRange_NoLoss() + { + var container = new InMemoryContainer("test", + new List { "/tenantId", "/userId" }) + { FeedRangeCount = 4 }; + + // Mix of null and non-null components + var items = new[] + { + (id: "both-set", t: "t1", u: "u1"), + (id: "tenant-null", t: (string?)null, u: "u2"), + (id: "user-null", t: "t3", u: (string?)null), + (id: "both-null", t: (string?)null, u: (string?)null), + }; + + foreach (var (id, t, u) in items) + { + var pkBuilder = new PartitionKeyBuilder(); + if (t != null) pkBuilder.Add(t); else pkBuilder.AddNullValue(); + if (u != null) pkBuilder.Add(u); else pkBuilder.AddNullValue(); + var pk = pkBuilder.Build(); + + var doc = new JObject { ["id"] = id }; + if (t != null) doc["tenantId"] = t; + if (u != null) doc["userId"] = u; + await container.CreateItemAsync(doc, pk); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(items.Length, "composite PK items with null components must not be lost"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1512,218 +1514,218 @@ public async Task CompositePartitionKey_WithNullComponents_FeedRange_NoLoss() public class FeedRangeChangeFeedDeepDiveTests { - [Fact] - public async Task ChangeFeed_Replace_WithFeedRange_UpdateAppearsInCorrectRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Original{i}" }, - new PartitionKey($"pk-{i}")); - - // Record which range each item is in - var ranges = await container.GetFeedRangesAsync(); - var itemToRange = new Dictionary(); - for (var r = 0; r < ranges.Count; r++) - { - var iter = container.GetItemQueryIterator( - ranges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) itemToRange[item.Id] = r; - } - } - - // Replace an item - var targetId = "5"; - var targetRange = itemToRange[targetId]; - await container.ReplaceItemAsync( - new TestDocument { Id = targetId, PartitionKey = $"pk-{targetId}", Name = "Replaced5" }, - targetId, new PartitionKey($"pk-{targetId}")); - - // Change feed for the target's range should contain the update - var cfIter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(ranges[targetRange]), - ChangeFeedMode.Incremental); - var feedItems = new List(); - while (cfIter.HasMoreResults) - { - var page = await cfIter.ReadNextAsync(); - feedItems.AddRange(page); - } - - feedItems.Should().Contain(item => item.Id == targetId && item.Name == "Replaced5", - "replaced item should appear in the correct range's change feed with updated data"); - } - - [Fact] - public async Task ChangeFeed_Stream_WithTime_AndFeedRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"E{i}" }, - new PartitionKey($"pk-{i}")); - - var midpoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"L{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allLateIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Time(midpoint.UtcDateTime, range), - ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - if (response.StatusCode == System.Net.HttpStatusCode.NotModified) break; - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - if (string.IsNullOrWhiteSpace(json)) continue; - var parsed = JObject.Parse(json); - var docs = parsed["Documents"]; - if (docs != null) - foreach (var doc in docs) - allLateIds.Add(doc["id"]!.ToString()); - } - } - - allLateIds.Should().HaveCount(10, "stream change feed with Time + FeedRange should return only late items"); - allLateIds.Should().OnlyContain(id => id.StartsWith("late-")); - } - - [Fact] - public async Task ChangeFeed_MultipleUpdates_IncrementalShowsLatest() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - await container.CreateItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk-target", Name = "V1" }, - new PartitionKey("pk-target")); - - // Update the same item 5 times - for (var v = 2; v <= 6; v++) - await container.UpsertItemAsync( - new TestDocument { Id = "target", PartitionKey = "pk-target", Name = $"V{v}" }, - new PartitionKey("pk-target")); - - // Find which range the item is in - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - var target = page.FirstOrDefault(p => p.Id == "target"); - if (target != null) - { - // Incremental mode shows only latest version - target.Name.Should().Be("V6", - "Incremental mode should return only the latest version of the item"); - return; - } - } - } - - throw new Exception("Target item not found in any range's change feed"); - } - - [Fact] - public async Task ChangeFeed_CrossContainerReuse_Works() - { - // FeedRange is just hash boundaries — should work across containers with same range config - var containerA = new InMemoryContainer("a", "/partitionKey") { FeedRangeCount = 4 }; - var containerB = new InMemoryContainer("b", "/partitionKey") { FeedRangeCount = 4 }; - - // Seed same items in both - for (var i = 0; i < 20; i++) - { - var doc = new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }; - await containerA.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); - await containerB.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); - } - - // Get FeedRange from container A, use it to query container B's change feed - var rangesA = await containerA.GetFeedRangesAsync(); - - foreach (var range in rangesA) - { - var iterA = container_QueryAll(containerA, range); - var iterB = container_QueryAll(containerB, range); - - var idsA = (await iterA).Select(d => d.Id).OrderBy(x => x).ToList(); - var idsB = (await iterB).Select(d => d.Id).OrderBy(x => x).ToList(); - - idsA.Should().Equal(idsB, - "same FeedRange should return same items from both containers"); - } - } - - private static async Task> container_QueryAll(InMemoryContainer container, FeedRange range) - { - var results = new List(); - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - [Fact(Skip = "DIVERGENT BEHAVIOUR: InMemoryChangeFeedProcessorContext.FeedRange always returns " + - "FeedRange.FromPartitionKey(PartitionKey.None) regardless of the actual partition range being " + - "processed. Real Cosmos DB returns the FeedRangeEpk of the specific lease/partition being processed. " + - "Implementing accurate FeedRange tracking would require the processor to split work across " + - "FeedRanges and track per-lease state, which is beyond the emulator's single-lease model.")] - public void ChangeFeed_Processor_Context_FeedRange_ReflectsActualProcessingRange() - { - // This test would verify that context.FeedRange reflects the actual processing range. - // Since the emulator always returns PartitionKey.None, this test is skipped. - // See sister test below for emulator's actual behavior. - } - - [Fact] - public void ChangeFeed_Processor_Context_FeedRange_EmulatorBehavior_AlwaysReturnsNone() - { - // Sister test: The emulator's InMemoryChangeFeedProcessorContext always - // returns FeedRange.FromPartitionKey(PartitionKey.None) for the FeedRange - // property. Real Cosmos DB returns the FeedRangeEpk of the specific partition - // range the processor lease covers. This is because: - // 1. The emulator uses a single-lease model (one processor handles all changes). - // 2. There's no partition split/merge simulation. - // 3. The LeaseToken is always "0" (single lease). - // We verify this by examining the processor context directly through the - // change feed processor builder pattern used in all existing change feed tests. - - // The InMemoryChangeFeedProcessorContext is internal, but its behavior - // is observable through the change feed processor handler that receives it. - // Rather than building a full processor (which needs WithInMemoryLeaseContainer), - // we verify the expected FeedRange value from the PartitionKey.None FeedRange. - var noneFeedRange = FeedRange.FromPartitionKey(PartitionKey.None); - noneFeedRange.Should().NotBeNull( - "FeedRange.FromPartitionKey(PartitionKey.None) should produce a valid FeedRange"); - - // The emulator's context always returns this same FeedRange value, - // regardless of which partition range is being processed. - // This is a documented limitation — see Known Limitations wiki. - var noneJson = noneFeedRange.ToJsonString(); - noneJson.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task ChangeFeed_Replace_WithFeedRange_UpdateAppearsInCorrectRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Original{i}" }, + new PartitionKey($"pk-{i}")); + + // Record which range each item is in + var ranges = await container.GetFeedRangesAsync(); + var itemToRange = new Dictionary(); + for (var r = 0; r < ranges.Count; r++) + { + var iter = container.GetItemQueryIterator( + ranges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) itemToRange[item.Id] = r; + } + } + + // Replace an item + var targetId = "5"; + var targetRange = itemToRange[targetId]; + await container.ReplaceItemAsync( + new TestDocument { Id = targetId, PartitionKey = $"pk-{targetId}", Name = "Replaced5" }, + targetId, new PartitionKey($"pk-{targetId}")); + + // Change feed for the target's range should contain the update + var cfIter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(ranges[targetRange]), + ChangeFeedMode.Incremental); + var feedItems = new List(); + while (cfIter.HasMoreResults) + { + var page = await cfIter.ReadNextAsync(); + feedItems.AddRange(page); + } + + feedItems.Should().Contain(item => item.Id == targetId && item.Name == "Replaced5", + "replaced item should appear in the correct range's change feed with updated data"); + } + + [Fact] + public async Task ChangeFeed_Stream_WithTime_AndFeedRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"E{i}" }, + new PartitionKey($"pk-{i}")); + + var midpoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"L{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allLateIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Time(midpoint.UtcDateTime, range), + ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + if (response.StatusCode == System.Net.HttpStatusCode.NotModified) break; + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(json)) continue; + var parsed = JObject.Parse(json); + var docs = parsed["Documents"]; + if (docs != null) + foreach (var doc in docs) + allLateIds.Add(doc["id"]!.ToString()); + } + } + + allLateIds.Should().HaveCount(10, "stream change feed with Time + FeedRange should return only late items"); + allLateIds.Should().OnlyContain(id => id.StartsWith("late-")); + } + + [Fact] + public async Task ChangeFeed_MultipleUpdates_IncrementalShowsLatest() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + await container.CreateItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk-target", Name = "V1" }, + new PartitionKey("pk-target")); + + // Update the same item 5 times + for (var v = 2; v <= 6; v++) + await container.UpsertItemAsync( + new TestDocument { Id = "target", PartitionKey = "pk-target", Name = $"V{v}" }, + new PartitionKey("pk-target")); + + // Find which range the item is in + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + var target = page.FirstOrDefault(p => p.Id == "target"); + if (target != null) + { + // Incremental mode shows only latest version + target.Name.Should().Be("V6", + "Incremental mode should return only the latest version of the item"); + return; + } + } + } + + throw new Exception("Target item not found in any range's change feed"); + } + + [Fact] + public async Task ChangeFeed_CrossContainerReuse_Works() + { + // FeedRange is just hash boundaries — should work across containers with same range config + var containerA = new InMemoryContainer("a", "/partitionKey") { FeedRangeCount = 4 }; + var containerB = new InMemoryContainer("b", "/partitionKey") { FeedRangeCount = 4 }; + + // Seed same items in both + for (var i = 0; i < 20; i++) + { + var doc = new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }; + await containerA.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); + await containerB.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); + } + + // Get FeedRange from container A, use it to query container B's change feed + var rangesA = await containerA.GetFeedRangesAsync(); + + foreach (var range in rangesA) + { + var iterA = container_QueryAll(containerA, range); + var iterB = container_QueryAll(containerB, range); + + var idsA = (await iterA).Select(d => d.Id).OrderBy(x => x).ToList(); + var idsB = (await iterB).Select(d => d.Id).OrderBy(x => x).ToList(); + + idsA.Should().Equal(idsB, + "same FeedRange should return same items from both containers"); + } + } + + private static async Task> container_QueryAll(InMemoryContainer container, FeedRange range) + { + var results = new List(); + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + [Fact(Skip = "DIVERGENT BEHAVIOUR: InMemoryChangeFeedProcessorContext.FeedRange always returns " + + "FeedRange.FromPartitionKey(PartitionKey.None) regardless of the actual partition range being " + + "processed. Real Cosmos DB returns the FeedRangeEpk of the specific lease/partition being processed. " + + "Implementing accurate FeedRange tracking would require the processor to split work across " + + "FeedRanges and track per-lease state, which is beyond the emulator's single-lease model.")] + public void ChangeFeed_Processor_Context_FeedRange_ReflectsActualProcessingRange() + { + // This test would verify that context.FeedRange reflects the actual processing range. + // Since the emulator always returns PartitionKey.None, this test is skipped. + // See sister test below for emulator's actual behavior. + } + + [Fact] + public void ChangeFeed_Processor_Context_FeedRange_EmulatorBehavior_AlwaysReturnsNone() + { + // Sister test: The emulator's InMemoryChangeFeedProcessorContext always + // returns FeedRange.FromPartitionKey(PartitionKey.None) for the FeedRange + // property. Real Cosmos DB returns the FeedRangeEpk of the specific partition + // range the processor lease covers. This is because: + // 1. The emulator uses a single-lease model (one processor handles all changes). + // 2. There's no partition split/merge simulation. + // 3. The LeaseToken is always "0" (single lease). + // We verify this by examining the processor context directly through the + // change feed processor builder pattern used in all existing change feed tests. + + // The InMemoryChangeFeedProcessorContext is internal, but its behavior + // is observable through the change feed processor handler that receives it. + // Rather than building a full processor (which needs WithInMemoryLeaseContainer), + // we verify the expected FeedRange value from the PartitionKey.None FeedRange. + var noneFeedRange = FeedRange.FromPartitionKey(PartitionKey.None); + noneFeedRange.Should().NotBeNull( + "FeedRange.FromPartitionKey(PartitionKey.None) should produce a valid FeedRange"); + + // The emulator's context always returns this same FeedRange value, + // regardless of which partition range is being processed. + // This is a documented limitation — see Known Limitations wiki. + var noneJson = noneFeedRange.ToJsonString(); + noneJson.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1732,192 +1734,194 @@ public void ChangeFeed_Processor_Context_FeedRange_EmulatorBehavior_AlwaysReturn public class FeedRangeQueryInteractionTests { - [Fact] - public async Task Aggregate_Count_WithFeedRange_ReturnsCountForRange() - { - // Aggregate queries produce results without PK fields, so FilterByFeedRange - // cannot filter them. Instead, we query SELECT * per range and count in code. - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var countPerRange = new int[2]; - - for (var r = 0; r < ranges.Count; r++) - { - var iter = container.GetItemQueryIterator( - ranges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - countPerRange[r] += page.Count; - } - } - - // Sum of counts across ranges must equal total items - countPerRange.Sum().Should().Be(20, "total across all ranges should be 20"); - // Each range should have some items (with 20 items and 2 ranges, statistically certain) - countPerRange.Should().AllSatisfy(c => c.Should().BeGreaterThan(0)); - } - - [Fact] - public async Task Aggregate_Sum_WithFeedRange_SumsOnlyRangeItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}", Value = i + 1 }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var sumPerRange = new int[2]; - - for (var r = 0; r < ranges.Count; r++) - { - var iter = container.GetItemQueryIterator( - ranges[r], new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - sumPerRange[r] += page.Sum(item => item.Value); - } - } - - // Sum across all ranges should equal 1+2+...+20 = 210 - sumPerRange.Sum().Should().Be(210, "total sum across all ranges should be 210"); - sumPerRange.Should().AllSatisfy(s => s.Should().BeGreaterThan(0)); - } - - [Fact] - public async Task OrderBy_WithFeedRange_OrdersMaintainedWithinRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Name-{i:D3}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var results = new List(); - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c ORDER BY c.name")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - // Results within this range should be ordered by name - var names = results.Select(r => r.Name).ToList(); - names.Should().BeInAscendingOrder("ORDER BY c.name within a FeedRange should produce sorted results"); - } - } - - [Fact] - public async Task Distinct_WithFeedRange_DeduplicatesWithinRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - // Create items with duplicate Name values but different PKs - var names = new[] { "Alpha", "Beta", "Alpha", "Beta", "Gamma" }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument - { - Id = $"{i}", PartitionKey = $"pk-{i}", - Name = names[i % names.Length], Value = i - }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allDistinctNames = new HashSet(); - - foreach (var range in ranges) - { - // Include partitionKey in projection so FilterByFeedRange can extract PK - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT DISTINCT c.name, c.partitionKey FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - allDistinctNames.Add(item["name"]!.ToString()); - } - } - - // Across both ranges, we should find Alpha, Beta, Gamma - allDistinctNames.Should().Contain("Alpha"); - allDistinctNames.Should().Contain("Beta"); - allDistinctNames.Should().Contain("Gamma"); - } - - [Fact] - public async Task Top_WithFeedRange_LimitsResultsPerRange() - { - // TOP is applied per-range (items are pre-filtered by FeedRange before query). - // Each range returns up to TOP N items from its own partition. - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allTopIds = new HashSet(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT TOP 10 * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allTopIds.Add(item.Id); - } - } - - // Each range returns up to 10 items. With 50 items across 2 ranges (~25 each), - // both ranges return 10 items = 20 total. - allTopIds.Should().HaveCount(20, "each of 2 ranges returns TOP 10 items = 20 total"); - } - - [Fact] - public async Task ParameterizedQuery_WithFeedRange_Works() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = i < 10 ? "Target" : "Other" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var matchCount = 0; - - foreach (var range in ranges) - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Target"); - var iter = container.GetItemQueryIterator(range, query); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - matchCount += page.Count; - } - } - - matchCount.Should().Be(10, "parameterized query with FeedRange should find all matching items"); - } + [Fact] + public async Task Aggregate_Count_WithFeedRange_ReturnsCountForRange() + { + // Aggregate queries produce results without PK fields, so FilterByFeedRange + // cannot filter them. Instead, we query SELECT * per range and count in code. + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var countPerRange = new int[2]; + + for (var r = 0; r < ranges.Count; r++) + { + var iter = container.GetItemQueryIterator( + ranges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + countPerRange[r] += page.Count; + } + } + + // Sum of counts across ranges must equal total items + countPerRange.Sum().Should().Be(20, "total across all ranges should be 20"); + // Each range should have some items (with 20 items and 2 ranges, statistically certain) + countPerRange.Should().AllSatisfy(c => c.Should().BeGreaterThan(0)); + } + + [Fact] + public async Task Aggregate_Sum_WithFeedRange_SumsOnlyRangeItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}", Value = i + 1 }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var sumPerRange = new int[2]; + + for (var r = 0; r < ranges.Count; r++) + { + var iter = container.GetItemQueryIterator( + ranges[r], new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + sumPerRange[r] += page.Sum(item => item.Value); + } + } + + // Sum across all ranges should equal 1+2+...+20 = 210 + sumPerRange.Sum().Should().Be(210, "total sum across all ranges should be 210"); + sumPerRange.Should().AllSatisfy(s => s.Should().BeGreaterThan(0)); + } + + [Fact] + public async Task OrderBy_WithFeedRange_OrdersMaintainedWithinRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Name-{i:D3}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var results = new List(); + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c ORDER BY c.name")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + // Results within this range should be ordered by name + var names = results.Select(r => r.Name).ToList(); + names.Should().BeInAscendingOrder("ORDER BY c.name within a FeedRange should produce sorted results"); + } + } + + [Fact] + public async Task Distinct_WithFeedRange_DeduplicatesWithinRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + // Create items with duplicate Name values but different PKs + var names = new[] { "Alpha", "Beta", "Alpha", "Beta", "Gamma" }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument + { + Id = $"{i}", + PartitionKey = $"pk-{i}", + Name = names[i % names.Length], + Value = i + }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allDistinctNames = new HashSet(); + + foreach (var range in ranges) + { + // Include partitionKey in projection so FilterByFeedRange can extract PK + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT DISTINCT c.name, c.partitionKey FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + allDistinctNames.Add(item["name"]!.ToString()); + } + } + + // Across both ranges, we should find Alpha, Beta, Gamma + allDistinctNames.Should().Contain("Alpha"); + allDistinctNames.Should().Contain("Beta"); + allDistinctNames.Should().Contain("Gamma"); + } + + [Fact] + public async Task Top_WithFeedRange_LimitsResultsPerRange() + { + // TOP is applied per-range (items are pre-filtered by FeedRange before query). + // Each range returns up to TOP N items from its own partition. + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allTopIds = new HashSet(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT TOP 10 * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allTopIds.Add(item.Id); + } + } + + // Each range returns up to 10 items. With 50 items across 2 ranges (~25 each), + // both ranges return 10 items = 20 total. + allTopIds.Should().HaveCount(20, "each of 2 ranges returns TOP 10 items = 20 total"); + } + + [Fact] + public async Task ParameterizedQuery_WithFeedRange_Works() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = i < 10 ? "Target" : "Other" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var matchCount = 0; + + foreach (var range in ranges) + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Target"); + var iter = container.GetItemQueryIterator(range, query); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + matchCount += page.Count; + } + } + + matchCount.Should().Be(10, "parameterized query with FeedRange should find all matching items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1926,150 +1930,150 @@ await container.CreateItemAsync( public class FeedRangeErrorHandlingTests { - [Fact] - public async Task NullFeedRange_QueryIterator_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var iter = container.GetItemQueryIterator( - (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10, "null FeedRange should return all items"); - } - - [Fact] - public async Task NullFeedRange_StreamIterator_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var iter = container.GetItemQueryStreamIterator( - (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); - var allIds = new HashSet(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var docs = JObject.Parse(json)["Documents"]!; - foreach (var doc in docs) allIds.Add(doc["id"]!.ToString()); - } - - allIds.Should().HaveCount(10, "null FeedRange in stream iterator should return all items"); - } - - [Fact] - public async Task FeedRange_FromDifferentContainerConfig_StillWorks() - { - var containerA = new InMemoryContainer("a", "/partitionKey") { FeedRangeCount = 4 }; - var containerB = new InMemoryContainer("b", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - await containerA.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - await containerB.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - } - - // Get FeedRange from A, use on B - var rangesA = await containerA.GetFeedRangesAsync(); - var totalFromB = 0; - - foreach (var range in rangesA) - { - var iter = containerB.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - totalFromB += page.Count; - } - } - - totalFromB.Should().Be(20, "FeedRange from container A should work on container B with same config"); - } - - [Fact] - public async Task MalformedFeedRange_VariousFormats_GracefulFallback() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Various malformed ranges — all should gracefully fall back to returning all items - var malformedRanges = new[] - { - "{\"Range\":{\"min\":\"ZZZZ\",\"max\":\"YYYY\"}}", - "{\"Range\":{\"min\":\"\",\"max\":\"\"}}", - }; - - foreach (var rangeJson in malformedRanges) - { - var range = FeedRange.FromJsonString(rangeJson); - var results = new List(); - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10, $"malformed FeedRange '{rangeJson}' should return all items"); - } - } - - [Fact] - public async Task FeedRangeCount_ChangedAfterSeeding_Redistributes() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Change FeedRangeCount after seeding - container.FeedRangeCount = 8; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(8); - - // All items should still be found across the new 8 ranges - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item.Id); - } - } - - allIds.Should().HaveCount(50, "all items should be found after changing FeedRangeCount"); - } + [Fact] + public async Task NullFeedRange_QueryIterator_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var iter = container.GetItemQueryIterator( + (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10, "null FeedRange should return all items"); + } + + [Fact] + public async Task NullFeedRange_StreamIterator_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var iter = container.GetItemQueryStreamIterator( + (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); + var allIds = new HashSet(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var docs = JObject.Parse(json)["Documents"]!; + foreach (var doc in docs) allIds.Add(doc["id"]!.ToString()); + } + + allIds.Should().HaveCount(10, "null FeedRange in stream iterator should return all items"); + } + + [Fact] + public async Task FeedRange_FromDifferentContainerConfig_StillWorks() + { + var containerA = new InMemoryContainer("a", "/partitionKey") { FeedRangeCount = 4 }; + var containerB = new InMemoryContainer("b", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + await containerA.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + await containerB.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + } + + // Get FeedRange from A, use on B + var rangesA = await containerA.GetFeedRangesAsync(); + var totalFromB = 0; + + foreach (var range in rangesA) + { + var iter = containerB.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + totalFromB += page.Count; + } + } + + totalFromB.Should().Be(20, "FeedRange from container A should work on container B with same config"); + } + + [Fact] + public async Task MalformedFeedRange_VariousFormats_GracefulFallback() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Various malformed ranges — all should gracefully fall back to returning all items + var malformedRanges = new[] + { + "{\"Range\":{\"min\":\"ZZZZ\",\"max\":\"YYYY\"}}", + "{\"Range\":{\"min\":\"\",\"max\":\"\"}}", + }; + + foreach (var rangeJson in malformedRanges) + { + var range = FeedRange.FromJsonString(rangeJson); + var results = new List(); + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10, $"malformed FeedRange '{rangeJson}' should return all items"); + } + } + + [Fact] + public async Task FeedRangeCount_ChangedAfterSeeding_Redistributes() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Change FeedRangeCount after seeding + container.FeedRangeCount = 8; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(8); + + // All items should still be found across the new 8 ranges + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item.Id); + } + } + + allIds.Should().HaveCount(50, "all items should be found after changing FeedRangeCount"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2078,65 +2082,65 @@ await container.CreateItemAsync( public class FeedRangeConcurrencyDeepTests { - [Fact] - public async Task ConcurrentFeedRangeReads_WhileWriting_NoCorruption() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - // Pre-seed some items - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - // Start concurrent readers on all 4 ranges while writing 100 new items - var writerTask = Task.Run(async () => - { - for (var i = 100; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - }); - - var readerTasks = ranges.Select(async range => - { - var count = 0; - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - count += page.Count; - } - return count; - }).ToList(); - - await writerTask; - var counts = await Task.WhenAll(readerTasks); - - // No exceptions should have been thrown. Total should be reasonable. - counts.Sum().Should().BeGreaterThan(0, "concurrent readers should return some items"); - } - - [Fact] - public async Task ConcurrentGetFeedRangesAsync_IsThreadSafe() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - var tasks = Enumerable.Range(0, 10) - .Select(_ => container.GetFeedRangesAsync()) - .ToList(); - - var allRanges = await Task.WhenAll(tasks); - - // All should return identical ranges - var reference = allRanges[0].Select(r => r.ToJsonString()).ToList(); - foreach (var ranges in allRanges) - { - var current = ranges.Select(r => r.ToJsonString()).ToList(); - current.Should().Equal(reference, "concurrent GetFeedRangesAsync calls should return identical ranges"); - } - } + [Fact] + public async Task ConcurrentFeedRangeReads_WhileWriting_NoCorruption() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + // Pre-seed some items + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + // Start concurrent readers on all 4 ranges while writing 100 new items + var writerTask = Task.Run(async () => + { + for (var i = 100; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + }); + + var readerTasks = ranges.Select(async range => + { + var count = 0; + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + count += page.Count; + } + return count; + }).ToList(); + + await writerTask; + var counts = await Task.WhenAll(readerTasks); + + // No exceptions should have been thrown. Total should be reasonable. + counts.Sum().Should().BeGreaterThan(0, "concurrent readers should return some items"); + } + + [Fact] + public async Task ConcurrentGetFeedRangesAsync_IsThreadSafe() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + var tasks = Enumerable.Range(0, 10) + .Select(_ => container.GetFeedRangesAsync()) + .ToList(); + + var allRanges = await Task.WhenAll(tasks); + + // All should return identical ranges + var reference = allRanges[0].Select(r => r.ToJsonString()).ToList(); + foreach (var ranges in allRanges) + { + var current = ranges.Select(r => r.ToJsonString()).ToList(); + current.Should().Equal(reference, "concurrent GetFeedRangesAsync calls should return identical ranges"); + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringDeepDiveTests.cs index 585335d..027cd91 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringDeepDiveTests.cs @@ -1,7 +1,7 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -12,160 +12,167 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeQueryClauseInteractionTests { - private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) - { - var container = new InMemoryContainer("fr-qci", "/partitionKey") { FeedRangeCount = feedRangeCount }; - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}", - category = $"cat{i % 4}", value = i * 10, nested = new { prop = $"n{i}" } }), - new PartitionKey($"pk-{i}")); - return container; - } - - [Fact] - public async Task QueryIterator_WithFeedRange_GroupBy_PerRangeGroupsSubsetOfGlobal() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - // Global GROUP BY - var globalIter = container.GetItemQueryIterator(new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); - var globalGroups = new HashSet(); - while (globalIter.HasMoreResults) - { - var page = await globalIter.ReadNextAsync(); - foreach (var item in page) globalGroups.Add(item["category"]!.Value()!); - } - - // Per-range GROUP BY — each range's groups should be a subset of global - var allRangeGroups = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allRangeGroups.Add(item["category"]!.Value()!); - } - } - - allRangeGroups.Should().BeEquivalentTo(globalGroups, "union of per-range groups should cover all global groups"); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_OffsetLimit_AppliesPerRange() - { - // OFFSET/LIMIT is applied per-range (items are pre-filtered by FeedRange before query). - // So each range gets its own OFFSET/LIMIT window, and the union covers all items. - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - // Per-range with OFFSET 0 LIMIT 10 — each range gets up to 10 items - var allRangeIds = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c OFFSET 0 LIMIT 10")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allRangeIds.Add(item["id"]!.Value()!); - } - } - - // Each range returns up to 10 items; with 50 items across 4 ranges, - // each range has ~12-13 items, so LIMIT 10 caps each range's results. - allRangeIds.Count.Should().BeGreaterThan(10, "multiple ranges each return up to 10 items"); - allRangeIds.Count.Should().BeLessThanOrEqualTo(40, "at most 4 ranges × 10 items each"); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_ValueKeyword_ReturnsScalarsFromRange() - { - var container = await CreatePopulatedContainer(20); - var ranges = await container.GetFeedRangesAsync(); - - // Get total count via SELECT * per range - var totalViaSelect = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - totalViaSelect += page.Count; - } - } - totalViaSelect.Should().Be(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_Projection_ReturnsProjectedFieldsFromRange() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.name FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - { - allIds.Add(item["id"]!.Value()!); - item["name"].Should().NotBeNull("projected field should be present"); - } - } - } - allIds.Should().HaveCount(50, "projection across all ranges covers all items"); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_NestedProperty_FiltersCorrectly() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - var allProps = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.nested.prop AS prop FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - { - var prop = item["prop"]?.Value(); - if (prop != null) allProps.Add(prop); - } - } - } - allProps.Should().HaveCount(50, "nested property query across ranges covers all items"); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_SumAggregate_ViaSelectStar_SumsCorrectly() - { - var container = await CreatePopulatedContainer(); - var ranges = await container.GetFeedRangesAsync(); - - // Workaround: SELECT * per range, then SUM in code - var globalSum = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) globalSum += item["value"]!.Value(); - } - } - - var expectedSum = Enumerable.Range(0, 50).Sum(i => i * 10); - globalSum.Should().Be(expectedSum, "SUM via SELECT * across all ranges should equal global SUM"); - } + private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) + { + var container = new InMemoryContainer("fr-qci", "/partitionKey") { FeedRangeCount = feedRangeCount }; + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + JObject.FromObject(new + { + id = $"{i}", + partitionKey = $"pk-{i}", + name = $"Item{i:D3}", + category = $"cat{i % 4}", + value = i * 10, + nested = new { prop = $"n{i}" } + }), + new PartitionKey($"pk-{i}")); + return container; + } + + [Fact] + public async Task QueryIterator_WithFeedRange_GroupBy_PerRangeGroupsSubsetOfGlobal() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + // Global GROUP BY + var globalIter = container.GetItemQueryIterator(new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); + var globalGroups = new HashSet(); + while (globalIter.HasMoreResults) + { + var page = await globalIter.ReadNextAsync(); + foreach (var item in page) globalGroups.Add(item["category"]!.Value()!); + } + + // Per-range GROUP BY — each range's groups should be a subset of global + var allRangeGroups = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allRangeGroups.Add(item["category"]!.Value()!); + } + } + + allRangeGroups.Should().BeEquivalentTo(globalGroups, "union of per-range groups should cover all global groups"); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_OffsetLimit_AppliesPerRange() + { + // OFFSET/LIMIT is applied per-range (items are pre-filtered by FeedRange before query). + // So each range gets its own OFFSET/LIMIT window, and the union covers all items. + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + // Per-range with OFFSET 0 LIMIT 10 — each range gets up to 10 items + var allRangeIds = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c OFFSET 0 LIMIT 10")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allRangeIds.Add(item["id"]!.Value()!); + } + } + + // Each range returns up to 10 items; with 50 items across 4 ranges, + // each range has ~12-13 items, so LIMIT 10 caps each range's results. + allRangeIds.Count.Should().BeGreaterThan(10, "multiple ranges each return up to 10 items"); + allRangeIds.Count.Should().BeLessThanOrEqualTo(40, "at most 4 ranges × 10 items each"); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_ValueKeyword_ReturnsScalarsFromRange() + { + var container = await CreatePopulatedContainer(20); + var ranges = await container.GetFeedRangesAsync(); + + // Get total count via SELECT * per range + var totalViaSelect = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + totalViaSelect += page.Count; + } + } + totalViaSelect.Should().Be(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_Projection_ReturnsProjectedFieldsFromRange() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.name FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + { + allIds.Add(item["id"]!.Value()!); + item["name"].Should().NotBeNull("projected field should be present"); + } + } + } + allIds.Should().HaveCount(50, "projection across all ranges covers all items"); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_NestedProperty_FiltersCorrectly() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + var allProps = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.nested.prop AS prop FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + { + var prop = item["prop"]?.Value(); + if (prop != null) allProps.Add(prop); + } + } + } + allProps.Should().HaveCount(50, "nested property query across ranges covers all items"); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_SumAggregate_ViaSelectStar_SumsCorrectly() + { + var container = await CreatePopulatedContainer(); + var ranges = await container.GetFeedRangesAsync(); + + // Workaround: SELECT * per range, then SUM in code + var globalSum = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) globalSum += item["value"]!.Value(); + } + } + + var expectedSum = Enumerable.Range(0, 50).Sum(i => i * 10); + globalSum.Should().Be(expectedSum, "SUM via SELECT * across all ranges should equal global SUM"); + } } // ═══════════════════════════════════════════════════════════ @@ -174,144 +181,144 @@ public async Task QueryIterator_WithFeedRange_SumAggregate_ViaSelectStar_SumsCor public class FeedRangeChangeFeedAdvancedFilteringTests { - [Fact] - public async Task ChangeFeed_Now_TypedIterator_WithFeedRange_LazyEvaluation_SeesNewItems() - { - var container = new InMemoryContainer("fr-cfnow", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - // Get typed iterators from Now for all ranges - var iterators = ranges.Select(range => - container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental)).ToList(); - - // Drain once to establish "now" position - foreach (var iter in iterators) - { - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - } - } - - // Add new items - for (var i = 10; i < 15; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Re-create iterators from Now — they should see the new items - var newIterators = ranges.Select(range => - container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental)).ToList(); - - // Actually for Now, "now" is captured at creation, so we need Beginning to see all - // Let's use Beginning instead since Now would show items added AFTER creation - var totalNewItems = 0; - foreach (var range in ranges) - { - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - totalNewItems += page.Count; - } - } - totalNewItems.Should().Be(15, "Beginning iterator should see all 15 items across ranges"); - } - - [Fact] - public async Task ChangeFeed_Beginning_WithFeedRange_AfterDeleteAndRecreate_ItemInSameRange() - { - var container = new InMemoryContainer("fr-cf-recrte", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk-x", name = "v1" }), - new PartitionKey("pk-x")); - - // Find which range item is in - var ranges = await container.GetFeedRangesAsync(); - int? originalRange = null; - for (var i = 0; i < ranges.Count; i++) - { - var iter = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.Any()) originalRange = i; - } - } - originalRange.Should().NotBeNull(); - - // Delete and recreate with same id+pk - await container.DeleteItemAsync("1", new PartitionKey("pk-x")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk-x", name = "v2" }), - new PartitionKey("pk-x")); - - // Item should be in same range - int? recreatedRange = null; - for (var i = 0; i < ranges.Count; i++) - { - var iter = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.Any()) recreatedRange = i; - } - } - recreatedRange.Should().Be(originalRange, "recreated item with same PK should be in same range"); - } - - [Fact] - public async Task ChangeFeed_Stream_WithFeedRange_ScopedContents_MatchTypedIterator() - { - var container = new InMemoryContainer("fr-cf-parity", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - // Typed iterator - var typedIds = new HashSet(); - var typedIter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (typedIter.HasMoreResults) - { - var page = await typedIter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - foreach (var item in page) typedIds.Add(item["id"]!.Value()!); - } - - // Stream iterator - var streamIds = new HashSet(); - var streamIter = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (streamIter.HasMoreResults) - { - var response = await streamIter.ReadNextAsync(); - if (response.StatusCode == HttpStatusCode.NotModified) break; - using var sr = new System.IO.StreamReader(response.Content); - var json = await sr.ReadToEndAsync(); - var doc = JObject.Parse(json); - var items = doc["Documents"]?.ToObject>() ?? []; - foreach (var item in items) streamIds.Add(item["id"]!.Value()!); - } - - typedIds.Should().BeEquivalentTo(streamIds, "typed and stream CF should return same items per range"); - } - } + [Fact] + public async Task ChangeFeed_Now_TypedIterator_WithFeedRange_LazyEvaluation_SeesNewItems() + { + var container = new InMemoryContainer("fr-cfnow", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + // Get typed iterators from Now for all ranges + var iterators = ranges.Select(range => + container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental)).ToList(); + + // Drain once to establish "now" position + foreach (var iter in iterators) + { + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + } + } + + // Add new items + for (var i = 10; i < 15; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Re-create iterators from Now — they should see the new items + var newIterators = ranges.Select(range => + container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(range), ChangeFeedMode.Incremental)).ToList(); + + // Actually for Now, "now" is captured at creation, so we need Beginning to see all + // Let's use Beginning instead since Now would show items added AFTER creation + var totalNewItems = 0; + foreach (var range in ranges) + { + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + totalNewItems += page.Count; + } + } + totalNewItems.Should().Be(15, "Beginning iterator should see all 15 items across ranges"); + } + + [Fact] + public async Task ChangeFeed_Beginning_WithFeedRange_AfterDeleteAndRecreate_ItemInSameRange() + { + var container = new InMemoryContainer("fr-cf-recrte", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk-x", name = "v1" }), + new PartitionKey("pk-x")); + + // Find which range item is in + var ranges = await container.GetFeedRangesAsync(); + int? originalRange = null; + for (var i = 0; i < ranges.Count; i++) + { + var iter = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.Any()) originalRange = i; + } + } + originalRange.Should().NotBeNull(); + + // Delete and recreate with same id+pk + await container.DeleteItemAsync("1", new PartitionKey("pk-x")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk-x", name = "v2" }), + new PartitionKey("pk-x")); + + // Item should be in same range + int? recreatedRange = null; + for (var i = 0; i < ranges.Count; i++) + { + var iter = container.GetItemQueryIterator(ranges[i], new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.Any()) recreatedRange = i; + } + } + recreatedRange.Should().Be(originalRange, "recreated item with same PK should be in same range"); + } + + [Fact] + public async Task ChangeFeed_Stream_WithFeedRange_ScopedContents_MatchTypedIterator() + { + var container = new InMemoryContainer("fr-cf-parity", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + // Typed iterator + var typedIds = new HashSet(); + var typedIter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (typedIter.HasMoreResults) + { + var page = await typedIter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + foreach (var item in page) typedIds.Add(item["id"]!.Value()!); + } + + // Stream iterator + var streamIds = new HashSet(); + var streamIter = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (streamIter.HasMoreResults) + { + var response = await streamIter.ReadNextAsync(); + if (response.StatusCode == HttpStatusCode.NotModified) break; + using var sr = new System.IO.StreamReader(response.Content); + var json = await sr.ReadToEndAsync(); + var doc = JObject.Parse(json); + var items = doc["Documents"]?.ToObject>() ?? []; + foreach (var item in items) streamIds.Add(item["id"]!.Value()!); + } + + typedIds.Should().BeEquivalentTo(streamIds, "typed and stream CF should return same items per range"); + } + } } // ═══════════════════════════════════════════════════════════ @@ -320,113 +327,113 @@ await container.CreateItemAsync( public class FeedRangePartitionKeyEdgeCaseFilteringTests { - [Fact] - public async Task EmptyStringPartitionKey_FeedRange_ConsistentHashing() - { - var container = new InMemoryContainer("fr-emptystr", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"empty{i}", partitionKey = "" }), - new PartitionKey("")); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"real{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var emptyPKCount = 0; - var emptyPKRange = -1; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = ''")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.Any()) - { - emptyPKCount += page.Count; - emptyPKRange = ranges.ToList().IndexOf(range); - } - } - } - emptyPKCount.Should().Be(5, "all empty PK items should be in one range"); - emptyPKRange.Should().BeGreaterThanOrEqualTo(0, "empty PK items should land in exactly one range"); - } - - [Fact] - public async Task LongPartitionKey_FeedRange_HashesCorrectly() - { - var longPk = new string('x', 1500); // under 2KB limit - var container = new InMemoryContainer("fr-longpk", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "long1", partitionKey = longPk }), - new PartitionKey(longPk)); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItem = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.Any()) rangesWithItem++; - } - } - rangesWithItem.Should().Be(1, "long PK item should land in exactly one range"); - } - - [Fact] - public async Task UnicodePartitionKey_FeedRange_HashesCorrectly() - { - var container = new InMemoryContainer("fr-unicode", "/partitionKey") { FeedRangeCount = 4 }; - var unicodePKs = new[] { "日本語", "中文", "한국어", "🎉🚀", "café", "naïve" }; - for (var i = 0; i < unicodePKs.Length; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"u{i}", partitionKey = unicodePKs[i] }), - new PartitionKey(unicodePKs[i])); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) allIds.Add(item["id"]!.Value()!); - } - } - allIds.Should().HaveCount(unicodePKs.Length, "all unicode PK items found across ranges"); - } - - [Fact] - public async Task MultipleItems_SamePartitionKey_AlwaysInSameRange() - { - var container = new InMemoryContainer("fr-samepk", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"same{i}", partitionKey = "shared-pk" }), - new PartitionKey("shared-pk")); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItems = 0; - var totalItems = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - count += page.Count; - } - if (count > 0) rangesWithItems++; - totalItems += count; - } - rangesWithItems.Should().Be(1, "all items with same PK should be in same range"); - totalItems.Should().Be(20); - } + [Fact] + public async Task EmptyStringPartitionKey_FeedRange_ConsistentHashing() + { + var container = new InMemoryContainer("fr-emptystr", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"empty{i}", partitionKey = "" }), + new PartitionKey("")); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"real{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var emptyPKCount = 0; + var emptyPKRange = -1; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = ''")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.Any()) + { + emptyPKCount += page.Count; + emptyPKRange = ranges.ToList().IndexOf(range); + } + } + } + emptyPKCount.Should().Be(5, "all empty PK items should be in one range"); + emptyPKRange.Should().BeGreaterThanOrEqualTo(0, "empty PK items should land in exactly one range"); + } + + [Fact] + public async Task LongPartitionKey_FeedRange_HashesCorrectly() + { + var longPk = new string('x', 1500); // under 2KB limit + var container = new InMemoryContainer("fr-longpk", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "long1", partitionKey = longPk }), + new PartitionKey(longPk)); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItem = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.Any()) rangesWithItem++; + } + } + rangesWithItem.Should().Be(1, "long PK item should land in exactly one range"); + } + + [Fact] + public async Task UnicodePartitionKey_FeedRange_HashesCorrectly() + { + var container = new InMemoryContainer("fr-unicode", "/partitionKey") { FeedRangeCount = 4 }; + var unicodePKs = new[] { "日本語", "中文", "한국어", "🎉🚀", "café", "naïve" }; + for (var i = 0; i < unicodePKs.Length; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"u{i}", partitionKey = unicodePKs[i] }), + new PartitionKey(unicodePKs[i])); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) allIds.Add(item["id"]!.Value()!); + } + } + allIds.Should().HaveCount(unicodePKs.Length, "all unicode PK items found across ranges"); + } + + [Fact] + public async Task MultipleItems_SamePartitionKey_AlwaysInSameRange() + { + var container = new InMemoryContainer("fr-samepk", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"same{i}", partitionKey = "shared-pk" }), + new PartitionKey("shared-pk")); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItems = 0; + var totalItems = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + count += page.Count; + } + if (count > 0) rangesWithItems++; + totalItems += count; + } + rangesWithItems.Should().Be(1, "all items with same PK should be in same range"); + totalItems.Should().Be(20); + } } // ═══════════════════════════════════════════════════════════ @@ -435,128 +442,128 @@ await container.CreateItemAsync( public class FeedRangeBoundaryPrecisionTests { - [Fact] - public async Task Items_InFirstRange_WithEmptyMin_FilteredCorrectly() - { - var container = new InMemoryContainer("fr-first", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var firstRange = ranges[0]; - - var r0 = JObject.Parse(firstRange.ToJsonString()); - r0["Range"]!["min"]!.ToString().Should().Be(""); - - var iter = container.GetItemQueryIterator(firstRange, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - count += page.Count; - } - count.Should().BeGreaterThan(0, "first range should have items"); - } - - [Fact] - public async Task Items_InLastRange_WithFFMax_FilteredCorrectly() - { - var container = new InMemoryContainer("fr-last", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var lastRange = ranges[^1]; - - var rLast = JObject.Parse(lastRange.ToJsonString()); - rLast["Range"]!["max"]!.ToString().Should().Be("FF"); - - var iter = container.GetItemQueryIterator(lastRange, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - count += page.Count; - } - count.Should().BeGreaterThan(0, "last range should have items"); - } - - [Fact] - public async Task FeedRangeCount_ChangedMidway_ExistingRangesStillWork() - { - var container = new InMemoryContainer("fr-midchg", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Get ranges with count=4 - var ranges4 = await container.GetFeedRangesAsync(); - - // Change to 8 - container.FeedRangeCount = 8; - var ranges8 = await container.GetFeedRangesAsync(); - ranges8.Should().HaveCount(8); - - // Old ranges (from count=4) should still work for queries - var totalOld = 0; - foreach (var range in ranges4) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - totalOld += page.Count; - } - } - totalOld.Should().Be(50, "old range boundaries still valid for queries"); - - // New ranges also cover all items - var totalNew = 0; - foreach (var range in ranges8) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - totalNew += page.Count; - } - } - totalNew.Should().Be(50); - } - - [Fact] - public async Task MaxItemCount_One_WithFeedRange_SingleItemPages() - { - var container = new InMemoryContainer("fr-max1", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var pageCount = 0; - var totalItems = 0; - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.Count > 0) pageCount++; - page.Count.Should().BeLessThanOrEqualTo(1, "each page should have at most 1 item"); - totalItems += page.Count; - } - if (totalItems > 1) - pageCount.Should().Be(totalItems, "each item should be on its own page when MaxItemCount=1"); - } - } + [Fact] + public async Task Items_InFirstRange_WithEmptyMin_FilteredCorrectly() + { + var container = new InMemoryContainer("fr-first", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var firstRange = ranges[0]; + + var r0 = JObject.Parse(firstRange.ToJsonString()); + r0["Range"]!["min"]!.ToString().Should().Be(""); + + var iter = container.GetItemQueryIterator(firstRange, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + count += page.Count; + } + count.Should().BeGreaterThan(0, "first range should have items"); + } + + [Fact] + public async Task Items_InLastRange_WithFFMax_FilteredCorrectly() + { + var container = new InMemoryContainer("fr-last", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var lastRange = ranges[^1]; + + var rLast = JObject.Parse(lastRange.ToJsonString()); + rLast["Range"]!["max"]!.ToString().Should().Be("FF"); + + var iter = container.GetItemQueryIterator(lastRange, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + count += page.Count; + } + count.Should().BeGreaterThan(0, "last range should have items"); + } + + [Fact] + public async Task FeedRangeCount_ChangedMidway_ExistingRangesStillWork() + { + var container = new InMemoryContainer("fr-midchg", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Get ranges with count=4 + var ranges4 = await container.GetFeedRangesAsync(); + + // Change to 8 + container.FeedRangeCount = 8; + var ranges8 = await container.GetFeedRangesAsync(); + ranges8.Should().HaveCount(8); + + // Old ranges (from count=4) should still work for queries + var totalOld = 0; + foreach (var range in ranges4) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + totalOld += page.Count; + } + } + totalOld.Should().Be(50, "old range boundaries still valid for queries"); + + // New ranges also cover all items + var totalNew = 0; + foreach (var range in ranges8) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + totalNew += page.Count; + } + } + totalNew.Should().Be(50); + } + + [Fact] + public async Task MaxItemCount_One_WithFeedRange_SingleItemPages() + { + var container = new InMemoryContainer("fr-max1", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var pageCount = 0; + var totalItems = 0; + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.Count > 0) pageCount++; + page.Count.Should().BeLessThanOrEqualTo(1, "each page should have at most 1 item"); + totalItems += page.Count; + } + if (totalItems > 1) + pageCount.Should().Be(totalItems, "each item should be on its own page when MaxItemCount=1"); + } + } } // ═══════════════════════════════════════════════════════════ @@ -565,45 +572,45 @@ await container.CreateItemAsync( public class FeedRangeQueryRequestOptionsFilteringTests { - [Fact] - public async Task QueryIterator_FeedRange_WithContinuationToken_ResumesCorrectly() - { - var container = new InMemoryContainer("fr-resume", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var range = ranges[0]; - - // Get first page with MaxItemCount=5 - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = 5 }); - var firstPageIds = new List(); - string? continuationToken = null; - if (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) firstPageIds.Add(item["id"]!.Value()!); - continuationToken = page.ContinuationToken; - } - - if (continuationToken == null) return; // range has ≤5 items - - // Resume with continuation token - var resumeIter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), - continuationToken, new QueryRequestOptions { MaxItemCount = 5 }); - var resumeIds = new List(); - while (resumeIter.HasMoreResults) - { - var page = await resumeIter.ReadNextAsync(); - foreach (var item in page) resumeIds.Add(item["id"]!.Value()!); - } - - // First page and resumed pages should not overlap - firstPageIds.Intersect(resumeIds).Should().BeEmpty("resumed query should not repeat items"); - } + [Fact] + public async Task QueryIterator_FeedRange_WithContinuationToken_ResumesCorrectly() + { + var container = new InMemoryContainer("fr-resume", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var range = ranges[0]; + + // Get first page with MaxItemCount=5 + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = 5 }); + var firstPageIds = new List(); + string? continuationToken = null; + if (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) firstPageIds.Add(item["id"]!.Value()!); + continuationToken = page.ContinuationToken; + } + + if (continuationToken == null) return; // range has ≤5 items + + // Resume with continuation token + var resumeIter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c"), + continuationToken, new QueryRequestOptions { MaxItemCount = 5 }); + var resumeIds = new List(); + while (resumeIter.HasMoreResults) + { + var page = await resumeIter.ReadNextAsync(); + foreach (var item in page) resumeIds.Add(item["id"]!.Value()!); + } + + // First page and resumed pages should not overlap + firstPageIds.Intersect(resumeIds).Should().BeEmpty("resumed query should not repeat items"); + } } // ═══════════════════════════════════════════════════════════ @@ -612,58 +619,58 @@ await container.CreateItemAsync( public class FeedRangeAggregateQueryFilteringTests { - [Fact] - public async Task AggregateCount_WithFeedRange_PerRangeSumsToTotal() - { - var container = new InMemoryContainer("fr-aggcount", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var perRangeSum = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) perRangeSum += item.Value(); - } - } - perRangeSum.Should().Be(50, "per-range COUNT should sum to total"); - } - - [Fact] - public async Task AggregateSumAvg_WithFeedRange_ViaSelectStar_SumsCorrectly() - { - var container = new InMemoryContainer("fr-aggsum", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 40; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", value = i * 5 }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var totalSum = 0; - var totalCount = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) - { - totalSum += item["value"]!.Value(); - totalCount++; - } - } - } - - var expectedSum = Enumerable.Range(0, 40).Sum(i => i * 5); - totalSum.Should().Be(expectedSum); - totalCount.Should().Be(40); - ((double)totalSum / totalCount).Should().BeApproximately((double)expectedSum / 40, 0.001); - } + [Fact] + public async Task AggregateCount_WithFeedRange_PerRangeSumsToTotal() + { + var container = new InMemoryContainer("fr-aggcount", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var perRangeSum = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT VALUE COUNT(1) FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) perRangeSum += item.Value(); + } + } + perRangeSum.Should().Be(50, "per-range COUNT should sum to total"); + } + + [Fact] + public async Task AggregateSumAvg_WithFeedRange_ViaSelectStar_SumsCorrectly() + { + var container = new InMemoryContainer("fr-aggsum", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 40; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", value = i * 5 }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var totalSum = 0; + var totalCount = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) + { + totalSum += item["value"]!.Value(); + totalCount++; + } + } + } + + var expectedSum = Enumerable.Range(0, 40).Sum(i => i * 5); + totalSum.Should().Be(expectedSum); + totalCount.Should().Be(40); + ((double)totalSum / totalCount).Should().BeApproximately((double)expectedSum / 40, 0.001); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringTests.cs index 65e351d..b9864a0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeFilteringTests.cs @@ -12,154 +12,154 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeCountTests { - [Fact] - public async Task FeedRangeCount_DefaultsToOne() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var ranges = await container.GetFeedRangesAsync(); - - ranges.Should().HaveCount(1); - } - - [Fact] - public async Task FeedRangeCount_ReturnsConfiguredCount() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - var ranges = await container.GetFeedRangesAsync(); - - ranges.Should().HaveCount(4); - } - - [Fact] - public async Task FeedRanges_AreRealFeedRangeEpk_NotMocks() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var json = range.ToJsonString(); - var obj = JObject.Parse(json); - obj["Range"].Should().NotBeNull(); - obj["Range"]!["min"].Should().NotBeNull(); - obj["Range"]!["max"].Should().NotBeNull(); - } - } - - [Fact] - public async Task FeedRanges_CoverFullRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 3 }; - - var ranges = await container.GetFeedRangesAsync(); - - // First range starts at "" - var firstJson = JObject.Parse(ranges[0].ToJsonString()); - firstJson["Range"]!["min"]!.ToString().Should().Be(""); - - // Last range ends at "FF" - var lastJson = JObject.Parse(ranges[^1].ToJsonString()); - lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); - } - - [Fact] - public async Task FeedRanges_AreContiguous() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - var ranges = await container.GetFeedRangesAsync(); - - for (var i = 0; i < ranges.Count - 1; i++) - { - var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); - var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); - currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); - } - } - - [Fact] - public async Task FeedRangeCount_SetToOne_ReturnsSingleFullRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; - - var ranges = await container.GetFeedRangesAsync(); - - ranges.Should().HaveCount(1); - var json = JObject.Parse(ranges[0].ToJsonString()); - json["Range"]!["min"]!.ToString().Should().Be(""); - json["Range"]!["max"]!.ToString().Should().Be("FF"); - } - - [Fact] - public async Task FeedRangeCount_VeryLarge_NoOverflowAndAllItemsFound() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 65536 }; - - for (var i = 0; i < 200; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(65536); - - var totalCount = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - totalCount += page.Count; - } - } - - totalCount.Should().Be(200); - } - - [Fact] - public async Task FeedRanges_AreNonOverlapping_NoGaps() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 5 }; - - var ranges = await container.GetFeedRangesAsync(); - var boundaries = ranges.Select(r => - { - var obj = JObject.Parse(r.ToJsonString()); - return (Min: obj["Range"]!["min"]!.ToString(), Max: obj["Range"]!["max"]!.ToString()); - }).ToList(); - - // No gaps: max of range[i] == min of range[i+1] - for (var i = 0; i < boundaries.Count - 1; i++) - boundaries[i].Max.Should().Be(boundaries[i + 1].Min, - $"range {i} max should equal range {i + 1} min (no gaps)"); - - // Full coverage - boundaries[0].Min.Should().Be(""); - boundaries[^1].Max.Should().Be("FF"); - } - - [Fact] - public async Task FeedRanges_AreOrderedByMinAscending() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 8 }; - - var ranges = await container.GetFeedRangesAsync(); - var minValues = ranges.Select(r => - { - var obj = JObject.Parse(r.ToJsonString()); - var minStr = obj["Range"]!["min"]!.ToString(); - return minStr == "" ? 0UL : Convert.ToUInt64(minStr, 16); - }).ToList(); - - for (var i = 0; i < minValues.Count - 1; i++) - minValues[i].Should().BeLessThan(minValues[i + 1], - $"range {i} min should be less than range {i + 1} min"); - } + [Fact] + public async Task FeedRangeCount_DefaultsToOne() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var ranges = await container.GetFeedRangesAsync(); + + ranges.Should().HaveCount(1); + } + + [Fact] + public async Task FeedRangeCount_ReturnsConfiguredCount() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + var ranges = await container.GetFeedRangesAsync(); + + ranges.Should().HaveCount(4); + } + + [Fact] + public async Task FeedRanges_AreRealFeedRangeEpk_NotMocks() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var json = range.ToJsonString(); + var obj = JObject.Parse(json); + obj["Range"].Should().NotBeNull(); + obj["Range"]!["min"].Should().NotBeNull(); + obj["Range"]!["max"].Should().NotBeNull(); + } + } + + [Fact] + public async Task FeedRanges_CoverFullRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 3 }; + + var ranges = await container.GetFeedRangesAsync(); + + // First range starts at "" + var firstJson = JObject.Parse(ranges[0].ToJsonString()); + firstJson["Range"]!["min"]!.ToString().Should().Be(""); + + // Last range ends at "FF" + var lastJson = JObject.Parse(ranges[^1].ToJsonString()); + lastJson["Range"]!["max"]!.ToString().Should().Be("FF"); + } + + [Fact] + public async Task FeedRanges_AreContiguous() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + var ranges = await container.GetFeedRangesAsync(); + + for (var i = 0; i < ranges.Count - 1; i++) + { + var currentMax = JObject.Parse(ranges[i].ToJsonString())["Range"]!["max"]!.ToString(); + var nextMin = JObject.Parse(ranges[i + 1].ToJsonString())["Range"]!["min"]!.ToString(); + currentMax.Should().Be(nextMin, $"range {i} max should equal range {i + 1} min"); + } + } + + [Fact] + public async Task FeedRangeCount_SetToOne_ReturnsSingleFullRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 }; + + var ranges = await container.GetFeedRangesAsync(); + + ranges.Should().HaveCount(1); + var json = JObject.Parse(ranges[0].ToJsonString()); + json["Range"]!["min"]!.ToString().Should().Be(""); + json["Range"]!["max"]!.ToString().Should().Be("FF"); + } + + [Fact] + public async Task FeedRangeCount_VeryLarge_NoOverflowAndAllItemsFound() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 65536 }; + + for (var i = 0; i < 200; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(65536); + + var totalCount = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + totalCount += page.Count; + } + } + + totalCount.Should().Be(200); + } + + [Fact] + public async Task FeedRanges_AreNonOverlapping_NoGaps() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 5 }; + + var ranges = await container.GetFeedRangesAsync(); + var boundaries = ranges.Select(r => + { + var obj = JObject.Parse(r.ToJsonString()); + return (Min: obj["Range"]!["min"]!.ToString(), Max: obj["Range"]!["max"]!.ToString()); + }).ToList(); + + // No gaps: max of range[i] == min of range[i+1] + for (var i = 0; i < boundaries.Count - 1; i++) + boundaries[i].Max.Should().Be(boundaries[i + 1].Min, + $"range {i} max should equal range {i + 1} min (no gaps)"); + + // Full coverage + boundaries[0].Min.Should().Be(""); + boundaries[^1].Max.Should().Be("FF"); + } + + [Fact] + public async Task FeedRanges_AreOrderedByMinAscending() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 8 }; + + var ranges = await container.GetFeedRangesAsync(); + var minValues = ranges.Select(r => + { + var obj = JObject.Parse(r.ToJsonString()); + var minStr = obj["Range"]!["min"]!.ToString(); + return minStr == "" ? 0UL : Convert.ToUInt64(minStr, 16); + }).ToList(); + + for (var i = 0; i < minValues.Count - 1; i++) + minValues[i].Should().BeLessThan(minValues[i + 1], + $"range {i} min should be less than range {i + 1} min"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -168,432 +168,432 @@ public async Task FeedRanges_AreOrderedByMinAscending() public class FeedRangeQueryFilteringTests { - [Fact] - public async Task QueryIterator_WithFeedRange_FiltersToMatchingPartitions() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - // Seed items across different partition keys - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all ranges should give us all 20 items (no duplicates, no missing) - allResults.Should().HaveCount(20); - allResults.Select(r => r.Id).Distinct().Should().HaveCount(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_ReturnsSubsetNotAll() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - // At least one range should return fewer than all items - var anyScopedCorrectly = false; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count < 100) - anyScopedCorrectly = true; - } - - anyScopedCorrectly.Should().BeTrue("at least one FeedRange should return a subset of items"); - } - - [Fact] - public async Task QueryStreamIterator_WithFeedRange_FiltersToMatchingPartitions() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryStreamIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - foreach (var item in doc["Documents"]!) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20); - } - - [Fact] - public async Task QueryIterator_WithSingleFeedRange_ReturnsAllItems() - { - // FeedRangeCount=1 (default) should return all items through the single range - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - - var iterator = container.GetItemQueryIterator( - ranges[0], new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_WhereClause_FiltersCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c WHERE c.name != 'NonExistent'")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // WHERE clause doesn't filter anything, union of ranges = all items - allResults.Should().HaveCount(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_Pagination_ContinuationToken_Works() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = 5 }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - page.Count.Should().BeLessThanOrEqualTo(5); - allResults.AddRange(page); - } - } - - allResults.Should().HaveCount(30); - allResults.Select(r => r.Id).Distinct().Should().HaveCount(30); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_EmptyContainer_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - } - - [Fact] - public async Task QueryIterator_WithFeedRange_AllItemsSamePartitionKey_MostRangesEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "samePK", Name = $"Item{i}" }, - new PartitionKey("samePK")); - - var ranges = await container.GetFeedRangesAsync(); - var nonEmptyCount = 0; - var totalItems = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count > 0) nonEmptyCount++; - totalItems += results.Count; - } - - nonEmptyCount.Should().Be(1, "all items share the same PK and should land in one range"); - totalItems.Should().Be(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_AggregateCOUNT() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var totalCount = 0; - - foreach (var range in ranges) - { - // Use SELECT * and count in code because aggregate queries return scalars - // without PK fields, which FilterByFeedRange cannot filter post-query - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - totalCount += page.Count; - } - } - - totalCount.Should().Be(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_OrderByWithinRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i:D3}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count > 1) - { - var names = results.Select(r => r.Name).ToList(); - names.Should().BeInAscendingOrder(); - } - } - } - - [Fact] - public async Task QueryIterator_WithFeedRange_Top() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT TOP 3 * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Count.Should().BeLessThanOrEqualTo(3); - } - } - - [Fact] - public async Task QueryIterator_WithFeedRange_Distinct() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = i % 2 == 0 ? "CategoryA" : "CategoryB" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - // Include partitionKey in projection so FilterByFeedRange can extract PK - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT DISTINCT c.name, c.partitionKey FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Each range should have at most 2 distinct names (CategoryA, CategoryB) - var distinctNames = results.Select(r => r["name"]!.ToString()).Distinct().ToList(); - distinctNames.Count.Should().BeLessThanOrEqualTo(2); - } - } - - [Fact] - public async Task QueryIterator_NullFeedRange_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var iterator = container.GetItemQueryIterator( - (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_ParameterizedQuery() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Item5"); - var iterator = container.GetItemQueryIterator(range, query); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // "Item5" exists once, should be in exactly one range - allResults.Should().HaveCount(1); - allResults[0].Name.Should().Be("Item5"); - } - - [Fact] - public async Task QueryStreamIterator_NullFeedRange_ReturnsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var iterator = container.GetItemQueryStreamIterator( - (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); - var allIds = new HashSet(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - foreach (var item in doc["Documents"]!) - allIds.Add(item["id"]!.ToString()); - } - - allIds.Should().HaveCount(20); - } + [Fact] + public async Task QueryIterator_WithFeedRange_FiltersToMatchingPartitions() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + // Seed items across different partition keys + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all ranges should give us all 20 items (no duplicates, no missing) + allResults.Should().HaveCount(20); + allResults.Select(r => r.Id).Distinct().Should().HaveCount(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_ReturnsSubsetNotAll() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + // At least one range should return fewer than all items + var anyScopedCorrectly = false; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count < 100) + anyScopedCorrectly = true; + } + + anyScopedCorrectly.Should().BeTrue("at least one FeedRange should return a subset of items"); + } + + [Fact] + public async Task QueryStreamIterator_WithFeedRange_FiltersToMatchingPartitions() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryStreamIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + foreach (var item in doc["Documents"]!) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20); + } + + [Fact] + public async Task QueryIterator_WithSingleFeedRange_ReturnsAllItems() + { + // FeedRangeCount=1 (default) should return all items through the single range + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + + var iterator = container.GetItemQueryIterator( + ranges[0], new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_WhereClause_FiltersCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c WHERE c.name != 'NonExistent'")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // WHERE clause doesn't filter anything, union of ranges = all items + allResults.Should().HaveCount(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_Pagination_ContinuationToken_Works() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = 5 }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + page.Count.Should().BeLessThanOrEqualTo(5); + allResults.AddRange(page); + } + } + + allResults.Should().HaveCount(30); + allResults.Select(r => r.Id).Distinct().Should().HaveCount(30); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_EmptyContainer_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + } + + [Fact] + public async Task QueryIterator_WithFeedRange_AllItemsSamePartitionKey_MostRangesEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "samePK", Name = $"Item{i}" }, + new PartitionKey("samePK")); + + var ranges = await container.GetFeedRangesAsync(); + var nonEmptyCount = 0; + var totalItems = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count > 0) nonEmptyCount++; + totalItems += results.Count; + } + + nonEmptyCount.Should().Be(1, "all items share the same PK and should land in one range"); + totalItems.Should().Be(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_AggregateCOUNT() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var totalCount = 0; + + foreach (var range in ranges) + { + // Use SELECT * and count in code because aggregate queries return scalars + // without PK fields, which FilterByFeedRange cannot filter post-query + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + totalCount += page.Count; + } + } + + totalCount.Should().Be(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_OrderByWithinRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i:D3}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count > 1) + { + var names = results.Select(r => r.Name).ToList(); + names.Should().BeInAscendingOrder(); + } + } + } + + [Fact] + public async Task QueryIterator_WithFeedRange_Top() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT TOP 3 * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Count.Should().BeLessThanOrEqualTo(3); + } + } + + [Fact] + public async Task QueryIterator_WithFeedRange_Distinct() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = i % 2 == 0 ? "CategoryA" : "CategoryB" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + // Include partitionKey in projection so FilterByFeedRange can extract PK + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT DISTINCT c.name, c.partitionKey FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Each range should have at most 2 distinct names (CategoryA, CategoryB) + var distinctNames = results.Select(r => r["name"]!.ToString()).Distinct().ToList(); + distinctNames.Count.Should().BeLessThanOrEqualTo(2); + } + } + + [Fact] + public async Task QueryIterator_NullFeedRange_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var iterator = container.GetItemQueryIterator( + (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_ParameterizedQuery() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Item5"); + var iterator = container.GetItemQueryIterator(range, query); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // "Item5" exists once, should be in exactly one range + allResults.Should().HaveCount(1); + allResults[0].Name.Should().Be("Item5"); + } + + [Fact] + public async Task QueryStreamIterator_NullFeedRange_ReturnsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var iterator = container.GetItemQueryStreamIterator( + (FeedRange)null!, new QueryDefinition("SELECT * FROM c")); + var allIds = new HashSet(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + foreach (var item in doc["Documents"]!) + allIds.Add(item["id"]!.ToString()); + } + + allIds.Should().HaveCount(20); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -602,344 +602,344 @@ await container.CreateItemAsync( public class FeedRangeChangeFeedFilteringTests { - [Fact] - public async Task ChangeFeed_Beginning_WithFeedRange_ScopesToRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all ranges should give us all 20 items - allResults.Should().HaveCount(20); - allResults.Select(r => r.Id).Distinct().Should().HaveCount(20); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_ReturnsSubsetNotAll() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var anyScopedCorrectly = false; - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count < 100) - anyScopedCorrectly = true; - } - - anyScopedCorrectly.Should().BeTrue("at least one FeedRange should scope to a subset"); - } - - [Fact] - public async Task ChangeFeedStream_WithFeedRange_ScopesToRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - if (doc["Documents"] is JArray docs) - { - foreach (var item in docs) - allIds.Add(item["id"]!.ToString()); - } - } - } - - allIds.Should().HaveCount(20); - } - - [Fact] - public async Task ChangeFeed_Now_WithFeedRange_AcceptsRangeParameter() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - var ranges = await container.GetFeedRangesAsync(); - - // Verify ChangeFeedStartFrom.Now(feedRange) is accepted without error - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(range), - ChangeFeedMode.Incremental); - - // "Now" means changes from this point forward — no items yet, so no results - iterator.HasMoreResults.Should().BeFalse(); - } - } - - [Fact] - public async Task ChangeFeed_WithSingleFeedRange_ReturnsAllItems() - { - // FeedRangeCount=1 with Beginning(range[0]) should return everything - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(ranges[0]), - ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10); - } - - [Fact] - public async Task ChangeFeed_Replace_WithFeedRange_UpdateInCorrectRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - // Upsert item "5" with new data (same PK) - await container.UpsertItemAsync( - new TestDocument { Id = "5", PartitionKey = "pk-5", Name = "Updated5" }, - new PartitionKey("pk-5")); - - var ranges = await container.GetFeedRangesAsync(); - - // Find which range contains item "5" - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - var item5 = results.FirstOrDefault(r => r.Id == "5"); - if (item5 != null) - { - item5.Name.Should().Be("Updated5", "the latest version should appear in the change feed"); - } - } - } - - [Fact] - public async Task ChangeFeed_IncrementalMode_MultipleUpdates_WithFeedRange_ShowsLatest() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-1", Name = "Version1" }, - new PartitionKey("pk-1")); - - for (var v = 2; v <= 6; v++) - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-1", Name = $"Version{v}" }, - new PartitionKey("pk-1")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Incremental mode shows only the latest version - allResults.Should().HaveCount(1); - allResults[0].Name.Should().Be("Version6"); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_Pagination_PageSizeHint() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 5 }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - page.Count.Should().BeLessThanOrEqualTo(5); - allResults.AddRange(page); - } - } - - allResults.Should().HaveCount(30); - } - - [Fact] - public async Task ChangeFeed_Beginning_WithFeedRange_EmptyContainer_NoResults() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - iterator.HasMoreResults.Should().BeFalse(); - } - } - - [Fact] - public async Task ChangeFeed_Time_WithFeedRange_FiltersBothDimensions() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - // Create "early" items - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"Early{i}" }, - new PartitionKey($"pk-{i}")); - - var midpoint = DateTime.UtcNow; - await Task.Delay(50); - - // Create "late" items with same PKs (upserts to same partitions) - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"Late{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allLateResults = new List(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Time(midpoint, range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allLateResults.AddRange(page); - } - } - - // Only "late" items should be returned - allLateResults.Should().HaveCount(20); - allLateResults.All(r => r.Name.StartsWith("Late")).Should().BeTrue(); - } - - [Fact] - public async Task ChangeFeed_QueryAndChangeFeed_SameRangeMapping() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - // Query path - var queryIter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var queryIds = new HashSet(); - while (queryIter.HasMoreResults) - { - var page = await queryIter.ReadNextAsync(); - foreach (var item in page) queryIds.Add(item.Id); - } - - // Change feed path - var cfIter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - var cfIds = new HashSet(); - while (cfIter.HasMoreResults) - { - var page = await cfIter.ReadNextAsync(); - foreach (var item in page) cfIds.Add(item.Id); - } - - queryIds.Should().BeEquivalentTo(cfIds, - "query path and change feed path should produce identical item sets per range"); - } - } + [Fact] + public async Task ChangeFeed_Beginning_WithFeedRange_ScopesToRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all ranges should give us all 20 items + allResults.Should().HaveCount(20); + allResults.Select(r => r.Id).Distinct().Should().HaveCount(20); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_ReturnsSubsetNotAll() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var anyScopedCorrectly = false; + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count < 100) + anyScopedCorrectly = true; + } + + anyScopedCorrectly.Should().BeTrue("at least one FeedRange should scope to a subset"); + } + + [Fact] + public async Task ChangeFeedStream_WithFeedRange_ScopesToRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + if (doc["Documents"] is JArray docs) + { + foreach (var item in docs) + allIds.Add(item["id"]!.ToString()); + } + } + } + + allIds.Should().HaveCount(20); + } + + [Fact] + public async Task ChangeFeed_Now_WithFeedRange_AcceptsRangeParameter() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + var ranges = await container.GetFeedRangesAsync(); + + // Verify ChangeFeedStartFrom.Now(feedRange) is accepted without error + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(range), + ChangeFeedMode.Incremental); + + // "Now" means changes from this point forward — no items yet, so no results + iterator.HasMoreResults.Should().BeFalse(); + } + } + + [Fact] + public async Task ChangeFeed_WithSingleFeedRange_ReturnsAllItems() + { + // FeedRangeCount=1 with Beginning(range[0]) should return everything + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(ranges[0]), + ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10); + } + + [Fact] + public async Task ChangeFeed_Replace_WithFeedRange_UpdateInCorrectRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + // Upsert item "5" with new data (same PK) + await container.UpsertItemAsync( + new TestDocument { Id = "5", PartitionKey = "pk-5", Name = "Updated5" }, + new PartitionKey("pk-5")); + + var ranges = await container.GetFeedRangesAsync(); + + // Find which range contains item "5" + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + var item5 = results.FirstOrDefault(r => r.Id == "5"); + if (item5 != null) + { + item5.Name.Should().Be("Updated5", "the latest version should appear in the change feed"); + } + } + } + + [Fact] + public async Task ChangeFeed_IncrementalMode_MultipleUpdates_WithFeedRange_ShowsLatest() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-1", Name = "Version1" }, + new PartitionKey("pk-1")); + + for (var v = 2; v <= 6; v++) + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-1", Name = $"Version{v}" }, + new PartitionKey("pk-1")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Incremental mode shows only the latest version + allResults.Should().HaveCount(1); + allResults[0].Name.Should().Be("Version6"); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_Pagination_PageSizeHint() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 5 }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + page.Count.Should().BeLessThanOrEqualTo(5); + allResults.AddRange(page); + } + } + + allResults.Should().HaveCount(30); + } + + [Fact] + public async Task ChangeFeed_Beginning_WithFeedRange_EmptyContainer_NoResults() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + iterator.HasMoreResults.Should().BeFalse(); + } + } + + [Fact] + public async Task ChangeFeed_Time_WithFeedRange_FiltersBothDimensions() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + // Create "early" items + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"early-{i}", PartitionKey = $"pk-{i}", Name = $"Early{i}" }, + new PartitionKey($"pk-{i}")); + + var midpoint = DateTime.UtcNow; + await Task.Delay(50); + + // Create "late" items with same PKs (upserts to same partitions) + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"late-{i}", PartitionKey = $"pk-{i}", Name = $"Late{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allLateResults = new List(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Time(midpoint, range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allLateResults.AddRange(page); + } + } + + // Only "late" items should be returned + allLateResults.Should().HaveCount(20); + allLateResults.All(r => r.Name.StartsWith("Late")).Should().BeTrue(); + } + + [Fact] + public async Task ChangeFeed_QueryAndChangeFeed_SameRangeMapping() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + // Query path + var queryIter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var queryIds = new HashSet(); + while (queryIter.HasMoreResults) + { + var page = await queryIter.ReadNextAsync(); + foreach (var item in page) queryIds.Add(item.Id); + } + + // Change feed path + var cfIter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + var cfIds = new HashSet(); + while (cfIter.HasMoreResults) + { + var page = await cfIter.ReadNextAsync(); + foreach (var item in page) cfIds.Add(item.Id); + } + + queryIds.Should().BeEquivalentTo(cfIds, + "query path and change feed path should produce identical item sets per range"); + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -948,42 +948,42 @@ await container.CreateItemAsync( public class FeedRangeLargeDocumentTests { - [Fact] - public async Task LargeDocuments_FeedRangeQuery_SumsToTotal() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - - // Create 20 items with large payloads (~10 KB each) - var bigPayload = new string('x', 10_000); - for (var i = 0; i < 20; i++) - { - var doc = new JObject - { - ["id"] = $"big-{i}", - ["partitionKey"] = $"pk-{i}", - ["data"] = bigPayload - }; - await container.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); - } - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(4); - - var totalCount = 0; - foreach (var range in ranges) - { - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - totalCount += page.Count; - } - } - - totalCount.Should().Be(20, "sum across all feed ranges should equal total items"); - } + [Fact] + public async Task LargeDocuments_FeedRangeQuery_SumsToTotal() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + + // Create 20 items with large payloads (~10 KB each) + var bigPayload = new string('x', 10_000); + for (var i = 0; i < 20; i++) + { + var doc = new JObject + { + ["id"] = $"big-{i}", + ["partitionKey"] = $"pk-{i}", + ["data"] = bigPayload + }; + await container.CreateItemAsync(doc, new PartitionKey($"pk-{i}")); + } + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(4); + + var totalCount = 0; + foreach (var range in ranges) + { + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + totalCount += page.Count; + } + } + + totalCount.Should().Be(20, "sum across all feed ranges should equal total items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -992,51 +992,51 @@ public async Task LargeDocuments_FeedRangeQuery_SumsToTotal() public class FeedRangeHandlerConsistencyTests { - [Fact] - public async Task InMemoryContainer_FeedRange_QueryFiltersCorrectly_VsDirectQuery() - { - // Verifies that querying with a specific feed range returns a strict - // subset compared to querying without a feed range, documenting the - // expected filtering behavior. - var container = new InMemoryContainer("test", "/partitionKey"); - container.FeedRangeCount = 4; - - for (var i = 0; i < 40; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, - new PartitionKey($"pk-{i}")); - - // Full query (no feed range) - var allResults = new List(); - var allIter = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - while (allIter.HasMoreResults) - { - var page = await allIter.ReadNextAsync(); - allResults.AddRange(page); - } - - // Per-feed-range queries - var ranges = await container.GetFeedRangesAsync(); - var perRangeTotal = 0; - foreach (var range in ranges) - { - var rangeResults = new List(); - var iter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - rangeResults.AddRange(page); - } - rangeResults.Count.Should().BeLessThan(allResults.Count, - "each feed range should return a subset"); - perRangeTotal += rangeResults.Count; - } - - perRangeTotal.Should().Be(allResults.Count, - "sum of per-range results should equal total"); - } + [Fact] + public async Task InMemoryContainer_FeedRange_QueryFiltersCorrectly_VsDirectQuery() + { + // Verifies that querying with a specific feed range returns a strict + // subset compared to querying without a feed range, documenting the + // expected filtering behavior. + var container = new InMemoryContainer("test", "/partitionKey"); + container.FeedRangeCount = 4; + + for (var i = 0; i < 40; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"N{i}" }, + new PartitionKey($"pk-{i}")); + + // Full query (no feed range) + var allResults = new List(); + var allIter = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + while (allIter.HasMoreResults) + { + var page = await allIter.ReadNextAsync(); + allResults.AddRange(page); + } + + // Per-feed-range queries + var ranges = await container.GetFeedRangesAsync(); + var perRangeTotal = 0; + foreach (var range in ranges) + { + var rangeResults = new List(); + var iter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + rangeResults.AddRange(page); + } + rangeResults.Count.Should().BeLessThan(allResults.Count, + "each feed range should return a subset"); + perRangeTotal += rangeResults.Count; + } + + perRangeTotal.Should().Be(allResults.Count, + "sum of per-range results should equal total"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1045,111 +1045,111 @@ await container.CreateItemAsync( public class FeedRangeCustomBoundaryTests { - [Fact] - public async Task QueryIterator_CustomFeedRange_SubsetOfFullRange_ReturnsSubset() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - // First half of hash space - var firstHalf = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"80000000\"}}"); - var firstIter = container.GetItemQueryIterator( - firstHalf, new QueryDefinition("SELECT * FROM c")); - var firstResults = new List(); - while (firstIter.HasMoreResults) - { - var page = await firstIter.ReadNextAsync(); - firstResults.AddRange(page); - } - - // Second half - var secondHalf = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"FF\"}}"); - var secondIter = container.GetItemQueryIterator( - secondHalf, new QueryDefinition("SELECT * FROM c")); - var secondResults = new List(); - while (secondIter.HasMoreResults) - { - var page = await secondIter.ReadNextAsync(); - secondResults.AddRange(page); - } - - firstResults.Count.Should().BeLessThan(50); - secondResults.Count.Should().BeLessThan(50); - (firstResults.Count + secondResults.Count).Should().Be(50); - } - - [Fact] - public async Task QueryIterator_FeedRange_InvertedMinMax_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var inverted = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"40000000\"}}"); - var iterator = container.GetItemQueryIterator( - inverted, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("inverted min > max means zero-width range"); - } - - [Fact] - public async Task QueryIterator_FeedRange_ZeroWidthRange_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var zeroWidth = FeedRange.FromJsonString("{\"Range\":{\"min\":\"55555555\",\"max\":\"55555555\"}}"); - var iterator = container.GetItemQueryIterator( - zeroWidth, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("identical min and max means zero-width range"); - } - - [Fact] - public async Task QueryIterator_FeedRange_MalformedHex_FallsBackToAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var malformed = FeedRange.FromJsonString("{\"Range\":{\"min\":\"GGGG\",\"max\":\"HHHH\"}}"); - var iterator = container.GetItemQueryIterator( - malformed, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(10, "malformed hex falls back to returning all items"); - } + [Fact] + public async Task QueryIterator_CustomFeedRange_SubsetOfFullRange_ReturnsSubset() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + // First half of hash space + var firstHalf = FeedRange.FromJsonString("{\"Range\":{\"min\":\"\",\"max\":\"80000000\"}}"); + var firstIter = container.GetItemQueryIterator( + firstHalf, new QueryDefinition("SELECT * FROM c")); + var firstResults = new List(); + while (firstIter.HasMoreResults) + { + var page = await firstIter.ReadNextAsync(); + firstResults.AddRange(page); + } + + // Second half + var secondHalf = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"FF\"}}"); + var secondIter = container.GetItemQueryIterator( + secondHalf, new QueryDefinition("SELECT * FROM c")); + var secondResults = new List(); + while (secondIter.HasMoreResults) + { + var page = await secondIter.ReadNextAsync(); + secondResults.AddRange(page); + } + + firstResults.Count.Should().BeLessThan(50); + secondResults.Count.Should().BeLessThan(50); + (firstResults.Count + secondResults.Count).Should().Be(50); + } + + [Fact] + public async Task QueryIterator_FeedRange_InvertedMinMax_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var inverted = FeedRange.FromJsonString("{\"Range\":{\"min\":\"80000000\",\"max\":\"40000000\"}}"); + var iterator = container.GetItemQueryIterator( + inverted, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("inverted min > max means zero-width range"); + } + + [Fact] + public async Task QueryIterator_FeedRange_ZeroWidthRange_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var zeroWidth = FeedRange.FromJsonString("{\"Range\":{\"min\":\"55555555\",\"max\":\"55555555\"}}"); + var iterator = container.GetItemQueryIterator( + zeroWidth, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("identical min and max means zero-width range"); + } + + [Fact] + public async Task QueryIterator_FeedRange_MalformedHex_FallsBackToAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var malformed = FeedRange.FromJsonString("{\"Range\":{\"min\":\"GGGG\",\"max\":\"HHHH\"}}"); + var iterator = container.GetItemQueryIterator( + malformed, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(10, "malformed hex falls back to returning all items"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1158,152 +1158,152 @@ await container.CreateItemAsync( public class FeedRangePartitionKeyTypeFilteringTests { - [Fact] - public async Task QueryIterator_WithFeedRange_CompositePartitionKey_NoItemsLost() - { - var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) - { - FeedRangeCount = 4 - }; - - for (var i = 0; i < 20; i++) - { - var doc = new JObject - { - ["id"] = $"{i}", - ["tenantId"] = $"tenant-{i % 4}", - ["userId"] = $"user-{i}", - ["name"] = $"Item{i}" - }; - await container.CreateItemAsync(doc, - new PartitionKeyBuilder() - .Add($"tenant-{i % 4}") - .Add($"user-{i}") - .Build()); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_NullPartitionKey_ItemInExactlyOneRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - await container.CreateItemAsync( - new TestDocument { Id = "null-pk", PartitionKey = null!, Name = "NullPK" }, - PartitionKey.None); - - var ranges = await container.GetFeedRangesAsync(); - var foundInRanges = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - if (results.Count > 0) foundInRanges++; - } - - foundInRanges.Should().Be(1, "null PK item should land in exactly one range"); - } - - [Fact] - public async Task QueryIterator_WithFeedRange_NumericPartitionKey_NoItemsLost() - { - var container = new InMemoryContainer("test", "/category") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - { - var doc = new JObject - { - ["id"] = $"{i}", - ["category"] = (i + 1) * 100, - ["name"] = $"Item{i}" - }; - await container.CreateItemAsync(doc, new PartitionKey((i + 1) * 100)); - } - - var ranges = await container.GetFeedRangesAsync(); - var totalCount = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - totalCount += page.Count; - } - } - - totalCount.Should().Be(20); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_CompositePartitionKey_NoItemsLost() - { - var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) - { - FeedRangeCount = 4 - }; - - for (var i = 0; i < 20; i++) - { - var doc = new JObject - { - ["id"] = $"{i}", - ["tenantId"] = $"tenant-{i % 4}", - ["userId"] = $"user-{i}", - ["name"] = $"Item{i}" - }; - await container.CreateItemAsync(doc, - new PartitionKeyBuilder() - .Add($"tenant-{i % 4}") - .Add($"user-{i}") - .Build()); - } - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var item in page) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().HaveCount(20); - } + [Fact] + public async Task QueryIterator_WithFeedRange_CompositePartitionKey_NoItemsLost() + { + var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) + { + FeedRangeCount = 4 + }; + + for (var i = 0; i < 20; i++) + { + var doc = new JObject + { + ["id"] = $"{i}", + ["tenantId"] = $"tenant-{i % 4}", + ["userId"] = $"user-{i}", + ["name"] = $"Item{i}" + }; + await container.CreateItemAsync(doc, + new PartitionKeyBuilder() + .Add($"tenant-{i % 4}") + .Add($"user-{i}") + .Build()); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_NullPartitionKey_ItemInExactlyOneRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + await container.CreateItemAsync( + new TestDocument { Id = "null-pk", PartitionKey = null!, Name = "NullPK" }, + PartitionKey.None); + + var ranges = await container.GetFeedRangesAsync(); + var foundInRanges = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + if (results.Count > 0) foundInRanges++; + } + + foundInRanges.Should().Be(1, "null PK item should land in exactly one range"); + } + + [Fact] + public async Task QueryIterator_WithFeedRange_NumericPartitionKey_NoItemsLost() + { + var container = new InMemoryContainer("test", "/category") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + { + var doc = new JObject + { + ["id"] = $"{i}", + ["category"] = (i + 1) * 100, + ["name"] = $"Item{i}" + }; + await container.CreateItemAsync(doc, new PartitionKey((i + 1) * 100)); + } + + var ranges = await container.GetFeedRangesAsync(); + var totalCount = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + totalCount += page.Count; + } + } + + totalCount.Should().Be(20); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_CompositePartitionKey_NoItemsLost() + { + var container = new InMemoryContainer("test", new List { "/tenantId", "/userId" }) + { + FeedRangeCount = 4 + }; + + for (var i = 0; i < 20; i++) + { + var doc = new JObject + { + ["id"] = $"{i}", + ["tenantId"] = $"tenant-{i % 4}", + ["userId"] = $"user-{i}", + ["name"] = $"Item{i}" + }; + await container.CreateItemAsync(doc, + new PartitionKeyBuilder() + .Add($"tenant-{i % 4}") + .Add($"user-{i}") + .Build()); + } + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var item in page) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().HaveCount(20); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1312,84 +1312,84 @@ await container.CreateItemAsync(doc, public class FeedRangeStreamFilteringParityTests { - [Fact] - public async Task QueryStreamIterator_WithFeedRange_Pagination_AllItemsDelivered() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var streamIds = new HashSet(); - var typedIds = new HashSet(); - - foreach (var range in ranges) - { - // Stream path - var streamIter = container.GetItemQueryStreamIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (streamIter.HasMoreResults) - { - var response = await streamIter.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - foreach (var item in doc["Documents"]!) - streamIds.Add(item["id"]!.ToString()); - } - - // Typed path - var typedIter = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (typedIter.HasMoreResults) - { - var page = await typedIter.ReadNextAsync(); - foreach (var item in page) - typedIds.Add(item.Id); - } - } - - streamIds.Should().HaveCount(30); - streamIds.Should().BeEquivalentTo(typedIds); - } - - [Fact] - public async Task ChangeFeedStream_WithFeedRange_SumsToTotal() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - if (doc["Documents"] is JArray docs) - { - foreach (var item in docs) - allIds.Add(item["id"]!.ToString()); - } - } - } - - allIds.Should().HaveCount(20); - } + [Fact] + public async Task QueryStreamIterator_WithFeedRange_Pagination_AllItemsDelivered() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var streamIds = new HashSet(); + var typedIds = new HashSet(); + + foreach (var range in ranges) + { + // Stream path + var streamIter = container.GetItemQueryStreamIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (streamIter.HasMoreResults) + { + var response = await streamIter.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + foreach (var item in doc["Documents"]!) + streamIds.Add(item["id"]!.ToString()); + } + + // Typed path + var typedIter = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (typedIter.HasMoreResults) + { + var page = await typedIter.ReadNextAsync(); + foreach (var item in page) + typedIds.Add(item.Id); + } + } + + streamIds.Should().HaveCount(30); + streamIds.Should().BeEquivalentTo(typedIds); + } + + [Fact] + public async Task ChangeFeedStream_WithFeedRange_SumsToTotal() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + if (doc["Documents"] is JArray docs) + { + foreach (var item in docs) + allIds.Add(item["id"]!.ToString()); + } + } + } + + allIds.Should().HaveCount(20); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1398,29 +1398,29 @@ await container.CreateItemAsync( public class FeedRangeQueryFilteringDivergentTests { - [Fact] - public async Task QueryIterator_FeedRangeFromPartitionKey_ScopesToSinglePartition() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var pkRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); - var iterator = container.GetItemQueryIterator( - pkRange, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Id.Should().Be("5"); - } + [Fact] + public async Task QueryIterator_FeedRangeFromPartitionKey_ScopesToSinglePartition() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var pkRange = FeedRange.FromPartitionKey(new PartitionKey("pk-5")); + var iterator = container.GetItemQueryIterator( + pkRange, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Id.Should().Be("5"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1429,36 +1429,36 @@ await container.CreateItemAsync( public class FeedRangeChangeFeedStreamDivergentTests { - [Fact] - public async Task ChangeFeedStream_Now_WithFeedRange_LazyEvaluation_SeesNewItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - var ranges = await container.GetFeedRangesAsync(); - - var iterator = container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Now(ranges[0]), - ChangeFeedMode.Incremental); - - // Add items after iterator creation - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var allIds = new HashSet(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - if (doc["Documents"] is JArray docs) - { - foreach (var item in docs) - allIds.Add(item["id"]!.ToString()); - } - } - - allIds.Should().NotBeEmpty("lazy evaluation should see items added after creation"); - } + [Fact] + public async Task ChangeFeedStream_Now_WithFeedRange_LazyEvaluation_SeesNewItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + var ranges = await container.GetFeedRangesAsync(); + + var iterator = container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Now(ranges[0]), + ChangeFeedMode.Incremental); + + // Add items after iterator creation + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var allIds = new HashSet(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + if (doc["Documents"] is JArray docs) + { + foreach (var item in docs) + allIds.Add(item["id"]!.ToString()); + } + } + + allIds.Should().NotBeEmpty("lazy evaluation should see items added after creation"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeTests.cs index ed5f5d5..9214ad9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FeedRangeTests.cs @@ -1,8 +1,8 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -10,138 +10,138 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FeedRangeGapTests3 { - [Fact] - public async Task GetFeedRanges_WithMultipleRanges_ReturnsMultiple() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCountGreaterThan(1); - } - - [Fact] - public async Task FeedRange_UsableWithQueryIterator() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().NotBeEmpty(); - - // Use the first range with a query — should still return results - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { }); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - } + [Fact] + public async Task GetFeedRanges_WithMultipleRanges_ReturnsMultiple() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCountGreaterThan(1); + } + + [Fact] + public async Task FeedRange_UsableWithQueryIterator() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().NotBeEmpty(); + + // Use the first range with a query — should still return results + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { }); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } } public class FeedRangeGapTests { - [Fact] - public async Task GetFeedRanges_ReturnsSingleRange_ByDefault() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var feedRanges = await container.GetFeedRangesAsync(); - - feedRanges.Should().HaveCount(1); - } + [Fact] + public async Task GetFeedRanges_ReturnsSingleRange_ByDefault() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var feedRanges = await container.GetFeedRangesAsync(); + + feedRanges.Should().HaveCount(1); + } } public class FeedRangeGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; - - [Fact] - public async Task FeedRange_UsableWithChangeFeedIterator() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), - ChangeFeedMode.Incremental); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all ranges covers both items - allResults.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; + + [Fact] + public async Task FeedRange_UsableWithChangeFeedIterator() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), + ChangeFeedMode.Incremental); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all ranges covers both items + allResults.Should().HaveCount(2); + } } public class FeedRangeGapTests2 { - [Fact] - public async Task GetFeedRanges_AlwaysReturnsSingleRange() - { - // InMemoryContainer always returns a single FeedRange regardless of data - var container = new InMemoryContainer("test", "/partitionKey"); - - for (var i = 0; i < 100; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i % 10}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i % 10}")); - } - - var feedRanges = await container.GetFeedRangesAsync(); - feedRanges.Should().HaveCount(1); - } + [Fact] + public async Task GetFeedRanges_AlwaysReturnsSingleRange() + { + // InMemoryContainer always returns a single FeedRange regardless of data + var container = new InMemoryContainer("test", "/partitionKey"); + + for (var i = 0; i < 100; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i % 10}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i % 10}")); + } + + var feedRanges = await container.GetFeedRangesAsync(); + feedRanges.Should().HaveCount(1); + } } public class FeedRangeDivergentBehaviorTests { - /// - /// InMemoryContainer defaults to FeedRangeCount=1 (single range covering the full hash space). - /// Real Cosmos DB dynamically creates partition ranges based on data volume and throughput. - /// Set FeedRangeCount > 1 to simulate multiple physical partitions for FeedRange-scoped - /// queries and change feed iterators. - /// - [Fact] - public async Task GetFeedRanges_DefaultsSingle_SetFeedRangeCountForMultiple() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - // Default: 1 range (unlike real Cosmos DB which may auto-split) - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - - // Opt-in: set FeedRangeCount for multi-range simulation - container.FeedRangeCount = 4; - var multiRanges = await container.GetFeedRangesAsync(); - multiRanges.Should().HaveCount(4); - } + /// + /// InMemoryContainer defaults to FeedRangeCount=1 (single range covering the full hash space). + /// Real Cosmos DB dynamically creates partition ranges based on data volume and throughput. + /// Set FeedRangeCount > 1 to simulate multiple physical partitions for FeedRange-scoped + /// queries and change feed iterators. + /// + [Fact] + public async Task GetFeedRanges_DefaultsSingle_SetFeedRangeCountForMultiple() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + // Default: 1 range (unlike real Cosmos DB which may auto-split) + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + + // Opt-in: set FeedRangeCount for multi-range simulation + container.FeedRangeCount = 4; + var multiRanges = await container.GetFeedRangesAsync(); + multiRanges.Should().HaveCount(4); + } } @@ -151,79 +151,79 @@ await container.CreateItemAsync( public class FeedRangeCountValidationTests { - [Fact] - public async Task FeedRangeCount_Zero_ClampedToOne() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 0 }; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - } - - [Fact] - public async Task FeedRangeCount_Negative_ClampedToOne() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = -5 }; - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(1); - } - - [Fact] - public async Task FeedRangeCount_256_AllItemsAccountedFor() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 256 }; - for (var i = 0; i < 500; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(256); - - var allItems = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - allItems.AddRange(await iterator.ReadNextAsync()); - } - - allItems.Should().HaveCount(500); - } - - [Fact] - public async Task FeedRangeCount_Two_SplitsItems() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCount(2); - - var total = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - total += (await iterator.ReadNextAsync()).Count; - } - - total.Should().Be(50); - } - - [Fact] - public async Task FeedRangeCount_ChangedBetweenCalls_ProducesDifferentRanges() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - var ranges2 = await container.GetFeedRangesAsync(); - ranges2.Should().HaveCount(2); - - container.FeedRangeCount = 4; - var ranges4 = await container.GetFeedRangesAsync(); - ranges4.Should().HaveCount(4); - } + [Fact] + public async Task FeedRangeCount_Zero_ClampedToOne() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 0 }; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + } + + [Fact] + public async Task FeedRangeCount_Negative_ClampedToOne() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = -5 }; + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(1); + } + + [Fact] + public async Task FeedRangeCount_256_AllItemsAccountedFor() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 256 }; + for (var i = 0; i < 500; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(256); + + var allItems = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + allItems.AddRange(await iterator.ReadNextAsync()); + } + + allItems.Should().HaveCount(500); + } + + [Fact] + public async Task FeedRangeCount_Two_SplitsItems() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCount(2); + + var total = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + total += (await iterator.ReadNextAsync()).Count; + } + + total.Should().Be(50); + } + + [Fact] + public async Task FeedRangeCount_ChangedBetweenCalls_ProducesDifferentRanges() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + var ranges2 = await container.GetFeedRangesAsync(); + ranges2.Should().HaveCount(2); + + container.FeedRangeCount = 4; + var ranges4 = await container.GetFeedRangesAsync(); + ranges4.Should().HaveCount(4); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -232,61 +232,61 @@ public async Task FeedRangeCount_ChangedBetweenCalls_ProducesDifferentRanges() public class FeedRangePartitionAffinityTests { - [Fact] - public async Task SamePartitionKey_AlwaysMapsToSameRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "samePK" }), - new PartitionKey("samePK")); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItems = 0; - - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iterator.HasMoreResults) - count += (await iterator.ReadNextAsync()).Count; - if (count > 0) rangesWithItems++; - } - - rangesWithItems.Should().Be(1, "all items with the same PK should land in exactly one range"); - } - - [Fact] - public void MurmurHash3_Deterministic_SameInputSameOutput() - { - var hash1 = PartitionKeyHash.MurmurHash3("test-key"); - var hash2 = PartitionKeyHash.MurmurHash3("test-key"); - var hash3 = PartitionKeyHash.MurmurHash3("test-key"); - - hash1.Should().Be(hash2).And.Be(hash3); - } - - [Fact] - public async Task HashDistribution_RoughlyEven_With1000Items() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 1000; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (iterator.HasMoreResults) - count += (await iterator.ReadNextAsync()).Count; - - count.Should().BeGreaterThan(0, "no range should be empty with 1000 items"); - count.Should().BeLessThan(500, "no range should have more than half the items"); - } - } + [Fact] + public async Task SamePartitionKey_AlwaysMapsToSameRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "samePK" }), + new PartitionKey("samePK")); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItems = 0; + + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iterator.HasMoreResults) + count += (await iterator.ReadNextAsync()).Count; + if (count > 0) rangesWithItems++; + } + + rangesWithItems.Should().Be(1, "all items with the same PK should land in exactly one range"); + } + + [Fact] + public void MurmurHash3_Deterministic_SameInputSameOutput() + { + var hash1 = PartitionKeyHash.MurmurHash3("test-key"); + var hash2 = PartitionKeyHash.MurmurHash3("test-key"); + var hash3 = PartitionKeyHash.MurmurHash3("test-key"); + + hash1.Should().Be(hash2).And.Be(hash3); + } + + [Fact] + public async Task HashDistribution_RoughlyEven_With1000Items() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 1000; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (iterator.HasMoreResults) + count += (await iterator.ReadNextAsync()).Count; + + count.Should().BeGreaterThan(0, "no range should be empty with 1000 items"); + count.Should().BeLessThan(500, "no range should have more than half the items"); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -295,84 +295,84 @@ await container.CreateItemAsync( public class FeedRangePartitionKeyTypeTests { - [Fact] - public async Task EmptyStringPartitionKey_LandsInOneRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "" }), - new PartitionKey("")); - - var ranges = await container.GetFeedRangesAsync(); - var foundInRanges = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - if ((await iterator.ReadNextAsync()).Count > 0) foundInRanges++; - } - - foundInRanges.Should().Be(1); - } - - [Fact] - public async Task UnicodePartitionKey_LandsInOneRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "日本語テスト🎉" }), - new PartitionKey("日本語テスト🎉")); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(1); - } - - [Fact] - public async Task VeryLongPartitionKey_LandsInOneRange() - { - var longPk = new string('x', 1000); - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = longPk }), - new PartitionKey(longPk)); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(1); - } - - [Fact] - public async Task BooleanPartitionKey_LandsInOneRange() - { - var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":true}"), new PartitionKey(true)); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"2\",\"pk\":false}"), new PartitionKey(false)); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(2); - } + [Fact] + public async Task EmptyStringPartitionKey_LandsInOneRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "" }), + new PartitionKey("")); + + var ranges = await container.GetFeedRangesAsync(); + var foundInRanges = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + if ((await iterator.ReadNextAsync()).Count > 0) foundInRanges++; + } + + foundInRanges.Should().Be(1); + } + + [Fact] + public async Task UnicodePartitionKey_LandsInOneRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "日本語テスト🎉" }), + new PartitionKey("日本語テスト🎉")); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(1); + } + + [Fact] + public async Task VeryLongPartitionKey_LandsInOneRange() + { + var longPk = new string('x', 1000); + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = longPk }), + new PartitionKey(longPk)); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(1); + } + + [Fact] + public async Task BooleanPartitionKey_LandsInOneRange() + { + var container = new InMemoryContainer("test", "/pk") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":true}"), new PartitionKey(true)); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"2\",\"pk\":false}"), new PartitionKey(false)); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -381,67 +381,67 @@ await container.CreateItemAsync( public class FeedRangeQueryFeatureTests { - [Fact] - public async Task ItemCount_WithFeedRange_SumEqualsTotal() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var totalCount = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) - totalCount += (await it.ReadNextAsync()).Count; - } - - totalCount.Should().Be(20); - } - - [Fact] - public async Task OrderBy_WithFeedRange_OrdersWithinRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC")); - var names = new List(); - while (it.HasMoreResults) - names.AddRange((await it.ReadNextAsync()).Select(j => j["name"]!.ToString())); - - names.Should().BeInAscendingOrder(); - } - } - - [Fact] - public async Task Top_WithFeedRange() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT TOP 3 * FROM c")); - var results = new List(); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Count.Should().BeLessThanOrEqualTo(3); - } - } + [Fact] + public async Task ItemCount_WithFeedRange_SumEqualsTotal() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var totalCount = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) + totalCount += (await it.ReadNextAsync()).Count; + } + + totalCount.Should().Be(20); + } + + [Fact] + public async Task OrderBy_WithFeedRange_OrdersWithinRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC")); + var names = new List(); + while (it.HasMoreResults) + names.AddRange((await it.ReadNextAsync()).Select(j => j["name"]!.ToString())); + + names.Should().BeInAscendingOrder(); + } + } + + [Fact] + public async Task Top_WithFeedRange() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT TOP 3 * FROM c")); + var results = new List(); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Count.Should().BeLessThanOrEqualTo(3); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -450,29 +450,29 @@ await container.CreateItemAsync( public class FeedRangeChangeFeedAdvancedTests { - [Fact] - public async Task ChangeFeed_EmptyRange_ReturnsNoItems() - { - // Create items with a single PK so they all land in one range - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "samePK" }), - new PartitionKey("samePK")); - - var ranges = await container.GetFeedRangesAsync(); - var emptyCount = 0; - foreach (var range in ranges) - { - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - var items = new List(); - while (iterator.HasMoreResults) - items.AddRange(await iterator.ReadNextAsync()); - if (items.Count == 0) emptyCount++; - } - - emptyCount.Should().BeGreaterThan(0, "at least some ranges should be empty"); - } + [Fact] + public async Task ChangeFeed_EmptyRange_ReturnsNoItems() + { + // Create items with a single PK so they all land in one range + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "samePK" }), + new PartitionKey("samePK")); + + var ranges = await container.GetFeedRangesAsync(); + var emptyCount = 0; + foreach (var range in ranges) + { + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + var items = new List(); + while (iterator.HasMoreResults) + items.AddRange(await iterator.ReadNextAsync()); + if (items.Count == 0) emptyCount++; + } + + emptyCount.Should().BeGreaterThan(0, "at least some ranges should be empty"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -481,63 +481,63 @@ await container.CreateItemAsync( public class FeedRangeEmptyContainerTests { - [Fact] - public async Task EmptyContainer_FeedRangeQuery_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - var ranges = await container.GetFeedRangesAsync(); - - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().BeEmpty(); - } - } - - [Fact] - public async Task SingleItem_HighRangeCount_MostRangesEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 50 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "onlyPK" }), - new PartitionKey("onlyPK")); - - var ranges = await container.GetFeedRangesAsync(); - var populatedRanges = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (it.HasMoreResults) count += (await it.ReadNextAsync()).Count; - if (count > 0) populatedRanges++; - } - - populatedRanges.Should().Be(1); - } - - [Fact] - public async Task AllItems_SamePartitionKey_OnlyOneRangePopulated() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = "samePK" }), - new PartitionKey("samePK")); - - var ranges = await container.GetFeedRangesAsync(); - var rangesWithItems = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var count = 0; - while (it.HasMoreResults) count += (await it.ReadNextAsync()).Count; - if (count > 0) rangesWithItems++; - } - - rangesWithItems.Should().Be(1); - } + [Fact] + public async Task EmptyContainer_FeedRangeQuery_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + var ranges = await container.GetFeedRangesAsync(); + + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + results.Should().BeEmpty(); + } + } + + [Fact] + public async Task SingleItem_HighRangeCount_MostRangesEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 50 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "onlyPK" }), + new PartitionKey("onlyPK")); + + var ranges = await container.GetFeedRangesAsync(); + var populatedRanges = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (it.HasMoreResults) count += (await it.ReadNextAsync()).Count; + if (count > 0) populatedRanges++; + } + + populatedRanges.Should().Be(1); + } + + [Fact] + public async Task AllItems_SamePartitionKey_OnlyOneRangePopulated() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = "samePK" }), + new PartitionKey("samePK")); + + var ranges = await container.GetFeedRangesAsync(); + var rangesWithItems = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var count = 0; + while (it.HasMoreResults) count += (await it.ReadNextAsync()).Count; + if (count > 0) rangesWithItems++; + } + + rangesWithItems.Should().Be(1); + } } @@ -547,213 +547,213 @@ await container.CreateItemAsync( public class FeedRangeQueryAdvancedTests { - private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) - { - var container = new InMemoryContainer("fr-adv", "/partitionKey") { FeedRangeCount = feedRangeCount }; - for (var i = 0; i < count; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}", category = $"cat{i % 4}", status = i % 2 == 0 ? "active" : "inactive" }), - new PartitionKey($"pk-{i}")); - return container; - } - - [Fact] - public async Task Count_WithFeedRange_PerRangeSumsToTotal() - { - var container = await CreatePopulatedContainer(50, 4); - var ranges = await container.GetFeedRangesAsync(); - - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) - total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(50); - } - - [Fact] - public async Task Sum_WithFeedRange_PerRangeSumsToTotal() - { - var container = new InMemoryContainer("fr-sum", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", val = i }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - long total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - total += item["val"]!.Value(); - } - - total.Should().Be(190); // 0+1+...+19 - } - - [Fact] - public async Task MinMax_WithFeedRange_ConsistentWithContents() - { - var container = new InMemoryContainer("fr-mm", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", val = i }), - new PartitionKey($"pk-{i}")); - - var range = (await container.GetFeedRangesAsync())[0]; - - var items = new List(); - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) items.AddRange(await it.ReadNextAsync()); - - if (items.Count > 0) - { - var actualMin = items.Min(j => j["val"]!.Value()); - var actualMax = items.Max(j => j["val"]!.Value()); - actualMin.Should().BeGreaterThanOrEqualTo(0); - actualMax.Should().BeLessThan(20); - actualMax.Should().BeGreaterThan(actualMin); - } - } - - [Fact] - public async Task Distinct_WithFeedRange_UnionEqualsGlobal() - { - var container = await CreatePopulatedContainer(20, 2); - var ranges = await container.GetFeedRangesAsync(); - - var perRangeCategories = new HashSet(); - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT DISTINCT c.category FROM c")); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - perRangeCategories.Add(item["category"]!.ToString()); - } - - // Global distinct - var globalIt = container.GetItemQueryIterator(new QueryDefinition("SELECT DISTINCT c.category FROM c")); - var globalCategories = new HashSet(); - while (globalIt.HasMoreResults) - foreach (var item in await globalIt.ReadNextAsync()) - globalCategories.Add(item["category"]!.ToString()); - - perRangeCategories.Should().BeEquivalentTo(globalCategories); - } - - [Fact] - public async Task OffsetLimit_WithFeedRange_PaginatesWithinRange() - { - var container = await CreatePopulatedContainer(20, 2); - var range = (await container.GetFeedRangesAsync())[0]; - - var page1 = new List(); - var it1 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.id OFFSET 0 LIMIT 3")); - while (it1.HasMoreResults) page1.AddRange(await it1.ReadNextAsync()); - - var page2 = new List(); - var it2 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.id OFFSET 3 LIMIT 3")); - while (it2.HasMoreResults) page2.AddRange(await it2.ReadNextAsync()); - - // Pages should not overlap - var ids1 = page1.Select(j => j["id"]!.ToString()).ToHashSet(); - var ids2 = page2.Select(j => j["id"]!.ToString()).ToHashSet(); - ids1.Overlaps(ids2).Should().BeFalse(); - } - - [Fact] - public async Task ParameterizedQuery_WithFeedRange_FiltersCorrectly() - { - var container = await CreatePopulatedContainer(20, 4); - var ranges = await container.GetFeedRangesAsync(); - - var totalFound = 0; - foreach (var range in ranges) - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name").WithParameter("@name", "Item005"); - var it = container.GetItemQueryIterator(range, qd); - while (it.HasMoreResults) totalFound += (await it.ReadNextAsync()).Count; - } - - totalFound.Should().Be(1); - } - - [Fact] - public async Task Projection_WithFeedRange_ReturnsProjectedFields() - { - var container = await CreatePopulatedContainer(10, 2); - var range = (await container.GetFeedRangesAsync())[0]; - - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.name FROM c")); - var results = new List(); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - if (results.Count > 0) - { - results[0]["id"].Should().NotBeNull(); - results[0]["name"].Should().NotBeNull(); - results[0]["partitionKey"].Should().BeNull("projection should not include non-selected fields"); - } - } - - [Fact] - public async Task ValueKeyword_WithFeedRange_ReturnsItemsFromRange() - { - var container = await CreatePopulatedContainer(10, 2); - var ranges = await container.GetFeedRangesAsync(); - - // VALUE queries return scalars without PK fields, so FeedRange post-filtering - // can't extract PK. Instead verify items-per-range sums correctly. - var allNames = new List(); - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - allNames.Add(item["name"]!.ToString()); - } - - allNames.Should().HaveCount(10); - } - - [Fact] - public async Task WhereClause_WithFeedRange_ComposesCorrectly() - { - var container = await CreatePopulatedContainer(40, 4); - var ranges = await container.GetFeedRangesAsync(); - - var totalActive = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.status = 'active'")); - while (it.HasMoreResults) totalActive += (await it.ReadNextAsync()).Count; - } - - totalActive.Should().Be(20); - } - - [Fact] - public async Task GroupBy_WithFeedRange_UnionCoversAllGroups() - { - var container = await CreatePopulatedContainer(20, 2); - var ranges = await container.GetFeedRangesAsync(); - - var allCategories = new HashSet(); - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - allCategories.Add(item["category"]!.ToString()); - } - - allCategories.Should().HaveCount(4); // cat0, cat1, cat2, cat3 - } + private static async Task CreatePopulatedContainer(int count = 50, int feedRangeCount = 4) + { + var container = new InMemoryContainer("fr-adv", "/partitionKey") { FeedRangeCount = feedRangeCount }; + for (var i = 0; i < count; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i:D3}", category = $"cat{i % 4}", status = i % 2 == 0 ? "active" : "inactive" }), + new PartitionKey($"pk-{i}")); + return container; + } + + [Fact] + public async Task Count_WithFeedRange_PerRangeSumsToTotal() + { + var container = await CreatePopulatedContainer(50, 4); + var ranges = await container.GetFeedRangesAsync(); + + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) + total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(50); + } + + [Fact] + public async Task Sum_WithFeedRange_PerRangeSumsToTotal() + { + var container = new InMemoryContainer("fr-sum", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", val = i }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + long total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + total += item["val"]!.Value(); + } + + total.Should().Be(190); // 0+1+...+19 + } + + [Fact] + public async Task MinMax_WithFeedRange_ConsistentWithContents() + { + var container = new InMemoryContainer("fr-mm", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", val = i }), + new PartitionKey($"pk-{i}")); + + var range = (await container.GetFeedRangesAsync())[0]; + + var items = new List(); + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) items.AddRange(await it.ReadNextAsync()); + + if (items.Count > 0) + { + var actualMin = items.Min(j => j["val"]!.Value()); + var actualMax = items.Max(j => j["val"]!.Value()); + actualMin.Should().BeGreaterThanOrEqualTo(0); + actualMax.Should().BeLessThan(20); + actualMax.Should().BeGreaterThan(actualMin); + } + } + + [Fact] + public async Task Distinct_WithFeedRange_UnionEqualsGlobal() + { + var container = await CreatePopulatedContainer(20, 2); + var ranges = await container.GetFeedRangesAsync(); + + var perRangeCategories = new HashSet(); + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT DISTINCT c.category FROM c")); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + perRangeCategories.Add(item["category"]!.ToString()); + } + + // Global distinct + var globalIt = container.GetItemQueryIterator(new QueryDefinition("SELECT DISTINCT c.category FROM c")); + var globalCategories = new HashSet(); + while (globalIt.HasMoreResults) + foreach (var item in await globalIt.ReadNextAsync()) + globalCategories.Add(item["category"]!.ToString()); + + perRangeCategories.Should().BeEquivalentTo(globalCategories); + } + + [Fact] + public async Task OffsetLimit_WithFeedRange_PaginatesWithinRange() + { + var container = await CreatePopulatedContainer(20, 2); + var range = (await container.GetFeedRangesAsync())[0]; + + var page1 = new List(); + var it1 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.id OFFSET 0 LIMIT 3")); + while (it1.HasMoreResults) page1.AddRange(await it1.ReadNextAsync()); + + var page2 = new List(); + var it2 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c ORDER BY c.id OFFSET 3 LIMIT 3")); + while (it2.HasMoreResults) page2.AddRange(await it2.ReadNextAsync()); + + // Pages should not overlap + var ids1 = page1.Select(j => j["id"]!.ToString()).ToHashSet(); + var ids2 = page2.Select(j => j["id"]!.ToString()).ToHashSet(); + ids1.Overlaps(ids2).Should().BeFalse(); + } + + [Fact] + public async Task ParameterizedQuery_WithFeedRange_FiltersCorrectly() + { + var container = await CreatePopulatedContainer(20, 4); + var ranges = await container.GetFeedRangesAsync(); + + var totalFound = 0; + foreach (var range in ranges) + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name").WithParameter("@name", "Item005"); + var it = container.GetItemQueryIterator(range, qd); + while (it.HasMoreResults) totalFound += (await it.ReadNextAsync()).Count; + } + + totalFound.Should().Be(1); + } + + [Fact] + public async Task Projection_WithFeedRange_ReturnsProjectedFields() + { + var container = await CreatePopulatedContainer(10, 2); + var range = (await container.GetFeedRangesAsync())[0]; + + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.name FROM c")); + var results = new List(); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + if (results.Count > 0) + { + results[0]["id"].Should().NotBeNull(); + results[0]["name"].Should().NotBeNull(); + results[0]["partitionKey"].Should().BeNull("projection should not include non-selected fields"); + } + } + + [Fact] + public async Task ValueKeyword_WithFeedRange_ReturnsItemsFromRange() + { + var container = await CreatePopulatedContainer(10, 2); + var ranges = await container.GetFeedRangesAsync(); + + // VALUE queries return scalars without PK fields, so FeedRange post-filtering + // can't extract PK. Instead verify items-per-range sums correctly. + var allNames = new List(); + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + allNames.Add(item["name"]!.ToString()); + } + + allNames.Should().HaveCount(10); + } + + [Fact] + public async Task WhereClause_WithFeedRange_ComposesCorrectly() + { + var container = await CreatePopulatedContainer(40, 4); + var ranges = await container.GetFeedRangesAsync(); + + var totalActive = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c WHERE c.status = 'active'")); + while (it.HasMoreResults) totalActive += (await it.ReadNextAsync()).Count; + } + + totalActive.Should().Be(20); + } + + [Fact] + public async Task GroupBy_WithFeedRange_UnionCoversAllGroups() + { + var container = await CreatePopulatedContainer(20, 2); + var ranges = await container.GetFeedRangesAsync(); + + var allCategories = new HashSet(); + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category")); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + allCategories.Add(item["category"]!.ToString()); + } + + allCategories.Should().HaveCount(4); // cat0, cat1, cat2, cat3 + } } @@ -763,112 +763,112 @@ public async Task GroupBy_WithFeedRange_UnionCoversAllGroups() public class FeedRangePartitionKeyAdvancedTypeTests { - [Fact] - public async Task ThreeLevelHierarchicalPK_AllItemsAccountedFor() - { - var container = new InMemoryContainer("fr-3pk", new List { "/tenantId", "/region", "/userId" }) { FeedRangeCount = 4 }; - for (var i = 0; i < 30; i++) - { - var pk = new PartitionKeyBuilder().Add($"t{i % 3}").Add($"r{i % 2}").Add($"u{i}").Build(); - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", tenantId = $"t{i % 3}", region = $"r{i % 2}", userId = $"u{i}" }), - pk); - } - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(30); - } - - [Fact] - public async Task IntegerPartitionKey_AllItemsAccountedFor() - { - var container = new InMemoryContainer("fr-int", "/category") { FeedRangeCount = 4 }; - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", category = i }), - new PartitionKey(i)); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(20); - } - - [Fact] - public async Task DoublePartitionKey_AllItemsAccountedFor() - { - var container = new InMemoryContainer("fr-dbl", "/score") { FeedRangeCount = 4 }; - var scores = new[] { 3.14, 2.71, 1.41, 0.577, 99.99 }; - for (var i = 0; i < scores.Length; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", score = scores[i] }), - new PartitionKey(scores[i])); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(5); - } - - [Fact] - public async Task EmptyStringPK_And_NullPK_BothAccountedFor() - { - var container = new InMemoryContainer("fr-empty", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "empty", partitionKey = "" }), - new PartitionKey("")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "null-pk" }), - PartitionKey.Null); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(2); - } - - [Fact] - public async Task SpecialCharacterPK_AllAccountedFor() - { - var container = new InMemoryContainer("fr-special", "/partitionKey") { FeedRangeCount = 4 }; - var specialPks = new[] { "new\nline", "tab\there", "cr\r\nline", "null\0char" }; - for (var i = 0; i < specialPks.Length; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = specialPks[i] }), - new PartitionKey(specialPks[i])); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(4); - } + [Fact] + public async Task ThreeLevelHierarchicalPK_AllItemsAccountedFor() + { + var container = new InMemoryContainer("fr-3pk", new List { "/tenantId", "/region", "/userId" }) { FeedRangeCount = 4 }; + for (var i = 0; i < 30; i++) + { + var pk = new PartitionKeyBuilder().Add($"t{i % 3}").Add($"r{i % 2}").Add($"u{i}").Build(); + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", tenantId = $"t{i % 3}", region = $"r{i % 2}", userId = $"u{i}" }), + pk); + } + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(30); + } + + [Fact] + public async Task IntegerPartitionKey_AllItemsAccountedFor() + { + var container = new InMemoryContainer("fr-int", "/category") { FeedRangeCount = 4 }; + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", category = i }), + new PartitionKey(i)); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(20); + } + + [Fact] + public async Task DoublePartitionKey_AllItemsAccountedFor() + { + var container = new InMemoryContainer("fr-dbl", "/score") { FeedRangeCount = 4 }; + var scores = new[] { 3.14, 2.71, 1.41, 0.577, 99.99 }; + for (var i = 0; i < scores.Length; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", score = scores[i] }), + new PartitionKey(scores[i])); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(5); + } + + [Fact] + public async Task EmptyStringPK_And_NullPK_BothAccountedFor() + { + var container = new InMemoryContainer("fr-empty", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "empty", partitionKey = "" }), + new PartitionKey("")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "null-pk" }), + PartitionKey.Null); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(2); + } + + [Fact] + public async Task SpecialCharacterPK_AllAccountedFor() + { + var container = new InMemoryContainer("fr-special", "/partitionKey") { FeedRangeCount = 4 }; + var specialPks = new[] { "new\nline", "tab\there", "cr\r\nline", "null\0char" }; + for (var i = 0; i < specialPks.Length; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = specialPks[i] }), + new PartitionKey(specialPks[i])); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(4); + } } @@ -878,57 +878,57 @@ await container.CreateItemAsync( public class FeedRangePaginationTests { - [Fact] - public async Task MaxItemCount_WithFeedRange_PagesCorrectly() - { - var container = new InMemoryContainer("fr-page", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var range = (await container.GetFeedRangesAsync())[0]; - var it = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); - - var allItems = new List(); - var pageCount = 0; - while (it.HasMoreResults) - { - var page = await it.ReadNextAsync(); - page.Count.Should().BeLessThanOrEqualTo(3); - allItems.AddRange(page); - pageCount++; - } - - allItems.Should().NotBeEmpty(); - if (allItems.Count > 3) pageCount.Should().BeGreaterThan(1); - } - - [Fact] - public async Task ChangeFeed_Pagination_WithFeedRange_AllItemsDelivered() - { - var container = new InMemoryContainer("fr-cf-page", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 50; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allIds = new HashSet(); - foreach (var range in ranges) - { - var it = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental, - new ChangeFeedRequestOptions { PageSizeHint = 3 }); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - allIds.Add(item["id"]!.ToString()); - } - - allIds.Should().HaveCount(50); - } + [Fact] + public async Task MaxItemCount_WithFeedRange_PagesCorrectly() + { + var container = new InMemoryContainer("fr-page", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var range = (await container.GetFeedRangesAsync())[0]; + var it = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); + + var allItems = new List(); + var pageCount = 0; + while (it.HasMoreResults) + { + var page = await it.ReadNextAsync(); + page.Count.Should().BeLessThanOrEqualTo(3); + allItems.AddRange(page); + pageCount++; + } + + allItems.Should().NotBeEmpty(); + if (allItems.Count > 3) pageCount.Should().BeGreaterThan(1); + } + + [Fact] + public async Task ChangeFeed_Pagination_WithFeedRange_AllItemsDelivered() + { + var container = new InMemoryContainer("fr-cf-page", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 50; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allIds = new HashSet(); + foreach (var range in ranges) + { + var it = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental, + new ChangeFeedRequestOptions { PageSizeHint = 3 }); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + allIds.Add(item["id"]!.ToString()); + } + + allIds.Should().HaveCount(50); + } } @@ -938,122 +938,122 @@ await container.CreateItemAsync( public class FeedRangeChangeFeedDeepTests { - [Fact] - public async Task ChangeFeed_Now_ThenAddItems_TypedIterator_SeesNewItems() - { - var container = new InMemoryContainer("fr-cf-now", "/partitionKey") { FeedRangeCount = 4 }; - var ranges = await container.GetFeedRangesAsync(); - - // Create iterators BEFORE adding items, starting from Now - var iterators = ranges.Select(r => container.GetChangeFeedIterator( - ChangeFeedStartFrom.Now(r), ChangeFeedMode.LatestVersion)).ToList(); - - // NOW add items - for (var i = 0; i < 20; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Read from iterators — lazy evaluation should see new items - var allIds = new HashSet(); - foreach (var it in iterators) - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - allIds.Add(item["id"]!.ToString()); - - allIds.Should().HaveCount(20); - } - - [Fact] - public async Task ChangeFeed_Now_ThenAddItems_StreamIterator_MayNotSeeNewItems() - { - var container = new InMemoryContainer("fr-cf-stream-now", "/partitionKey") { FeedRangeCount = 2 }; - var ranges = await container.GetFeedRangesAsync(); - var iterators = ranges.Select(r => container.GetChangeFeedStreamIterator( - ChangeFeedStartFrom.Now(r), ChangeFeedMode.LatestVersion)).ToList(); - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var total = 0; - foreach (var it in iterators) - while (it.HasMoreResults) - { - var resp = await it.ReadNextAsync(); - if (!resp.IsSuccessStatusCode || resp.Content == null) break; - using var reader = new StreamReader(resp.Content); - var body = await reader.ReadToEndAsync(); - if (string.IsNullOrWhiteSpace(body)) break; - var jObj = JObject.Parse(body); - if (jObj["Documents"] is JArray docs) - total += docs.Count; - } - - total.Should().Be(10); - } - - [Fact] - public async Task ChangeFeed_Incremental_UpdatesReturnLatestVersion_WithFeedRange() - { - var container = new InMemoryContainer("fr-cf-upd", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = "original" }), - new PartitionKey($"pk-{i}")); - - // Update 5 items - for (var i = 0; i < 5; i++) - await container.UpsertItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = "updated" }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allItems = new List(); - foreach (var range in ranges) - { - var it = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (it.HasMoreResults) allItems.AddRange(await it.ReadNextAsync()); - } - - allItems.Should().HaveCount(10); - var updatedItems = allItems.Where(j => j["name"]!.ToString() == "updated").ToList(); - updatedItems.Should().HaveCount(5); - } - - [Fact] - public async Task ChangeFeed_Beginning_UnionMatchesGlobalChangeFeed() - { - var container = new InMemoryContainer("fr-cf-union", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - // Global change feed - var globalIt = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var globalIds = new HashSet(); - while (globalIt.HasMoreResults) - foreach (var item in await globalIt.ReadNextAsync()) - globalIds.Add(item["id"]!.ToString()); - - // Per-range change feed - var ranges = await container.GetFeedRangesAsync(); - var perRangeIds = new HashSet(); - foreach (var range in ranges) - { - var it = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); - while (it.HasMoreResults) - foreach (var item in await it.ReadNextAsync()) - perRangeIds.Add(item["id"]!.ToString()); - } - - perRangeIds.Should().BeEquivalentTo(globalIds); - } + [Fact] + public async Task ChangeFeed_Now_ThenAddItems_TypedIterator_SeesNewItems() + { + var container = new InMemoryContainer("fr-cf-now", "/partitionKey") { FeedRangeCount = 4 }; + var ranges = await container.GetFeedRangesAsync(); + + // Create iterators BEFORE adding items, starting from Now + var iterators = ranges.Select(r => container.GetChangeFeedIterator( + ChangeFeedStartFrom.Now(r), ChangeFeedMode.LatestVersion)).ToList(); + + // NOW add items + for (var i = 0; i < 20; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Read from iterators — lazy evaluation should see new items + var allIds = new HashSet(); + foreach (var it in iterators) + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + allIds.Add(item["id"]!.ToString()); + + allIds.Should().HaveCount(20); + } + + [Fact] + public async Task ChangeFeed_Now_ThenAddItems_StreamIterator_MayNotSeeNewItems() + { + var container = new InMemoryContainer("fr-cf-stream-now", "/partitionKey") { FeedRangeCount = 2 }; + var ranges = await container.GetFeedRangesAsync(); + var iterators = ranges.Select(r => container.GetChangeFeedStreamIterator( + ChangeFeedStartFrom.Now(r), ChangeFeedMode.LatestVersion)).ToList(); + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var total = 0; + foreach (var it in iterators) + while (it.HasMoreResults) + { + var resp = await it.ReadNextAsync(); + if (!resp.IsSuccessStatusCode || resp.Content == null) break; + using var reader = new StreamReader(resp.Content); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) break; + var jObj = JObject.Parse(body); + if (jObj["Documents"] is JArray docs) + total += docs.Count; + } + + total.Should().Be(10); + } + + [Fact] + public async Task ChangeFeed_Incremental_UpdatesReturnLatestVersion_WithFeedRange() + { + var container = new InMemoryContainer("fr-cf-upd", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = "original" }), + new PartitionKey($"pk-{i}")); + + // Update 5 items + for (var i = 0; i < 5; i++) + await container.UpsertItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = "updated" }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allItems = new List(); + foreach (var range in ranges) + { + var it = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (it.HasMoreResults) allItems.AddRange(await it.ReadNextAsync()); + } + + allItems.Should().HaveCount(10); + var updatedItems = allItems.Where(j => j["name"]!.ToString() == "updated").ToList(); + updatedItems.Should().HaveCount(5); + } + + [Fact] + public async Task ChangeFeed_Beginning_UnionMatchesGlobalChangeFeed() + { + var container = new InMemoryContainer("fr-cf-union", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + // Global change feed + var globalIt = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var globalIds = new HashSet(); + while (globalIt.HasMoreResults) + foreach (var item in await globalIt.ReadNextAsync()) + globalIds.Add(item["id"]!.ToString()); + + // Per-range change feed + var ranges = await container.GetFeedRangesAsync(); + var perRangeIds = new HashSet(); + foreach (var range in ranges) + { + var it = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(range), ChangeFeedMode.Incremental); + while (it.HasMoreResults) + foreach (var item in await it.ReadNextAsync()) + perRangeIds.Add(item["id"]!.ToString()); + } + + perRangeIds.Should().BeEquivalentTo(globalIds); + } } @@ -1063,43 +1063,43 @@ await container.CreateItemAsync( public class FeedRangeStreamParityTests { - [Fact] - public async Task QueryStreamIterator_WithFeedRange_MatchesTypedIterator() - { - var container = new InMemoryContainer("fr-stream", "/partitionKey") { FeedRangeCount = 3 }; - for (var i = 0; i < 30; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - var range = (await container.GetFeedRangesAsync())[0]; - - // Typed - var typedIt = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - var typedIds = new HashSet(); - while (typedIt.HasMoreResults) - foreach (var item in await typedIt.ReadNextAsync()) - typedIds.Add(item["id"]!.ToString()); - - // Stream - var streamIt = container.GetItemQueryStreamIterator(range, new QueryDefinition("SELECT * FROM c")); - var streamIds = new HashSet(); - while (streamIt.HasMoreResults) - { - var resp = await streamIt.ReadNextAsync(); - if (resp.IsSuccessStatusCode) - { - using var reader = new StreamReader(resp.Content); - var body = await reader.ReadToEndAsync(); - var docs = JObject.Parse(body)["Documents"] as JArray; - if (docs != null) - foreach (var doc in docs) - streamIds.Add(doc["id"]!.ToString()); - } - } - - streamIds.Should().BeEquivalentTo(typedIds); - } + [Fact] + public async Task QueryStreamIterator_WithFeedRange_MatchesTypedIterator() + { + var container = new InMemoryContainer("fr-stream", "/partitionKey") { FeedRangeCount = 3 }; + for (var i = 0; i < 30; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + var range = (await container.GetFeedRangesAsync())[0]; + + // Typed + var typedIt = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + var typedIds = new HashSet(); + while (typedIt.HasMoreResults) + foreach (var item in await typedIt.ReadNextAsync()) + typedIds.Add(item["id"]!.ToString()); + + // Stream + var streamIt = container.GetItemQueryStreamIterator(range, new QueryDefinition("SELECT * FROM c")); + var streamIds = new HashSet(); + while (streamIt.HasMoreResults) + { + var resp = await streamIt.ReadNextAsync(); + if (resp.IsSuccessStatusCode) + { + using var reader = new StreamReader(resp.Content); + var body = await reader.ReadToEndAsync(); + var docs = JObject.Parse(body)["Documents"] as JArray; + if (docs != null) + foreach (var doc in docs) + streamIds.Add(doc["id"]!.ToString()); + } + } + + streamIds.Should().BeEquivalentTo(typedIds); + } } @@ -1109,81 +1109,81 @@ await container.CreateItemAsync( public class FeedRangeEdgeCaseAdvancedTests { - [Fact] - public async Task DeleteAllItems_ThenFeedRangeQuery_ReturnsEmpty() - { - var container = new InMemoryContainer("fr-del", "/partitionKey") { FeedRangeCount = 2 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), - new PartitionKey($"pk-{i}")); - - for (var i = 0; i < 10; i++) - await container.DeleteItemAsync($"{i}", new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var total = 0; - foreach (var range in ranges) - { - var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; - } - - total.Should().Be(0); - } - - [Fact] - public async Task UpsertSameItem_StaysInSameRange() - { - var container = new InMemoryContainer("fr-upsert", "/partitionKey") { FeedRangeCount = 4 }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "upsert-test", partitionKey = "test-pk", name = "original" }), - new PartitionKey("test-pk")); - - var ranges = await container.GetFeedRangesAsync(); - int originalRange = -1; - for (var r = 0; r < ranges.Count; r++) - { - var it = container.GetItemQueryIterator(ranges[r], new QueryDefinition("SELECT * FROM c WHERE c.id = 'upsert-test'")); - while (it.HasMoreResults) - if ((await it.ReadNextAsync()).Count > 0) originalRange = r; - } - - // Upsert with new data - await container.UpsertItemAsync( - JObject.FromObject(new { id = "upsert-test", partitionKey = "test-pk", name = "updated" }), - new PartitionKey("test-pk")); - - int newRange = -1; - for (var r = 0; r < ranges.Count; r++) - { - var it = container.GetItemQueryIterator(ranges[r], new QueryDefinition("SELECT * FROM c WHERE c.id = 'upsert-test'")); - while (it.HasMoreResults) - if ((await it.ReadNextAsync()).Count > 0) newRange = r; - } - - newRange.Should().Be(originalRange); - } - - [Fact] - public async Task MultipleDifferentQueries_SameFeedRange_ConsistentSubset() - { - var container = new InMemoryContainer("fr-multi-q", "/partitionKey") { FeedRangeCount = 4 }; - for (var i = 0; i < 40; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), - new PartitionKey($"pk-{i}")); - - var range = (await container.GetFeedRangesAsync())[0]; - - var query1Ids = new HashSet(); - var it1 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); - while (it1.HasMoreResults) foreach (var j in await it1.ReadNextAsync()) query1Ids.Add(j["id"]!.ToString()); - - var query2Ids = new HashSet(); - var it2 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.partitionKey FROM c")); - while (it2.HasMoreResults) foreach (var j in await it2.ReadNextAsync()) query2Ids.Add(j["id"]!.ToString()); - - query1Ids.Should().BeEquivalentTo(query2Ids); - } + [Fact] + public async Task DeleteAllItems_ThenFeedRangeQuery_ReturnsEmpty() + { + var container = new InMemoryContainer("fr-del", "/partitionKey") { FeedRangeCount = 2 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}" }), + new PartitionKey($"pk-{i}")); + + for (var i = 0; i < 10; i++) + await container.DeleteItemAsync($"{i}", new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var total = 0; + foreach (var range in ranges) + { + var it = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it.HasMoreResults) total += (await it.ReadNextAsync()).Count; + } + + total.Should().Be(0); + } + + [Fact] + public async Task UpsertSameItem_StaysInSameRange() + { + var container = new InMemoryContainer("fr-upsert", "/partitionKey") { FeedRangeCount = 4 }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "upsert-test", partitionKey = "test-pk", name = "original" }), + new PartitionKey("test-pk")); + + var ranges = await container.GetFeedRangesAsync(); + int originalRange = -1; + for (var r = 0; r < ranges.Count; r++) + { + var it = container.GetItemQueryIterator(ranges[r], new QueryDefinition("SELECT * FROM c WHERE c.id = 'upsert-test'")); + while (it.HasMoreResults) + if ((await it.ReadNextAsync()).Count > 0) originalRange = r; + } + + // Upsert with new data + await container.UpsertItemAsync( + JObject.FromObject(new { id = "upsert-test", partitionKey = "test-pk", name = "updated" }), + new PartitionKey("test-pk")); + + int newRange = -1; + for (var r = 0; r < ranges.Count; r++) + { + var it = container.GetItemQueryIterator(ranges[r], new QueryDefinition("SELECT * FROM c WHERE c.id = 'upsert-test'")); + while (it.HasMoreResults) + if ((await it.ReadNextAsync()).Count > 0) newRange = r; + } + + newRange.Should().Be(originalRange); + } + + [Fact] + public async Task MultipleDifferentQueries_SameFeedRange_ConsistentSubset() + { + var container = new InMemoryContainer("fr-multi-q", "/partitionKey") { FeedRangeCount = 4 }; + for (var i = 0; i < 40; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", partitionKey = $"pk-{i}", name = $"Item{i}" }), + new PartitionKey($"pk-{i}")); + + var range = (await container.GetFeedRangesAsync())[0]; + + var query1Ids = new HashSet(); + var it1 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT * FROM c")); + while (it1.HasMoreResults) foreach (var j in await it1.ReadNextAsync()) query1Ids.Add(j["id"]!.ToString()); + + var query2Ids = new HashSet(); + var it2 = container.GetItemQueryIterator(range, new QueryDefinition("SELECT c.id, c.partitionKey FROM c")); + while (it2.HasMoreResults) foreach (var j in await it2.ReadNextAsync()) query2Ids.Add(j["id"]!.ToString()); + + query1Ids.Should().BeEquivalentTo(query2Ids); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FromRootAliasParserBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FromRootAliasParserBugTests.cs index d34c2f6..cc90e6b 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FromRootAliasParserBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FromRootAliasParserBugTests.cs @@ -14,165 +14,165 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class FromRootAliasParserBugTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly HttpClient _httpClient; - - public FromRootAliasParserBugTests() - { - _handler = new FakeCosmosHandler(_container); - _httpClient = new HttpClient(_handler) - { - BaseAddress = new Uri("https://localhost:9999/") - }; - } - - public void Dispose() - { - _httpClient.Dispose(); - _handler.Dispose(); - } - - [Fact] - public async Task SelectFieldWithFromRootAlias_ReturnsCorrectValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT c.name FROM root c WHERE c.id = '1'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task SelectValueWithFromRootAlias_ReturnsProjectedObject() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob", Value = 42 }, - new PartitionKey("pk1")); - - // EF Core-style query: SELECT VALUE { "Name": c.name } FROM root c - var query = new QueryDefinition( - "SELECT VALUE { \"Name\": c.name, \"Val\": c.value } FROM root c WHERE c.id = '1'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["Name"]!.Value().Should().Be("Bob"); - results[0]["Val"]!.Value().Should().Be(42); - } - - [Fact] - public async Task SelectStarWithFromRootAlias_ReturnsAllDocuments() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 99 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Diana", Value = 50 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM root c WHERE c.partitionKey = 'pk1'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results.Select(d => d.Name).Should().BeEquivalentTo(["Charlie", "Diana"]); - } - - [Fact] - public async Task WhereWithFromRootAlias_FiltersCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Eve", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Frank", Value = 20 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT c.name FROM root c WHERE c.partitionKey = 'pk1' AND c.value > 15"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["name"]!.Value().Should().Be("Frank"); - } - - [Fact] - public async Task FromRootWithAsAlias_AlsoWorks() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Grace", Value = 5 }, - new PartitionKey("pk1")); - - // FROM root AS c — explicit AS keyword - var query = new QueryDefinition("SELECT c.name FROM root AS c WHERE c.id = '1'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["name"]!.Value().Should().Be("Grace"); - } - - [Fact] - public async Task QueryPlan_CountDistinct_FromRootAlias_ExtractsCorrectPropertyPath() - { - // The regex in HandleQueryPlanAsync captures the first word after FROM, - // which is "root" for "FROM root c" — but the alias is "c". - // The distinctExpr is "c.status", so stripping the alias should yield "status". - var sql = "SELECT COUNT(DISTINCT c.status) FROM root c"; - var body = new JObject { ["query"] = sql }.ToString(); - var request = new HttpRequestMessage(HttpMethod.Post, - "dbs/fakeDb/colls/test-container/docs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); - request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); - - var response = await _httpClient.SendAsync(request); - var json = await response.Content.ReadAsStringAsync(); - var plan = JObject.Parse(json); - - var dCountInfo = plan["queryInfo"]!["dCountInfo"] as JObject; - dCountInfo.Should().NotBeNull(); - // With the bug, fromAlias is "root", so "c.status" doesn't start with "root." - // and the propertyPath would be "c.status" instead of "status" - dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly HttpClient _httpClient; + + public FromRootAliasParserBugTests() + { + _handler = new FakeCosmosHandler(_container); + _httpClient = new HttpClient(_handler) + { + BaseAddress = new Uri("https://localhost:9999/") + }; + } + + public void Dispose() + { + _httpClient.Dispose(); + _handler.Dispose(); + } + + [Fact] + public async Task SelectFieldWithFromRootAlias_ReturnsCorrectValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT c.name FROM root c WHERE c.id = '1'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task SelectValueWithFromRootAlias_ReturnsProjectedObject() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob", Value = 42 }, + new PartitionKey("pk1")); + + // EF Core-style query: SELECT VALUE { "Name": c.name } FROM root c + var query = new QueryDefinition( + "SELECT VALUE { \"Name\": c.name, \"Val\": c.value } FROM root c WHERE c.id = '1'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["Name"]!.Value().Should().Be("Bob"); + results[0]["Val"]!.Value().Should().Be(42); + } + + [Fact] + public async Task SelectStarWithFromRootAlias_ReturnsAllDocuments() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 99 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Diana", Value = 50 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM root c WHERE c.partitionKey = 'pk1'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results.Select(d => d.Name).Should().BeEquivalentTo(["Charlie", "Diana"]); + } + + [Fact] + public async Task WhereWithFromRootAlias_FiltersCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Eve", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Frank", Value = 20 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT c.name FROM root c WHERE c.partitionKey = 'pk1' AND c.value > 15"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Frank"); + } + + [Fact] + public async Task FromRootWithAsAlias_AlsoWorks() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Grace", Value = 5 }, + new PartitionKey("pk1")); + + // FROM root AS c — explicit AS keyword + var query = new QueryDefinition("SELECT c.name FROM root AS c WHERE c.id = '1'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Grace"); + } + + [Fact] + public async Task QueryPlan_CountDistinct_FromRootAlias_ExtractsCorrectPropertyPath() + { + // The regex in HandleQueryPlanAsync captures the first word after FROM, + // which is "root" for "FROM root c" — but the alias is "c". + // The distinctExpr is "c.status", so stripping the alias should yield "status". + var sql = "SELECT COUNT(DISTINCT c.status) FROM root c"; + var body = new JObject { ["query"] = sql }.ToString(); + var request = new HttpRequestMessage(HttpMethod.Post, + "dbs/fakeDb/colls/test-container/docs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); + request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); + + var response = await _httpClient.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + var plan = JObject.Parse(json); + + var dCountInfo = plan["queryInfo"]!["dCountInfo"] as JObject; + dCountInfo.Should().NotBeNull(); + // With the bug, fromAlias is "root", so "c.status" doesn't start with "root." + // and the propertyPath would be "c.status" instead of "status" + dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FullTextSearchTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FullTextSearchTests.cs index 65f6f25..719e767 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FullTextSearchTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/FullTextSearchTests.cs @@ -20,472 +20,472 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FullTextContainsTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_MatchingTerm_ReturnsDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", description = "The quick brown fox jumps over the lazy dog" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.description, 'fox')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task FullTextContains_NonMatchingTerm_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", description = "The quick brown fox" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.description, 'elephant')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContains_IsCaseInsensitive() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", description = "Cosmos Database Service" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.description, 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_NullField_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.description, 'test')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContains_MultipleDocuments_FiltersCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "SQL Server is a relational database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "Azure Functions is serverless" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task FullTextContains_WithPartitionKey_RespectsFilter() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "Hello cosmos" }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_MatchingTerm_ReturnsDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", description = "The quick brown fox jumps over the lazy dog" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.description, 'fox')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task FullTextContains_NonMatchingTerm_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", description = "The quick brown fox" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.description, 'elephant')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContains_IsCaseInsensitive() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", description = "Cosmos Database Service" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.description, 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_NullField_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.description, 'test')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContains_MultipleDocuments_FiltersCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a NoSQL database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "SQL Server is a relational database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "Azure Functions is serverless" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task FullTextContains_WithPartitionKey_RespectsFilter() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "Hello cosmos" }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } public class FullTextContainsAllTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAll_AllTermsPresent_ReturnsDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a fast NoSQL database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAll_SomeTermsMissing_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is fast" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'elephant')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAll_SingleTerm_WorksLikeContains() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'world')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAll_IsCaseInsensitive() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "AZURE COSMOS DATABASE" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'azure', 'cosmos', 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAll_AllTermsPresent_ReturnsDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a fast NoSQL database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAll_SomeTermsMissing_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is fast" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'elephant')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAll_SingleTerm_WorksLikeContains() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'world')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAll_IsCaseInsensitive() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "AZURE COSMOS DATABASE" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'azure', 'cosmos', 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } } public class FullTextContainsAnyTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAny_OneTermMatches_ReturnsDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'elephant', 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_NoTermsMatch_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'elephant', 'giraffe')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAny_AllTermsMatch_ReturnsDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'cosmos', 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_FiltersMultipleDocumentsCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "NoSQL database on Azure" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "SQL relational engine" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "Graph processing system" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'database', 'engine')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAny_OneTermMatches_ReturnsDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'elephant', 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_NoTermsMatch_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'elephant', 'giraffe')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAny_AllTermsMatch_ReturnsDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'cosmos', 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_FiltersMultipleDocumentsCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "NoSQL database on Azure" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "SQL relational engine" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "Graph processing system" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'database', 'engine')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); + } } public class FullTextScoreTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextScore_ReturnsNumericValue() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is a great database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - var score = results[0]["score"]!.Value(); - score.Should().BeGreaterThan(0); - } - - [Fact] - public async Task FullTextScore_MoreMatchingTerms_HigherScore() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is fast" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database is a great database service for cosmos" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - var scores = results.ToDictionary(r => r["id"]!.Value()!, r => r["score"]!.Value()); - scores["2"].Should().BeGreaterThan(scores["1"], - "document 2 has more occurrences of the search terms"); - } - - [Fact] - public async Task FullTextScore_NoMatchingTerms_ReturnsZero() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['elephant', 'giraffe']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().Be(0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextScore_ReturnsNumericValue() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is a great database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + var score = results[0]["score"]!.Value(); + score.Should().BeGreaterThan(0); + } + + [Fact] + public async Task FullTextScore_MoreMatchingTerms_HigherScore() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is fast" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database is a great database service for cosmos" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + var scores = results.ToDictionary(r => r["id"]!.Value()!, r => r["score"]!.Value()); + scores["2"].Should().BeGreaterThan(scores["1"], + "document 2 has more occurrences of the search terms"); + } + + [Fact] + public async Task FullTextScore_NoMatchingTerms_ReturnsZero() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['elephant', 'giraffe']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().Be(0); + } } public class OrderByRankTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task OrderByRank_SortsByFullTextScoreDescending() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure cloud platform" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database is a database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "Cosmos database service for database storage of database records" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database', 'cosmos'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - // Document 3 has most occurrences (3x database + 1x cosmos = 4), should be first - // Document 2 has (2x database + 1x cosmos = 3), should be second - // Document 1 has 0 matches, should be last - results[0]["id"]!.Value().Should().Be("3"); - results[1]["id"]!.Value().Should().Be("2"); - results[2]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task OrderByRank_WithWhereClause_FiltersAndSorts() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos DB overview", category = "docs" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos DB database guide for database", category = "docs" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "Unrelated topic", category = "other" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.category = 'docs' ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.Value().Should().Be("2"); - results[1]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task OrderByRank_WithTopClause_LimitsResults() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Low relevance" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "database database database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "database service" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT TOP 2 * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.Value().Should().Be("2"); - results[1]["id"]!.Value().Should().Be("3"); - } - - [Fact] - public async Task OrderByRank_WithSelectScore_ReturnsSortedWithScores() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database cosmos database cosmos" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['cosmos', 'database']) AS score FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos', 'database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.Value().Should().Be("2"); - var score1 = results[0]["score"]!.Value(); - var score2 = results[1]["score"]!.Value(); - score1.Should().BeGreaterThan(score2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task OrderByRank_SortsByFullTextScoreDescending() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure cloud platform" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database is a database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "Cosmos database service for database storage of database records" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database', 'cosmos'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + // Document 3 has most occurrences (3x database + 1x cosmos = 4), should be first + // Document 2 has (2x database + 1x cosmos = 3), should be second + // Document 1 has 0 matches, should be last + results[0]["id"]!.Value().Should().Be("3"); + results[1]["id"]!.Value().Should().Be("2"); + results[2]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task OrderByRank_WithWhereClause_FiltersAndSorts() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos DB overview", category = "docs" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos DB database guide for database", category = "docs" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "Unrelated topic", category = "other" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.category = 'docs' ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.Value().Should().Be("2"); + results[1]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task OrderByRank_WithTopClause_LimitsResults() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Low relevance" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "database database database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "database service" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT TOP 2 * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.Value().Should().Be("2"); + results[1]["id"]!.Value().Should().Be("3"); + } + + [Fact] + public async Task OrderByRank_WithSelectScore_ReturnsSortedWithScores() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database cosmos database cosmos" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['cosmos', 'database']) AS score FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos', 'database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.Value().Should().Be("2"); + var score1 = results[0]["score"]!.Value(); + var score2 = results[1]["score"]!.Value(); + score1.Should().BeGreaterThan(score2); + } } public class FullTextCombinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_CombinedWithOtherWhereConditions() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database", active = true }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database", active = false }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND c.active = true"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task FullTextContainsAny_CombinedWithContainsAll() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", title = "Azure Cosmos", body = "A fast database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", title = "Azure Functions", body = "Serverless compute" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.title, 'cosmos', 'functions') AND FullTextContainsAll(c.body, 'fast', 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_CombinedWithOtherWhereConditions() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database", active = true }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Cosmos database", active = false }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND c.active = true"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task FullTextContainsAny_CombinedWithContainsAll() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", title = "Azure Cosmos", body = "A fast database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", title = "Azure Functions", body = "Serverless compute" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.title, 'cosmos', 'functions') AND FullTextContainsAll(c.body, 'fast', 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -497,231 +497,235 @@ await _container.CreateItemAsync( public class FullTextSearchDivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - /// - /// DIVERGENT BEHAVIOUR #1 — No full-text indexing policy required. - /// - /// Real Cosmos DB: Calling FULLTEXTCONTAINS on a container without a full-text - /// indexing policy throws a BadRequest error: - /// "Full-text search queries are not supported for accounts or containers - /// without a full-text index policy." - /// - /// InMemoryEmulator: Full-text functions work on any container without any - /// indexing configuration. This is intentional — the emulator approximates - /// the function behaviour using simple string matching so tests can exercise - /// their query logic without needing to configure indexing policies. - /// - [Fact] - public async Task FullTextContains_WorksWithoutFullTextIndexPolicy() - { - // No full-text index policy configured on this container — real Cosmos would reject this. - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Emulator returns results; real Cosmos DB would throw BadRequest - results.Should().ContainSingle(); - } - - /// - /// DIVERGENT BEHAVIOUR #2 — Naive term-frequency scoring vs BM25. - /// - /// Real Cosmos DB: FULLTEXTSCORE uses the BM25 (Best Matching 25) algorithm - /// which considers term frequency, inverse document frequency, and document - /// length normalization. A term appearing 3x in a short document scores - /// differently than 3x in a long document. - /// - /// InMemoryEmulator: Uses a simple case-insensitive count of how many times - /// each search term appears in the field text. The score is the sum of all - /// term occurrence counts. No IDF or length normalization is applied. - /// - /// This means: - /// - Relative ordering is usually correct (more occurrences = higher score) - /// - Absolute score values will differ significantly - /// - Edge cases with document length variation may produce different orderings - /// since BM25 penalises very long documents - /// - [Fact] - public async Task FullTextScore_UsesNaiveTermFrequency_NotBM25() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "short", pk = "a", text = "database database database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "long", pk = "a", - text = "database database database plus many other words to make this a much longer document that would score differently under BM25 length normalization" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['database']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Both have 3 occurrences of "database" — emulator scores them equally. - // Real BM25 would score the shorter document higher due to length normalization. - var scores = results.ToDictionary(r => r["id"]!.Value()!, r => r["score"]!.Value()); - scores["short"].Should().Be(scores["long"], - "the emulator uses naive term-frequency counting without BM25 length normalization"); - } - - /// - /// DIVERGENT BEHAVIOUR #3 — No tokenization/stemming. - /// - /// Real Cosmos DB: The full-text engine tokenizes text and applies stemming, - /// so searching for "running" would match "runs", "ran", "runner" etc. - /// - /// InMemoryEmulator: Uses literal case-insensitive substring matching, so - /// "running" only matches text containing the exact substring "running". - /// - [Fact] - public async Task FullTextContains_NoStemming_LiteralMatchOnly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The runner runs quickly" }), - new PartitionKey("a")); - - // Search for "running" — real Cosmos with stemming might match "runner"/"runs" - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'running')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Emulator does literal matching — "running" is not a substring of "The runner runs quickly" - results.Should().BeEmpty( - "the emulator uses literal substring matching without stemming"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + /// + /// DIVERGENT BEHAVIOUR #1 — No full-text indexing policy required. + /// + /// Real Cosmos DB: Calling FULLTEXTCONTAINS on a container without a full-text + /// indexing policy throws a BadRequest error: + /// "Full-text search queries are not supported for accounts or containers + /// without a full-text index policy." + /// + /// InMemoryEmulator: Full-text functions work on any container without any + /// indexing configuration. This is intentional — the emulator approximates + /// the function behaviour using simple string matching so tests can exercise + /// their query logic without needing to configure indexing policies. + /// + [Fact] + public async Task FullTextContains_WorksWithoutFullTextIndexPolicy() + { + // No full-text index policy configured on this container — real Cosmos would reject this. + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Emulator returns results; real Cosmos DB would throw BadRequest + results.Should().ContainSingle(); + } + + /// + /// DIVERGENT BEHAVIOUR #2 — Naive term-frequency scoring vs BM25. + /// + /// Real Cosmos DB: FULLTEXTSCORE uses the BM25 (Best Matching 25) algorithm + /// which considers term frequency, inverse document frequency, and document + /// length normalization. A term appearing 3x in a short document scores + /// differently than 3x in a long document. + /// + /// InMemoryEmulator: Uses a simple case-insensitive count of how many times + /// each search term appears in the field text. The score is the sum of all + /// term occurrence counts. No IDF or length normalization is applied. + /// + /// This means: + /// - Relative ordering is usually correct (more occurrences = higher score) + /// - Absolute score values will differ significantly + /// - Edge cases with document length variation may produce different orderings + /// since BM25 penalises very long documents + /// + [Fact] + public async Task FullTextScore_UsesNaiveTermFrequency_NotBM25() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "short", pk = "a", text = "database database database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new + { + id = "long", + pk = "a", + text = "database database database plus many other words to make this a much longer document that would score differently under BM25 length normalization" + }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['database']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Both have 3 occurrences of "database" — emulator scores them equally. + // Real BM25 would score the shorter document higher due to length normalization. + var scores = results.ToDictionary(r => r["id"]!.Value()!, r => r["score"]!.Value()); + scores["short"].Should().Be(scores["long"], + "the emulator uses naive term-frequency counting without BM25 length normalization"); + } + + /// + /// DIVERGENT BEHAVIOUR #3 — No tokenization/stemming. + /// + /// Real Cosmos DB: The full-text engine tokenizes text and applies stemming, + /// so searching for "running" would match "runs", "ran", "runner" etc. + /// + /// InMemoryEmulator: Uses literal case-insensitive substring matching, so + /// "running" only matches text containing the exact substring "running". + /// + [Fact] + public async Task FullTextContains_NoStemming_LiteralMatchOnly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The runner runs quickly" }), + new PartitionKey("a")); + + // Search for "running" — real Cosmos with stemming might match "runner"/"runs" + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'running')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Emulator does literal matching — "running" is not a substring of "The runner runs quickly" + results.Should().BeEmpty( + "the emulator uses literal substring matching without stemming"); + } } public class FullTextSearchSkippedTests { - /// - /// Real Cosmos DB requires a full-text indexing policy on the container definition. - /// The emulator does not require or validate this configuration. - /// - /// In real Cosmos DB, you must configure the container with a full-text policy: - /// - /// new ContainerProperties("container", "/pk") - /// { - /// FullTextPolicy = new FullTextPolicy - /// { - /// DefaultLanguage = "en-US", - /// FullTextPaths = { new FullTextPath { Path = "/text", Language = "en-US" } } - /// }, - /// IndexingPolicy = new IndexingPolicy - /// { - /// FullTextIndexes = { new FullTextIndexPath { Path = "/text" } } - /// } - /// } - /// - /// - /// Without this configuration, all FULLTEXT* queries return HTTP 400. - /// The emulator skips this validation entirely to keep test setup simple. - /// - [Fact(Skip = "Full-text indexing policy validation is not implemented. " + - "Real Cosmos DB requires a FullTextPolicy and FullTextIndexes on the container's " + - "IndexingPolicy. Without it, all FULLTEXT* queries return HTTP 400 BadRequest. " + - "The emulator intentionally skips this validation so queries work without " + - "indexing policy configuration. See FullTextSearchDivergentBehaviorTests for details.")] - public async Task FullTextContains_WithoutFullTextIndex_ShouldThrow400() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello" }), - new PartitionKey("a")); - - // In real Cosmos DB, this would throw CosmosException with StatusCode 400 - var act = async () => - { - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - /// - /// DIVERGENT BEHAVIOUR — FULLTEXTSCORE allowed in SELECT projection. - /// - /// Real Cosmos DB: The FULLTEXTSCORE function "can't be part of a projection - /// (for example, SELECT FullTextScore(c.text, 'keyword') AS Score FROM c is invalid)." - /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/fulltextscore#remarks - /// It can ONLY be used inside ORDER BY RANK or as an argument to RRF. - /// - /// InMemoryEmulator: Allows FULLTEXTSCORE in any SELECT projection, which is - /// useful for debugging relevance scores during development. This will succeed - /// in the emulator but would fail with a BadRequest in real Cosmos DB. - /// - [Fact(Skip = "FULLTEXTSCORE in SELECT projection is not valid in real Cosmos DB. " + - "Per Microsoft docs: 'This function can't be part of a projection'. " + - "The emulator intentionally allows it for debugging convenience. " + - "See FullTextScore_InProjection_WorksInEmulator for the emulator behaviour.")] - public async Task FullTextScore_InProjection_ShouldBeInvalid() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), - new PartitionKey("a")); - - // In real Cosmos DB, this would throw CosmosException with StatusCode 400 - // because FULLTEXTSCORE cannot appear in a SELECT projection. - var act = async () => - { - var iterator = container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['database']) AS score FROM c"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - /// - /// DIVERGENT BEHAVIOUR — RRF (Reciprocal Rank Fusion) is not implemented. - /// - /// Real Cosmos DB: The RRF function fuses multiple scoring functions (e.g. - /// FullTextScore + VectorDistance) using Reciprocal Rank Fusion for hybrid search: - /// SELECT TOP 10 * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'keyword'), VectorDistance(c.vector, [1,2,3])) - /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/rrf - /// - /// InMemoryEmulator: RRF is not recognised. The parser will fail because RRF - /// is not registered as a known function. This is a known gap. - /// - [Fact(Skip = "RRF (Reciprocal Rank Fusion) is not implemented. " + - "Real Cosmos DB supports ORDER BY RANK RRF(FullTextScore(...), VectorDistance(...)) " + - "for hybrid search combining full-text and vector similarity scoring. " + - "The emulator does not recognise the RRF function. " + - "See RRF_NotSupported_ThrowsOnParse for the current emulator behaviour.")] - public async Task RRF_BasicFusion_ShouldCombineScores() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Another database record" }), - new PartitionKey("a")); - - // In real Cosmos DB, RRF fuses two FullTextScore functions: - var iterator = container.GetItemQueryIterator( - "SELECT TOP 10 * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'cosmos'), FullTextScore(c.text, 'database'))"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - } + /// + /// Real Cosmos DB requires a full-text indexing policy on the container definition. + /// The emulator does not require or validate this configuration. + /// + /// In real Cosmos DB, you must configure the container with a full-text policy: + /// + /// new ContainerProperties("container", "/pk") + /// { + /// FullTextPolicy = new FullTextPolicy + /// { + /// DefaultLanguage = "en-US", + /// FullTextPaths = { new FullTextPath { Path = "/text", Language = "en-US" } } + /// }, + /// IndexingPolicy = new IndexingPolicy + /// { + /// FullTextIndexes = { new FullTextIndexPath { Path = "/text" } } + /// } + /// } + /// + /// + /// Without this configuration, all FULLTEXT* queries return HTTP 400. + /// The emulator skips this validation entirely to keep test setup simple. + /// + [Fact(Skip = "Full-text indexing policy validation is not implemented. " + + "Real Cosmos DB requires a FullTextPolicy and FullTextIndexes on the container's " + + "IndexingPolicy. Without it, all FULLTEXT* queries return HTTP 400 BadRequest. " + + "The emulator intentionally skips this validation so queries work without " + + "indexing policy configuration. See FullTextSearchDivergentBehaviorTests for details.")] + public async Task FullTextContains_WithoutFullTextIndex_ShouldThrow400() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello" }), + new PartitionKey("a")); + + // In real Cosmos DB, this would throw CosmosException with StatusCode 400 + var act = async () => + { + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + /// + /// DIVERGENT BEHAVIOUR — FULLTEXTSCORE allowed in SELECT projection. + /// + /// Real Cosmos DB: The FULLTEXTSCORE function "can't be part of a projection + /// (for example, SELECT FullTextScore(c.text, 'keyword') AS Score FROM c is invalid)." + /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/fulltextscore#remarks + /// It can ONLY be used inside ORDER BY RANK or as an argument to RRF. + /// + /// InMemoryEmulator: Allows FULLTEXTSCORE in any SELECT projection, which is + /// useful for debugging relevance scores during development. This will succeed + /// in the emulator but would fail with a BadRequest in real Cosmos DB. + /// + [Fact(Skip = "FULLTEXTSCORE in SELECT projection is not valid in real Cosmos DB. " + + "Per Microsoft docs: 'This function can't be part of a projection'. " + + "The emulator intentionally allows it for debugging convenience. " + + "See FullTextScore_InProjection_WorksInEmulator for the emulator behaviour.")] + public async Task FullTextScore_InProjection_ShouldBeInvalid() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), + new PartitionKey("a")); + + // In real Cosmos DB, this would throw CosmosException with StatusCode 400 + // because FULLTEXTSCORE cannot appear in a SELECT projection. + var act = async () => + { + var iterator = container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['database']) AS score FROM c"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + /// + /// DIVERGENT BEHAVIOUR — RRF (Reciprocal Rank Fusion) is not implemented. + /// + /// Real Cosmos DB: The RRF function fuses multiple scoring functions (e.g. + /// FullTextScore + VectorDistance) using Reciprocal Rank Fusion for hybrid search: + /// SELECT TOP 10 * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'keyword'), VectorDistance(c.vector, [1,2,3])) + /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/rrf + /// + /// InMemoryEmulator: RRF is not recognised. The parser will fail because RRF + /// is not registered as a known function. This is a known gap. + /// + [Fact(Skip = "RRF (Reciprocal Rank Fusion) is not implemented. " + + "Real Cosmos DB supports ORDER BY RANK RRF(FullTextScore(...), VectorDistance(...)) " + + "for hybrid search combining full-text and vector similarity scoring. " + + "The emulator does not recognise the RRF function. " + + "See RRF_NotSupported_ThrowsOnParse for the current emulator behaviour.")] + public async Task RRF_BasicFusion_ShouldCombineScores() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Another database record" }), + new PartitionKey("a")); + + // In real Cosmos DB, RRF fuses two FullTextScore functions: + var iterator = container.GetItemQueryIterator( + "SELECT TOP 10 * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'cosmos'), FullTextScore(c.text, 'database'))"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -733,108 +737,108 @@ await container.CreateItemAsync( public class FullTextSearchAdditionalDivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - /// - /// DIVERGENT BEHAVIOUR — FULLTEXTSCORE in SELECT projection works in emulator. - /// - /// This is the sister test for FullTextScore_InProjection_ShouldBeInvalid (skipped). - /// Real Cosmos DB rejects FULLTEXTSCORE in projections — it can only appear inside - /// ORDER BY RANK or as an argument to RRF. The emulator allows it, which is useful - /// for debugging relevance scores during development. - /// - /// Real Cosmos DB docs state: - /// "This function can't be part of a projection (for example, - /// SELECT FullTextScore(c.text, 'keyword') AS Score FROM c is invalid)." - /// - /// The emulator intentionally diverges here for developer convenience. - /// - [Fact] - public async Task FullTextScore_InProjection_WorksInEmulator() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is a great database" }), - new PartitionKey("a")); - - // Real Cosmos DB would reject this with HTTP 400. - // The emulator allows it and returns the naive term-frequency score. - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - // 2 occurrences of "database" + 1 occurrence of "cosmos" = 3.0 - results[0]["score"]!.Value().Should().Be(3.0, - "emulator allows FULLTEXTSCORE in SELECT projection (real Cosmos DB would reject this)"); - } - - /// - /// DIVERGENT BEHAVIOUR — Substring matching instead of word-boundary tokenization. - /// - /// Real Cosmos DB: Full-text search tokenizes text at word boundaries. Searching for - /// "data" would NOT match a document containing "database" because they are different - /// tokens. The search term must match a complete token (after stemming). - /// - /// InMemoryEmulator: Uses string.Contains() for substring matching. So "data" DOES - /// match "database" because "data" is a substring of "database". This is different - /// from both stemming (which maps word forms to a root) and whole-word matching. - /// - /// This is documented in Known Limitations § 13 (Full-Text Search Uses Naive Matching). - /// - [Fact] - public async Task FullTextContains_SubstringMatchesMidWord_DivergentFromRealCosmos() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The database stores records" }), - new PartitionKey("a")); - - // Search for "data" — this is a substring of "database" but not a separate word/token. - // Real Cosmos DB would NOT match because "data" and "database" are different tokens. - // The emulator DOES match because it uses substring Contains(), not tokenization. - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'data')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle( - "the emulator uses substring matching — 'data' matches 'database'. " + - "Real Cosmos DB uses word-boundary tokenization so 'data' would NOT match 'database'."); - } - - /// - /// DIVERGENT BEHAVIOUR — RRF (Reciprocal Rank Fusion) not supported. - /// - /// This is the sister test for RRF_BasicFusion_ShouldCombineScores (skipped). - /// The emulator does not implement the RRF function. Attempting to use it - /// results in a parse/evaluation error because RRF is not a registered function. - /// - /// Real Cosmos DB supports: ORDER BY RANK RRF(FullTextScore(...), VectorDistance(...)) - /// for hybrid search fusing full-text and vector similarity scores. - /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/rrf - /// - [Fact] - public async Task RRF_NotSupported_ThrowsOnParse() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), - new PartitionKey("a")); - - // RRF is not implemented — the parser/evaluator does not recognise it. - // This will throw because RRF is not a known function in the emulator. - var act = async () => - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'cosmos'), FullTextScore(c.text, 'database'))"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync( - "RRF is not implemented in the emulator — the query should fail"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + /// + /// DIVERGENT BEHAVIOUR — FULLTEXTSCORE in SELECT projection works in emulator. + /// + /// This is the sister test for FullTextScore_InProjection_ShouldBeInvalid (skipped). + /// Real Cosmos DB rejects FULLTEXTSCORE in projections — it can only appear inside + /// ORDER BY RANK or as an argument to RRF. The emulator allows it, which is useful + /// for debugging relevance scores during development. + /// + /// Real Cosmos DB docs state: + /// "This function can't be part of a projection (for example, + /// SELECT FullTextScore(c.text, 'keyword') AS Score FROM c is invalid)." + /// + /// The emulator intentionally diverges here for developer convenience. + /// + [Fact] + public async Task FullTextScore_InProjection_WorksInEmulator() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database is a great database" }), + new PartitionKey("a")); + + // Real Cosmos DB would reject this with HTTP 400. + // The emulator allows it and returns the naive term-frequency score. + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['database', 'cosmos']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + // 2 occurrences of "database" + 1 occurrence of "cosmos" = 3.0 + results[0]["score"]!.Value().Should().Be(3.0, + "emulator allows FULLTEXTSCORE in SELECT projection (real Cosmos DB would reject this)"); + } + + /// + /// DIVERGENT BEHAVIOUR — Substring matching instead of word-boundary tokenization. + /// + /// Real Cosmos DB: Full-text search tokenizes text at word boundaries. Searching for + /// "data" would NOT match a document containing "database" because they are different + /// tokens. The search term must match a complete token (after stemming). + /// + /// InMemoryEmulator: Uses string.Contains() for substring matching. So "data" DOES + /// match "database" because "data" is a substring of "database". This is different + /// from both stemming (which maps word forms to a root) and whole-word matching. + /// + /// This is documented in Known Limitations § 13 (Full-Text Search Uses Naive Matching). + /// + [Fact] + public async Task FullTextContains_SubstringMatchesMidWord_DivergentFromRealCosmos() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The database stores records" }), + new PartitionKey("a")); + + // Search for "data" — this is a substring of "database" but not a separate word/token. + // Real Cosmos DB would NOT match because "data" and "database" are different tokens. + // The emulator DOES match because it uses substring Contains(), not tokenization. + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'data')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle( + "the emulator uses substring matching — 'data' matches 'database'. " + + "Real Cosmos DB uses word-boundary tokenization so 'data' would NOT match 'database'."); + } + + /// + /// DIVERGENT BEHAVIOUR — RRF (Reciprocal Rank Fusion) not supported. + /// + /// This is the sister test for RRF_BasicFusion_ShouldCombineScores (skipped). + /// The emulator does not implement the RRF function. Attempting to use it + /// results in a parse/evaluation error because RRF is not a registered function. + /// + /// Real Cosmos DB supports: ORDER BY RANK RRF(FullTextScore(...), VectorDistance(...)) + /// for hybrid search fusing full-text and vector similarity scores. + /// See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/rrf + /// + [Fact] + public async Task RRF_NotSupported_ThrowsOnParse() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), + new PartitionKey("a")); + + // RRF is not implemented — the parser/evaluator does not recognise it. + // This will throw because RRF is not a known function in the emulator. + var act = async () => + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK RRF(FullTextScore(c.text, 'cosmos'), FullTextScore(c.text, 'database'))"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync( + "RRF is not implemented in the emulator — the query should fail"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -846,175 +850,175 @@ await act.Should().ThrowAsync( public class FullTextContainsAllParityTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAll_NullField_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.description, 'test', 'data')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAll_WithPartitionKey_RespectsFilter() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "Cosmos database platform" }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'database')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAll_NullField_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.description, 'test', 'data')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAll_WithPartitionKey_RespectsFilter() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "Cosmos database platform" }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'database')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } public class FullTextContainsAnyParityTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAny_NullField_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.description, 'test', 'data')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAny_IsCaseInsensitive() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "AZURE COSMOS DATABASE" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'azure', 'elephant')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_SingleTerm_WorksLikeContains() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'world')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_WithPartitionKey_RespectsFilter() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "Hello cosmos" }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'world', 'elephant')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAny_NullField_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.description, 'test', 'data')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAny_IsCaseInsensitive() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "AZURE COSMOS DATABASE" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'azure', 'elephant')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_SingleTerm_WorksLikeContains() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'world')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_WithPartitionKey_RespectsFilter() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "Hello cosmos" }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'world', 'elephant')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } public class FullTextScoreParityTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextScore_NullField_ReturnsZero() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.description, ['test']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().Be(0); - } - - [Fact] - public async Task FullTextScore_SingleSearchTerm_ReturnsCount() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "database database database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['database']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().Be(3); - } - - [Fact] - public async Task FullTextScore_IsCaseInsensitive() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "COSMOS Database cosmos" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT FullTextScore(c.text, ['cosmos']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - // "COSMOS" and "cosmos" both match — 2 occurrences - results[0]["score"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextScore_NullField_ReturnsZero() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.description, ['test']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().Be(0); + } + + [Fact] + public async Task FullTextScore_SingleSearchTerm_ReturnsCount() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "database database database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['database']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().Be(3); + } + + [Fact] + public async Task FullTextScore_IsCaseInsensitive() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "COSMOS Database cosmos" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT FullTextScore(c.text, ['cosmos']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + // "COSMOS" and "cosmos" both match — 2 occurrences + results[0]["score"]!.Value().Should().Be(2); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1026,375 +1030,375 @@ await _container.CreateItemAsync( public class FullTextEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_EmptyStringTerm_MatchesEverything() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), - new PartitionKey("a")); - - // Empty string is a substring of all strings in .NET: "Hello".Contains("") == true - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, '')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_EmptyStringField_ReturnsEmpty() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContains_NestedPropertyPath() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", metadata = new { description = "Azure Cosmos database" } }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.metadata.description, 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_MultiWordPhrase() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox jumps over" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "brown jumps quick fox" }), - new PartitionKey("a")); - - // Multi-word phrase — substring matching means the exact phrase must appear contiguously - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'quick brown')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Only doc 1 has the exact substring "quick brown" - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task FullTextContains_SpecialCharacters() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "C# is great! Don't you think? café résumé" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'café')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_WithNotOperator() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Azure functions" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE NOT FullTextContains(c.text, 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task FullTextContains_WithOrOperator() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "Azure functions" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "SQL Server" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') OR FullTextContains(c.text, 'functions')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task FullTextContainsAll_ManyTerms() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a fast scalable NoSQL database service" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'azure', 'cosmos', 'fast', 'scalable', 'database')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_ManyTerms() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Simple text" }), - new PartitionKey("a")); - - // None of the first 4 terms match, but "text" does - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'alpha', 'bravo', 'charlie', 'delta', 'text')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_EmptyStringTerm_MatchesEverything() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Hello world" }), + new PartitionKey("a")); + + // Empty string is a substring of all strings in .NET: "Hello".Contains("") == true + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, '')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_EmptyStringField_ReturnsEmpty() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'hello')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContains_NestedPropertyPath() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", metadata = new { description = "Azure Cosmos database" } }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.metadata.description, 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_MultiWordPhrase() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox jumps over" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "brown jumps quick fox" }), + new PartitionKey("a")); + + // Multi-word phrase — substring matching means the exact phrase must appear contiguously + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'quick brown')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Only doc 1 has the exact substring "quick brown" + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task FullTextContains_SpecialCharacters() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "C# is great! Don't you think? café résumé" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'café')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_WithNotOperator() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Azure functions" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE NOT FullTextContains(c.text, 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task FullTextContains_WithOrOperator() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "Azure functions" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "SQL Server" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') OR FullTextContains(c.text, 'functions')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task FullTextContainsAll_ManyTerms() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos DB is a fast scalable NoSQL database service" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'azure', 'cosmos', 'fast', 'scalable', 'database')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_ManyTerms() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Simple text" }), + new PartitionKey("a")); + + // None of the first 4 terms match, but "text" does + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'alpha', 'bravo', 'charlie', 'delta', 'text')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } } public class OrderByRankEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task OrderByRank_WithOffsetLimit_ReturnsPage() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "database database database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "database database" }), - new PartitionKey("a")); - - // RANK order: id=2 (3), id=3 (2), id=1 (1). OFFSET 1 LIMIT 1 → id=3 - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database']) OFFSET 1 LIMIT 1"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("3"); - } - - [Fact] - public async Task OrderByRank_TiedScores_ReturnsAllDocuments() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "database service" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "database platform" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Both have score of 1 — both should be returned - results.Should().HaveCount(2); - } - - [Fact] - public async Task OrderByRank_EmptyContainer_ReturnsEmpty() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task OrderByRank_SingleDocument_ReturnsThatDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task OrderByRank_WithDistinct_ReturnsUniqueResults() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "database database database", category = "docs" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "database service", category = "docs" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", text = "database platform", category = "api" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT DISTINCT c.category FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["category"]!.Value()).Should().BeEquivalentTo(["docs", "api"]); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task OrderByRank_WithOffsetLimit_ReturnsPage() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "database database database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "database database" }), + new PartitionKey("a")); + + // RANK order: id=2 (3), id=3 (2), id=1 (1). OFFSET 1 LIMIT 1 → id=3 + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database']) OFFSET 1 LIMIT 1"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("3"); + } + + [Fact] + public async Task OrderByRank_TiedScores_ReturnsAllDocuments() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "database service" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "database platform" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Both have score of 1 — both should be returned + results.Should().HaveCount(2); + } + + [Fact] + public async Task OrderByRank_EmptyContainer_ReturnsEmpty() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task OrderByRank_SingleDocument_ReturnsThatDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task OrderByRank_WithDistinct_ReturnsUniqueResults() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "database database database", category = "docs" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "database service", category = "docs" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", text = "database platform", category = "api" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT DISTINCT c.category FROM c ORDER BY RANK FullTextScore(c.text, ['database'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["category"]!.Value()).Should().BeEquivalentTo(["docs", "api"]); + } } public class FullTextParameterizedQueryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_WithParameterizedTerm() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos database" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", text = "SQL Server engine" }), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT * FROM c WHERE FullTextContains(c.text, @term)") - .WithParameter("@term", "cosmos"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task FullTextScore_WithParameterizedTerms() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), - new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT c.id, FullTextScore(c.text, [@t1, @t2]) AS score FROM c") - .WithParameter("@t1", "cosmos") - .WithParameter("@t2", "database"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_WithParameterizedTerm() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Azure Cosmos database" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", text = "SQL Server engine" }), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT * FROM c WHERE FullTextContains(c.text, @term)") + .WithParameter("@term", "cosmos"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task FullTextScore_WithParameterizedTerms() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Cosmos database service" }), + new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT c.id, FullTextScore(c.text, [@t1, @t2]) AS score FROM c") + .WithParameter("@t1", "cosmos") + .WithParameter("@t2", "database"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeGreaterThan(0); + } } public class FullTextNonStringFieldTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_NumericField_ConvertsToString() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = 12345 }), - new PartitionKey("a")); - - // The implementation calls args[0]?.ToString() which converts 12345 → "12345" - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.value, '234')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // "234" is a substring of "12345" - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_BooleanField_ConvertsToString() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", active = true }), - new PartitionKey("a")); - - // Boolean.ToString() produces "True" (capital T in .NET) - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.active, 'rue')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_ArrayField_UsesJsonRepresentation() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "cosmos", "database" } }), - new PartitionKey("a")); - - // JArray.ToString() produces a JSON array string representation - // The search term needs to match something in that string - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.tags, 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_NumericField_ConvertsToString() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = 12345 }), + new PartitionKey("a")); + + // The implementation calls args[0]?.ToString() which converts 12345 → "12345" + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.value, '234')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // "234" is a substring of "12345" + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_BooleanField_ConvertsToString() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", active = true }), + new PartitionKey("a")); + + // Boolean.ToString() produces "True" (capital T in .NET) + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.active, 'rue')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_ArrayField_UsesJsonRepresentation() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "cosmos", "database" } }), + new PartitionKey("a")); + + // JArray.ToString() produces a JSON array string representation + // The search term needs to match something in that string + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.tags, 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1403,81 +1407,81 @@ await _container.CreateItemAsync( public class FullTextScoreEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextScore_EmptyTermInArray_DoesNotHang() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['', 'fox']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(1.0); - } - - [Fact] - public async Task FullTextScore_AllEmptyTerms_ReturnsZero() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Some content here" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['', '']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(0.0); - } - - [Fact] - public async Task FullTextScore_OverlappingTerms_CountsNonOverlapping() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "aaa" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['aa']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(1.0); - } - - [Fact] - public async Task FullTextScore_WithPartitionKey_RespectsFilter() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos" }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, FullTextScore(c.text, ['cosmos']) AS score FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - ((double)results[0]["score"]!).Should().Be(3.0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextScore_EmptyTermInArray_DoesNotHang() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['', 'fox']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(1.0); + } + + [Fact] + public async Task FullTextScore_AllEmptyTerms_ReturnsZero() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Some content here" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['', '']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(0.0); + } + + [Fact] + public async Task FullTextScore_OverlappingTerms_CountsNonOverlapping() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "aaa" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['aa']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(1.0); + } + + [Fact] + public async Task FullTextScore_WithPartitionKey_RespectsFilter() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos" }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, FullTextScore(c.text, ['cosmos']) AS score FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + ((double)results[0]["score"]!).Should().Be(3.0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1486,72 +1490,72 @@ await _container.CreateItemAsync( public class FullTextContainsAdditionalEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_WhitespaceOnlyTerm_Matches() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "hello world" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, ' ')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("space character is a valid substring"); - } - - [Fact] - public async Task FullTextContains_UnicodeCharacters_CaseInsensitive() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "Über cool database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'über')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("OrdinalIgnoreCase should match Ü/ü"); - } - - [Fact] - public async Task FullTextContains_VeryLongText_PerformsReasonably() - { - var longText = string.Join(" ", Enumerable.Repeat("word", 10_000)) + " needle"; - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = longText }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'needle')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContains_TermLongerThanText_ReturnsFalse() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "hi" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'this is a much longer search term')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_WhitespaceOnlyTerm_Matches() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "hello world" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, ' ')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("space character is a valid substring"); + } + + [Fact] + public async Task FullTextContains_UnicodeCharacters_CaseInsensitive() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "Über cool database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'über')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("OrdinalIgnoreCase should match Ü/ü"); + } + + [Fact] + public async Task FullTextContains_VeryLongText_PerformsReasonably() + { + var longText = string.Join(" ", Enumerable.Repeat("word", 10_000)) + " needle"; + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = longText }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'needle')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContains_TermLongerThanText_ReturnsFalse() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "hi" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'this is a much longer search term')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1560,55 +1564,55 @@ await _container.CreateItemAsync( public class FullTextContainsAllAdditionalTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAll_DuplicateTerms_MatchesOnce() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("duplicate terms should still match"); - } - - [Fact] - public async Task FullTextContainsAll_EmptyStringTerm_MatchesAll() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, '', 'anything')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("empty string is a substring of everything"); - } - - [Fact] - public async Task FullTextContainsAll_ZeroTerms_ReturnsFalse() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAll(c.text)"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("zero search terms returns false (args.Length < 2 guard)"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAll_DuplicateTerms_MatchesOnce() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'cosmos', 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("duplicate terms should still match"); + } + + [Fact] + public async Task FullTextContainsAll_EmptyStringTerm_MatchesAll() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, '', 'anything')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("empty string is a substring of everything"); + } + + [Fact] + public async Task FullTextContainsAll_ZeroTerms_ReturnsFalse() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAll(c.text)"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("zero search terms returns false (args.Length < 2 guard)"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1617,55 +1621,55 @@ await _container.CreateItemAsync( public class FullTextContainsAnyAdditionalTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAny_DuplicateTerms_StillMatches() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'cosmos', 'cosmos')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_EmptyStringTerm_MatchesAll() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, '', 'nope')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("empty string is a substring of everything"); - } - - [Fact] - public async Task FullTextContainsAny_ZeroTerms_ReturnsFalse() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContainsAny(c.text)"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("zero search terms returns false"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAny_DuplicateTerms_StillMatches() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'cosmos', 'cosmos')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_EmptyStringTerm_MatchesAll() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, '', 'nope')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("empty string is a substring of everything"); + } + + [Fact] + public async Task FullTextContainsAny_ZeroTerms_ReturnsFalse() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "anything" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContainsAny(c.text)"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("zero search terms returns false"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1674,69 +1678,69 @@ await _container.CreateItemAsync( public class OrderByRankAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task OrderByRank_WithDistinct_HigherRankedDocumentPreferred() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos", category = "db" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos", category = "db" }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - "SELECT DISTINCT c.category FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["category"]!.ToString().Should().Be("db"); - } - - [Fact] - public async Task OrderByRank_WithWhereFullTextContains_CombinesFiltering() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database engine" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cosmos cosmos" }), - new PartitionKey("b")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", text = "unrelated content" }), - new PartitionKey("c")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2, "only docs containing 'cosmos' are returned"); - results[0]["id"]!.ToString().Should().Be("2", "doc with 3 occurrences ranks first"); - results[1]["id"]!.ToString().Should().Be("1", "doc with 1 occurrence ranks second"); - } - - [Fact] - public async Task OrderByRank_WithTopAndOffset_CombinedPagination() - { - for (var i = 1; i <= 10; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"p{i}", text = string.Concat(Enumerable.Repeat("cosmos ", i)).Trim() }), - new PartitionKey($"p{i}")); - - var iterator = _container.GetItemQueryIterator( - "SELECT TOP 5 * FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(5); - results[0]["id"]!.ToString().Should().Be("10"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task OrderByRank_WithDistinct_HigherRankedDocumentPreferred() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos", category = "db" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos", category = "db" }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + "SELECT DISTINCT c.category FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["category"]!.ToString().Should().Be("db"); + } + + [Fact] + public async Task OrderByRank_WithWhereFullTextContains_CombinesFiltering() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database engine" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cosmos cosmos" }), + new PartitionKey("b")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", text = "unrelated content" }), + new PartitionKey("c")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2, "only docs containing 'cosmos' are returned"); + results[0]["id"]!.ToString().Should().Be("2", "doc with 3 occurrences ranks first"); + results[1]["id"]!.ToString().Should().Be("1", "doc with 1 occurrence ranks second"); + } + + [Fact] + public async Task OrderByRank_WithTopAndOffset_CombinedPagination() + { + for (var i = 1; i <= 10; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"p{i}", text = string.Concat(Enumerable.Repeat("cosmos ", i)).Trim() }), + new PartitionKey($"p{i}")); + + var iterator = _container.GetItemQueryIterator( + "SELECT TOP 5 * FROM c ORDER BY RANK FullTextScore(c.text, ['cosmos'])"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(5); + results[0]["id"]!.ToString().Should().Be("10"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1745,50 +1749,50 @@ await _container.CreateItemAsync( public class FullTextCrossFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContains_WithIN_Operator() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database", type = "tech" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cloud", type = "cloud" }), - new PartitionKey("b")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", text = "cosmos service", type = "other" }), - new PartitionKey("c")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND c.type IN ('tech', 'cloud')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); - } - - [Fact] - public async Task FullTextContains_WithArrayContains() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database", tags = new[] { "db", "nosql" } }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cloud", tags = new[] { "cloud" } }), - new PartitionKey("b")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND ARRAY_CONTAINS(c.tags, 'nosql')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContains_WithIN_Operator() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database", type = "tech" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cloud", type = "cloud" }), + new PartitionKey("b")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", text = "cosmos service", type = "other" }), + new PartitionKey("c")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND c.type IN ('tech', 'cloud')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); + } + + [Fact] + public async Task FullTextContains_WithArrayContains() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database", tags = new[] { "db", "nosql" } }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos cloud", tags = new[] { "cloud" } }), + new PartitionKey("b")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'cosmos') AND ARRAY_CONTAINS(c.tags, 'nosql')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1797,58 +1801,58 @@ await _container.CreateItemAsync( public class FullTextScoreAccuracyTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextScore_MultipleOccurrences_ExactCount() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "db db db" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['db']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(3.0); - } - - [Fact] - public async Task FullTextScore_MultipleTerms_SumsCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "hello world hello" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['hello', 'world']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(3.0); - } - - [Fact] - public async Task FullTextScore_CaseVariations_CountedCorrectly() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "DB Db dB db" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.id, FullTextScore(c.text, ['db']) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["score"]!).Should().Be(4.0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextScore_MultipleOccurrences_ExactCount() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "db db db" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['db']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(3.0); + } + + [Fact] + public async Task FullTextScore_MultipleTerms_SumsCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "hello world hello" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['hello', 'world']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(3.0); + } + + [Fact] + public async Task FullTextScore_CaseVariations_CountedCorrectly() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "DB Db dB db" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.id, FullTextScore(c.text, ['db']) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["score"]!).Should().Be(4.0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1857,67 +1861,67 @@ await _container.CreateItemAsync( public class FullTextParameterizedQueryAdditionalTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task FullTextContainsAll_WithParameterizedTerms() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database engine" }), - new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, @t1, @t2)") - .WithParameter("@t1", "cosmos") - .WithParameter("@t2", "engine"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAny_WithParameterizedTerms() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), - new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, @t1, @t2)") - .WithParameter("@t1", "nonexistent") - .WithParameter("@t2", "cosmos"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task OrderByRank_WithParameterizedScore() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", text = "cosmos" }), - new PartitionKey("b")); - - var query = new QueryDefinition( - "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, [@term])") - .WithParameter("@term", "cosmos"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.ToString().Should().Be("1", "higher score ranks first"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task FullTextContainsAll_WithParameterizedTerms() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database engine" }), + new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, @t1, @t2)") + .WithParameter("@t1", "cosmos") + .WithParameter("@t2", "engine"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAny_WithParameterizedTerms() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos database" }), + new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, @t1, @t2)") + .WithParameter("@t1", "nonexistent") + .WithParameter("@t2", "cosmos"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task OrderByRank_WithParameterizedScore() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "cosmos cosmos cosmos" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", text = "cosmos" }), + new PartitionKey("b")); + + var query = new QueryDefinition( + "SELECT * FROM c ORDER BY RANK FullTextScore(c.text, [@term])") + .WithParameter("@term", "cosmos"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.ToString().Should().Be("1", "higher score ranks first"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1926,42 +1930,42 @@ await _container.CreateItemAsync( public class FullTextStopWordDivergentTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact(Skip = "Real Cosmos DB removes stop words during text analysis. " + - "Searching for common words like 'the' may return no results or be ignored. " + - "The emulator treats all words equally — 'the' is matched like any other substring.")] - public async Task FullTextContains_StopWordRemoval_ShouldIgnoreCommonWords() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'the')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContains_StopWords_EmulatorMatchesAll() - { - // DIVERGENT BEHAVIOUR: The emulator treats stop words like "the" as - // regular substrings. Real Cosmos DB removes stop words during text - // analysis, so searching for "the" may return no results. - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE FullTextContains(c.text, 'the')"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle("emulator does substring matching on 'the'"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact(Skip = "Real Cosmos DB removes stop words during text analysis. " + + "Searching for common words like 'the' may return no results or be ignored. " + + "The emulator treats all words equally — 'the' is matched like any other substring.")] + public async Task FullTextContains_StopWordRemoval_ShouldIgnoreCommonWords() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'the')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContains_StopWords_EmulatorMatchesAll() + { + // DIVERGENT BEHAVIOUR: The emulator treats stop words like "the" as + // regular substrings. Real Cosmos DB removes stop words during text + // analysis, so searching for "the" may return no results. + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "The quick brown fox" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE FullTextContains(c.text, 'the')"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle("emulator does substring matching on 'the'"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/HierarchicalPkPrefixBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/HierarchicalPkPrefixBugTests.cs index a5bf278..3a0200d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/HierarchicalPkPrefixBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/HierarchicalPkPrefixBugTests.cs @@ -1,383 +1,383 @@ using System.Net; +using AwesomeAssertions; using CosmosDB.InMemoryEmulator; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; -using AwesomeAssertions; namespace CosmosDB.InMemoryEmulator.Tests; public class HierarchicalPkPrefixBugTests(ITestOutputHelper output) { - public class HierarchicalDoc - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("level1")] public string Level1 { get; set; } = ""; - [JsonProperty("level2")] public string Level2 { get; set; } = ""; - [JsonProperty("level3")] public string Level3 { get; set; } = ""; - [JsonProperty("data")] public string Data { get; set; } = ""; - } - - private class LoggingHandler(HttpMessageHandler inner, ITestOutputHelper output) : DelegatingHandler(inner) - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var line = $"{request.Method} {request.RequestUri?.PathAndQuery}"; - foreach (var h in request.Headers) - { - if (h.Key.StartsWith("x-ms-documentdb-partition")) - line += $" [{h.Key}={string.Join(",", h.Value)}]"; - } - if (request.Content is not null) - { - var body = await request.Content.ReadAsStringAsync(cancellationToken); - if (body.Contains("query")) - line += $" BODY={body}"; - } - output.WriteLine(line); - return await base.SendAsync(request, cancellationToken); - } - } - - /// - /// Diagnostic: see which range IDs the SDK targets for prefix PK queries - /// when the handler exposes multiple partition key ranges. - /// - [Theory] - [InlineData(4)] - [InlineData(8)] - [InlineData(16)] - public async Task Diagnostic_MultiRange_PrefixPK_Routing(int rangeCount) - { - var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); - var options = new FakeCosmosHandlerOptions { PartitionKeyRangeCount = rangeCount }; - var fakeHandler = new FakeCosmosHandler(inMemoryContainer, options); - using var client = fakeHandler.CreateClient(); - - var container = client.GetContainer("test-db", "items"); - - // Seed documents with different tenants - await container.CreateItemAsync( - new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); - await container.CreateItemAsync( - new { id = "id-2", tenantId = "tenant-A", category = "CatB", region = "us" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CatB").Add("us").Build()); - await container.CreateItemAsync( - new { id = "id-3", tenantId = "tenant-B", category = "CatA", region = "eu" }, - new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); - - // Check which range tenant-A and tenant-B hash to - var hashA = PartitionKeyHash.MurmurHash3("tenant-A"); - var hashB = PartitionKeyHash.MurmurHash3("tenant-B"); - var rangeA = PartitionKeyHash.GetRangeIndex("tenant-A", rangeCount); - var rangeB = PartitionKeyHash.GetRangeIndex("tenant-B", rangeCount); - output.WriteLine($"\n=== Range count: {rangeCount} ==="); - output.WriteLine($"tenant-A hash=0x{hashA:X8} range={rangeA}"); - output.WriteLine($"tenant-B hash=0x{hashB:X8} range={rangeB}"); - - output.WriteLine("\n=== Query with 1-component prefix PK (tenant-A) ==="); - var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); - var results = new List(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); - - output.WriteLine("\n=== Query with 2-component prefix PK (tenant-A, CatA) ==="); - prefixPk = new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Build(); - results.Clear(); - iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); - - output.WriteLine("\n=== Query with full 3-component PK (tenant-A, CatA, eu) ==="); - var fullPk = new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build(); - results.Clear(); - iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = fullPk }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); - } - - [Fact] - public async Task PrefixQuery_SingleComponent_ReturnsMatchingItems_Direct() - { - var container = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); - - var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); - var fullPk2 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); - var fullPk3 = new PartitionKeyBuilder().Add("z").Add("b").Add("c").Build(); - - await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); - await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "x", Level3 = "y", Data = "match2" }, fullPk2); - await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "z", Level2 = "b", Level3 = "c", Data = "nomatch" }, fullPk3); - - // Query with 1-component prefix PK - var prefixPk = new PartitionKeyBuilder().Add("a").Build(); - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task PrefixQuery_TwoComponents_ReturnsMatchingItems_Direct() - { - var container = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); - - var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); - var fullPk2 = new PartitionKeyBuilder().Add("a").Add("b").Add("z").Build(); - var fullPk3 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); - - await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); - await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "b", Level3 = "z", Data = "match2" }, fullPk2); - await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "a", Level2 = "x", Level3 = "y", Data = "nomatch" }, fullPk3); - - // Query with 2-component prefix PK - var prefixPk = new PartitionKeyBuilder().Add("a").Add("b").Build(); - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task PrefixQuery_ViaFakeCosmosHandler_Works() - { - var inMemoryContainer = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); - var handler = new FakeCosmosHandler(inMemoryContainer); - using var client = handler.CreateClient(); - - var container = client.GetContainer("db", "test"); - - var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); - var fullPk2 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); - var fullPk3 = new PartitionKeyBuilder().Add("z").Add("b").Add("c").Build(); - - await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); - await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "x", Level3 = "y", Data = "match2" }, fullPk2); - await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "z", Level2 = "b", Level3 = "c", Data = "nomatch" }, fullPk3); - - // Query with prefix PK through FakeCosmosHandler — no WHERE clause needed, - // the prefix PK scoping should filter by partition key alone - var prefixPk = new PartitionKeyBuilder().Add("a").Build(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); - } - - /// - /// GitHub issue #6: prefix PK scoping broken via FakeCosmosHandler + CosmosClient path. - /// Direct InMemoryContainer works, but the SDK path returns all documents. - /// - [Fact] - public async Task Issue6_TwoLevelPrefix_ViaFakeCosmosHandler_ScopesCorrectly() - { - var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); - var handler = new FakeCosmosHandler(inMemoryContainer); - using var client = handler.CreateClient(); - - var container = client.GetContainer("test-db", "items"); - - var tenantId = "tenant-001"; - var region = "eu-west"; - - await container.CreateItemAsync( - new { id = "id-1", tenantId, category = "CategoryA", region, data = "data A" }, - new PartitionKeyBuilder().Add(tenantId).Add("CategoryA").Add(region).Build()); - - await container.CreateItemAsync( - new { id = "id-2", tenantId, category = "CategoryB", region, data = "data B" }, - new PartitionKeyBuilder().Add(tenantId).Add("CategoryB").Add(region).Build()); - - // Query scoped to 2-level prefix (tenantId + "CategoryA") - var prefixPk = new PartitionKeyBuilder().Add(tenantId).Add("CategoryA").Build(); - var requestOptions = new QueryRequestOptions { PartitionKey = prefixPk }; - - var results = new List(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), requestOptions: requestOptions); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["category"]!.Value().Should().Be("CategoryA"); - } - - [Fact] - public async Task Issue6_OneLevelPrefix_ViaFakeCosmosHandler_ScopesCorrectly() - { - var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); - var handler = new FakeCosmosHandler(inMemoryContainer); - using var client = handler.CreateClient(); - - var container = client.GetContainer("test-db", "items"); - - var region = "eu-west"; - - await container.CreateItemAsync( - new { id = "id-a", tenantId = "tenant-A", category = "CategoryA", region, data = "data A" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CategoryA").Add(region).Build()); - - await container.CreateItemAsync( - new { id = "id-b", tenantId = "tenant-B", category = "CategoryA", region, data = "data B" }, - new PartitionKeyBuilder().Add("tenant-B").Add("CategoryA").Add(region).Build()); - - // Query scoped to 1-level prefix (tenant-A only) - var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); - var requestOptions = new QueryRequestOptions { PartitionKey = prefixPk }; - - var results = new List(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), requestOptions: requestOptions); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["tenantId"]!.Value().Should().Be("tenant-A"); - } - - [Fact] - public async Task LinqPrefixPK_ViaFakeCosmosHandler_ScopesCorrectly() - { - var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); - var handler = new FakeCosmosHandler(inMemoryContainer); - using var client = handler.CreateClient(); - - var container = client.GetContainer("test-db", "items"); - - await container.CreateItemAsync( - new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu", data = "A1" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); - - await container.CreateItemAsync( - new { id = "id-2", tenantId = "tenant-A", category = "CatB", region = "us", data = "A2" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CatB").Add("us").Build()); - - await container.CreateItemAsync( - new { id = "id-3", tenantId = "tenant-B", category = "CatA", region = "eu", data = "B1" }, - new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); - - // LINQ query with 1-component prefix PK — no WithPartitionKey wrapper needed - var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); - var iterator = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }) - .ToFeedIterator(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r["tenantId"]!.Value()).Should().AllBe("tenant-A"); - } - - [Fact] - public async Task WrapClient_WithCustomPipeline_PrefixPKStillWorks() - { - var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); - var handler = new FakeCosmosHandler(inMemoryContainer); - - // Simulate a custom HTTP pipeline (e.g. tracking handler) by wrapping the handler - var delegatingHandler = new PassThroughHandler(handler); - - var innerClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(delegatingHandler) - }); - - // WrapClient adds prefix PK support on top of the user's custom pipeline - using var client = handler.WrapClient(innerClient); - - var container = client.GetContainer("test-db", "items"); - - await container.CreateItemAsync( - new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu" }, - new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); - - await container.CreateItemAsync( - new { id = "id-2", tenantId = "tenant-B", category = "CatA", region = "eu" }, - new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); - - var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0]["tenantId"]!.Value().Should().Be("tenant-A"); - } - - private class PassThroughHandler(HttpMessageHandler inner) : DelegatingHandler(inner) - { - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - => base.SendAsync(request, cancellationToken); - } + public class HierarchicalDoc + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("level1")] public string Level1 { get; set; } = ""; + [JsonProperty("level2")] public string Level2 { get; set; } = ""; + [JsonProperty("level3")] public string Level3 { get; set; } = ""; + [JsonProperty("data")] public string Data { get; set; } = ""; + } + + private class LoggingHandler(HttpMessageHandler inner, ITestOutputHelper output) : DelegatingHandler(inner) + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var line = $"{request.Method} {request.RequestUri?.PathAndQuery}"; + foreach (var h in request.Headers) + { + if (h.Key.StartsWith("x-ms-documentdb-partition")) + line += $" [{h.Key}={string.Join(",", h.Value)}]"; + } + if (request.Content is not null) + { + var body = await request.Content.ReadAsStringAsync(cancellationToken); + if (body.Contains("query")) + line += $" BODY={body}"; + } + output.WriteLine(line); + return await base.SendAsync(request, cancellationToken); + } + } + + /// + /// Diagnostic: see which range IDs the SDK targets for prefix PK queries + /// when the handler exposes multiple partition key ranges. + /// + [Theory] + [InlineData(4)] + [InlineData(8)] + [InlineData(16)] + public async Task Diagnostic_MultiRange_PrefixPK_Routing(int rangeCount) + { + var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); + var options = new FakeCosmosHandlerOptions { PartitionKeyRangeCount = rangeCount }; + var fakeHandler = new FakeCosmosHandler(inMemoryContainer, options); + using var client = fakeHandler.CreateClient(); + + var container = client.GetContainer("test-db", "items"); + + // Seed documents with different tenants + await container.CreateItemAsync( + new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); + await container.CreateItemAsync( + new { id = "id-2", tenantId = "tenant-A", category = "CatB", region = "us" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CatB").Add("us").Build()); + await container.CreateItemAsync( + new { id = "id-3", tenantId = "tenant-B", category = "CatA", region = "eu" }, + new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); + + // Check which range tenant-A and tenant-B hash to + var hashA = PartitionKeyHash.MurmurHash3("tenant-A"); + var hashB = PartitionKeyHash.MurmurHash3("tenant-B"); + var rangeA = PartitionKeyHash.GetRangeIndex("tenant-A", rangeCount); + var rangeB = PartitionKeyHash.GetRangeIndex("tenant-B", rangeCount); + output.WriteLine($"\n=== Range count: {rangeCount} ==="); + output.WriteLine($"tenant-A hash=0x{hashA:X8} range={rangeA}"); + output.WriteLine($"tenant-B hash=0x{hashB:X8} range={rangeB}"); + + output.WriteLine("\n=== Query with 1-component prefix PK (tenant-A) ==="); + var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); + var results = new List(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); + + output.WriteLine("\n=== Query with 2-component prefix PK (tenant-A, CatA) ==="); + prefixPk = new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Build(); + results.Clear(); + iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); + + output.WriteLine("\n=== Query with full 3-component PK (tenant-A, CatA, eu) ==="); + var fullPk = new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build(); + results.Clear(); + iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = fullPk }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + output.WriteLine($"Results: {results.Count} items: {string.Join(", ", results.Select(r => r["id"]))}"); + } + + [Fact] + public async Task PrefixQuery_SingleComponent_ReturnsMatchingItems_Direct() + { + var container = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); + + var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); + var fullPk2 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); + var fullPk3 = new PartitionKeyBuilder().Add("z").Add("b").Add("c").Build(); + + await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); + await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "x", Level3 = "y", Data = "match2" }, fullPk2); + await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "z", Level2 = "b", Level3 = "c", Data = "nomatch" }, fullPk3); + + // Query with 1-component prefix PK + var prefixPk = new PartitionKeyBuilder().Add("a").Build(); + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task PrefixQuery_TwoComponents_ReturnsMatchingItems_Direct() + { + var container = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); + + var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); + var fullPk2 = new PartitionKeyBuilder().Add("a").Add("b").Add("z").Build(); + var fullPk3 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); + + await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); + await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "b", Level3 = "z", Data = "match2" }, fullPk2); + await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "a", Level2 = "x", Level3 = "y", Data = "nomatch" }, fullPk3); + + // Query with 2-component prefix PK + var prefixPk = new PartitionKeyBuilder().Add("a").Add("b").Build(); + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task PrefixQuery_ViaFakeCosmosHandler_Works() + { + var inMemoryContainer = new InMemoryContainer("test", new[] { "/level1", "/level2", "/level3" }); + var handler = new FakeCosmosHandler(inMemoryContainer); + using var client = handler.CreateClient(); + + var container = client.GetContainer("db", "test"); + + var fullPk1 = new PartitionKeyBuilder().Add("a").Add("b").Add("c").Build(); + var fullPk2 = new PartitionKeyBuilder().Add("a").Add("x").Add("y").Build(); + var fullPk3 = new PartitionKeyBuilder().Add("z").Add("b").Add("c").Build(); + + await container.CreateItemAsync(new HierarchicalDoc { Id = "1", Level1 = "a", Level2 = "b", Level3 = "c", Data = "match1" }, fullPk1); + await container.CreateItemAsync(new HierarchicalDoc { Id = "2", Level1 = "a", Level2 = "x", Level3 = "y", Data = "match2" }, fullPk2); + await container.CreateItemAsync(new HierarchicalDoc { Id = "3", Level1 = "z", Level2 = "b", Level3 = "c", Data = "nomatch" }, fullPk3); + + // Query with prefix PK through FakeCosmosHandler — no WHERE clause needed, + // the prefix PK scoping should filter by partition key alone + var prefixPk = new PartitionKeyBuilder().Add("a").Build(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Id).Should().BeEquivalentTo(["1", "2"]); + } + + /// + /// GitHub issue #6: prefix PK scoping broken via FakeCosmosHandler + CosmosClient path. + /// Direct InMemoryContainer works, but the SDK path returns all documents. + /// + [Fact] + public async Task Issue6_TwoLevelPrefix_ViaFakeCosmosHandler_ScopesCorrectly() + { + var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); + var handler = new FakeCosmosHandler(inMemoryContainer); + using var client = handler.CreateClient(); + + var container = client.GetContainer("test-db", "items"); + + var tenantId = "tenant-001"; + var region = "eu-west"; + + await container.CreateItemAsync( + new { id = "id-1", tenantId, category = "CategoryA", region, data = "data A" }, + new PartitionKeyBuilder().Add(tenantId).Add("CategoryA").Add(region).Build()); + + await container.CreateItemAsync( + new { id = "id-2", tenantId, category = "CategoryB", region, data = "data B" }, + new PartitionKeyBuilder().Add(tenantId).Add("CategoryB").Add(region).Build()); + + // Query scoped to 2-level prefix (tenantId + "CategoryA") + var prefixPk = new PartitionKeyBuilder().Add(tenantId).Add("CategoryA").Build(); + var requestOptions = new QueryRequestOptions { PartitionKey = prefixPk }; + + var results = new List(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), requestOptions: requestOptions); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["category"]!.Value().Should().Be("CategoryA"); + } + + [Fact] + public async Task Issue6_OneLevelPrefix_ViaFakeCosmosHandler_ScopesCorrectly() + { + var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); + var handler = new FakeCosmosHandler(inMemoryContainer); + using var client = handler.CreateClient(); + + var container = client.GetContainer("test-db", "items"); + + var region = "eu-west"; + + await container.CreateItemAsync( + new { id = "id-a", tenantId = "tenant-A", category = "CategoryA", region, data = "data A" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CategoryA").Add(region).Build()); + + await container.CreateItemAsync( + new { id = "id-b", tenantId = "tenant-B", category = "CategoryA", region, data = "data B" }, + new PartitionKeyBuilder().Add("tenant-B").Add("CategoryA").Add(region).Build()); + + // Query scoped to 1-level prefix (tenant-A only) + var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); + var requestOptions = new QueryRequestOptions { PartitionKey = prefixPk }; + + var results = new List(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), requestOptions: requestOptions); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["tenantId"]!.Value().Should().Be("tenant-A"); + } + + [Fact] + public async Task LinqPrefixPK_ViaFakeCosmosHandler_ScopesCorrectly() + { + var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); + var handler = new FakeCosmosHandler(inMemoryContainer); + using var client = handler.CreateClient(); + + var container = client.GetContainer("test-db", "items"); + + await container.CreateItemAsync( + new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu", data = "A1" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); + + await container.CreateItemAsync( + new { id = "id-2", tenantId = "tenant-A", category = "CatB", region = "us", data = "A2" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CatB").Add("us").Build()); + + await container.CreateItemAsync( + new { id = "id-3", tenantId = "tenant-B", category = "CatA", region = "eu", data = "B1" }, + new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); + + // LINQ query with 1-component prefix PK — no WithPartitionKey wrapper needed + var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); + var iterator = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }) + .ToFeedIterator(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r["tenantId"]!.Value()).Should().AllBe("tenant-A"); + } + + [Fact] + public async Task WrapClient_WithCustomPipeline_PrefixPKStillWorks() + { + var inMemoryContainer = new InMemoryContainer("items", new[] { "/tenantId", "/category", "/region" }); + var handler = new FakeCosmosHandler(inMemoryContainer); + + // Simulate a custom HTTP pipeline (e.g. tracking handler) by wrapping the handler + var delegatingHandler = new PassThroughHandler(handler); + + var innerClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(delegatingHandler) + }); + + // WrapClient adds prefix PK support on top of the user's custom pipeline + using var client = handler.WrapClient(innerClient); + + var container = client.GetContainer("test-db", "items"); + + await container.CreateItemAsync( + new { id = "id-1", tenantId = "tenant-A", category = "CatA", region = "eu" }, + new PartitionKeyBuilder().Add("tenant-A").Add("CatA").Add("eu").Build()); + + await container.CreateItemAsync( + new { id = "id-2", tenantId = "tenant-B", category = "CatA", region = "eu" }, + new PartitionKeyBuilder().Add("tenant-B").Add("CatA").Add("eu").Build()); + + var prefixPk = new PartitionKeyBuilder().Add("tenant-A").Build(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0]["tenantId"]!.Value().Should().Be("tenant-A"); + } + + private class PassThroughHandler(HttpMessageHandler inner) : DelegatingHandler(inner) + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => base.SendAsync(request, cancellationToken); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IifFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IifFunctionTests.cs index 5eaed8b..d4c698f 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IifFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IifFunctionTests.cs @@ -7,1126 +7,1126 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class IifFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["dot", "net"], Nested = new NestedObject { Description = "desc", Score = 9.5 } }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["java"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 0, IsActive = true, Tags = [] }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Original tests (pre-existing) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_TrueCondition_ReturnsSecondArgument() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("yes"); - } - - [Fact] - public async Task Iif_FalseCondition_ReturnsThirdArgument() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS result FROM c WHERE c.id = '2'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_WithComparisonExpression_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value > 15, 'high', 'low') AS level FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["level"]!.ToString().Should().Be("low"); // value=10 - results[1]["level"]!.ToString().Should().Be("high"); // value=20 - results[2]["level"]!.ToString().Should().Be("low"); // value=0 - } - - [Fact] - public async Task Iif_WithNumericReturnValues_ReturnsNumbers() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, 1, 0) AS flag FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["flag"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Iif_InWhereClause_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE IIF(c.isActive, c.value, 0) > 5"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - [Fact] - public async Task Iif_NestedIif_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value > 15, 'high', IIF(c.value > 5, 'medium', 'low')) AS level FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["level"]!.ToString().Should().Be("medium"); // value=10 - results[1]["level"]!.ToString().Should().Be("high"); // value=20 - results[2]["level"]!.ToString().Should().Be("low"); // value=0 - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 1: Non-boolean condition bug fix - // Real Cosmos DB IIF only treats boolean true as truthy. - // Numbers, strings, arrays, objects all return the false branch. - // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/iif - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_NumericNonZeroCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(42, ...) → false branch (42 is not boolean true) - var query = new QueryDefinition("SELECT IIF(42, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_NumericZeroCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(0, ...) → false branch (0 is not boolean true) - var query = new QueryDefinition("SELECT IIF(0, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_StringCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF('hello', ...) → false branch ('hello' is not boolean true) - var query = new QueryDefinition("SELECT IIF('hello', 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_EmptyStringCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF('', ...) → false branch ('' is not boolean true) - var query = new QueryDefinition("SELECT IIF('', 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_NumericFieldAsCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(c.value, ...) → false branch for all items (numeric field is never boolean) - var query = new QueryDefinition("SELECT IIF(c.value, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("no"); // value=10 — not boolean - results[1]["result"]!.ToString().Should().Be("no"); // value=20 — not boolean - results[2]["result"]!.ToString().Should().Be("no"); // value=0 — not boolean - } - - [Fact] - public async Task Iif_StringFieldAsCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(c.name, ...) → false branch for all items (string field is never boolean) - var query = new QueryDefinition("SELECT IIF(c.name, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("no"); // name=Alice — not boolean - results[1]["result"]!.ToString().Should().Be("no"); // name=Bob — not boolean - results[2]["result"]!.ToString().Should().Be("no"); // name=Charlie — not boolean - } - - [Fact] - public async Task Iif_ArrayFieldAsCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(c.tags, ...) → false branch for all items (array is never boolean) - // Even empty arrays return false branch — only boolean true returns true branch - var query = new QueryDefinition("SELECT IIF(c.tags, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("no"); // tags=["dot","net"] — not boolean - results[1]["result"]!.ToString().Should().Be("no"); // tags=["java"] — not boolean - results[2]["result"]!.ToString().Should().Be("no"); // tags=[] — not boolean - } - - [Fact] - public async Task Iif_ObjectFieldAsCondition_ReturnsFalseBranch() - { - await SeedItems(); - // Real Cosmos DB: IIF(c.nested, ...) → false branch (object is never boolean) - // Item 1 has nested = { description: "desc", score: 9.5 } - var query = new QueryDefinition("SELECT IIF(c.nested, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 2: Null and undefined conditions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_NullCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(null, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_UndefinedPropertyCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.nonExistentField, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 3: Complex boolean expressions in condition - // These produce actual boolean values, so IIF should work normally. - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithAndCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive AND c.value > 5, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("yes"); // id=1: active AND 10>5 - results[1]["result"]!.ToString().Should().Be("no"); // id=2: NOT active - results[2]["result"]!.ToString().Should().Be("no"); // id=3: active AND NOT 0>5 - } - - [Fact] - public async Task Iif_WithOrCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive OR c.value > 15, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("yes"); // id=1: active - results[1]["result"]!.ToString().Should().Be("yes"); // id=2: 20>15 - results[2]["result"]!.ToString().Should().Be("yes"); // id=3: active - } - - [Fact] - public async Task Iif_WithNotCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(NOT c.isActive, 'inactive', 'active') AS status FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["status"]!.ToString().Should().Be("active"); // id=1: NOT true = false - results[1]["status"]!.ToString().Should().Be("inactive"); // id=2: NOT false = true - results[2]["status"]!.ToString().Should().Be("active"); // id=3: NOT true = false - } - - [Fact] - public async Task Iif_WithEqualityCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.name = 'Alice', 'found', 'not found') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("found"); // id=1: Alice - results[1]["result"]!.ToString().Should().Be("not found"); // id=2: Bob - results[2]["result"]!.ToString().Should().Be("not found"); // id=3: Charlie - } - - [Fact] - public async Task Iif_WithIsDefinedCondition_EvaluatesCorrectly() - { - await SeedItems(); - // Item 1 has nested set (non-null), items 2&3 have nested = null (property still exists in JSON) - var query = new QueryDefinition("SELECT IIF(IS_DEFINED(c.nested), 'has nested', 'no nested') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("has nested"); // id=1: nested is defined - } - - [Fact] - public async Task Iif_WithContainsFunctionCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(CONTAINS(c.name, 'Ali'), 'match', 'no match') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("match"); // Alice contains 'Ali' - results[1]["result"]!.ToString().Should().Be("no match"); // Bob - results[2]["result"]!.ToString().Should().Be("no match"); // Charlie - } - - [Fact] - public async Task Iif_WithArrayLengthComparisonCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(ARRAY_LENGTH(c.tags) > 0, 'tagged', 'untagged') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("tagged"); // id=1: 2 tags - results[1]["result"]!.ToString().Should().Be("tagged"); // id=2: 1 tag - results[2]["result"]!.ToString().Should().Be("untagged"); // id=3: 0 tags - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 4: Return value variations - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithMixedReturnTypes_ReturnsCorrectType() - { - await SeedItems(); - var queryTrue = new QueryDefinition("SELECT IIF(c.isActive, 42, 'inactive') AS result FROM c WHERE c.id = '1'"); - var queryFalse = new QueryDefinition("SELECT IIF(c.isActive, 42, 'inactive') AS result FROM c WHERE c.id = '2'"); - - var resultsTrue = await QueryAll(queryTrue); - var resultsFalse = await QueryAll(queryFalse); - - resultsTrue[0]["result"]!.Value().Should().Be(42); - resultsFalse[0]["result"]!.ToString().Should().Be("inactive"); - } - - [Fact] - public async Task Iif_WithNullTrueBranch_ReturnsNull() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, null, 'fallback') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Iif_WithNullFalseBranch_ReturnsNull() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(false, 'value', null) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["result"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Iif_WithExpressionReturnValues_EvaluatesExpressions() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, c.value * 2, c.value) AS computed FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["computed"]!.Value().Should().Be(20); // id=1: active, 10*2 - results[1]["computed"]!.Value().Should().Be(20); // id=2: not active, raw 20 - results[2]["computed"]!.Value().Should().Be(0); // id=3: active, 0*2 - } - - [Fact] - public async Task Iif_WithFunctionCallReturnValues_EvaluatesFunctions() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, UPPER(c.name), LOWER(c.name)) AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("ALICE"); // id=1: active → UPPER - results[1]["result"]!.ToString().Should().Be("bob"); // id=2: not active → LOWER - results[2]["result"]!.ToString().Should().Be("CHARLIE"); // id=3: active → UPPER - } - - [Fact] - public async Task Iif_WithBooleanReturnValues_ReturnsBooleans() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value > 10, true, false) AS overTen FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["overTen"]!.Value().Should().BeFalse(); // 10 NOT > 10 - results[1]["overTen"]!.Value().Should().BeTrue(); // 20 > 10 - results[2]["overTen"]!.Value().Should().BeFalse(); // 0 - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 5: Advanced nesting and composition - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_TripleNested_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT IIF(c.value > 15, 'high', IIF(c.value > 5, 'medium', IIF(c.value > 0, 'low', 'zero'))) AS tier FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["tier"]!.ToString().Should().Be("medium"); // value=10 - results[1]["tier"]!.ToString().Should().Be("high"); // value=20 - results[2]["tier"]!.ToString().Should().Be("zero"); // value=0 - } - - [Fact] - public async Task Iif_InsideConcat_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT CONCAT(IIF(c.isActive, 'Active', 'Inactive'), ': ', c.name) AS label FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["label"]!.ToString().Should().Be("Active: Alice"); - results[1]["label"]!.ToString().Should().Be("Inactive: Bob"); - results[2]["label"]!.ToString().Should().Be("Active: Charlie"); - } - - [Fact] - public async Task Iif_MultipleInSameSelect_EvaluatesIndependently() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT IIF(c.isActive, 'active', 'inactive') AS status, IIF(c.value > 10, 'high', 'low') AS level FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["status"]!.ToString().Should().Be("active"); - results[0]["level"]!.ToString().Should().Be("low"); // 10 NOT > 10 - results[1]["status"]!.ToString().Should().Be("inactive"); - results[1]["level"]!.ToString().Should().Be("high"); // 20 > 10 - results[2]["status"]!.ToString().Should().Be("active"); - results[2]["level"]!.ToString().Should().Be("low"); // 0 - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 6: Usage contexts - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_InOrderBy_SortsCorrectly() - { - await SeedItems(); - // Sort active items first (0) then inactive (1), secondary sort by name - var query = new QueryDefinition( - "SELECT c.name FROM c ORDER BY IIF(c.isActive, 0, 1), c.name"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - // Active items first (sorted: Alice, Charlie), then inactive (Bob) - results[0]["name"]!.ToString().Should().Be("Alice"); - results[1]["name"]!.ToString().Should().Be("Charlie"); - results[2]["name"]!.ToString().Should().Be("Bob"); - } - - [Fact] - public async Task Iif_WithParameterizedQuery_UsesParameterValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value > @threshold, 'high', 'low') AS level FROM c ORDER BY c.id") - .WithParameter("@threshold", 15); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["level"]!.ToString().Should().Be("low"); // 10 - results[1]["level"]!.ToString().Should().Be("high"); // 20 - results[2]["level"]!.ToString().Should().Be("low"); // 0 - } - - [Fact] - public async Task Iif_InValueSelect_ReturnsScalar() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IIF(c.isActive, 'yes', 'no') FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle().Which.Should().Be("yes"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 7: Edge cases - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_FunctionNameCaseInsensitive_Works() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT iif(c.isActive, 'yes', 'no') AS r1, Iif(c.isActive, 'yes', 'no') AS r2 FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["r1"]!.ToString().Should().Be("yes"); - results[0]["r2"]!.ToString().Should().Be("yes"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 8: Non-boolean condition edge cases (Category A) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_NegativeNumberCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(-1, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_FloatCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(3.14, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_BooleanTrueLiteral_ReturnsTrueBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["result"]!.ToString().Should().Be("yes"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 9: Type-checking function conditions (Category B) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithIsNullCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IS_NULL(c.nested), 'null', 'not null') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("not null"); // nested = object - results[1]["result"]!.ToString().Should().Be("null"); // nested = null - results[2]["result"]!.ToString().Should().Be("null"); // nested = null - } - - [Fact] - public async Task Iif_WithIsNumberCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IS_NUMBER(c.value), 'number', 'not number') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("number")); - } - - [Fact] - public async Task Iif_WithIsBoolCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IS_BOOL(c.isActive), 'bool', 'not bool') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("bool")); - } - - [Fact] - public async Task Iif_WithIsStringCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IS_STRING(c.name), 'string', 'not string') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("string")); - } - - [Fact] - public async Task Iif_WithIsArrayCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IS_ARRAY(c.tags), 'array', 'not array') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("array")); - } - - [Fact] - public async Task Iif_WithStartsWithCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(STARTSWITH(c.name, 'A'), 'A-name', 'other') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("A-name"); // Alice - results[1]["result"]!.ToString().Should().Be("other"); // Bob - results[2]["result"]!.ToString().Should().Be("other"); // Charlie - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 10: Complex expression conditions (Category C) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithArithmeticCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value + 5 > 12, 'yes', 'no') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("yes"); // 10+5=15 > 12 - results[1]["result"]!.ToString().Should().Be("yes"); // 20+5=25 > 12 - results[2]["result"]!.ToString().Should().Be("no"); // 0+5=5 NOT > 12 - } - - [Fact] - public async Task Iif_WithNestedPropertyCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT IIF(IS_DEFINED(c.nested) AND NOT IS_NULL(c.nested) AND c.nested.score > 5, 'high', 'low') AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("high"); // score=9.5 - results[1]["result"]!.ToString().Should().Be("low"); // nested=null - results[2]["result"]!.ToString().Should().Be("low"); // nested=null - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 11: Undefined property in return branches (Category D) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_TrueCondition_UndefinedInUnselectedBranch_ReturnsValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, 'value', c.nonExistent) AS r FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["r"]!.ToString().Should().Be("value"); - } - - [Fact] - public async Task Iif_FalseCondition_UndefinedInUnselectedBranch_ReturnsValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(false, c.nonExistent, 'fallback') AS r FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["r"]!.ToString().Should().Be("fallback"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 12: Complex return values (Category E) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithNestedPropertyReturnValue_ReturnsNestedValue() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT IIF(IS_DEFINED(c.nested) AND NOT IS_NULL(c.nested), c.nested.score, 0) AS result FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - ((double)results[0]["result"]!).Should().Be(9.5); - ((long)results[1]["result"]!).Should().Be(0); - ((long)results[2]["result"]!).Should().Be(0); - } - - [Fact] - public async Task Iif_WithFloatReturnValues_ReturnsFloat() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, 3.14, 0) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - ((double)results[0]["result"]!).Should().Be(3.14); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Group 13: Composition with other SQL features (Category F) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithCoalesce_ComposesCorrectly() - { - await SeedItems(); - // IIF(false, null, null) → null. COALESCE(null, 'default') → null (null is defined) - var query = new QueryDefinition("SELECT COALESCE(IIF(false, null, null), 'default') AS r FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["r"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Iif_MultipleInWhereWithAnd_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT c.name FROM c WHERE IIF(c.isActive, true, false) = true AND c.value > 5"); - - var results = await QueryAll(query); - - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Iif_WithStringConcatInBothBranches_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT IIF(c.isActive, CONCAT('Active-', c.name), CONCAT('Inactive-', c.name)) AS label FROM c ORDER BY c.id"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - results[0]["label"]!.ToString().Should().Be("Active-Alice"); - results[1]["label"]!.ToString().Should().Be("Inactive-Bob"); - results[2]["label"]!.ToString().Should().Be("Active-Charlie"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 1A: High-priority gap tests (G1–G4) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_SelectedBranchUndefined_OmitsProperty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, c.nonExistent, 'fallback') AS r FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Property("r").Should().BeNull("selected branch is undefined so property should be omitted"); - } - - [Fact] - public async Task Iif_FalseBranchSelected_UndefinedBranch_OmitsProperty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(false, 'value', c.nonExistent) AS r FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Property("r").Should().BeNull("false branch is undefined so property should be omitted"); - } - - [Fact] - public async Task Iif_UndefinedComparisonCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.nonExistent > 5, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_BooleanFalseLiteralCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(false, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_AllArgsUndefined_OmitsProperty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.missing1, c.missing2, c.missing3) AS r FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Property("r").Should().BeNull("condition is undefined → false branch → c.missing3 → undefined → omit"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 1B: SQL feature composition tests (G5–G15) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithGroupBy_GroupsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, 'active', 'inactive') AS status, COUNT(1) AS cnt FROM c GROUP BY IIF(c.isActive, 'active', 'inactive')"); - var results = await QueryAll(query); - results.Should().HaveCount(2); - var active = results.First(r => r["status"]!.ToString() == "active"); - var inactive = results.First(r => r["status"]!.ToString() == "inactive"); - active["cnt"]!.Value().Should().Be(2); - inactive["cnt"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Iif_WithDistinct_ReturnsDistinctValues() - { - await SeedItems(); - var query = new QueryDefinition("SELECT DISTINCT IIF(c.isActive, 'yes', 'no') AS status FROM c"); - var results = await QueryAll(query); - results.Should().HaveCount(2); - results.Select(r => r["status"]!.ToString()).Should().BeEquivalentTo(["yes", "no"]); - } - - [Fact] - public async Task Iif_WithTop_LimitsResults() - { - await SeedItems(); - var query = new QueryDefinition("SELECT TOP 2 IIF(c.value > 10, 'high', 'low') AS level FROM c ORDER BY c.value DESC"); - var results = await QueryAll(query); - results.Should().HaveCount(2); - results[0]["level"]!.ToString().Should().Be("high"); // value=20 - results[1]["level"]!.ToString().Should().Be("low"); // value=10 - } - - [Fact] - public async Task Iif_WithOffsetLimit_PaginatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS status FROM c ORDER BY c.id OFFSET 1 LIMIT 1"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["status"]!.ToString().Should().Be("no"); // id='2' → inactive - } - - [Fact] - public async Task Iif_WithSelectStar_IncludesComputedField() - { - await SeedItems(); - var query = new QueryDefinition("SELECT *, IIF(c.isActive, 'yes', 'no') AS status FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["status"]!.ToString().Should().Be("yes"); - results[0]["id"]!.ToString().Should().Be("1"); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact(Skip = "Known limitation: scalar subquery (SELECT VALUE IIF(...)) AS alias not supported by parser")] - public async Task Iif_InScalarSubquery_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT (SELECT VALUE IIF(c.isActive, 'yes', 'no')) AS status FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["status"]!.ToString().Should().Be("yes"); - } - - [Fact] - public async Task Iif_WithJoin_EvaluatesPerJoinedRow() - { - await SeedItems(); - var query = new QueryDefinition("SELECT t AS tag, IIF(t = 'dot', 'match', 'no') AS result FROM c JOIN t IN c.tags WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(2); - var dotResult = results.First(r => r["tag"]!.ToString() == "dot"); - var netResult = results.First(r => r["tag"]!.ToString() == "net"); - dotResult["result"]!.ToString().Should().Be("match"); - netResult["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_WithBetweenCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value BETWEEN 5 AND 15, 'in range', 'out') AS range FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["range"]!.ToString().Should().Be("in range"); // value=10 - results[1]["range"]!.ToString().Should().Be("out"); // value=20 - results[2]["range"]!.ToString().Should().Be("out"); // value=0 - } - - [Fact] - public async Task Iif_WithInCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value IN (10, 20), 'match', 'no match') AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("match"); // value=10 - results[1]["result"]!.ToString().Should().Be("match"); // value=20 - results[2]["result"]!.ToString().Should().Be("no match"); // value=0 - } - - [Fact] - public async Task Iif_WithLikeCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.name LIKE 'A%', 'A-name', 'other') AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("A-name"); // Alice - results[1]["result"]!.ToString().Should().Be("other"); // Bob - results[2]["result"]!.ToString().Should().Be("other"); // Charlie - } - - [Fact] - public async Task Iif_WithIsNullSyntaxCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.nested IS NULL, 'null', 'not null') AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("not null"); // nested exists - results[1]["result"]!.ToString().Should().Be("null"); // nested = null - results[2]["result"]!.ToString().Should().Be("null"); // nested = null - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Phase 1C: Additional edge cases (G16–G27) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Iif_WithCoalesceInBranch_ComposesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, c.nested.score ?? 0, -1) AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.Value().Should().Be(9.5); // id1: active, nested.score=9.5 - results[1]["result"]!.Value().Should().Be(-1); // id2: inactive - results[2]["result"]!.Value().Should().Be(0); // id3: active, nested=null → coalesce to 0 - } - - [Fact] - public async Task Iif_WithTernaryInBranch_ComposesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, (c.value > 10 ? 'high' : 'low'), 'inactive') AS r FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["r"]!.ToString().Should().Be("low"); // id1: active, 10 NOT > 10 - results[1]["r"]!.ToString().Should().Be("inactive"); // id2: not active - results[2]["r"]!.ToString().Should().Be("low"); // id3: active, 0 NOT > 10 - } - - [Fact] - public async Task Iif_WithParameterizedBooleanCondition_UsesParam() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(@flag, 'yes', 'no') AS result FROM c WHERE c.id = '1'") - .WithParameter("@flag", true); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("yes"); - } - - [Fact] - public async Task Iif_WithParameterizedNonBooleanCondition_ReturnsFalseBranch() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(@val, 'yes', 'no') AS result FROM c WHERE c.id = '1'") - .WithParameter("@val", 42); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.ToString().Should().Be("no"); - } - - [Fact] - public async Task Iif_WithMathFunctionsInBranches_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.value > 0, SQRT(c.value), ABS(c.value)) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().BeApproximately(Math.Sqrt(10), 0.001); - } - - [Fact] - public async Task Iif_WithArrayContainsCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(ARRAY_CONTAINS(c.tags, 'dot'), 'dotnet', 'other') AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("dotnet"); // id1: tags=["dot","net"] - results[1]["result"]!.ToString().Should().Be("other"); // id2: tags=["java"] - results[2]["result"]!.ToString().Should().Be("other"); // id3: tags=[] - } - - [Fact] - public async Task Iif_WithEndswithCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(ENDSWITH(c.name, 'e'), 'ends-e', 'no') AS result FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["result"]!.ToString().Should().Be("ends-e"); // Alice - results[1]["result"]!.ToString().Should().Be("no"); // Bob - results[2]["result"]!.ToString().Should().Be("ends-e"); // Charlie - } - - [Fact] - public async Task Iif_NestedIifInCondition_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(IIF(c.isActive, true, false), 'active', 'inactive') AS r FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["r"]!.ToString().Should().Be("active"); // id1 - results[1]["r"]!.ToString().Should().Be("inactive"); // id2 - results[2]["r"]!.ToString().Should().Be("active"); // id3 - } - - [Fact] - public async Task Iif_WithUnaryMinusInReturn_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(c.isActive, c.value, -c.value) AS r FROM c ORDER BY c.id"); - var results = await QueryAll(query); - results.Should().HaveCount(3); - results[0]["r"]!.Value().Should().Be(10); // id1: active - results[1]["r"]!.Value().Should().Be(-20); // id2: not active → -20 - results[2]["r"]!.Value().Should().Be(0); // id3: active, value=0 - } - - [Fact] - public async Task Iif_EagerEvaluation_BothBranchesEvaluated_DivisionByZero() - { - // Emulator eagerly evaluates both branches. 1/0 in unselected branch should not crash - // because integer division by zero typically produces Infinity or is handled gracefully. - await SeedItems(); - var query = new QueryDefinition("SELECT IIF(true, 'safe', 1/0) AS r FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["r"]!.ToString().Should().Be("safe"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Helper - // ═══════════════════════════════════════════════════════════════════════════ - - private async Task> QueryAll(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["dot", "net"], Nested = new NestedObject { Description = "desc", Score = 9.5 } }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["java"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 0, IsActive = true, Tags = [] }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Original tests (pre-existing) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_TrueCondition_ReturnsSecondArgument() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("yes"); + } + + [Fact] + public async Task Iif_FalseCondition_ReturnsThirdArgument() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS result FROM c WHERE c.id = '2'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_WithComparisonExpression_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value > 15, 'high', 'low') AS level FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["level"]!.ToString().Should().Be("low"); // value=10 + results[1]["level"]!.ToString().Should().Be("high"); // value=20 + results[2]["level"]!.ToString().Should().Be("low"); // value=0 + } + + [Fact] + public async Task Iif_WithNumericReturnValues_ReturnsNumbers() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, 1, 0) AS flag FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["flag"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Iif_InWhereClause_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE IIF(c.isActive, c.value, 0) > 5"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task Iif_NestedIif_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value > 15, 'high', IIF(c.value > 5, 'medium', 'low')) AS level FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["level"]!.ToString().Should().Be("medium"); // value=10 + results[1]["level"]!.ToString().Should().Be("high"); // value=20 + results[2]["level"]!.ToString().Should().Be("low"); // value=0 + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 1: Non-boolean condition bug fix + // Real Cosmos DB IIF only treats boolean true as truthy. + // Numbers, strings, arrays, objects all return the false branch. + // See: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/iif + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_NumericNonZeroCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(42, ...) → false branch (42 is not boolean true) + var query = new QueryDefinition("SELECT IIF(42, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_NumericZeroCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(0, ...) → false branch (0 is not boolean true) + var query = new QueryDefinition("SELECT IIF(0, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_StringCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF('hello', ...) → false branch ('hello' is not boolean true) + var query = new QueryDefinition("SELECT IIF('hello', 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_EmptyStringCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF('', ...) → false branch ('' is not boolean true) + var query = new QueryDefinition("SELECT IIF('', 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_NumericFieldAsCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(c.value, ...) → false branch for all items (numeric field is never boolean) + var query = new QueryDefinition("SELECT IIF(c.value, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("no"); // value=10 — not boolean + results[1]["result"]!.ToString().Should().Be("no"); // value=20 — not boolean + results[2]["result"]!.ToString().Should().Be("no"); // value=0 — not boolean + } + + [Fact] + public async Task Iif_StringFieldAsCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(c.name, ...) → false branch for all items (string field is never boolean) + var query = new QueryDefinition("SELECT IIF(c.name, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("no"); // name=Alice — not boolean + results[1]["result"]!.ToString().Should().Be("no"); // name=Bob — not boolean + results[2]["result"]!.ToString().Should().Be("no"); // name=Charlie — not boolean + } + + [Fact] + public async Task Iif_ArrayFieldAsCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(c.tags, ...) → false branch for all items (array is never boolean) + // Even empty arrays return false branch — only boolean true returns true branch + var query = new QueryDefinition("SELECT IIF(c.tags, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("no"); // tags=["dot","net"] — not boolean + results[1]["result"]!.ToString().Should().Be("no"); // tags=["java"] — not boolean + results[2]["result"]!.ToString().Should().Be("no"); // tags=[] — not boolean + } + + [Fact] + public async Task Iif_ObjectFieldAsCondition_ReturnsFalseBranch() + { + await SeedItems(); + // Real Cosmos DB: IIF(c.nested, ...) → false branch (object is never boolean) + // Item 1 has nested = { description: "desc", score: 9.5 } + var query = new QueryDefinition("SELECT IIF(c.nested, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 2: Null and undefined conditions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_NullCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(null, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_UndefinedPropertyCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.nonExistentField, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 3: Complex boolean expressions in condition + // These produce actual boolean values, so IIF should work normally. + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithAndCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive AND c.value > 5, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("yes"); // id=1: active AND 10>5 + results[1]["result"]!.ToString().Should().Be("no"); // id=2: NOT active + results[2]["result"]!.ToString().Should().Be("no"); // id=3: active AND NOT 0>5 + } + + [Fact] + public async Task Iif_WithOrCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive OR c.value > 15, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("yes"); // id=1: active + results[1]["result"]!.ToString().Should().Be("yes"); // id=2: 20>15 + results[2]["result"]!.ToString().Should().Be("yes"); // id=3: active + } + + [Fact] + public async Task Iif_WithNotCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(NOT c.isActive, 'inactive', 'active') AS status FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["status"]!.ToString().Should().Be("active"); // id=1: NOT true = false + results[1]["status"]!.ToString().Should().Be("inactive"); // id=2: NOT false = true + results[2]["status"]!.ToString().Should().Be("active"); // id=3: NOT true = false + } + + [Fact] + public async Task Iif_WithEqualityCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.name = 'Alice', 'found', 'not found') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("found"); // id=1: Alice + results[1]["result"]!.ToString().Should().Be("not found"); // id=2: Bob + results[2]["result"]!.ToString().Should().Be("not found"); // id=3: Charlie + } + + [Fact] + public async Task Iif_WithIsDefinedCondition_EvaluatesCorrectly() + { + await SeedItems(); + // Item 1 has nested set (non-null), items 2&3 have nested = null (property still exists in JSON) + var query = new QueryDefinition("SELECT IIF(IS_DEFINED(c.nested), 'has nested', 'no nested') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("has nested"); // id=1: nested is defined + } + + [Fact] + public async Task Iif_WithContainsFunctionCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(CONTAINS(c.name, 'Ali'), 'match', 'no match') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("match"); // Alice contains 'Ali' + results[1]["result"]!.ToString().Should().Be("no match"); // Bob + results[2]["result"]!.ToString().Should().Be("no match"); // Charlie + } + + [Fact] + public async Task Iif_WithArrayLengthComparisonCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(ARRAY_LENGTH(c.tags) > 0, 'tagged', 'untagged') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("tagged"); // id=1: 2 tags + results[1]["result"]!.ToString().Should().Be("tagged"); // id=2: 1 tag + results[2]["result"]!.ToString().Should().Be("untagged"); // id=3: 0 tags + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 4: Return value variations + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithMixedReturnTypes_ReturnsCorrectType() + { + await SeedItems(); + var queryTrue = new QueryDefinition("SELECT IIF(c.isActive, 42, 'inactive') AS result FROM c WHERE c.id = '1'"); + var queryFalse = new QueryDefinition("SELECT IIF(c.isActive, 42, 'inactive') AS result FROM c WHERE c.id = '2'"); + + var resultsTrue = await QueryAll(queryTrue); + var resultsFalse = await QueryAll(queryFalse); + + resultsTrue[0]["result"]!.Value().Should().Be(42); + resultsFalse[0]["result"]!.ToString().Should().Be("inactive"); + } + + [Fact] + public async Task Iif_WithNullTrueBranch_ReturnsNull() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, null, 'fallback') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Iif_WithNullFalseBranch_ReturnsNull() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(false, 'value', null) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["result"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Iif_WithExpressionReturnValues_EvaluatesExpressions() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, c.value * 2, c.value) AS computed FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["computed"]!.Value().Should().Be(20); // id=1: active, 10*2 + results[1]["computed"]!.Value().Should().Be(20); // id=2: not active, raw 20 + results[2]["computed"]!.Value().Should().Be(0); // id=3: active, 0*2 + } + + [Fact] + public async Task Iif_WithFunctionCallReturnValues_EvaluatesFunctions() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, UPPER(c.name), LOWER(c.name)) AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("ALICE"); // id=1: active → UPPER + results[1]["result"]!.ToString().Should().Be("bob"); // id=2: not active → LOWER + results[2]["result"]!.ToString().Should().Be("CHARLIE"); // id=3: active → UPPER + } + + [Fact] + public async Task Iif_WithBooleanReturnValues_ReturnsBooleans() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value > 10, true, false) AS overTen FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["overTen"]!.Value().Should().BeFalse(); // 10 NOT > 10 + results[1]["overTen"]!.Value().Should().BeTrue(); // 20 > 10 + results[2]["overTen"]!.Value().Should().BeFalse(); // 0 + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 5: Advanced nesting and composition + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_TripleNested_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT IIF(c.value > 15, 'high', IIF(c.value > 5, 'medium', IIF(c.value > 0, 'low', 'zero'))) AS tier FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["tier"]!.ToString().Should().Be("medium"); // value=10 + results[1]["tier"]!.ToString().Should().Be("high"); // value=20 + results[2]["tier"]!.ToString().Should().Be("zero"); // value=0 + } + + [Fact] + public async Task Iif_InsideConcat_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT CONCAT(IIF(c.isActive, 'Active', 'Inactive'), ': ', c.name) AS label FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["label"]!.ToString().Should().Be("Active: Alice"); + results[1]["label"]!.ToString().Should().Be("Inactive: Bob"); + results[2]["label"]!.ToString().Should().Be("Active: Charlie"); + } + + [Fact] + public async Task Iif_MultipleInSameSelect_EvaluatesIndependently() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT IIF(c.isActive, 'active', 'inactive') AS status, IIF(c.value > 10, 'high', 'low') AS level FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["status"]!.ToString().Should().Be("active"); + results[0]["level"]!.ToString().Should().Be("low"); // 10 NOT > 10 + results[1]["status"]!.ToString().Should().Be("inactive"); + results[1]["level"]!.ToString().Should().Be("high"); // 20 > 10 + results[2]["status"]!.ToString().Should().Be("active"); + results[2]["level"]!.ToString().Should().Be("low"); // 0 + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 6: Usage contexts + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_InOrderBy_SortsCorrectly() + { + await SeedItems(); + // Sort active items first (0) then inactive (1), secondary sort by name + var query = new QueryDefinition( + "SELECT c.name FROM c ORDER BY IIF(c.isActive, 0, 1), c.name"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + // Active items first (sorted: Alice, Charlie), then inactive (Bob) + results[0]["name"]!.ToString().Should().Be("Alice"); + results[1]["name"]!.ToString().Should().Be("Charlie"); + results[2]["name"]!.ToString().Should().Be("Bob"); + } + + [Fact] + public async Task Iif_WithParameterizedQuery_UsesParameterValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value > @threshold, 'high', 'low') AS level FROM c ORDER BY c.id") + .WithParameter("@threshold", 15); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["level"]!.ToString().Should().Be("low"); // 10 + results[1]["level"]!.ToString().Should().Be("high"); // 20 + results[2]["level"]!.ToString().Should().Be("low"); // 0 + } + + [Fact] + public async Task Iif_InValueSelect_ReturnsScalar() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IIF(c.isActive, 'yes', 'no') FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle().Which.Should().Be("yes"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 7: Edge cases + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_FunctionNameCaseInsensitive_Works() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT iif(c.isActive, 'yes', 'no') AS r1, Iif(c.isActive, 'yes', 'no') AS r2 FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["r1"]!.ToString().Should().Be("yes"); + results[0]["r2"]!.ToString().Should().Be("yes"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 8: Non-boolean condition edge cases (Category A) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_NegativeNumberCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(-1, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_FloatCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(3.14, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_BooleanTrueLiteral_ReturnsTrueBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["result"]!.ToString().Should().Be("yes"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 9: Type-checking function conditions (Category B) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithIsNullCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IS_NULL(c.nested), 'null', 'not null') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("not null"); // nested = object + results[1]["result"]!.ToString().Should().Be("null"); // nested = null + results[2]["result"]!.ToString().Should().Be("null"); // nested = null + } + + [Fact] + public async Task Iif_WithIsNumberCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IS_NUMBER(c.value), 'number', 'not number') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("number")); + } + + [Fact] + public async Task Iif_WithIsBoolCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IS_BOOL(c.isActive), 'bool', 'not bool') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("bool")); + } + + [Fact] + public async Task Iif_WithIsStringCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IS_STRING(c.name), 'string', 'not string') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("string")); + } + + [Fact] + public async Task Iif_WithIsArrayCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IS_ARRAY(c.tags), 'array', 'not array') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r["result"]!.ToString().Should().Be("array")); + } + + [Fact] + public async Task Iif_WithStartsWithCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(STARTSWITH(c.name, 'A'), 'A-name', 'other') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("A-name"); // Alice + results[1]["result"]!.ToString().Should().Be("other"); // Bob + results[2]["result"]!.ToString().Should().Be("other"); // Charlie + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 10: Complex expression conditions (Category C) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithArithmeticCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value + 5 > 12, 'yes', 'no') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("yes"); // 10+5=15 > 12 + results[1]["result"]!.ToString().Should().Be("yes"); // 20+5=25 > 12 + results[2]["result"]!.ToString().Should().Be("no"); // 0+5=5 NOT > 12 + } + + [Fact] + public async Task Iif_WithNestedPropertyCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT IIF(IS_DEFINED(c.nested) AND NOT IS_NULL(c.nested) AND c.nested.score > 5, 'high', 'low') AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("high"); // score=9.5 + results[1]["result"]!.ToString().Should().Be("low"); // nested=null + results[2]["result"]!.ToString().Should().Be("low"); // nested=null + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 11: Undefined property in return branches (Category D) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_TrueCondition_UndefinedInUnselectedBranch_ReturnsValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, 'value', c.nonExistent) AS r FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["r"]!.ToString().Should().Be("value"); + } + + [Fact] + public async Task Iif_FalseCondition_UndefinedInUnselectedBranch_ReturnsValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(false, c.nonExistent, 'fallback') AS r FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["r"]!.ToString().Should().Be("fallback"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 12: Complex return values (Category E) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithNestedPropertyReturnValue_ReturnsNestedValue() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT IIF(IS_DEFINED(c.nested) AND NOT IS_NULL(c.nested), c.nested.score, 0) AS result FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + ((double)results[0]["result"]!).Should().Be(9.5); + ((long)results[1]["result"]!).Should().Be(0); + ((long)results[2]["result"]!).Should().Be(0); + } + + [Fact] + public async Task Iif_WithFloatReturnValues_ReturnsFloat() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, 3.14, 0) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + ((double)results[0]["result"]!).Should().Be(3.14); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Group 13: Composition with other SQL features (Category F) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithCoalesce_ComposesCorrectly() + { + await SeedItems(); + // IIF(false, null, null) → null. COALESCE(null, 'default') → null (null is defined) + var query = new QueryDefinition("SELECT COALESCE(IIF(false, null, null), 'default') AS r FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["r"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Iif_MultipleInWhereWithAnd_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT c.name FROM c WHERE IIF(c.isActive, true, false) = true AND c.value > 5"); + + var results = await QueryAll(query); + + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Iif_WithStringConcatInBothBranches_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT IIF(c.isActive, CONCAT('Active-', c.name), CONCAT('Inactive-', c.name)) AS label FROM c ORDER BY c.id"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + results[0]["label"]!.ToString().Should().Be("Active-Alice"); + results[1]["label"]!.ToString().Should().Be("Inactive-Bob"); + results[2]["label"]!.ToString().Should().Be("Active-Charlie"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 1A: High-priority gap tests (G1–G4) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_SelectedBranchUndefined_OmitsProperty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, c.nonExistent, 'fallback') AS r FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0].Property("r").Should().BeNull("selected branch is undefined so property should be omitted"); + } + + [Fact] + public async Task Iif_FalseBranchSelected_UndefinedBranch_OmitsProperty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(false, 'value', c.nonExistent) AS r FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0].Property("r").Should().BeNull("false branch is undefined so property should be omitted"); + } + + [Fact] + public async Task Iif_UndefinedComparisonCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.nonExistent > 5, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_BooleanFalseLiteralCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(false, 'yes', 'no') AS result FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_AllArgsUndefined_OmitsProperty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.missing1, c.missing2, c.missing3) AS r FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0].Property("r").Should().BeNull("condition is undefined → false branch → c.missing3 → undefined → omit"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 1B: SQL feature composition tests (G5–G15) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithGroupBy_GroupsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, 'active', 'inactive') AS status, COUNT(1) AS cnt FROM c GROUP BY IIF(c.isActive, 'active', 'inactive')"); + var results = await QueryAll(query); + results.Should().HaveCount(2); + var active = results.First(r => r["status"]!.ToString() == "active"); + var inactive = results.First(r => r["status"]!.ToString() == "inactive"); + active["cnt"]!.Value().Should().Be(2); + inactive["cnt"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Iif_WithDistinct_ReturnsDistinctValues() + { + await SeedItems(); + var query = new QueryDefinition("SELECT DISTINCT IIF(c.isActive, 'yes', 'no') AS status FROM c"); + var results = await QueryAll(query); + results.Should().HaveCount(2); + results.Select(r => r["status"]!.ToString()).Should().BeEquivalentTo(["yes", "no"]); + } + + [Fact] + public async Task Iif_WithTop_LimitsResults() + { + await SeedItems(); + var query = new QueryDefinition("SELECT TOP 2 IIF(c.value > 10, 'high', 'low') AS level FROM c ORDER BY c.value DESC"); + var results = await QueryAll(query); + results.Should().HaveCount(2); + results[0]["level"]!.ToString().Should().Be("high"); // value=20 + results[1]["level"]!.ToString().Should().Be("low"); // value=10 + } + + [Fact] + public async Task Iif_WithOffsetLimit_PaginatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, 'yes', 'no') AS status FROM c ORDER BY c.id OFFSET 1 LIMIT 1"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["status"]!.ToString().Should().Be("no"); // id='2' → inactive + } + + [Fact] + public async Task Iif_WithSelectStar_IncludesComputedField() + { + await SeedItems(); + var query = new QueryDefinition("SELECT *, IIF(c.isActive, 'yes', 'no') AS status FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["status"]!.ToString().Should().Be("yes"); + results[0]["id"]!.ToString().Should().Be("1"); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact(Skip = "Known limitation: scalar subquery (SELECT VALUE IIF(...)) AS alias not supported by parser")] + public async Task Iif_InScalarSubquery_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT (SELECT VALUE IIF(c.isActive, 'yes', 'no')) AS status FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["status"]!.ToString().Should().Be("yes"); + } + + [Fact] + public async Task Iif_WithJoin_EvaluatesPerJoinedRow() + { + await SeedItems(); + var query = new QueryDefinition("SELECT t AS tag, IIF(t = 'dot', 'match', 'no') AS result FROM c JOIN t IN c.tags WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(2); + var dotResult = results.First(r => r["tag"]!.ToString() == "dot"); + var netResult = results.First(r => r["tag"]!.ToString() == "net"); + dotResult["result"]!.ToString().Should().Be("match"); + netResult["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_WithBetweenCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value BETWEEN 5 AND 15, 'in range', 'out') AS range FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["range"]!.ToString().Should().Be("in range"); // value=10 + results[1]["range"]!.ToString().Should().Be("out"); // value=20 + results[2]["range"]!.ToString().Should().Be("out"); // value=0 + } + + [Fact] + public async Task Iif_WithInCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value IN (10, 20), 'match', 'no match') AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("match"); // value=10 + results[1]["result"]!.ToString().Should().Be("match"); // value=20 + results[2]["result"]!.ToString().Should().Be("no match"); // value=0 + } + + [Fact] + public async Task Iif_WithLikeCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.name LIKE 'A%', 'A-name', 'other') AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("A-name"); // Alice + results[1]["result"]!.ToString().Should().Be("other"); // Bob + results[2]["result"]!.ToString().Should().Be("other"); // Charlie + } + + [Fact] + public async Task Iif_WithIsNullSyntaxCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.nested IS NULL, 'null', 'not null') AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("not null"); // nested exists + results[1]["result"]!.ToString().Should().Be("null"); // nested = null + results[2]["result"]!.ToString().Should().Be("null"); // nested = null + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Phase 1C: Additional edge cases (G16–G27) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Iif_WithCoalesceInBranch_ComposesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, c.nested.score ?? 0, -1) AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.Value().Should().Be(9.5); // id1: active, nested.score=9.5 + results[1]["result"]!.Value().Should().Be(-1); // id2: inactive + results[2]["result"]!.Value().Should().Be(0); // id3: active, nested=null → coalesce to 0 + } + + [Fact] + public async Task Iif_WithTernaryInBranch_ComposesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, (c.value > 10 ? 'high' : 'low'), 'inactive') AS r FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["r"]!.ToString().Should().Be("low"); // id1: active, 10 NOT > 10 + results[1]["r"]!.ToString().Should().Be("inactive"); // id2: not active + results[2]["r"]!.ToString().Should().Be("low"); // id3: active, 0 NOT > 10 + } + + [Fact] + public async Task Iif_WithParameterizedBooleanCondition_UsesParam() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(@flag, 'yes', 'no') AS result FROM c WHERE c.id = '1'") + .WithParameter("@flag", true); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("yes"); + } + + [Fact] + public async Task Iif_WithParameterizedNonBooleanCondition_ReturnsFalseBranch() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(@val, 'yes', 'no') AS result FROM c WHERE c.id = '1'") + .WithParameter("@val", 42); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["result"]!.ToString().Should().Be("no"); + } + + [Fact] + public async Task Iif_WithMathFunctionsInBranches_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.value > 0, SQRT(c.value), ABS(c.value)) AS result FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().BeApproximately(Math.Sqrt(10), 0.001); + } + + [Fact] + public async Task Iif_WithArrayContainsCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(ARRAY_CONTAINS(c.tags, 'dot'), 'dotnet', 'other') AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("dotnet"); // id1: tags=["dot","net"] + results[1]["result"]!.ToString().Should().Be("other"); // id2: tags=["java"] + results[2]["result"]!.ToString().Should().Be("other"); // id3: tags=[] + } + + [Fact] + public async Task Iif_WithEndswithCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(ENDSWITH(c.name, 'e'), 'ends-e', 'no') AS result FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["result"]!.ToString().Should().Be("ends-e"); // Alice + results[1]["result"]!.ToString().Should().Be("no"); // Bob + results[2]["result"]!.ToString().Should().Be("ends-e"); // Charlie + } + + [Fact] + public async Task Iif_NestedIifInCondition_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(IIF(c.isActive, true, false), 'active', 'inactive') AS r FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["r"]!.ToString().Should().Be("active"); // id1 + results[1]["r"]!.ToString().Should().Be("inactive"); // id2 + results[2]["r"]!.ToString().Should().Be("active"); // id3 + } + + [Fact] + public async Task Iif_WithUnaryMinusInReturn_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(c.isActive, c.value, -c.value) AS r FROM c ORDER BY c.id"); + var results = await QueryAll(query); + results.Should().HaveCount(3); + results[0]["r"]!.Value().Should().Be(10); // id1: active + results[1]["r"]!.Value().Should().Be(-20); // id2: not active → -20 + results[2]["r"]!.Value().Should().Be(0); // id3: active, value=0 + } + + [Fact] + public async Task Iif_EagerEvaluation_BothBranchesEvaluated_DivisionByZero() + { + // Emulator eagerly evaluates both branches. 1/0 in unselected branch should not crash + // because integer division by zero typically produces Infinity or is handled gracefully. + await SeedItems(); + var query = new QueryDefinition("SELECT IIF(true, 'safe', 1/0) AS r FROM c WHERE c.id = '1'"); + var results = await QueryAll(query); + results.Should().HaveCount(1); + results[0]["r"]!.ToString().Should().Be("safe"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Helper + // ═══════════════════════════════════════════════════════════════════════════ + + private async Task> QueryAll(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosClientTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosClientTests.cs index 2f3fd68..3c86553 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosClientTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosClientTests.cs @@ -1,232 +1,232 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class InMemoryCosmosClientTests { - // ── Database management ───────────────────────────────────────────────── + // ── Database management ───────────────────────────────────────────────── - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_CreatesDatabaseSuccessfully() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_CreatesDatabaseSuccessfully() + { + var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - response.Resource.Id.Should().Be("test-db"); - } + response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + response.Resource.Id.Should().Be("test-db"); + } - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_SecondCall_ReturnsSameDatabase() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_SecondCall_ReturnsSameDatabase() + { + var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); - var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); + await client.CreateDatabaseIfNotExistsAsync("test-db"); + var response = await client.CreateDatabaseIfNotExistsAsync("test-db"); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - } + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + } - [Fact] - public void GetDatabase_ReturnsDatabase() - { - var client = new InMemoryCosmosClient(); + [Fact] + public void GetDatabase_ReturnsDatabase() + { + var client = new InMemoryCosmosClient(); - var database = client.GetDatabase("test-db"); + var database = client.GetDatabase("test-db"); - database.Should().NotBeNull(); - database.Id.Should().Be("test-db"); - } + database.Should().NotBeNull(); + database.Id.Should().Be("test-db"); + } - // ── Container management ──────────────────────────────────────────────── + // ── Container management ──────────────────────────────────────────────── - [Fact] - public async Task GetContainer_ReturnsWorkingContainer() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); + [Fact] + public async Task GetContainer_ReturnsWorkingContainer() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container = client.GetContainer("test-db", "test-container"); + var container = client.GetContainer("test-db", "test-container"); - container.Should().NotBeNull(); - container.Id.Should().Be("test-container"); - } + container.Should().NotBeNull(); + container.Id.Should().Be("test-container"); + } - [Fact] - public async Task Container_CanStoreThenReadItem() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container = client.GetContainer("test-db", "test-container"); + [Fact] + public async Task Container_CanStoreThenReadItem() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); + var container = client.GetContainer("test-db", "test-container"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice", Value = 10 }, - new PartitionKey("1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice", Value = 10 }, + new PartitionKey("1")); - var readResponse = await container.ReadItemAsync("1", new PartitionKey("1")); - readResponse.Resource.Name.Should().Be("Alice"); - } + var readResponse = await container.ReadItemAsync("1", new PartitionKey("1")); + readResponse.Resource.Name.Should().Be("Alice"); + } - // ── Multi-database isolation ──────────────────────────────────────────── + // ── Multi-database isolation ──────────────────────────────────────────── - [Fact] - public async Task Containers_InDifferentDatabases_AreIsolated() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("db1"); - await client.CreateDatabaseIfNotExistsAsync("db2"); + [Fact] + public async Task Containers_InDifferentDatabases_AreIsolated() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("db1"); + await client.CreateDatabaseIfNotExistsAsync("db2"); - var container1 = client.GetContainer("db1", "shared-name"); - var container2 = client.GetContainer("db2", "shared-name"); + var container1 = client.GetContainer("db1", "shared-name"); + var container2 = client.GetContainer("db2", "shared-name"); - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "From DB1" }, - new PartitionKey("1")); + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "From DB1" }, + new PartitionKey("1")); - container1.Should().BeOfType(); - ((InMemoryContainer)container1).ItemCount.Should().Be(1); - ((InMemoryContainer)container2).ItemCount.Should().Be(0); - } + container1.Should().BeOfType(); + ((InMemoryContainer)container1).ItemCount.Should().Be(1); + ((InMemoryContainer)container2).ItemCount.Should().Be(0); + } - // ── Multi-container within same database ──────────────────────────────── + // ── Multi-container within same database ──────────────────────────────── - [Fact] - public async Task MultipleContainers_InSameDatabase_AreIsolated() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); + [Fact] + public async Task MultipleContainers_InSameDatabase_AreIsolated() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container1 = client.GetContainer("test-db", "container-1"); - var container2 = client.GetContainer("test-db", "container-2"); + var container1 = client.GetContainer("test-db", "container-1"); + var container2 = client.GetContainer("test-db", "container-2"); - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "Container1Only" }, - new PartitionKey("1")); + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "Container1Only" }, + new PartitionKey("1")); - ((InMemoryContainer)container1).ItemCount.Should().Be(1); - ((InMemoryContainer)container2).ItemCount.Should().Be(0); - } + ((InMemoryContainer)container1).ItemCount.Should().Be(1); + ((InMemoryContainer)container2).ItemCount.Should().Be(0); + } - // ── GetContainer returns same instance for same database+container ────── + // ── GetContainer returns same instance for same database+container ────── - [Fact] - public async Task GetContainer_ReturnsSameInstance_ForSameId() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); + [Fact] + public async Task GetContainer_ReturnsSameInstance_ForSameId() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container1 = client.GetContainer("test-db", "test-container"); - var container2 = client.GetContainer("test-db", "test-container"); + var container1 = client.GetContainer("test-db", "test-container"); + var container2 = client.GetContainer("test-db", "test-container"); - container1.Should().BeSameAs(container2); - } + container1.Should().BeSameAs(container2); + } - // ── Querying works through client-created containers ──────────────────── + // ── Querying works through client-created containers ──────────────────── - [Fact] - public async Task Container_SupportsQuerying() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container = client.GetContainer("test-db", "items"); + [Fact] + public async Task Container_SupportsQuerying() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); + var container = client.GetContainer("test-db", "items"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice", Value = 10 }, - new PartitionKey("1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "2", Name = "Bob", Value = 20 }, - new PartitionKey("2")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice", Value = 10 }, + new PartitionKey("1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "2", Name = "Bob", Value = 20 }, + new PartitionKey("2")); - var query = new QueryDefinition("SELECT * FROM c WHERE c.value > 15"); - var iterator = container.GetItemQueryIterator(query); + var query = new QueryDefinition("SELECT * FROM c WHERE c.value > 15"); + var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } - results.Should().HaveCount(1); - results[0].Name.Should().Be("Bob"); - } + results.Should().HaveCount(1); + results[0].Name.Should().Be("Bob"); + } - // ── Dispose doesn't throw ─────────────────────────────────────────────── + // ── Dispose doesn't throw ─────────────────────────────────────────────── - [Fact] - public void Dispose_DoesNotThrow() - { - var client = new InMemoryCosmosClient(); + [Fact] + public void Dispose_DoesNotThrow() + { + var client = new InMemoryCosmosClient(); - var action = () => client.Dispose(); + var action = () => client.Dispose(); - action.Should().NotThrow(); - } + action.Should().NotThrow(); + } - // ── CreateDatabaseAsync ───────────────────────────────────────────────── + // ── CreateDatabaseAsync ───────────────────────────────────────────────── - [Fact] - public async Task CreateDatabaseAsync_CreatesDatabaseSuccessfully() - { - var client = new InMemoryCosmosClient(); + [Fact] + public async Task CreateDatabaseAsync_CreatesDatabaseSuccessfully() + { + var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("test-db"); + var response = await client.CreateDatabaseAsync("test-db"); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - response.Resource.Id.Should().Be("test-db"); - } + response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + response.Resource.Id.Should().Be("test-db"); + } - // ── Database containers can use custom partition key paths ─────────────── + // ── Database containers can use custom partition key paths ─────────────── - [Fact] - public async Task GetContainer_UsesDefaultPartitionKeyPath() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseIfNotExistsAsync("test-db"); + [Fact] + public async Task GetContainer_UsesDefaultPartitionKeyPath() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseIfNotExistsAsync("test-db"); - var container = client.GetContainer("test-db", "items"); + var container = client.GetContainer("test-db", "items"); - // The default partition key path should be /id for InMemoryCosmosClient containers - container.Should().BeOfType(); - } + // The default partition key path should be /id for InMemoryCosmosClient containers + container.Should().BeOfType(); + } - // ── Database.CreateContainerIfNotExistsAsync ──────────────────────────── + // ── Database.CreateContainerIfNotExistsAsync ──────────────────────────── - [Fact] - public async Task Database_CreateContainerIfNotExistsAsync_CreatesContainer() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; + [Fact] + public async Task Database_CreateContainerIfNotExistsAsync_CreatesContainer() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; - var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); + var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/partitionKey"); - containerResponse.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); - containerResponse.Resource.Id.Should().Be("my-container"); - } + containerResponse.StatusCode.Should().Be(System.Net.HttpStatusCode.Created); + containerResponse.Resource.Id.Should().Be("my-container"); + } - [Fact] - public async Task Database_CreateContainerIfNotExistsAsync_WithCustomPartitionKey_Works() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); - var database = dbResponse.Database; + [Fact] + public async Task Database_CreateContainerIfNotExistsAsync_WithCustomPartitionKey_Works() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("test-db"); + var database = dbResponse.Database; - var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/customKey"); - var container = containerResponse.Container; + var containerResponse = await database.CreateContainerIfNotExistsAsync("my-container", "/customKey"); + var container = containerResponse.Container; - await container.CreateItemAsync( - new CustomKeyDocument { Id = "1", CustomKey = "ck1", Name = "Test" }, - new PartitionKey("ck1")); + await container.CreateItemAsync( + new CustomKeyDocument { Id = "1", CustomKey = "ck1", Name = "Test" }, + new PartitionKey("ck1")); - var readResponse = await container.ReadItemAsync("1", new PartitionKey("ck1")); - readResponse.Resource.Name.Should().Be("Test"); - } + var readResponse = await container.ReadItemAsync("1", new PartitionKey("ck1")); + readResponse.Resource.Name.Should().Be("Test"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -235,27 +235,27 @@ await container.CreateItemAsync( public class InMemoryCosmosClientPropertyTests { - [Fact] - public void Endpoint_ReturnsLocalhostEmulatorUri() - { - var client = new InMemoryCosmosClient(); - client.Endpoint.Should().Be(new Uri("https://localhost:8081/")); - } - - [Fact] - public void ClientOptions_ReturnsNonNullOptions() - { - var client = new InMemoryCosmosClient(); - client.ClientOptions.Should().NotBeNull(); - } - - [Fact] - public async Task ReadAccountAsync_ReturnsAccountWithInMemoryEmulatorId() - { - var client = new InMemoryCosmosClient(); - var account = await client.ReadAccountAsync(); - account.Id.Should().Be("in-memory-emulator"); - } + [Fact] + public void Endpoint_ReturnsLocalhostEmulatorUri() + { + var client = new InMemoryCosmosClient(); + client.Endpoint.Should().Be(new Uri("https://localhost:8081/")); + } + + [Fact] + public void ClientOptions_ReturnsNonNullOptions() + { + var client = new InMemoryCosmosClient(); + client.ClientOptions.Should().NotBeNull(); + } + + [Fact] + public async Task ReadAccountAsync_ReturnsAccountWithInMemoryEmulatorId() + { + var client = new InMemoryCosmosClient(); + var account = await client.ReadAccountAsync(); + account.Id.Should().Be("in-memory-emulator"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -264,76 +264,76 @@ public async Task ReadAccountAsync_ReturnsAccountWithInMemoryEmulatorId() public class InMemoryCosmosClientCreateTests { - [Fact] - public async Task CreateDatabaseAsync_DuplicateId_ThrowsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - - var act = () => client.CreateDatabaseAsync("db1"); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_NewDatabase_ReturnsCreated() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync( - new DatabaseProperties("db1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_Duplicate_ReturnsConflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseStreamAsync(new DatabaseProperties("db1")); - var response = await client.CreateDatabaseStreamAsync( - new DatabaseProperties("db1")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateDatabaseAsync_ResponseResource_HasCorrectId() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("my-db"); - - response.Resource.Id.Should().Be("my-db"); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_ResponseDatabase_IsFunctional() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); - - var containerResponse = await response.Database.CreateContainerAsync("c1", "/pk"); - containerResponse.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseAsync_WithThroughputProperties_Returns201() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync( - "db1", ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_WithThroughput_Returns201() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseIfNotExistsAsync("db1", 400); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task CreateDatabaseAsync_DuplicateId_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + + var act = () => client.CreateDatabaseAsync("db1"); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_NewDatabase_ReturnsCreated() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync( + new DatabaseProperties("db1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_Duplicate_ReturnsConflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseStreamAsync(new DatabaseProperties("db1")); + var response = await client.CreateDatabaseStreamAsync( + new DatabaseProperties("db1")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateDatabaseAsync_ResponseResource_HasCorrectId() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("my-db"); + + response.Resource.Id.Should().Be("my-db"); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_ResponseDatabase_IsFunctional() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); + + var containerResponse = await response.Database.CreateContainerAsync("c1", "/pk"); + containerResponse.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseAsync_WithThroughputProperties_Returns201() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync( + "db1", ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_WithThroughput_Returns201() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseIfNotExistsAsync("db1", 400); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -342,47 +342,47 @@ public async Task CreateDatabaseIfNotExistsAsync_WithThroughput_Returns201() public class InMemoryCosmosClientQueryIteratorTests { - [Fact] - public async Task GetDatabaseQueryIterator_ReturnsAllDatabases() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - await client.CreateDatabaseAsync("db3"); - - var iterator = client.GetDatabaseQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results.Select(r => r.Id).Should().BeEquivalentTo("db1", "db2", "db3"); - } - - [Fact] - public async Task GetDatabaseQueryIterator_EmptyClient_ReturnsEmpty() - { - var client = new InMemoryCosmosClient(); - - var iterator = client.GetDatabaseQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task GetDatabaseQueryStreamIterator_ReturnsOkResponse() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - - var iterator = client.GetDatabaseQueryStreamIterator(); - var response = await iterator.ReadNextAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task GetDatabaseQueryIterator_ReturnsAllDatabases() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + await client.CreateDatabaseAsync("db3"); + + var iterator = client.GetDatabaseQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results.Select(r => r.Id).Should().BeEquivalentTo("db1", "db2", "db3"); + } + + [Fact] + public async Task GetDatabaseQueryIterator_EmptyClient_ReturnsEmpty() + { + var client = new InMemoryCosmosClient(); + + var iterator = client.GetDatabaseQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task GetDatabaseQueryStreamIterator_ReturnsOkResponse() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + + var iterator = client.GetDatabaseQueryStreamIterator(); + var response = await iterator.ReadNextAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -391,37 +391,37 @@ public async Task GetDatabaseQueryStreamIterator_ReturnsOkResponse() public class InMemoryCosmosClientShortcutTests { - [Fact] - public async Task GetContainer_WithoutCreatingDatabase_AutoCreates() - { - var client = new InMemoryCosmosClient(); - var container = client.GetContainer("db1", "items"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1" }), - new PartitionKey("1")); - - var response = await container.ReadItemAsync("1", new PartitionKey("1")); - response.Resource["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public void GetContainer_DefaultPartitionKeyPath_IsId() - { - var client = new InMemoryCosmosClient(); - var container = client.GetContainer("db1", "items"); - - ((InMemoryContainer)container).PartitionKeyPaths[0].Should().Be("/id"); - } - - [Fact] - public void Database_Client_ReturnsParentCosmosClient() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("db1"); - - db.Client.Should().BeSameAs(client); - } + [Fact] + public async Task GetContainer_WithoutCreatingDatabase_AutoCreates() + { + var client = new InMemoryCosmosClient(); + var container = client.GetContainer("db1", "items"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1" }), + new PartitionKey("1")); + + var response = await container.ReadItemAsync("1", new PartitionKey("1")); + response.Resource["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public void GetContainer_DefaultPartitionKeyPath_IsId() + { + var client = new InMemoryCosmosClient(); + var container = client.GetContainer("db1", "items"); + + ((InMemoryContainer)container).PartitionKeyPaths[0].Should().Be("/id"); + } + + [Fact] + public void Database_Client_ReturnsParentCosmosClient() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("db1"); + + db.Client.Should().BeSameAs(client); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -430,40 +430,40 @@ public void Database_Client_ReturnsParentCosmosClient() public class InMemoryCosmosClientDeleteTests { - [Fact] - public async Task DeleteDatabase_RemovesFromQueryIterator() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - await client.GetDatabase("db1").DeleteAsync(); - - var iterator = client.GetDatabaseQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Id.Should().Be("db2"); - } - - [Fact] - public async Task DeleteDatabase_ThenReCreate_ContainersGone() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("db1")).Database; - await db.CreateContainerAsync("c1", "/pk"); - - await db.DeleteAsync(); - - var newDb = (await client.CreateDatabaseAsync("db1")).Database; - var iterator = newDb.GetContainerQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("re-created database should have no containers"); - } + [Fact] + public async Task DeleteDatabase_RemovesFromQueryIterator() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + await client.GetDatabase("db1").DeleteAsync(); + + var iterator = client.GetDatabaseQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Id.Should().Be("db2"); + } + + [Fact] + public async Task DeleteDatabase_ThenReCreate_ContainersGone() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("db1")).Database; + await db.CreateContainerAsync("c1", "/pk"); + + await db.DeleteAsync(); + + var newDb = (await client.CreateDatabaseAsync("db1")).Database; + var iterator = newDb.GetContainerQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("re-created database should have no containers"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -472,27 +472,27 @@ public async Task DeleteDatabase_ThenReCreate_ContainersGone() public class InMemoryCosmosClientDisposeTests { - [Fact] - public void Dispose_MultipleTimes_DoesNotThrow() - { - var client = new InMemoryCosmosClient(); - var act = () => - { - client.Dispose(); - client.Dispose(); - }; - act.Should().NotThrow(); - } - - [Fact] - public async Task Dispose_ThenUseClient_ThrowsObjectDisposedException() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); - - var act = () => client.CreateDatabaseAsync("db1"); - await act.Should().ThrowAsync(); - } + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + var client = new InMemoryCosmosClient(); + var act = () => + { + client.Dispose(); + client.Dispose(); + }; + act.Should().NotThrow(); + } + + [Fact] + public async Task Dispose_ThenUseClient_ThrowsObjectDisposedException() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); + + var act = () => client.CreateDatabaseAsync("db1"); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -501,33 +501,33 @@ public async Task Dispose_ThenUseClient_ThrowsObjectDisposedException() public class InMemoryCosmosClientConcurrencyTests { - [Fact] - public async Task ConcurrentCreateDatabaseIfNotExistsAsync_SameId_ExactlyOneCreated() - { - var client = new InMemoryCosmosClient(); - var tasks = Enumerable.Range(0, 20) - .Select(_ => client.CreateDatabaseIfNotExistsAsync("shared-db")) - .ToList(); - - var responses = await Task.WhenAll(tasks); - - responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); - responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); - } - - [Fact] - public async Task ConcurrentCreateDatabaseAsync_DifferentIds_AllSucceed() - { - var client = new InMemoryCosmosClient(); - var tasks = Enumerable.Range(0, 20) - .Select(i => client.CreateDatabaseAsync($"db-{i}")) - .ToList(); - - var responses = await Task.WhenAll(tasks); - - responses.Should().AllSatisfy(r => - r.StatusCode.Should().Be(HttpStatusCode.Created)); - } + [Fact] + public async Task ConcurrentCreateDatabaseIfNotExistsAsync_SameId_ExactlyOneCreated() + { + var client = new InMemoryCosmosClient(); + var tasks = Enumerable.Range(0, 20) + .Select(_ => client.CreateDatabaseIfNotExistsAsync("shared-db")) + .ToList(); + + var responses = await Task.WhenAll(tasks); + + responses.Count(r => r.StatusCode == HttpStatusCode.Created).Should().Be(1); + responses.Count(r => r.StatusCode == HttpStatusCode.OK).Should().Be(19); + } + + [Fact] + public async Task ConcurrentCreateDatabaseAsync_DifferentIds_AllSucceed() + { + var client = new InMemoryCosmosClient(); + var tasks = Enumerable.Range(0, 20) + .Select(i => client.CreateDatabaseAsync($"db-{i}")) + .ToList(); + + var responses = await Task.WhenAll(tasks); + + responses.Should().AllSatisfy(r => + r.StatusCode.Should().Be(HttpStatusCode.Created)); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -536,68 +536,68 @@ public async Task ConcurrentCreateDatabaseAsync_DifferentIds_AllSucceed() public class InMemoryCosmosClientEdgeCaseTests { - [Fact] - public async Task Client_Supports100Databases() - { - var client = new InMemoryCosmosClient(); - for (var i = 0; i < 100; i++) - await client.CreateDatabaseAsync($"db-{i}"); - - var iterator = client.GetDatabaseQueryIterator(); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(100); - } - - [Fact] - public async Task CreateDatabaseAsync_IdWithSpaces_Succeeds() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("my database"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("my database"); - } - - [Fact] - public async Task CreateDatabaseAsync_IdWithUnicode_Succeeds() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("db-日本語"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task SameContainerName_DifferentDatabases_DataIsolated() - { - var client = new InMemoryCosmosClient(); - var db1 = (await client.CreateDatabaseAsync("db1")).Database; - var db2 = (await client.CreateDatabaseAsync("db2")).Database; - - var c1 = (await db1.CreateContainerAsync("items", "/pk")).Container; - var c2 = (await db2.CreateContainerAsync("items", "/pk")).Container; - - await c1.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await c2.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - - ((InMemoryContainer)c1).ItemCount.Should().Be(1); - ((InMemoryContainer)c2).ItemCount.Should().Be(1); - } - - [Fact] - public async Task Database_ReadAsync_ReturnsOkWithProperties() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("mydb"); - - var response = await client.GetDatabase("mydb").ReadAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("mydb"); - } + [Fact] + public async Task Client_Supports100Databases() + { + var client = new InMemoryCosmosClient(); + for (var i = 0; i < 100; i++) + await client.CreateDatabaseAsync($"db-{i}"); + + var iterator = client.GetDatabaseQueryIterator(); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(100); + } + + [Fact] + public async Task CreateDatabaseAsync_IdWithSpaces_Succeeds() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("my database"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("my database"); + } + + [Fact] + public async Task CreateDatabaseAsync_IdWithUnicode_Succeeds() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("db-日本語"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task SameContainerName_DifferentDatabases_DataIsolated() + { + var client = new InMemoryCosmosClient(); + var db1 = (await client.CreateDatabaseAsync("db1")).Database; + var db2 = (await client.CreateDatabaseAsync("db2")).Database; + + var c1 = (await db1.CreateContainerAsync("items", "/pk")).Container; + var c2 = (await db2.CreateContainerAsync("items", "/pk")).Container; + + await c1.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await c2.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + + ((InMemoryContainer)c1).ItemCount.Should().Be(1); + ((InMemoryContainer)c2).ItemCount.Should().Be(1); + } + + [Fact] + public async Task Database_ReadAsync_ReturnsOkWithProperties() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("mydb"); + + var response = await client.GetDatabase("mydb").ReadAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("mydb"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -606,44 +606,44 @@ public async Task Database_ReadAsync_ReturnsOkWithProperties() public class InMemoryCosmosClientAutoCreationDivergentTests { - [Fact] - public async Task GetDatabase_NonExistent_ReadAsync_Throws404() - { - var client = new InMemoryCosmosClient(); - var db = client.GetDatabase("nonexistent"); - - var act = () => db.ReadAsync(); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task GetContainer_NonExistent_ReadAsync_Throws404() - { - var client = new InMemoryCosmosClient(); - var container = client.GetContainer("db", "nonexistent"); - - var act = () => container.ReadContainerAsync(); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DivergentBehavior_GetContainer_LazilyCreatesDatabaseAndContainer() - { - // DIVERGENT BEHAVIOR: Real SDK's GetContainer returns a proxy that - // fails with 404 when the resource doesn't exist. The emulator - // auto-creates to simplify test setup. - var client = new InMemoryCosmosClient(); - var container = client.GetContainer("auto-db", "auto-container"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1" }), - new PartitionKey("1")); - - var response = await container.ReadItemAsync("1", new PartitionKey("1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task GetDatabase_NonExistent_ReadAsync_Throws404() + { + var client = new InMemoryCosmosClient(); + var db = client.GetDatabase("nonexistent"); + + var act = () => db.ReadAsync(); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetContainer_NonExistent_ReadAsync_Throws404() + { + var client = new InMemoryCosmosClient(); + var container = client.GetContainer("db", "nonexistent"); + + var act = () => container.ReadContainerAsync(); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DivergentBehavior_GetContainer_LazilyCreatesDatabaseAndContainer() + { + // DIVERGENT BEHAVIOR: Real SDK's GetContainer returns a proxy that + // fails with 404 when the resource doesn't exist. The emulator + // auto-creates to simplify test setup. + var client = new InMemoryCosmosClient(); + var container = client.GetContainer("auto-db", "auto-container"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1" }), + new PartitionKey("1")); + + var response = await container.ReadItemAsync("1", new PartitionKey("1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -652,35 +652,35 @@ await container.CreateItemAsync( public class InMemoryCosmosClientDisposeGuardDeepDiveTests { - [Fact] - public async Task Dispose_ThenCreateDatabaseStreamAsync_ThrowsObjectDisposedException() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); - - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db")); - await act.Should().ThrowAsync(); - } - - [Fact] - public void Dispose_ThenGetDatabase_ThrowsObjectDisposedException() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); - - var act = () => client.GetDatabase("db"); - act.Should().Throw(); - } - - [Fact] - public void Dispose_ThenGetContainer_ThrowsObjectDisposedException() - { - var client = new InMemoryCosmosClient(); - client.Dispose(); - - var act = () => client.GetContainer("db", "container"); - act.Should().Throw(); - } + [Fact] + public async Task Dispose_ThenCreateDatabaseStreamAsync_ThrowsObjectDisposedException() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); + + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db")); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Dispose_ThenGetDatabase_ThrowsObjectDisposedException() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); + + var act = () => client.GetDatabase("db"); + act.Should().Throw(); + } + + [Fact] + public void Dispose_ThenGetContainer_ThrowsObjectDisposedException() + { + var client = new InMemoryCosmosClient(); + client.Dispose(); + + var act = () => client.GetContainer("db", "container"); + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -689,57 +689,57 @@ public void Dispose_ThenGetContainer_ThrowsObjectDisposedException() public class InMemoryCosmosClientStreamResponseDeepDiveTests { - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseBody_ContainsDatabaseJson() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("stream-db")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().NotBeNull(); - using var sr = new System.IO.StreamReader(response.Content); - var json = await sr.ReadToEndAsync(); - var obj = JObject.Parse(json); - obj["id"]!.ToString().Should().Be("stream-db"); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveActivityId() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-act")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - Guid.TryParse(response.Headers["x-ms-activity-id"], out _).Should().BeTrue(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveRequestCharge() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-rc")); - - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveSessionToken() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-st")); - - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync( - new DatabaseProperties("tp-db"), ThroughputProperties.CreateManualThroughput(400)); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseBody_ContainsDatabaseJson() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("stream-db")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().NotBeNull(); + using var sr = new System.IO.StreamReader(response.Content); + var json = await sr.ReadToEndAsync(); + var obj = JObject.Parse(json); + obj["id"]!.ToString().Should().Be("stream-db"); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveActivityId() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-act")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + Guid.TryParse(response.Headers["x-ms-activity-id"], out _).Should().BeTrue(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveRequestCharge() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-rc")); + + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_ResponseHeaders_HaveSessionToken() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("hdr-st")); + + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCreated() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync( + new DatabaseProperties("tp-db"), ThroughputProperties.CreateManualThroughput(400)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -748,38 +748,38 @@ public async Task CreateDatabaseStreamAsync_WithThroughputProperties_ReturnsCrea public class InMemoryCosmosClientCancellationTokenDeepDiveTests { - [Fact] - public async Task CreateDatabaseAsync_CancelledToken_ThrowsOperationCancelled() - { - var client = new InMemoryCosmosClient(); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => client.CreateDatabaseAsync("db1", cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_CancelledToken_ThrowsOperationCancelled() - { - var client = new InMemoryCosmosClient(); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => client.CreateDatabaseIfNotExistsAsync("db1", cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_CancelledToken_ThrowsOperationCancelled() - { - var client = new InMemoryCosmosClient(); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db1"), cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } + [Fact] + public async Task CreateDatabaseAsync_CancelledToken_ThrowsOperationCancelled() + { + var client = new InMemoryCosmosClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.CreateDatabaseAsync("db1", cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_CancelledToken_ThrowsOperationCancelled() + { + var client = new InMemoryCosmosClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.CreateDatabaseIfNotExistsAsync("db1", cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_CancelledToken_ThrowsOperationCancelled() + { + var client = new InMemoryCosmosClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db1"), cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -788,24 +788,24 @@ public async Task CreateDatabaseStreamAsync_CancelledToken_ThrowsOperationCancel public class InMemoryCosmosClientResourceNameDeepDiveTests { - [Fact] - public async Task CreateDatabaseStreamAsync_NameWithForwardSlash_ThrowsBadRequest() - { - var client = new InMemoryCosmosClient(); - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db/name")); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_NameWith256Chars_ThrowsBadRequest() - { - var client = new InMemoryCosmosClient(); - var longName = new string('a', 256); - var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties(longName)); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task CreateDatabaseStreamAsync_NameWithForwardSlash_ThrowsBadRequest() + { + var client = new InMemoryCosmosClient(); + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties("db/name")); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_NameWith256Chars_ThrowsBadRequest() + { + var client = new InMemoryCosmosClient(); + var longName = new string('a', 256); + var act = () => client.CreateDatabaseStreamAsync(new DatabaseProperties(longName)); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -814,25 +814,25 @@ public async Task CreateDatabaseStreamAsync_NameWith256Chars_ThrowsBadRequest() public class InMemoryCosmosClientAutoCreationDeepDiveTests { - [Fact] - public async Task GetDatabaseQueryIterator_IncludesAutoCreatedDatabases() - { - var client = new InMemoryCosmosClient(); - // Auto-create by using GetDatabase - var db = client.GetDatabase("auto-db"); - // Use the database (lazy creation) - await db.CreateContainerIfNotExistsAsync( - new ContainerProperties("c1", "/pk")); - - var iterator = client.GetDatabaseQueryIterator(); - var allDbs = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - foreach (var dbProps in page) allDbs.Add(dbProps.Id); - } - allDbs.Should().Contain("auto-db"); - } + [Fact] + public async Task GetDatabaseQueryIterator_IncludesAutoCreatedDatabases() + { + var client = new InMemoryCosmosClient(); + // Auto-create by using GetDatabase + var db = client.GetDatabase("auto-db"); + // Use the database (lazy creation) + await db.CreateContainerIfNotExistsAsync( + new ContainerProperties("c1", "/pk")); + + var iterator = client.GetDatabaseQueryIterator(); + var allDbs = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + foreach (var dbProps in page) allDbs.Add(dbProps.Id); + } + allDbs.Should().Contain("auto-db"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -841,29 +841,29 @@ await db.CreateContainerIfNotExistsAsync( public class InMemoryCosmosClientStreamIteratorDivergentDeepDiveTests { - [Fact] - public async Task DivergentBehavior_GetDatabaseQueryStreamIterator_IgnoresQueryText() - { - // DIVERGENT: Stream iterators ignore queryText/WHERE filtering — return all databases - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("db1"); - await client.CreateDatabaseAsync("db2"); - - var iterator = client.GetDatabaseQueryStreamIterator("SELECT * FROM c WHERE c.id = 'db1'"); - var allIds = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.Content == null) continue; - using var sr = new System.IO.StreamReader(response.Content); - var json = await sr.ReadToEndAsync(); - var obj = JObject.Parse(json); - var databases = obj["Databases"]?.ToObject>() ?? []; - foreach (var db in databases) allIds.Add(db["id"]!.ToString()); - } - - // Divergent: returns ALL databases, not just db1 - allIds.Should().Contain("db1"); - allIds.Should().Contain("db2", "stream iterator ignores WHERE clause"); - } + [Fact] + public async Task DivergentBehavior_GetDatabaseQueryStreamIterator_IgnoresQueryText() + { + // DIVERGENT: Stream iterators ignore queryText/WHERE filtering — return all databases + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("db1"); + await client.CreateDatabaseAsync("db2"); + + var iterator = client.GetDatabaseQueryStreamIterator("SELECT * FROM c WHERE c.id = 'db1'"); + var allIds = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.Content == null) continue; + using var sr = new System.IO.StreamReader(response.Content); + var json = await sr.ReadToEndAsync(); + var obj = JObject.Parse(json); + var databases = obj["Databases"]?.ToObject>() ?? []; + foreach (var db in databases) allIds.Add(db["id"]!.ToString()); + } + + // Divergent: returns ALL databases, not just db1 + allIds.Should().Contain("db1"); + allIds.Should().Contain("db2", "stream iterator ignores WHERE clause"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosDynamicContainerTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosDynamicContainerTests.cs index e56ac94..e81e256 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosDynamicContainerTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosDynamicContainerTests.cs @@ -1,249 +1,249 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class InMemoryCosmosDynamicContainerTests { - [Fact] - public async Task CreateContainerAsync_CreatesUsableContainer() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - var props = new ContainerProperties("products", "/categoryId"); - var response = await db.CreateContainerAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); - await productsContainer.CreateItemAsync( - new { id = "p1", categoryId = "c1", name = "Widget" }, - new PartitionKey("c1")); - - var readResponse = await productsContainer.ReadItemAsync("p1", new PartitionKey("c1")); - readResponse.Resource["name"]!.ToString().Should().Be("Widget"); - } - - [Fact] - public async Task CreateContainerAsync_AppearsInContainersDictionary() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - - cosmos.Containers.Should().ContainKey("products"); - cosmos.Containers.Should().HaveCount(2); // orders + products - } - - [Fact] - public async Task CreateContainerAsync_AppearsInHandlersDictionary() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - - cosmos.Handlers.Should().ContainKey("products"); - cosmos.Handlers.Should().HaveCount(2); - } - - [Fact] - public async Task CreateContainerAsync_SetupContainerWorksForDynamic() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - - var setup = cosmos.SetupContainer("products"); - setup.Should().NotBeNull(); - } - - [Fact] - public async Task CreateContainerAsync_GetHandlerWorksForDynamic() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - - var handler = cosmos.GetHandler("products"); - handler.Should().NotBeNull(); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_CreatesWhenNew() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - var response = await db.CreateContainerIfNotExistsAsync( - new ContainerProperties("products", "/categoryId")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_ReturnsOkWhenExists() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - var response = await db.CreateContainerIfNotExistsAsync( - new ContainerProperties("products", "/categoryId")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteContainerAsync_RemovesContainer() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("products", "/categoryId") - .Build(); - - var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); - await productsContainer.DeleteContainerAsync(); - - cosmos.Containers.Should().NotContainKey("products"); - cosmos.Containers.Should().HaveCount(1); - cosmos.Handlers.Should().NotContainKey("products"); - } - - [Fact] - public async Task DeleteContainerAsync_SubsequentOperationsReturn404() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("products", "/categoryId") - .Build(); - - var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); - await productsContainer.DeleteContainerAsync(); - - var act = () => productsContainer.CreateItemAsync( - new { id = "p1", categoryId = "c1" }, new PartitionKey("c1")); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteContainerAsync_SingularContainerPropertyStillWorks() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - - await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); - - // Delete the dynamically created container - var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); - await productsContainer.DeleteContainerAsync(); - - // Original Container property still works - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - } - - [Fact] - public async Task ReplaceContainerAsync_UpdatesMutableProperties() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); - var readResponse = await container.ReadContainerAsync(); - var props = readResponse.Resource; - props.DefaultTimeToLive = 3600; - - var replaceResponse = await container.ReplaceContainerAsync(props); - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceContainerAsync_RejectsPkPathChange() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); - var readResponse = await container.ReadContainerAsync(); - var props = readResponse.Resource; - props.PartitionKeyPath = "/differentKey"; - - var act = () => container.ReplaceContainerAsync(props); - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task CreateContainerAsync_CreatesUsableContainer() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + var props = new ContainerProperties("products", "/categoryId"); + var response = await db.CreateContainerAsync(props); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); + await productsContainer.CreateItemAsync( + new { id = "p1", categoryId = "c1", name = "Widget" }, + new PartitionKey("c1")); + + var readResponse = await productsContainer.ReadItemAsync("p1", new PartitionKey("c1")); + readResponse.Resource["name"]!.ToString().Should().Be("Widget"); + } + + [Fact] + public async Task CreateContainerAsync_AppearsInContainersDictionary() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + + cosmos.Containers.Should().ContainKey("products"); + cosmos.Containers.Should().HaveCount(2); // orders + products + } + + [Fact] + public async Task CreateContainerAsync_AppearsInHandlersDictionary() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + + cosmos.Handlers.Should().ContainKey("products"); + cosmos.Handlers.Should().HaveCount(2); + } + + [Fact] + public async Task CreateContainerAsync_SetupContainerWorksForDynamic() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + + var setup = cosmos.SetupContainer("products"); + setup.Should().NotBeNull(); + } + + [Fact] + public async Task CreateContainerAsync_GetHandlerWorksForDynamic() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + + var handler = cosmos.GetHandler("products"); + handler.Should().NotBeNull(); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_CreatesWhenNew() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + var response = await db.CreateContainerIfNotExistsAsync( + new ContainerProperties("products", "/categoryId")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_ReturnsOkWhenExists() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + var response = await db.CreateContainerIfNotExistsAsync( + new ContainerProperties("products", "/categoryId")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteContainerAsync_RemovesContainer() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("products", "/categoryId") + .Build(); + + var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); + await productsContainer.DeleteContainerAsync(); + + cosmos.Containers.Should().NotContainKey("products"); + cosmos.Containers.Should().HaveCount(1); + cosmos.Handlers.Should().NotContainKey("products"); + } + + [Fact] + public async Task DeleteContainerAsync_SubsequentOperationsReturn404() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("products", "/categoryId") + .Build(); + + var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); + await productsContainer.DeleteContainerAsync(); + + var act = () => productsContainer.CreateItemAsync( + new { id = "p1", categoryId = "c1" }, new PartitionKey("c1")); + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteContainerAsync_SingularContainerPropertyStillWorks() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + + await db.CreateContainerAsync(new ContainerProperties("products", "/categoryId")); + + // Delete the dynamically created container + var productsContainer = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "products"); + await productsContainer.DeleteContainerAsync(); + + // Original Container property still works + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + } + + [Fact] + public async Task ReplaceContainerAsync_UpdatesMutableProperties() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); + var readResponse = await container.ReadContainerAsync(); + var props = readResponse.Resource; + props.DefaultTimeToLive = 3600; + + var replaceResponse = await container.ReplaceContainerAsync(props); + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceContainerAsync_RejectsPkPathChange() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); + var readResponse = await container.ReadContainerAsync(); + var props = readResponse.Resource; + props.PartitionKeyPath = "/differentKey"; + + var act = () => container.ReplaceContainerAsync(props); + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } public class InMemoryCosmosDatabaseCrudTests { - [Fact] - public async Task CreateDatabaseAsync_Succeeds() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var response = await cosmos.Client.CreateDatabaseAsync("mydb"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_Succeeds() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - // Database always "exists" in the stub handler, so both calls return OK - var response = await cosmos.Client.CreateDatabaseIfNotExistsAsync("mydb"); - response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.OK); - - var response2 = await cosmos.Client.CreateDatabaseIfNotExistsAsync("mydb"); - response2.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReadDatabaseAsync_Returns200() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - var response = await db.ReadAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteDatabaseAsync_Returns204() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - await cosmos.Client.CreateDatabaseAsync("temp-db"); - var db = cosmos.Client.GetDatabase("temp-db"); - var response = await db.DeleteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task GetContainerQueryIterator_ListsContainers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); - var iterator = db.GetContainerQueryIterator(); - var containers = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - containers.AddRange(page); - } - - containers.Select(c => c.Id).Should().Contain("orders"); - containers.Select(c => c.Id).Should().Contain("customers"); - } + [Fact] + public async Task CreateDatabaseAsync_Succeeds() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var response = await cosmos.Client.CreateDatabaseAsync("mydb"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_Succeeds() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + // Database always "exists" in the stub handler, so both calls return OK + var response = await cosmos.Client.CreateDatabaseIfNotExistsAsync("mydb"); + response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.OK); + + var response2 = await cosmos.Client.CreateDatabaseIfNotExistsAsync("mydb"); + response2.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReadDatabaseAsync_Returns200() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + var response = await db.ReadAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteDatabaseAsync_Returns204() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + await cosmos.Client.CreateDatabaseAsync("temp-db"); + var db = cosmos.Client.GetDatabase("temp-db"); + var response = await db.DeleteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task GetContainerQueryIterator_ListsContainers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var db = cosmos.Client.GetDatabase(InMemoryCosmos.DefaultDatabaseName); + var iterator = db.GetContainerQueryIterator(); + var containers = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + containers.AddRange(page); + } + + containers.Select(c => c.Id).Should().Contain("orders"); + containers.Select(c => c.Id).Should().Contain("customers"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosMultiDatabaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosMultiDatabaseTests.cs index cc5cd1e..9cb0041 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosMultiDatabaseTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosMultiDatabaseTests.cs @@ -1,287 +1,287 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class InMemoryCosmosMultiDatabaseTests { - [Fact] - public async Task AddDatabase_CreatesContainersInSeparateDatabases() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => - { - db.AddContainer("events", "/userId"); - db.AddContainer("profiles", "/userId"); - }) - .AddDatabase("orders-db", db => - { - db.AddContainer("events", "/orderId"); - db.AddContainer("products", "/categoryId"); - }) - .Build(); - - // Database-scoped access - var userEvents = cosmos.Database("users-db").Containers["events"]; - var orderEvents = cosmos.Database("orders-db").Containers["events"]; - - userEvents.Should().NotBeSameAs(orderEvents); - - // Write to users-db/events - await userEvents.CreateItemAsync( - new { id = "1", userId = "u1", type = "login" }, - new PartitionKey("u1")); - - // Write to orders-db/events - await orderEvents.CreateItemAsync( - new { id = "1", orderId = "o1", type = "placed" }, - new PartitionKey("o1")); - - // Read back from each — separate data - var userEvent = await userEvents.ReadItemAsync("1", new PartitionKey("u1")); - userEvent.Resource["type"]!.ToString().Should().Be("login"); - - var orderEvent = await orderEvents.ReadItemAsync("1", new PartitionKey("o1")); - orderEvent.Resource["type"]!.ToString().Should().Be("placed"); - } - - [Fact] - public void Database_ReturnsCorrectResult() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => - { - db.AddContainer("profiles", "/userId"); - }) - .Build(); - - var dbResult = cosmos.Database("users-db"); - dbResult.Should().NotBeNull(); - dbResult.DatabaseName.Should().Be("users-db"); - dbResult.Containers.Should().ContainKey("profiles"); - } - - [Fact] - public void Database_NonexistentName_Throws() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => - { - db.AddContainer("profiles", "/userId"); - }) - .Build(); - - var act = () => cosmos.Database("nonexistent"); - - act.Should().Throw() - .WithMessage("*Database 'nonexistent' not found*Available databases*users-db*"); - } - - [Fact] - public void Databases_Dictionary_HasAllDatabases() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) - .Build(); - - cosmos.Databases.Should().HaveCount(2); - cosmos.Databases.Should().ContainKey("users-db"); - cosmos.Databases.Should().ContainKey("orders-db"); - } - - [Fact] - public void DatabaseResult_SetupContainer_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) - .Build(); - - var setup = cosmos.Database("users-db").SetupContainer("profiles"); - setup.Should().NotBeNull(); - setup.Should().BeAssignableTo(); - } - - [Fact] - public void DatabaseResult_GetHandler_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) - .Build(); - - var handler = cosmos.Database("users-db").GetHandler("profiles"); - handler.Should().NotBeNull(); - } - - [Fact] - public void DatabaseResult_SetFaultInjector_SetsOnDatabaseHandlers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => - { - db.AddContainer("profiles", "/userId"); - db.AddContainer("events", "/userId"); - }) - .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) - .Build(); - - Func injector = - req => new HttpResponseMessage((HttpStatusCode)503); - - // Set only on users-db - cosmos.Database("users-db").SetFaultInjector(injector); - - cosmos.Database("users-db").Handlers["profiles"].FaultInjector.Should().BeSameAs(injector); - cosmos.Database("users-db").Handlers["events"].FaultInjector.Should().BeSameAs(injector); - cosmos.Database("orders-db").Handlers["orders"].FaultInjector.Should().BeNull(); - } + [Fact] + public async Task AddDatabase_CreatesContainersInSeparateDatabases() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => + { + db.AddContainer("events", "/userId"); + db.AddContainer("profiles", "/userId"); + }) + .AddDatabase("orders-db", db => + { + db.AddContainer("events", "/orderId"); + db.AddContainer("products", "/categoryId"); + }) + .Build(); + + // Database-scoped access + var userEvents = cosmos.Database("users-db").Containers["events"]; + var orderEvents = cosmos.Database("orders-db").Containers["events"]; + + userEvents.Should().NotBeSameAs(orderEvents); + + // Write to users-db/events + await userEvents.CreateItemAsync( + new { id = "1", userId = "u1", type = "login" }, + new PartitionKey("u1")); + + // Write to orders-db/events + await orderEvents.CreateItemAsync( + new { id = "1", orderId = "o1", type = "placed" }, + new PartitionKey("o1")); + + // Read back from each — separate data + var userEvent = await userEvents.ReadItemAsync("1", new PartitionKey("u1")); + userEvent.Resource["type"]!.ToString().Should().Be("login"); + + var orderEvent = await orderEvents.ReadItemAsync("1", new PartitionKey("o1")); + orderEvent.Resource["type"]!.ToString().Should().Be("placed"); + } + + [Fact] + public void Database_ReturnsCorrectResult() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => + { + db.AddContainer("profiles", "/userId"); + }) + .Build(); + + var dbResult = cosmos.Database("users-db"); + dbResult.Should().NotBeNull(); + dbResult.DatabaseName.Should().Be("users-db"); + dbResult.Containers.Should().ContainKey("profiles"); + } + + [Fact] + public void Database_NonexistentName_Throws() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => + { + db.AddContainer("profiles", "/userId"); + }) + .Build(); + + var act = () => cosmos.Database("nonexistent"); + + act.Should().Throw() + .WithMessage("*Database 'nonexistent' not found*Available databases*users-db*"); + } + + [Fact] + public void Databases_Dictionary_HasAllDatabases() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) + .Build(); + + cosmos.Databases.Should().HaveCount(2); + cosmos.Databases.Should().ContainKey("users-db"); + cosmos.Databases.Should().ContainKey("orders-db"); + } + + [Fact] + public void DatabaseResult_SetupContainer_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) + .Build(); + + var setup = cosmos.Database("users-db").SetupContainer("profiles"); + setup.Should().NotBeNull(); + setup.Should().BeAssignableTo(); + } + + [Fact] + public void DatabaseResult_GetHandler_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) + .Build(); + + var handler = cosmos.Database("users-db").GetHandler("profiles"); + handler.Should().NotBeNull(); + } + + [Fact] + public void DatabaseResult_SetFaultInjector_SetsOnDatabaseHandlers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => + { + db.AddContainer("profiles", "/userId"); + db.AddContainer("events", "/userId"); + }) + .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) + .Build(); + + Func injector = + req => new HttpResponseMessage((HttpStatusCode)503); + + // Set only on users-db + cosmos.Database("users-db").SetFaultInjector(injector); + + cosmos.Database("users-db").Handlers["profiles"].FaultInjector.Should().BeSameAs(injector); + cosmos.Database("users-db").Handlers["events"].FaultInjector.Should().BeSameAs(injector); + cosmos.Database("orders-db").Handlers["orders"].FaultInjector.Should().BeNull(); + } } public class InMemoryCosmosMultiDatabaseMixingTests { - [Fact] - public void MixAddContainerAndAddDatabase_UniqueNames_FlatAccessWorks() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/customerId") - .AddDatabase("audit-db", db => - { - db.AddContainer("events", "/eventId"); - }) - .Build(); - - // Flat access works — names are globally unique - cosmos.Containers.Should().ContainKey("orders"); - cosmos.Containers.Should().ContainKey("events"); - } - - [Fact] - public void MixAddContainerAndAddDatabase_SetupContainer_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/customerId") - .AddDatabase("audit-db", db => - { - db.AddContainer("events", "/eventId"); - }) - .Build(); - - cosmos.SetupContainer("orders").Should().NotBeNull(); - cosmos.SetupContainer("events").Should().NotBeNull(); - } + [Fact] + public void MixAddContainerAndAddDatabase_UniqueNames_FlatAccessWorks() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/customerId") + .AddDatabase("audit-db", db => + { + db.AddContainer("events", "/eventId"); + }) + .Build(); + + // Flat access works — names are globally unique + cosmos.Containers.Should().ContainKey("orders"); + cosmos.Containers.Should().ContainKey("events"); + } + + [Fact] + public void MixAddContainerAndAddDatabase_SetupContainer_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/customerId") + .AddDatabase("audit-db", db => + { + db.AddContainer("events", "/eventId"); + }) + .Build(); + + cosmos.SetupContainer("orders").Should().NotBeNull(); + cosmos.SetupContainer("events").Should().NotBeNull(); + } } public class InMemoryCosmosAmbiguityTests { - [Fact] - public void Containers_AmbiguousName_ThrowsWithGuidance() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) - .Build(); - - var act = () => cosmos.Containers["events"]; - - act.Should().Throw() - .WithMessage("*events*multiple databases*"); - } - - [Fact] - public void SetupContainer_AmbiguousName_ThrowsWithGuidance() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) - .Build(); - - var act = () => cosmos.SetupContainer("events"); - - act.Should().Throw() - .WithMessage("*events*multiple databases*"); - } - - [Fact] - public void GetHandler_AmbiguousName_ThrowsWithGuidance() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) - .Build(); - - var act = () => cosmos.GetHandler("events"); - - act.Should().Throw() - .WithMessage("*events*multiple databases*"); - } - - [Fact] - public void Database_ScopedAccess_WorksForAmbiguousNames() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) - .Build(); - - // Database-scoped access works fine - cosmos.Database("users-db").Containers["events"].Should().NotBeNull(); - cosmos.Database("orders-db").Containers["events"].Should().NotBeNull(); - cosmos.Database("users-db").SetupContainer("events").Should().NotBeNull(); - cosmos.Database("orders-db").GetHandler("events").Should().NotBeNull(); - } - - [Fact] - public void Containers_UniqueNamesAcrossDatabases_FlatAccessWorks() - { - using var cosmos = InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) - .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) - .Build(); - - // Unique names — flat access works - cosmos.Containers.Should().ContainKey("profiles"); - cosmos.Containers.Should().ContainKey("orders"); - } + [Fact] + public void Containers_AmbiguousName_ThrowsWithGuidance() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) + .Build(); + + var act = () => cosmos.Containers["events"]; + + act.Should().Throw() + .WithMessage("*events*multiple databases*"); + } + + [Fact] + public void SetupContainer_AmbiguousName_ThrowsWithGuidance() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) + .Build(); + + var act = () => cosmos.SetupContainer("events"); + + act.Should().Throw() + .WithMessage("*events*multiple databases*"); + } + + [Fact] + public void GetHandler_AmbiguousName_ThrowsWithGuidance() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) + .Build(); + + var act = () => cosmos.GetHandler("events"); + + act.Should().Throw() + .WithMessage("*events*multiple databases*"); + } + + [Fact] + public void Database_ScopedAccess_WorksForAmbiguousNames() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("events", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("events", "/orderId")) + .Build(); + + // Database-scoped access works fine + cosmos.Database("users-db").Containers["events"].Should().NotBeNull(); + cosmos.Database("orders-db").Containers["events"].Should().NotBeNull(); + cosmos.Database("users-db").SetupContainer("events").Should().NotBeNull(); + cosmos.Database("orders-db").GetHandler("events").Should().NotBeNull(); + } + + [Fact] + public void Containers_UniqueNamesAcrossDatabases_FlatAccessWorks() + { + using var cosmos = InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) + .AddDatabase("orders-db", db => db.AddContainer("orders", "/orderId")) + .Build(); + + // Unique names — flat access works + cosmos.Containers.Should().ContainKey("profiles"); + cosmos.Containers.Should().ContainKey("orders"); + } } public class InMemoryCosmosMultiDatabaseValidationTests { - [Fact] - public void AddDatabase_DuplicateName_Throws() - { - var act = () => InMemoryCosmos.Builder() - .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) - .AddDatabase("users-db", db => db.AddContainer("other", "/id")); - - act.Should().Throw() - .WithMessage("*Database 'users-db' has already been added*"); - } - - [Fact] - public void AddDatabase_EmptyCallback_Throws() - { - var act = () => InMemoryCosmos.Builder() - .AddDatabase("empty-db", db => { }) - .Build(); - - act.Should().Throw() - .WithMessage("*Database 'empty-db' must have at least one container*"); - } - - [Fact] - public void AddDatabase_DuplicateContainerWithinDatabase_Throws() - { - var act = () => InMemoryCosmos.Builder() - .AddDatabase("users-db", db => - { - db.AddContainer("events", "/userId"); - db.AddContainer("events", "/id"); - }); - - act.Should().Throw() - .WithMessage("*Container 'events' has already been added*"); - } + [Fact] + public void AddDatabase_DuplicateName_Throws() + { + var act = () => InMemoryCosmos.Builder() + .AddDatabase("users-db", db => db.AddContainer("profiles", "/userId")) + .AddDatabase("users-db", db => db.AddContainer("other", "/id")); + + act.Should().Throw() + .WithMessage("*Database 'users-db' has already been added*"); + } + + [Fact] + public void AddDatabase_EmptyCallback_Throws() + { + var act = () => InMemoryCosmos.Builder() + .AddDatabase("empty-db", db => { }) + .Build(); + + act.Should().Throw() + .WithMessage("*Database 'empty-db' must have at least one container*"); + } + + [Fact] + public void AddDatabase_DuplicateContainerWithinDatabase_Throws() + { + var act = () => InMemoryCosmos.Builder() + .AddDatabase("users-db", db => + { + db.AddContainer("events", "/userId"); + db.AddContainer("events", "/id"); + }); + + act.Should().Throw() + .WithMessage("*Container 'events' has already been added*"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosTests.cs index 8f9cbd7..d5cf674 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/InMemoryCosmosTests.cs @@ -1,738 +1,738 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Scripts; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class InMemoryCosmosCreateTests { - [Fact] - public async Task Create_SimplestCase_ReturnsWorkingContainer() - { - using var cosmos = InMemoryCosmos.Create("orders"); - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "Widget" }, - new PartitionKey("1")); - var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("1")); - response.Resource.Name.Should().Be("Widget"); - } - - [Fact] - public async Task Create_WithCustomPartitionKey_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task Create_WithHierarchicalPartitionKey_Works() - { - using var cosmos = InMemoryCosmos.Create("events", new[] { "/tenantId", "/category" }); - var item = new { id = "1", tenantId = "t1", category = "c1", name = "ev" }; - var pk = new PartitionKeyBuilder().Add("t1").Add("c1").Build(); - await cosmos.Container.CreateItemAsync(item, pk); - var response = await cosmos.Container.ReadItemAsync("1", pk); - response.Resource["name"]!.ToString().Should().Be("ev"); - } - - [Fact] - public void Create_ContainerProperty_ReturnsSdkContainer() - { - using var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Container.Should().NotBeNull(); - cosmos.Container.Should().BeAssignableTo(); - } - - [Fact] - public void Create_ContainersDictionary_ContainsSingleEntry() - { - using var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Containers.Should().HaveCount(1); - cosmos.Containers.Should().ContainKey("orders"); - cosmos.Containers["orders"].Should().BeSameAs(cosmos.Container); - } - - [Fact] - public void Create_HandlerProperty_ReturnsFakeCosmosHandler() - { - using var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Handler.Should().NotBeNull(); - cosmos.Handler.Should().BeOfType(); - } - - [Fact] - public void Create_HandlersDictionary_ContainsSingleEntry() - { - using var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Handlers.Should().HaveCount(1); - cosmos.Handlers.Should().ContainKey("orders"); - cosmos.Handlers["orders"].Should().BeSameAs(cosmos.Handler); - } - - [Fact] - public void Create_ClientProperty_ReturnsCosmosClient() - { - using var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Client.Should().NotBeNull(); - cosmos.Client.Should().BeAssignableTo(); - } - - [Fact] - public void Create_DefaultDatabaseName_IsDefault() - { - InMemoryCosmos.DefaultDatabaseName.Should().Be("default"); - } - - [Fact] - public async Task Create_ClientGetContainer_WorksWithDefaultDatabase() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Test"); - } + [Fact] + public async Task Create_SimplestCase_ReturnsWorkingContainer() + { + using var cosmos = InMemoryCosmos.Create("orders"); + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "Widget" }, + new PartitionKey("1")); + var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("1")); + response.Resource.Name.Should().Be("Widget"); + } + + [Fact] + public async Task Create_WithCustomPartitionKey_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task Create_WithHierarchicalPartitionKey_Works() + { + using var cosmos = InMemoryCosmos.Create("events", new[] { "/tenantId", "/category" }); + var item = new { id = "1", tenantId = "t1", category = "c1", name = "ev" }; + var pk = new PartitionKeyBuilder().Add("t1").Add("c1").Build(); + await cosmos.Container.CreateItemAsync(item, pk); + var response = await cosmos.Container.ReadItemAsync("1", pk); + response.Resource["name"]!.ToString().Should().Be("ev"); + } + + [Fact] + public void Create_ContainerProperty_ReturnsSdkContainer() + { + using var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Container.Should().NotBeNull(); + cosmos.Container.Should().BeAssignableTo(); + } + + [Fact] + public void Create_ContainersDictionary_ContainsSingleEntry() + { + using var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Containers.Should().HaveCount(1); + cosmos.Containers.Should().ContainKey("orders"); + cosmos.Containers["orders"].Should().BeSameAs(cosmos.Container); + } + + [Fact] + public void Create_HandlerProperty_ReturnsFakeCosmosHandler() + { + using var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Handler.Should().NotBeNull(); + cosmos.Handler.Should().BeOfType(); + } + + [Fact] + public void Create_HandlersDictionary_ContainsSingleEntry() + { + using var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Handlers.Should().HaveCount(1); + cosmos.Handlers.Should().ContainKey("orders"); + cosmos.Handlers["orders"].Should().BeSameAs(cosmos.Handler); + } + + [Fact] + public void Create_ClientProperty_ReturnsCosmosClient() + { + using var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Client.Should().NotBeNull(); + cosmos.Client.Should().BeAssignableTo(); + } + + [Fact] + public void Create_DefaultDatabaseName_IsDefault() + { + InMemoryCosmos.DefaultDatabaseName.Should().Be("default"); + } + + [Fact] + public async Task Create_ClientGetContainer_WorksWithDefaultDatabase() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + var container = cosmos.Client.GetContainer(InMemoryCosmos.DefaultDatabaseName, "orders"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Test"); + } } public class InMemoryCosmosCreateWithOptionsTests { - [Fact] - public async Task Create_WithWrapHandler_WrapsTheHandler() - { - var wasCalled = false; - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", - wrapHandler: h => - { - wasCalled = true; - return new TrackingDelegatingHandler(h); - }); - - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - wasCalled.Should().BeTrue(); - } - - [Fact] - public async Task Create_WithConfigureContainer_ConfiguresContainer() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", - configureContainer: c => c.DefaultTimeToLive = 3600); - - // The setup container should reflect the configuration - var setup = cosmos.SetupContainer(); - setup.Should().NotBeNull(); - } - - [Fact] - public async Task Create_WithConfigureOptions_AppliesOptions() - { - // Just verifying it doesn't throw - configureOptions runs on CosmosClientOptions - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", - configureOptions: o => o.MaxRetryAttemptsOnRateLimitedRequests = 0); - - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - } - - [Fact] - public void Create_WithConfigureOptions_RejectsHttpClientFactory() - { - var act = () => InMemoryCosmos.Create("orders", "/partitionKey", - configureOptions: o => o.HttpClientFactory = () => new HttpClient()); - - act.Should().Throw() - .WithMessage("*HttpClientFactory*wrapHandler*"); - } - - private class TrackingDelegatingHandler : DelegatingHandler - { - public TrackingDelegatingHandler(HttpMessageHandler inner) : base(inner) { } - } + [Fact] + public async Task Create_WithWrapHandler_WrapsTheHandler() + { + var wasCalled = false; + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", + wrapHandler: h => + { + wasCalled = true; + return new TrackingDelegatingHandler(h); + }); + + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + wasCalled.Should().BeTrue(); + } + + [Fact] + public async Task Create_WithConfigureContainer_ConfiguresContainer() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", + configureContainer: c => c.DefaultTimeToLive = 3600); + + // The setup container should reflect the configuration + var setup = cosmos.SetupContainer(); + setup.Should().NotBeNull(); + } + + [Fact] + public async Task Create_WithConfigureOptions_AppliesOptions() + { + // Just verifying it doesn't throw - configureOptions runs on CosmosClientOptions + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey", + configureOptions: o => o.MaxRetryAttemptsOnRateLimitedRequests = 0); + + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + } + + [Fact] + public void Create_WithConfigureOptions_RejectsHttpClientFactory() + { + var act = () => InMemoryCosmos.Create("orders", "/partitionKey", + configureOptions: o => o.HttpClientFactory = () => new HttpClient()); + + act.Should().Throw() + .WithMessage("*HttpClientFactory*wrapHandler*"); + } + + private class TrackingDelegatingHandler : DelegatingHandler + { + public TrackingDelegatingHandler(HttpMessageHandler inner) : base(inner) { } + } } public class InMemoryCosmosBuilderTests { - [Fact] - public async Task Builder_MultiContainer_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - await cosmos.Containers["orders"].CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Order1" }, - new PartitionKey("pk1")); - - await cosmos.Containers["customers"].CreateItemAsync( - new { id = "c1", name = "Alice" }, - new PartitionKey("c1")); - - var orderResponse = await cosmos.Containers["orders"].ReadItemAsync("1", new PartitionKey("pk1")); - orderResponse.Resource.Name.Should().Be("Order1"); - } - - [Fact] - public void Builder_ContainersDictionary_HasAllContainers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - cosmos.Containers.Should().HaveCount(2); - cosmos.Containers.Should().ContainKey("orders"); - cosmos.Containers.Should().ContainKey("customers"); - } - - [Fact] - public void Builder_HandlersDictionary_HasAllHandlers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - cosmos.Handlers.Should().HaveCount(2); - cosmos.Handlers.Should().ContainKey("orders"); - cosmos.Handlers.Should().ContainKey("customers"); - } - - [Fact] - public void Builder_ContainerSingular_ThrowsForMulti() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var act = () => cosmos.Container; - - act.Should().Throw() - .WithMessage("*single-container*"); - } - - [Fact] - public void Builder_HandlerSingular_ThrowsForMulti() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var act = () => cosmos.Handler; - - act.Should().Throw() - .WithMessage("*single-container*"); - } - - [Fact] - public void Builder_WithHierarchicalPk_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("events", new[] { "/tenantId", "/category" }) - .Build(); - - cosmos.Containers.Should().ContainKey("events"); - } - - [Fact] - public async Task Builder_WrapHandler_WrapsAllHandlers() - { - var callCount = 0; - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .WrapHandler(h => - { - Interlocked.Increment(ref callCount); - return h; - }) - .Build(); - - // wrapHandler is called once per container handler (or once for the router) - callCount.Should().BeGreaterThan(0); - } - - [Fact] - public void Builder_ConfigureOptions_AppliesOptions() - { - // Should not throw - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .ConfigureOptions(o => o.MaxRetryAttemptsOnRateLimitedRequests = 0) - .Build(); - } - - [Fact] - public async Task Builder_AddContainerWithConfigure_ConfiguresContainer() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey", container => - { - container.DefaultTimeToLive = 3600; - }) - .Build(); - - // Container was configured - cosmos.SetupContainer("orders").Should().NotBeNull(); - } - - [Fact] - public void Builder_BuildMultipleTimes_CreatesIndependentResults() - { - var builder = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey"); - - using var cosmos1 = builder.Build(); - using var cosmos2 = builder.Build(); - - cosmos1.Container.Should().NotBeSameAs(cosmos2.Container); - cosmos1.Handler.Should().NotBeSameAs(cosmos2.Handler); - } + [Fact] + public async Task Builder_MultiContainer_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + await cosmos.Containers["orders"].CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Order1" }, + new PartitionKey("pk1")); + + await cosmos.Containers["customers"].CreateItemAsync( + new { id = "c1", name = "Alice" }, + new PartitionKey("c1")); + + var orderResponse = await cosmos.Containers["orders"].ReadItemAsync("1", new PartitionKey("pk1")); + orderResponse.Resource.Name.Should().Be("Order1"); + } + + [Fact] + public void Builder_ContainersDictionary_HasAllContainers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + cosmos.Containers.Should().HaveCount(2); + cosmos.Containers.Should().ContainKey("orders"); + cosmos.Containers.Should().ContainKey("customers"); + } + + [Fact] + public void Builder_HandlersDictionary_HasAllHandlers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + cosmos.Handlers.Should().HaveCount(2); + cosmos.Handlers.Should().ContainKey("orders"); + cosmos.Handlers.Should().ContainKey("customers"); + } + + [Fact] + public void Builder_ContainerSingular_ThrowsForMulti() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var act = () => cosmos.Container; + + act.Should().Throw() + .WithMessage("*single-container*"); + } + + [Fact] + public void Builder_HandlerSingular_ThrowsForMulti() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var act = () => cosmos.Handler; + + act.Should().Throw() + .WithMessage("*single-container*"); + } + + [Fact] + public void Builder_WithHierarchicalPk_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("events", new[] { "/tenantId", "/category" }) + .Build(); + + cosmos.Containers.Should().ContainKey("events"); + } + + [Fact] + public async Task Builder_WrapHandler_WrapsAllHandlers() + { + var callCount = 0; + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .WrapHandler(h => + { + Interlocked.Increment(ref callCount); + return h; + }) + .Build(); + + // wrapHandler is called once per container handler (or once for the router) + callCount.Should().BeGreaterThan(0); + } + + [Fact] + public void Builder_ConfigureOptions_AppliesOptions() + { + // Should not throw + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .ConfigureOptions(o => o.MaxRetryAttemptsOnRateLimitedRequests = 0) + .Build(); + } + + [Fact] + public async Task Builder_AddContainerWithConfigure_ConfiguresContainer() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey", container => + { + container.DefaultTimeToLive = 3600; + }) + .Build(); + + // Container was configured + cosmos.SetupContainer("orders").Should().NotBeNull(); + } + + [Fact] + public void Builder_BuildMultipleTimes_CreatesIndependentResults() + { + var builder = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey"); + + using var cosmos1 = builder.Build(); + using var cosmos2 = builder.Build(); + + cosmos1.Container.Should().NotBeSameAs(cosmos2.Container); + cosmos1.Handler.Should().NotBeSameAs(cosmos2.Handler); + } } public class InMemoryCosmosValidationTests { - [Fact] - public void Builder_BuildWithNoContainers_Throws() - { - var builder = InMemoryCosmos.Builder(); - - var act = () => builder.Build(); - - act.Should().Throw() - .WithMessage("*At least one container*"); - } - - [Fact] - public void Builder_DuplicateContainerName_Throws() - { - var act = () => InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("orders", "/id"); - - act.Should().Throw() - .WithMessage("*Container 'orders' has already been added*"); - } - - [Fact] - public void Create_NullContainerName_Throws() - { - var act = () => InMemoryCosmos.Create(null!); - act.Should().Throw(); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Create_EmptyOrWhitespaceContainerName_Throws(string name) - { - var act = () => InMemoryCosmos.Create(name); - act.Should().Throw(); - } - - [Fact] - public void Builder_AddContainer_NullName_Throws() - { - var act = () => InMemoryCosmos.Builder().AddContainer(null!, "/id"); - act.Should().Throw(); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Builder_AddContainer_EmptyOrWhitespaceName_Throws(string name) - { - var act = () => InMemoryCosmos.Builder().AddContainer(name, "/id"); - act.Should().Throw(); - } - - [Fact] - public void Create_PkPathNotStartingWithSlash_Throws() - { - var act = () => InMemoryCosmos.Create("orders", "customerId"); - - act.Should().Throw() - .WithMessage("*Partition key path must start with*"); - } - - [Fact] - public void Builder_AddContainer_PkPathNotStartingWithSlash_Throws() - { - var act = () => InMemoryCosmos.Builder().AddContainer("orders", "customerId"); - - act.Should().Throw() - .WithMessage("*Partition key path must start with*"); - } - - [Fact] - public void Create_EmptyPartitionKeyPathsArray_Throws() - { - var act = () => InMemoryCosmos.Create("orders", Array.Empty()); - act.Should().Throw(); - } + [Fact] + public void Builder_BuildWithNoContainers_Throws() + { + var builder = InMemoryCosmos.Builder(); + + var act = () => builder.Build(); + + act.Should().Throw() + .WithMessage("*At least one container*"); + } + + [Fact] + public void Builder_DuplicateContainerName_Throws() + { + var act = () => InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("orders", "/id"); + + act.Should().Throw() + .WithMessage("*Container 'orders' has already been added*"); + } + + [Fact] + public void Create_NullContainerName_Throws() + { + var act = () => InMemoryCosmos.Create(null!); + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Create_EmptyOrWhitespaceContainerName_Throws(string name) + { + var act = () => InMemoryCosmos.Create(name); + act.Should().Throw(); + } + + [Fact] + public void Builder_AddContainer_NullName_Throws() + { + var act = () => InMemoryCosmos.Builder().AddContainer(null!, "/id"); + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Builder_AddContainer_EmptyOrWhitespaceName_Throws(string name) + { + var act = () => InMemoryCosmos.Builder().AddContainer(name, "/id"); + act.Should().Throw(); + } + + [Fact] + public void Create_PkPathNotStartingWithSlash_Throws() + { + var act = () => InMemoryCosmos.Create("orders", "customerId"); + + act.Should().Throw() + .WithMessage("*Partition key path must start with*"); + } + + [Fact] + public void Builder_AddContainer_PkPathNotStartingWithSlash_Throws() + { + var act = () => InMemoryCosmos.Builder().AddContainer("orders", "customerId"); + + act.Should().Throw() + .WithMessage("*Partition key path must start with*"); + } + + [Fact] + public void Create_EmptyPartitionKeyPathsArray_Throws() + { + var act = () => InMemoryCosmos.Create("orders", Array.Empty()); + act.Should().Throw(); + } } public class InMemoryCosmosSetupContainerTests { - [Fact] - public void SetupContainer_SingleContainer_NoArg_ReturnsSetup() - { - using var cosmos = InMemoryCosmos.Create("orders"); - var setup = cosmos.SetupContainer(); - setup.Should().NotBeNull(); - setup.Should().BeAssignableTo(); - } - - [Fact] - public void SetupContainer_SingleContainer_ByName_ReturnsSetup() - { - using var cosmos = InMemoryCosmos.Create("orders"); - var setup = cosmos.SetupContainer("orders"); - setup.Should().NotBeNull(); - } - - [Fact] - public void SetupContainer_MultiContainer_NoArg_Throws() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var act = () => cosmos.SetupContainer(); - - act.Should().Throw() - .WithMessage("*SetupContainer()*single-container*"); - } - - [Fact] - public void SetupContainer_MultiContainer_ByName_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var ordersSetup = cosmos.SetupContainer("orders"); - ordersSetup.Should().NotBeNull(); - - var customersSetup = cosmos.SetupContainer("customers"); - customersSetup.Should().NotBeNull(); - } - - [Fact] - public void SetupContainer_NonexistentName_Throws() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .Build(); - - var act = () => cosmos.SetupContainer("nonexistent"); - - act.Should().Throw() - .WithMessage("*Container 'nonexistent' not found*Available containers*orders*"); - } - - [Fact] - public void GetHandler_NonexistentName_Throws() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .Build(); - - var act = () => cosmos.GetHandler("nonexistent"); - - act.Should().Throw() - .WithMessage("*Container 'nonexistent' not found*Available containers*orders*"); - } + [Fact] + public void SetupContainer_SingleContainer_NoArg_ReturnsSetup() + { + using var cosmos = InMemoryCosmos.Create("orders"); + var setup = cosmos.SetupContainer(); + setup.Should().NotBeNull(); + setup.Should().BeAssignableTo(); + } + + [Fact] + public void SetupContainer_SingleContainer_ByName_ReturnsSetup() + { + using var cosmos = InMemoryCosmos.Create("orders"); + var setup = cosmos.SetupContainer("orders"); + setup.Should().NotBeNull(); + } + + [Fact] + public void SetupContainer_MultiContainer_NoArg_Throws() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var act = () => cosmos.SetupContainer(); + + act.Should().Throw() + .WithMessage("*SetupContainer()*single-container*"); + } + + [Fact] + public void SetupContainer_MultiContainer_ByName_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var ordersSetup = cosmos.SetupContainer("orders"); + ordersSetup.Should().NotBeNull(); + + var customersSetup = cosmos.SetupContainer("customers"); + customersSetup.Should().NotBeNull(); + } + + [Fact] + public void SetupContainer_NonexistentName_Throws() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .Build(); + + var act = () => cosmos.SetupContainer("nonexistent"); + + act.Should().Throw() + .WithMessage("*Container 'nonexistent' not found*Available containers*orders*"); + } + + [Fact] + public void GetHandler_NonexistentName_Throws() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .Build(); + + var act = () => cosmos.GetHandler("nonexistent"); + + act.Should().Throw() + .WithMessage("*Container 'nonexistent' not found*Available containers*orders*"); + } } public class InMemoryCosmosSetupOperationsTests { - [Fact] - public void SetupContainer_RegisterStoredProcedure_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - // Should not throw - verifies registration through IContainerTestSetup - cosmos.SetupContainer().RegisterStoredProcedure("mysproc", - (pk, args) => "{\"result\":\"ok\"}"); - } - - [Fact] - public async Task SetupContainer_RegisterUdf_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - cosmos.SetupContainer().RegisterUdf("toUpper", - args => ((string)args[0]!).ToUpper()); - - // UDF registered — can be used in queries - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, - new PartitionKey("pk1")); - - var query = cosmos.Container.GetItemQueryIterator( - "SELECT udf.toUpper(c.name) AS upper FROM c"); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - results.Should().NotBeEmpty(); - results[0]["upper"]!.ToString().Should().Be("HELLO"); - } - - [Fact] - public void SetupContainer_RegisterTrigger_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - // Should not throw - cosmos.SetupContainer().RegisterTrigger("audit", - TriggerType.Pre, TriggerOperation.All, - doc => { doc["audited"] = true; return doc; }); - } - - [Fact] - public void SetupContainer_ClearItems_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - cosmos.SetupContainer().ClearItems(); - // Should not throw - just verifying the method exists and works - } - - [Fact] - public void ClearItems_ConvenienceMethod_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - cosmos.ClearItems(); - // Should not throw - convenience method on InMemoryCosmosResult - } - - [Fact] - public async Task SetupContainer_ExportImportState_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var state = cosmos.ExportState(); - state.Should().Contain("\"id\""); - state.Should().Contain("Test"); - - cosmos.ClearItems(); - cosmos.ImportState(state); - - var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task ExportImportState_NamedContainer_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("products", "/categoryId") - .Build(); - - await cosmos.Containers["orders"].CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Order1" }, - new PartitionKey("pk1")); - - var state = cosmos.ExportState("orders"); - state.Should().Contain("Order1"); - - cosmos.ClearItems("orders"); - cosmos.ImportState(state, "orders"); - - var response = await cosmos.Containers["orders"].ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Order1"); - } - - [Fact] - public void SetupContainer_JsStoredProcedure_WithoutEngine_ThrowsHelpful() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var act = () => cosmos.SetupContainer().RegisterStoredProcedure("mysproc", - "function(prefix) { var context = getContext(); }"); - - act.Should().Throw() - .WithMessage("*JsTriggers*"); - } - - [Fact] - public void SetupContainer_JsUdf_WithoutEngine_ThrowsHelpful() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var act = () => cosmos.SetupContainer().RegisterUdf("toUpper", - "function(s) { return s.toUpperCase(); }"); - - act.Should().Throw() - .WithMessage("*JsTriggers*"); - } - - [Fact] - public void SetupContainer_JsTrigger_WithoutEngine_ThrowsHelpful() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - var act = () => cosmos.SetupContainer().RegisterTrigger("validate", - TriggerType.Pre, TriggerOperation.Create, - "function() { var context = getContext(); }"); - - act.Should().Throw() - .WithMessage("*JsTriggers*"); - } + [Fact] + public void SetupContainer_RegisterStoredProcedure_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + // Should not throw - verifies registration through IContainerTestSetup + cosmos.SetupContainer().RegisterStoredProcedure("mysproc", + (pk, args) => "{\"result\":\"ok\"}"); + } + + [Fact] + public async Task SetupContainer_RegisterUdf_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + cosmos.SetupContainer().RegisterUdf("toUpper", + args => ((string)args[0]!).ToUpper()); + + // UDF registered — can be used in queries + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, + new PartitionKey("pk1")); + + var query = cosmos.Container.GetItemQueryIterator( + "SELECT udf.toUpper(c.name) AS upper FROM c"); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + results.Should().NotBeEmpty(); + results[0]["upper"]!.ToString().Should().Be("HELLO"); + } + + [Fact] + public void SetupContainer_RegisterTrigger_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + // Should not throw + cosmos.SetupContainer().RegisterTrigger("audit", + TriggerType.Pre, TriggerOperation.All, + doc => { doc["audited"] = true; return doc; }); + } + + [Fact] + public void SetupContainer_ClearItems_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + cosmos.SetupContainer().ClearItems(); + // Should not throw - just verifying the method exists and works + } + + [Fact] + public void ClearItems_ConvenienceMethod_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + cosmos.ClearItems(); + // Should not throw - convenience method on InMemoryCosmosResult + } + + [Fact] + public async Task SetupContainer_ExportImportState_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var state = cosmos.ExportState(); + state.Should().Contain("\"id\""); + state.Should().Contain("Test"); + + cosmos.ClearItems(); + cosmos.ImportState(state); + + var response = await cosmos.Container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task ExportImportState_NamedContainer_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("products", "/categoryId") + .Build(); + + await cosmos.Containers["orders"].CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Order1" }, + new PartitionKey("pk1")); + + var state = cosmos.ExportState("orders"); + state.Should().Contain("Order1"); + + cosmos.ClearItems("orders"); + cosmos.ImportState(state, "orders"); + + var response = await cosmos.Containers["orders"].ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Order1"); + } + + [Fact] + public void SetupContainer_JsStoredProcedure_WithoutEngine_ThrowsHelpful() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var act = () => cosmos.SetupContainer().RegisterStoredProcedure("mysproc", + "function(prefix) { var context = getContext(); }"); + + act.Should().Throw() + .WithMessage("*JsTriggers*"); + } + + [Fact] + public void SetupContainer_JsUdf_WithoutEngine_ThrowsHelpful() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var act = () => cosmos.SetupContainer().RegisterUdf("toUpper", + "function(s) { return s.toUpperCase(); }"); + + act.Should().Throw() + .WithMessage("*JsTriggers*"); + } + + [Fact] + public void SetupContainer_JsTrigger_WithoutEngine_ThrowsHelpful() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + var act = () => cosmos.SetupContainer().RegisterTrigger("validate", + TriggerType.Pre, TriggerOperation.Create, + "function() { var context = getContext(); }"); + + act.Should().Throw() + .WithMessage("*JsTriggers*"); + } } public class InMemoryCosmosFaultInjectionTests { - [Fact] - public async Task Handler_FaultInjector_Works() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - - cosmos.Handler.FaultInjector = req => - { - if (req.Method == HttpMethod.Post) - return new HttpResponseMessage((HttpStatusCode)429); - return null; - }; - - var act = () => cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be((HttpStatusCode)429); - } - - [Fact] - public void SetFaultInjector_SetsOnAllHandlers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - Func injector = - req => new HttpResponseMessage((HttpStatusCode)503); - - cosmos.SetFaultInjector(injector); - - cosmos.Handlers["orders"].FaultInjector.Should().BeSameAs(injector); - cosmos.Handlers["customers"].FaultInjector.Should().BeSameAs(injector); - } - - [Fact] - public void SetFaultInjector_Null_ClearsAllHandlers() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - cosmos.SetFaultInjector(req => new HttpResponseMessage((HttpStatusCode)503)); - cosmos.SetFaultInjector(null); - - cosmos.Handlers["orders"].FaultInjector.Should().BeNull(); - cosmos.Handlers["customers"].FaultInjector.Should().BeNull(); - } - - [Fact] - public void GetHandler_ReturnsCorrectHandler() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("orders", "/partitionKey") - .AddContainer("customers", "/id") - .Build(); - - var handler = cosmos.GetHandler("orders"); - handler.Should().BeSameAs(cosmos.Handlers["orders"]); - } + [Fact] + public async Task Handler_FaultInjector_Works() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + + cosmos.Handler.FaultInjector = req => + { + if (req.Method == HttpMethod.Post) + return new HttpResponseMessage((HttpStatusCode)429); + return null; + }; + + var act = () => cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be((HttpStatusCode)429); + } + + [Fact] + public void SetFaultInjector_SetsOnAllHandlers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + Func injector = + req => new HttpResponseMessage((HttpStatusCode)503); + + cosmos.SetFaultInjector(injector); + + cosmos.Handlers["orders"].FaultInjector.Should().BeSameAs(injector); + cosmos.Handlers["customers"].FaultInjector.Should().BeSameAs(injector); + } + + [Fact] + public void SetFaultInjector_Null_ClearsAllHandlers() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + cosmos.SetFaultInjector(req => new HttpResponseMessage((HttpStatusCode)503)); + cosmos.SetFaultInjector(null); + + cosmos.Handlers["orders"].FaultInjector.Should().BeNull(); + cosmos.Handlers["customers"].FaultInjector.Should().BeNull(); + } + + [Fact] + public void GetHandler_ReturnsCorrectHandler() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("orders", "/partitionKey") + .AddContainer("customers", "/id") + .Build(); + + var handler = cosmos.GetHandler("orders"); + handler.Should().BeSameAs(cosmos.Handlers["orders"]); + } } public class InMemoryCosmosDisposeTests { - [Fact] - public void Dispose_DoesNotThrow() - { - var cosmos = InMemoryCosmos.Create("orders"); - cosmos.Dispose(); - // Should not throw - } - - [Fact] - public async Task AsyncDispose_DoesNotThrow() - { - var cosmos = InMemoryCosmos.Create("orders"); - await cosmos.DisposeAsync(); - // Should not throw - } - - [Fact] - public void Create_MultipleIndependentResults() - { - using var cosmos1 = InMemoryCosmos.Create("orders"); - using var cosmos2 = InMemoryCosmos.Create("orders"); - - // Independent results — different instances - cosmos1.Container.Should().NotBeSameAs(cosmos2.Container); - cosmos1.Handler.Should().NotBeSameAs(cosmos2.Handler); - } + [Fact] + public void Dispose_DoesNotThrow() + { + var cosmos = InMemoryCosmos.Create("orders"); + cosmos.Dispose(); + // Should not throw + } + + [Fact] + public async Task AsyncDispose_DoesNotThrow() + { + var cosmos = InMemoryCosmos.Create("orders"); + await cosmos.DisposeAsync(); + // Should not throw + } + + [Fact] + public void Create_MultipleIndependentResults() + { + using var cosmos1 = InMemoryCosmos.Create("orders"); + using var cosmos2 = InMemoryCosmos.Create("orders"); + + // Independent results — different instances + cosmos1.Container.Should().NotBeSameAs(cosmos2.Container); + cosmos1.Handler.Should().NotBeSameAs(cosmos2.Handler); + } } public class InMemoryCosmosContainerCasingTests { - [Fact] - public void ContainersDictionary_IsCaseSensitive() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("Orders", "/partitionKey") - .Build(); - - cosmos.Containers.Should().ContainKey("Orders"); - cosmos.Containers.Keys.Should().NotContain("orders"); - } + [Fact] + public void ContainersDictionary_IsCaseSensitive() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("Orders", "/partitionKey") + .Build(); + + cosmos.Containers.Should().ContainKey("Orders"); + cosmos.Containers.Keys.Should().NotContain("orders"); + } } public class InMemoryCosmosQueryLogTests { - [Fact] - public async Task Handler_QueryLog_RecordsQueries() - { - using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); + [Fact] + public async Task Handler_QueryLog_RecordsQueries() + { + using var cosmos = InMemoryCosmos.Create("orders", "/partitionKey"); - await cosmos.Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await cosmos.Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - var query = cosmos.Container.GetItemQueryIterator("SELECT * FROM c"); - while (query.HasMoreResults) await query.ReadNextAsync(); + var query = cosmos.Container.GetItemQueryIterator("SELECT * FROM c"); + while (query.HasMoreResults) await query.ReadNextAsync(); - cosmos.Handler.QueryLog.Should().NotBeEmpty(); - } + cosmos.Handler.QueryLog.Should().NotBeEmpty(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationDeepDiveTests.cs index a182ca4..0d9ae9b 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationDeepDiveTests.cs @@ -1,8 +1,8 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -13,117 +13,117 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class FakeCosmosHandlerMetadataDeepDiveTests { - private static async Task GetCollectionMetadataViaClient(InMemoryContainer container) - { - using var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("db", container.Id); - var response = await cosmosContainer.ReadContainerAsync(); - var json = JsonConvert.SerializeObject(response.Resource); - return JObject.Parse(json); - } - - [Fact] - public async Task Metadata_ReflectsIndexingMode_None() - { - var container = new InMemoryContainer("meta-mode", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy { IndexingMode = IndexingMode.None, Automatic = false }; - container.IndexingPolicy.IncludedPaths.Clear(); - - var result = await GetCollectionMetadataViaClient(container); - var policy = result["indexingPolicy"]!; - policy["indexingMode"]!.ToString().Should().BeOneOf("none", "None"); - policy["automatic"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task Metadata_ReflectsIncludedPaths() - { - var container = new InMemoryContainer("meta-inc", "/partitionKey"); - container.IndexingPolicy.IncludedPaths.Clear(); - container.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/name/?" }); - - var result = await GetCollectionMetadataViaClient(container); - var included = result["indexingPolicy"]!["includedPaths"]!.ToObject>()!; - included.Should().ContainSingle(p => p["path"]!.ToString() == "/name/?"); - } - - [Fact] - public async Task Metadata_ReflectsExcludedPaths() - { - var container = new InMemoryContainer("meta-exc", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/secret/*" }); - - var result = await GetCollectionMetadataViaClient(container); - var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; - excluded.Should().Contain(p => p["path"]!.ToString() == "/secret/*"); - } - - [Fact] - public async Task Metadata_DefaultExcludedPaths_AlwaysIncludesEtag() - { - var container = new InMemoryContainer("meta-etag", "/partitionKey"); - // No explicit excluded paths - var result = await GetCollectionMetadataViaClient(container); - var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; - excluded.Should().Contain(p => p["path"]!.ToString() == "/\"_etag\"/?"); - } - - [Fact] - public async Task Metadata_ExcludedPaths_AlwaysIncludesEtag_EvenWithUserPaths() - { - var container = new InMemoryContainer("meta-etag2", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/secret/*" }); - - var result = await GetCollectionMetadataViaClient(container); - var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; - excluded.Should().Contain(p => p["path"]!.ToString() == "/\"_etag\"/?", "BUG-3 fix: _etag always present"); - excluded.Should().Contain(p => p["path"]!.ToString() == "/secret/*"); - } - - [Fact] - public async Task Metadata_CompositeIndexes_Serialized() - { - var container = new InMemoryContainer("meta-comp", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection - { - new() { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new() { Path = "/value", Order = CompositePathSortOrder.Descending } - }); - - var result = await GetCollectionMetadataViaClient(container); - var composites = result["indexingPolicy"]!["compositeIndexes"]; - composites.Should().NotBeNull("BUG-2 fix: composite indexes should be serialized"); - var firstSet = composites![0]!.ToObject>()!; - firstSet.Should().HaveCount(2); - firstSet[0]["path"]!.ToString().Should().Be("/name"); - firstSet[1]["path"]!.ToString().Should().Be("/value"); - } - - [Fact] - public async Task Metadata_SpatialIndexes_Serialized() - { - var container = new InMemoryContainer("meta-spatial", "/partitionKey"); - container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath - { - Path = "/location/*", - SpatialTypes = { SpatialType.Point, SpatialType.Polygon } - }); - - var result = await GetCollectionMetadataViaClient(container); - var spatials = result["indexingPolicy"]!["spatialIndexes"]; - spatials.Should().NotBeNull("BUG-2 fix: spatial indexes should be serialized"); - var firstSpatial = spatials![0]!; - firstSpatial["path"]!.ToString().Should().Be("/location/*"); - } + private static async Task GetCollectionMetadataViaClient(InMemoryContainer container) + { + using var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("db", container.Id); + var response = await cosmosContainer.ReadContainerAsync(); + var json = JsonConvert.SerializeObject(response.Resource); + return JObject.Parse(json); + } + + [Fact] + public async Task Metadata_ReflectsIndexingMode_None() + { + var container = new InMemoryContainer("meta-mode", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy { IndexingMode = IndexingMode.None, Automatic = false }; + container.IndexingPolicy.IncludedPaths.Clear(); + + var result = await GetCollectionMetadataViaClient(container); + var policy = result["indexingPolicy"]!; + policy["indexingMode"]!.ToString().Should().BeOneOf("none", "None"); + policy["automatic"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task Metadata_ReflectsIncludedPaths() + { + var container = new InMemoryContainer("meta-inc", "/partitionKey"); + container.IndexingPolicy.IncludedPaths.Clear(); + container.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/name/?" }); + + var result = await GetCollectionMetadataViaClient(container); + var included = result["indexingPolicy"]!["includedPaths"]!.ToObject>()!; + included.Should().ContainSingle(p => p["path"]!.ToString() == "/name/?"); + } + + [Fact] + public async Task Metadata_ReflectsExcludedPaths() + { + var container = new InMemoryContainer("meta-exc", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/secret/*" }); + + var result = await GetCollectionMetadataViaClient(container); + var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; + excluded.Should().Contain(p => p["path"]!.ToString() == "/secret/*"); + } + + [Fact] + public async Task Metadata_DefaultExcludedPaths_AlwaysIncludesEtag() + { + var container = new InMemoryContainer("meta-etag", "/partitionKey"); + // No explicit excluded paths + var result = await GetCollectionMetadataViaClient(container); + var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; + excluded.Should().Contain(p => p["path"]!.ToString() == "/\"_etag\"/?"); + } + + [Fact] + public async Task Metadata_ExcludedPaths_AlwaysIncludesEtag_EvenWithUserPaths() + { + var container = new InMemoryContainer("meta-etag2", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/secret/*" }); + + var result = await GetCollectionMetadataViaClient(container); + var excluded = result["indexingPolicy"]!["excludedPaths"]!.ToObject>()!; + excluded.Should().Contain(p => p["path"]!.ToString() == "/\"_etag\"/?", "BUG-3 fix: _etag always present"); + excluded.Should().Contain(p => p["path"]!.ToString() == "/secret/*"); + } + + [Fact] + public async Task Metadata_CompositeIndexes_Serialized() + { + var container = new InMemoryContainer("meta-comp", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection + { + new() { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/value", Order = CompositePathSortOrder.Descending } + }); + + var result = await GetCollectionMetadataViaClient(container); + var composites = result["indexingPolicy"]!["compositeIndexes"]; + composites.Should().NotBeNull("BUG-2 fix: composite indexes should be serialized"); + var firstSet = composites![0]!.ToObject>()!; + firstSet.Should().HaveCount(2); + firstSet[0]["path"]!.ToString().Should().Be("/name"); + firstSet[1]["path"]!.ToString().Should().Be("/value"); + } + + [Fact] + public async Task Metadata_SpatialIndexes_Serialized() + { + var container = new InMemoryContainer("meta-spatial", "/partitionKey"); + container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath + { + Path = "/location/*", + SpatialTypes = { SpatialType.Point, SpatialType.Polygon } + }); + + var result = await GetCollectionMetadataViaClient(container); + var spatials = result["indexingPolicy"]!["spatialIndexes"]; + spatials.Should().NotBeNull("BUG-2 fix: spatial indexes should be serialized"); + var firstSpatial = spatials![0]!; + firstSpatial["path"]!.ToString().Should().Be("/location/*"); + } } // ═══════════════════════════════════════════════════════════ @@ -132,98 +132,98 @@ public async Task Metadata_SpatialIndexes_Serialized() public class ExcludedPathQueryDeepDiveTests { - private readonly InMemoryContainer _container; - - public ExcludedPathQueryDeepDiveTests() - { - _container = new InMemoryContainer("excl-path", "/partitionKey"); - _container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/value/*" }); - _container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - } - - private async Task SeedItems() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "Alice", value = 10 }), new PartitionKey("pk")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk", name = "Bob", value = 20 }), new PartitionKey("pk")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk", name = "Charlie", value = 0 }), new PartitionKey("pk")); - } - - private async Task> QueryAll(QueryDefinition query) - { - var iter = _container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ExcludedPath_BetweenQuery_StillWorks() - { - await SeedItems(); - var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 5 AND 15")); - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ExcludedPath_LikeQuery_StillWorks() - { - await SeedItems(); - var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'Al%'")); - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ExcludedPath_InQuery_StillWorks() - { - await SeedItems(); - var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.value IN (10, 20)")); - results.Should().HaveCount(2); - } - - [Fact] - public async Task ExcludedPath_AggregateQuery_StillWorks() - { - await SeedItems(); - var iter = _container.GetItemQueryIterator(new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.value > 0")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(1); - results[0].Value().Should().Be(2); - } - - [Fact] - public async Task ExcludedPath_OrderByExcludedField_StillWorks() - { - await SeedItems(); - var results = await QueryAll(new QueryDefinition("SELECT * FROM c ORDER BY c.value")); - results.Should().HaveCount(3); - results[0]["value"]!.Value().Should().Be(0); - results[2]["value"]!.Value().Should().Be(20); - } - - [Fact] - public async Task ExcludedPath_GroupByExcludedField_StillWorks() - { - await SeedItems(); - var results = await QueryAll(new QueryDefinition("SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name")); - results.Should().HaveCount(3); - } - - [Fact] - public async Task ExcludedPath_PartitionKeyPath_QueriesStillWork() - { - var container = new InMemoryContainer("excl-pk", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/partitionKey/*" }); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk2" }), new PartitionKey("pk2")); - - var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk1'")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(1); - } + private readonly InMemoryContainer _container; + + public ExcludedPathQueryDeepDiveTests() + { + _container = new InMemoryContainer("excl-path", "/partitionKey"); + _container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/value/*" }); + _container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + } + + private async Task SeedItems() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk", name = "Alice", value = 10 }), new PartitionKey("pk")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk", name = "Bob", value = 20 }), new PartitionKey("pk")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk", name = "Charlie", value = 0 }), new PartitionKey("pk")); + } + + private async Task> QueryAll(QueryDefinition query) + { + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ExcludedPath_BetweenQuery_StillWorks() + { + await SeedItems(); + var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 5 AND 15")); + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ExcludedPath_LikeQuery_StillWorks() + { + await SeedItems(); + var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'Al%'")); + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ExcludedPath_InQuery_StillWorks() + { + await SeedItems(); + var results = await QueryAll(new QueryDefinition("SELECT * FROM c WHERE c.value IN (10, 20)")); + results.Should().HaveCount(2); + } + + [Fact] + public async Task ExcludedPath_AggregateQuery_StillWorks() + { + await SeedItems(); + var iter = _container.GetItemQueryIterator(new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.value > 0")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(1); + results[0].Value().Should().Be(2); + } + + [Fact] + public async Task ExcludedPath_OrderByExcludedField_StillWorks() + { + await SeedItems(); + var results = await QueryAll(new QueryDefinition("SELECT * FROM c ORDER BY c.value")); + results.Should().HaveCount(3); + results[0]["value"]!.Value().Should().Be(0); + results[2]["value"]!.Value().Should().Be(20); + } + + [Fact] + public async Task ExcludedPath_GroupByExcludedField_StillWorks() + { + await SeedItems(); + var results = await QueryAll(new QueryDefinition("SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name")); + results.Should().HaveCount(3); + } + + [Fact] + public async Task ExcludedPath_PartitionKeyPath_QueriesStillWork() + { + var container = new InMemoryContainer("excl-pk", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/partitionKey/*" }); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk2" }), new PartitionKey("pk2")); + + var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = 'pk1'")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(1); + } } // ═══════════════════════════════════════════════════════════ @@ -232,92 +232,92 @@ public async Task ExcludedPath_PartitionKeyPath_QueriesStillWork() public class CompositeIndexRoundtripDeepDiveTests { - [Fact] - public async Task CompositeIndex_SurvivesReadContainerAsync() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var containerProps = new ContainerProperties("comp-rt", "/pk"); - containerProps.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection - { - new() { Path = "/a", Order = CompositePathSortOrder.Ascending }, - new() { Path = "/b", Order = CompositePathSortOrder.Descending } - }); - var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); - - var readResponse = await result.Container.ReadContainerAsync(); - readResponse.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); - readResponse.Resource.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); - readResponse.Resource.IndexingPolicy.CompositeIndexes[0][0].Path.Should().Be("/a"); - readResponse.Resource.IndexingPolicy.CompositeIndexes[0][1].Order.Should().Be(CompositePathSortOrder.Descending); - } - - [Fact] - public async Task CompositeIndex_UpdatedViaReplaceContainerAsync() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("comp-repl", "/pk")); - - var props = (await result.Container.ReadContainerAsync()).Resource; - props.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection - { - new() { Path = "/x", Order = CompositePathSortOrder.Ascending } - }); - await result.Container.ReplaceContainerAsync(props); - - var readBack = await result.Container.ReadContainerAsync(); - readBack.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); - } - - [Fact] - public async Task SpatialIndex_SurvivesReadContainerAsync() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var containerProps = new ContainerProperties("spatial-rt", "/pk"); - containerProps.IndexingPolicy.SpatialIndexes.Add(new SpatialPath - { - Path = "/location/*", - SpatialTypes = { SpatialType.Point, SpatialType.Polygon } - }); - var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); - - var readResponse = await result.Container.ReadContainerAsync(); - readResponse.Resource.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); - readResponse.Resource.IndexingPolicy.SpatialIndexes[0].Path.Should().Be("/location/*"); - } - - [Fact] - public async Task SpatialIndex_UpdatedViaReplaceContainerAsync() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("spatial-repl", "/pk")); - - var props = (await result.Container.ReadContainerAsync()).Resource; - props.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/geo/*", SpatialTypes = { SpatialType.Point } }); - await result.Container.ReplaceContainerAsync(props); - - var readBack = await result.Container.ReadContainerAsync(); - readBack.Resource.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); - } - - [Fact] - public async Task CompositeIndex_EmptyContainer_NoError() - { - var container = new InMemoryContainer("comp-empty", "/pk"); - container.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection - { - new() { Path = "/a", Order = CompositePathSortOrder.Ascending }, - new() { Path = "/b", Order = CompositePathSortOrder.Descending } - }); - - var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c ORDER BY c.a ASC, c.b DESC")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().BeEmpty(); - } + [Fact] + public async Task CompositeIndex_SurvivesReadContainerAsync() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var containerProps = new ContainerProperties("comp-rt", "/pk"); + containerProps.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection + { + new() { Path = "/a", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/b", Order = CompositePathSortOrder.Descending } + }); + var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); + + var readResponse = await result.Container.ReadContainerAsync(); + readResponse.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); + readResponse.Resource.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); + readResponse.Resource.IndexingPolicy.CompositeIndexes[0][0].Path.Should().Be("/a"); + readResponse.Resource.IndexingPolicy.CompositeIndexes[0][1].Order.Should().Be(CompositePathSortOrder.Descending); + } + + [Fact] + public async Task CompositeIndex_UpdatedViaReplaceContainerAsync() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("comp-repl", "/pk")); + + var props = (await result.Container.ReadContainerAsync()).Resource; + props.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection + { + new() { Path = "/x", Order = CompositePathSortOrder.Ascending } + }); + await result.Container.ReplaceContainerAsync(props); + + var readBack = await result.Container.ReadContainerAsync(); + readBack.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); + } + + [Fact] + public async Task SpatialIndex_SurvivesReadContainerAsync() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var containerProps = new ContainerProperties("spatial-rt", "/pk"); + containerProps.IndexingPolicy.SpatialIndexes.Add(new SpatialPath + { + Path = "/location/*", + SpatialTypes = { SpatialType.Point, SpatialType.Polygon } + }); + var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); + + var readResponse = await result.Container.ReadContainerAsync(); + readResponse.Resource.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); + readResponse.Resource.IndexingPolicy.SpatialIndexes[0].Path.Should().Be("/location/*"); + } + + [Fact] + public async Task SpatialIndex_UpdatedViaReplaceContainerAsync() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("spatial-repl", "/pk")); + + var props = (await result.Container.ReadContainerAsync()).Resource; + props.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/geo/*", SpatialTypes = { SpatialType.Point } }); + await result.Container.ReplaceContainerAsync(props); + + var readBack = await result.Container.ReadContainerAsync(); + readBack.Resource.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); + } + + [Fact] + public async Task CompositeIndex_EmptyContainer_NoError() + { + var container = new InMemoryContainer("comp-empty", "/pk"); + container.IndexingPolicy.CompositeIndexes.Add(new System.Collections.ObjectModel.Collection + { + new() { Path = "/a", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/b", Order = CompositePathSortOrder.Descending } + }); + + var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c ORDER BY c.a ASC, c.b DESC")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════ @@ -326,144 +326,144 @@ public async Task CompositeIndex_EmptyContainer_NoError() public class OrderByDeepDiveTests { - private async Task> QueryAll(InMemoryContainer container, string sql) - { - var iter = container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_BooleanValues_SortCorrectly() - { - var container = new InMemoryContainer("ob-bool", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", flag = true }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", flag = false }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", flag = true }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.flag"); - results[0]["flag"]!.Value().Should().BeFalse("false sorts before true in ASC"); - } - - [Fact] - public async Task OrderBy_DescWithNulls_NullsSortCorrectly() - { - var container = new InMemoryContainer("ob-descnull", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 10 }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk" }), new PartitionKey("pk")); // no val - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 20 }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.val DESC"); - // Items with val should come before items without val - results.Should().HaveCount(3); - var firstVal = results[0]["val"]?.Value(); - firstVal.Should().NotBeNull(); - } - - [Fact] - public async Task OrderBy_WithDistinct() - { - var container = new InMemoryContainer("ob-distinct", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Alice" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Bob" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "Alice" }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT DISTINCT c.name FROM c ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[1]["name"]!.ToString().Should().Be("Bob"); - } - - [Fact] - public async Task OrderBy_WithGroupBy() - { - var container = new InMemoryContainer("ob-groupby", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Bob" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Alice" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "Alice" }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["name"]!.ToString().Should().Be("Bob"); - } - - [Fact] - public async Task OrderBy_WithParameterizedWhere() - { - var container = new InMemoryContainer("ob-param", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 5 }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", val = 15 }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 10 }), new PartitionKey("pk")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.val > @min ORDER BY c.val").WithParameter("@min", 4); - var iter = container.GetItemQueryIterator(query); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["val"]!.Value().Should().Be(5); - results[2]["val"]!.Value().Should().Be(15); - } - - [Fact] - public async Task OrderBy_StringValues_CaseSensitive() - { - var container = new InMemoryContainer("ob-case", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "bob" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Alice" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "alice" }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.name"); - // Ordinal: uppercase letters come before lowercase - results.Should().HaveCount(3); - } - - [Fact] - public async Task OrderBy_NumericPrecision_IntAndFloat() - { - var container = new InMemoryContainer("ob-numeric", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 42 }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", val = 42.5 }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 41.9 }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.val"); - results[0]["val"]!.Value().Should().BeLessThan(42); - results[1]["val"]!.Value().Should().Be(42); - results[2]["val"]!.Value().Should().BeGreaterThan(42); - } - - [Fact] - public async Task OrderBy_EmptyStringVsNull() - { - var container = new InMemoryContainer("ob-empty", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Bob" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "" }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = (string?)null }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.name"); - results.Should().HaveCount(3); - // null sorts before empty string, empty string sorts before "Bob" - results[0]["name"]!.Type.Should().Be(JTokenType.Null); - results[1]["name"]!.ToString().Should().Be(""); - results[2]["name"]!.ToString().Should().Be("Bob"); - } - - [Fact] - public async Task OrderBy_DeepNestedProperty_ThreeLevel() - { - var container = new InMemoryContainer("ob-deep", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", a = new { b = new { c = 30 } } }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", a = new { b = new { c = 10 } } }), new PartitionKey("pk")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", a = new { b = new { c = 20 } } }), new PartitionKey("pk")); - - var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.a.b.c"); - results[0]["a"]!["b"]!["c"]!.Value().Should().Be(10); - results[1]["a"]!["b"]!["c"]!.Value().Should().Be(20); - results[2]["a"]!["b"]!["c"]!.Value().Should().Be(30); - } + private async Task> QueryAll(InMemoryContainer container, string sql) + { + var iter = container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_BooleanValues_SortCorrectly() + { + var container = new InMemoryContainer("ob-bool", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", flag = true }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", flag = false }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", flag = true }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.flag"); + results[0]["flag"]!.Value().Should().BeFalse("false sorts before true in ASC"); + } + + [Fact] + public async Task OrderBy_DescWithNulls_NullsSortCorrectly() + { + var container = new InMemoryContainer("ob-descnull", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 10 }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk" }), new PartitionKey("pk")); // no val + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 20 }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.val DESC"); + // Items with val should come before items without val + results.Should().HaveCount(3); + var firstVal = results[0]["val"]?.Value(); + firstVal.Should().NotBeNull(); + } + + [Fact] + public async Task OrderBy_WithDistinct() + { + var container = new InMemoryContainer("ob-distinct", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Alice" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Bob" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "Alice" }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT DISTINCT c.name FROM c ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[1]["name"]!.ToString().Should().Be("Bob"); + } + + [Fact] + public async Task OrderBy_WithGroupBy() + { + var container = new InMemoryContainer("ob-groupby", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Bob" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Alice" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "Alice" }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["name"]!.ToString().Should().Be("Bob"); + } + + [Fact] + public async Task OrderBy_WithParameterizedWhere() + { + var container = new InMemoryContainer("ob-param", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 5 }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", val = 15 }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 10 }), new PartitionKey("pk")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.val > @min ORDER BY c.val").WithParameter("@min", 4); + var iter = container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["val"]!.Value().Should().Be(5); + results[2]["val"]!.Value().Should().Be(15); + } + + [Fact] + public async Task OrderBy_StringValues_CaseSensitive() + { + var container = new InMemoryContainer("ob-case", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "bob" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "Alice" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = "alice" }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.name"); + // Ordinal: uppercase letters come before lowercase + results.Should().HaveCount(3); + } + + [Fact] + public async Task OrderBy_NumericPrecision_IntAndFloat() + { + var container = new InMemoryContainer("ob-numeric", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", val = 42 }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", val = 42.5 }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", val = 41.9 }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.val"); + results[0]["val"]!.Value().Should().BeLessThan(42); + results[1]["val"]!.Value().Should().Be(42); + results[2]["val"]!.Value().Should().BeGreaterThan(42); + } + + [Fact] + public async Task OrderBy_EmptyStringVsNull() + { + var container = new InMemoryContainer("ob-empty", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", name = "Bob" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", name = "" }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", name = (string?)null }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.name"); + results.Should().HaveCount(3); + // null sorts before empty string, empty string sorts before "Bob" + results[0]["name"]!.Type.Should().Be(JTokenType.Null); + results[1]["name"]!.ToString().Should().Be(""); + results[2]["name"]!.ToString().Should().Be("Bob"); + } + + [Fact] + public async Task OrderBy_DeepNestedProperty_ThreeLevel() + { + var container = new InMemoryContainer("ob-deep", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk", a = new { b = new { c = 30 } } }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "pk", a = new { b = new { c = 10 } } }), new PartitionKey("pk")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "pk", a = new { b = new { c = 20 } } }), new PartitionKey("pk")); + + var results = await QueryAll(container, "SELECT * FROM c ORDER BY c.a.b.c"); + results[0]["a"]!["b"]!["c"]!.Value().Should().Be(10); + results[1]["a"]!["b"]!["c"]!.Value().Should().Be(20); + results[2]["a"]!["b"]!["c"]!.Value().Should().Be(30); + } } // ═══════════════════════════════════════════════════════════ @@ -472,69 +472,69 @@ public async Task OrderBy_DeepNestedProperty_ThreeLevel() public class IndexPolicyEdgeCaseDeepDiveTests { - [Fact] - public async Task IndexingPolicy_ExportImport_PolicyNotPersisted() - { - var container = new InMemoryContainer("exp-pol", "/pk"); - container.IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None }; - container.IndexingPolicy.IncludedPaths.Clear(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk" }), new PartitionKey("pk")); - - var state = container.ExportState(); - var newContainer = new InMemoryContainer("exp-pol", "/pk"); - newContainer.ImportState(state); - - // Policy is NOT preserved in export/import — resets to defaults - newContainer.IndexingPolicy.Automatic.Should().BeTrue("policy not persisted in export/import"); - newContainer.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.Consistent); - } - - [Fact] - public async Task ReplaceContainer_NullIndexingPolicy_PreservesExisting() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("rpol", "/pk")); - - // Set custom policy - var props = (await result.Container.ReadContainerAsync()).Resource; - props.IndexingPolicy.Automatic = false; - await result.Container.ReplaceContainerAsync(props); - - // Read, null out indexing policy, replace - var props2 = (await result.Container.ReadContainerAsync()).Resource; - props2.IndexingPolicy.Automatic.Should().BeFalse("custom policy was set"); - } - - [Fact] - public async Task IndexingPolicy_ReadContainerStreamAsync_SyncsPolicy() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var containerProps = new ContainerProperties("stream-read", "/pk"); - containerProps.IndexingPolicy.Automatic = false; - var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); - - using var streamResponse = await result.Container.ReadContainerStreamAsync(); - streamResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var sr = new System.IO.StreamReader(streamResponse.Content); - var json = await sr.ReadToEndAsync(); - var obj = JObject.Parse(json); - obj["indexingPolicy"]!["automatic"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IndexingPolicy_ReplaceContainerStreamAsync_UpdatesPolicy() - { - var client = new InMemoryCosmosClient(); - var db = await client.CreateDatabaseIfNotExistsAsync("db"); - var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("stream-repl", "/pk")); - - var props = (await result.Container.ReadContainerAsync()).Resource; - props.IndexingPolicy.Automatic = false; - await result.Container.ReplaceContainerAsync(props); - - var readBack = await result.Container.ReadContainerAsync(); - readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); - } + [Fact] + public async Task IndexingPolicy_ExportImport_PolicyNotPersisted() + { + var container = new InMemoryContainer("exp-pol", "/pk"); + container.IndexingPolicy = new IndexingPolicy { Automatic = false, IndexingMode = IndexingMode.None }; + container.IndexingPolicy.IncludedPaths.Clear(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "pk" }), new PartitionKey("pk")); + + var state = container.ExportState(); + var newContainer = new InMemoryContainer("exp-pol", "/pk"); + newContainer.ImportState(state); + + // Policy is NOT preserved in export/import — resets to defaults + newContainer.IndexingPolicy.Automatic.Should().BeTrue("policy not persisted in export/import"); + newContainer.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.Consistent); + } + + [Fact] + public async Task ReplaceContainer_NullIndexingPolicy_PreservesExisting() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("rpol", "/pk")); + + // Set custom policy + var props = (await result.Container.ReadContainerAsync()).Resource; + props.IndexingPolicy.Automatic = false; + await result.Container.ReplaceContainerAsync(props); + + // Read, null out indexing policy, replace + var props2 = (await result.Container.ReadContainerAsync()).Resource; + props2.IndexingPolicy.Automatic.Should().BeFalse("custom policy was set"); + } + + [Fact] + public async Task IndexingPolicy_ReadContainerStreamAsync_SyncsPolicy() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var containerProps = new ContainerProperties("stream-read", "/pk"); + containerProps.IndexingPolicy.Automatic = false; + var result = await db.Database.CreateContainerIfNotExistsAsync(containerProps); + + using var streamResponse = await result.Container.ReadContainerStreamAsync(); + streamResponse.StatusCode.Should().Be(HttpStatusCode.OK); + using var sr = new System.IO.StreamReader(streamResponse.Content); + var json = await sr.ReadToEndAsync(); + var obj = JObject.Parse(json); + obj["indexingPolicy"]!["automatic"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IndexingPolicy_ReplaceContainerStreamAsync_UpdatesPolicy() + { + var client = new InMemoryCosmosClient(); + var db = await client.CreateDatabaseIfNotExistsAsync("db"); + var result = await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties("stream-repl", "/pk")); + + var props = (await result.Container.ReadContainerAsync()).Resource; + props.IndexingPolicy.Automatic = false; + await result.Container.ReplaceContainerAsync(props); + + var readBack = await result.Container.ReadContainerAsync(); + readBack.Resource.IndexingPolicy.Automatic.Should().BeFalse(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationTests.cs index b0bc7de..9bea930 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/IndexSimulationTests.cs @@ -1,212 +1,212 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class IndexSimulationTests { - // ── Basic index policy support ────────────────────────────────────────── - - [Fact] - public void DefaultIndexingPolicy_HasAutomaticIndexing() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - var policy = container.IndexingPolicy; - - policy.Should().NotBeNull(); - policy.Automatic.Should().BeTrue(); - policy.IndexingMode.Should().Be(IndexingMode.Consistent); - } - - [Fact] - public void SetIndexingPolicy_UpdatesPolicy() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - var policy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - - container.IndexingPolicy = policy; - - container.IndexingPolicy.Automatic.Should().BeFalse(); - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - // ── Included/Excluded paths ───────────────────────────────────────────── - - [Fact] - public void IndexingPolicy_DefaultIncludesAllPaths() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - container.IndexingPolicy.IncludedPaths.Should().ContainSingle(p => p.Path == "/*"); - } - - [Fact] - public async Task ExcludedPath_StillAllowsPointReads() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ExcludedPath_QueriesStillWorkButLogWarning() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - // Queries on excluded paths still work in our in-memory implementation - // (they would just be slower in real Cosmos DB - a full scan instead of index lookup) - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - } - - // ── Composite indexes ─────────────────────────────────────────────────── - - [Fact] - public void CompositeIndexes_CanBeConfigured() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - container.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); - container.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); - } - - [Fact] - public async Task OrderBy_WithCompositeIndex_ReturnsCorrectOrder() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 5 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(3); - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(20); - results[1].Name.Should().Be("Alice"); - results[1].Value.Should().Be(10); - results[2].Name.Should().Be("Bob"); - } - - // ── Spatial indexes ───────────────────────────────────────────────────── - - [Fact] - public void SpatialIndexes_CanBeConfigured() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath - { - Path = "/location/*" - }); - - container.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); - } - - // ── Request charge reflects index usage ───────────────────────────────── - // Behavioral difference: In real Cosmos DB, queries on excluded paths consume - // more RUs because they require a full scan. Our implementation always returns - // a synthetic request charge - we simulate this by returning a higher synthetic - // charge when querying on excluded paths. - - [Fact(Skip = "InMemoryContainer uses a synthetic request charge of 1.0 for all operations. " + - "Real Cosmos DB varies request charge based on index usage, document size and query complexity. " + - "Implementing realistic RU calculation would require modeling the full Cosmos DB cost model.")] - public async Task ExcludedPath_QueryReturnsHigherRequestCharge() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - // Indexed query - var indexedQuery = new QueryDefinition("SELECT * FROM c WHERE c.value = 10"); - var indexedIterator = container.GetItemQueryIterator(indexedQuery); - var indexedResponse = await indexedIterator.ReadNextAsync(); - var indexedCharge = indexedResponse.RequestCharge; - - // Exclude the name path - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - - // Query on excluded path - var excludedQuery = new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"); - var excludedIterator = container.GetItemQueryIterator(excludedQuery); - var excludedResponse = await excludedIterator.ReadNextAsync(); - var excludedCharge = excludedResponse.RequestCharge; - - excludedCharge.Should().BeGreaterThan(indexedCharge); - } - - // Behavioral difference test: documents the actual divergent behaviour - [Fact] - public async Task BehavioralDifference_RequestChargeIsAlwaysSynthetic() - { - // In real Cosmos DB, request charge varies based on index usage, document size, - // and query complexity. Our implementation always returns a synthetic flat charge. - var container = new InMemoryContainer("test-container", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.value = 10"); - var iterator = container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - - // Our synthetic charge is always the same regardless of index configuration - response.RequestCharge.Should().Be(1.0); - } + // ── Basic index policy support ────────────────────────────────────────── + + [Fact] + public void DefaultIndexingPolicy_HasAutomaticIndexing() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + var policy = container.IndexingPolicy; + + policy.Should().NotBeNull(); + policy.Automatic.Should().BeTrue(); + policy.IndexingMode.Should().Be(IndexingMode.Consistent); + } + + [Fact] + public void SetIndexingPolicy_UpdatesPolicy() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + var policy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + + container.IndexingPolicy = policy; + + container.IndexingPolicy.Automatic.Should().BeFalse(); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + // ── Included/Excluded paths ───────────────────────────────────────────── + + [Fact] + public void IndexingPolicy_DefaultIncludesAllPaths() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + container.IndexingPolicy.IncludedPaths.Should().ContainSingle(p => p.Path == "/*"); + } + + [Fact] + public async Task ExcludedPath_StillAllowsPointReads() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ExcludedPath_QueriesStillWorkButLogWarning() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + // Queries on excluded paths still work in our in-memory implementation + // (they would just be slower in real Cosmos DB - a full scan instead of index lookup) + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + } + + // ── Composite indexes ─────────────────────────────────────────────────── + + [Fact] + public void CompositeIndexes_CanBeConfigured() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + container.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); + container.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); + } + + [Fact] + public async Task OrderBy_WithCompositeIndex_ReturnsCorrectOrder() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 5 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(3); + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(20); + results[1].Name.Should().Be("Alice"); + results[1].Value.Should().Be(10); + results[2].Name.Should().Be("Bob"); + } + + // ── Spatial indexes ───────────────────────────────────────────────────── + + [Fact] + public void SpatialIndexes_CanBeConfigured() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath + { + Path = "/location/*" + }); + + container.IndexingPolicy.SpatialIndexes.Should().HaveCount(1); + } + + // ── Request charge reflects index usage ───────────────────────────────── + // Behavioral difference: In real Cosmos DB, queries on excluded paths consume + // more RUs because they require a full scan. Our implementation always returns + // a synthetic request charge - we simulate this by returning a higher synthetic + // charge when querying on excluded paths. + + [Fact(Skip = "InMemoryContainer uses a synthetic request charge of 1.0 for all operations. " + + "Real Cosmos DB varies request charge based on index usage, document size and query complexity. " + + "Implementing realistic RU calculation would require modeling the full Cosmos DB cost model.")] + public async Task ExcludedPath_QueryReturnsHigherRequestCharge() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + // Indexed query + var indexedQuery = new QueryDefinition("SELECT * FROM c WHERE c.value = 10"); + var indexedIterator = container.GetItemQueryIterator(indexedQuery); + var indexedResponse = await indexedIterator.ReadNextAsync(); + var indexedCharge = indexedResponse.RequestCharge; + + // Exclude the name path + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + + // Query on excluded path + var excludedQuery = new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"); + var excludedIterator = container.GetItemQueryIterator(excludedQuery); + var excludedResponse = await excludedIterator.ReadNextAsync(); + var excludedCharge = excludedResponse.RequestCharge; + + excludedCharge.Should().BeGreaterThan(indexedCharge); + } + + // Behavioral difference test: documents the actual divergent behaviour + [Fact] + public async Task BehavioralDifference_RequestChargeIsAlwaysSynthetic() + { + // In real Cosmos DB, request charge varies based on index usage, document size, + // and query complexity. Our implementation always returns a synthetic flat charge. + var container = new InMemoryContainer("test-container", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.value = 10"); + var iterator = container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + + // Our synthetic charge is always the same regardless of index configuration + response.RequestCharge.Should().Be(1.0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -215,136 +215,136 @@ await container.CreateItemAsync( public class IndexPolicyRoundtripTests { - [Fact] - public async Task IndexingPolicy_SurvivesReadContainerAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - var response = await container.ReadContainerAsync(); - - response.Resource.IndexingPolicy.Should().NotBeNull(); - response.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); - } - - [Fact] - public async Task IndexingPolicy_SurvivesReadContainerAsync_EmulatorBehavior() - { - // ReadContainerAsync now syncs IndexingPolicy into _containerProperties. - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - var response = await container.ReadContainerAsync(); - - response.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1, - "ReadContainerAsync now syncs IndexingPolicy correctly"); - } - - [Fact] - public async Task IndexingPolicy_UpdatedViaReplaceContainerAsync() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var newProperties = new ContainerProperties("test", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - - await container.ReplaceContainerAsync(newProperties); - - container.IndexingPolicy.Automatic.Should().BeFalse(); - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public async Task IndexingPolicy_UpdatedViaReplaceContainerAsync_EmulatorBehavior() - { - // ReplaceContainerAsync now syncs IndexingPolicy back to the public property. - var container = new InMemoryContainer("test", "/partitionKey"); - - var newProperties = new ContainerProperties("test", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - - await container.ReplaceContainerAsync(newProperties); - - container.IndexingPolicy.Automatic.Should().BeFalse( - "ReplaceContainerAsync now syncs IndexingPolicy correctly"); - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public void IndexingPolicy_DefaultExcludedPaths_Empty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Real Cosmos defaults to /_etag/? in excluded paths - container.IndexingPolicy.ExcludedPaths.Should().ContainSingle() - .Which.Path.Should().Be("/\"_etag\"/?"); - } - - [Fact] - public async Task IndexingPolicy_PreservedWhenCreatedViaDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var properties = new ContainerProperties("testcontainer", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - - var response = await db.CreateContainerAsync(properties); - var container = response.Container as InMemoryContainer; - - container!.IndexingPolicy.Automatic.Should().BeFalse(); - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public async Task IndexingPolicy_PreservedWhenCreatedViaDatabase_EmulatorBehavior() - { - // CreateContainerAsync(ContainerProperties) now preserves IndexingPolicy. - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var properties = new ContainerProperties("testcontainer", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - - var response = await db.CreateContainerAsync(properties); - var container = response.Container as InMemoryContainer; - - container!.IndexingPolicy.Automatic.Should().BeFalse( - "IndexingPolicy is now preserved during database container creation"); - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } + [Fact] + public async Task IndexingPolicy_SurvivesReadContainerAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + var response = await container.ReadContainerAsync(); + + response.Resource.IndexingPolicy.Should().NotBeNull(); + response.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); + } + + [Fact] + public async Task IndexingPolicy_SurvivesReadContainerAsync_EmulatorBehavior() + { + // ReadContainerAsync now syncs IndexingPolicy into _containerProperties. + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + var response = await container.ReadContainerAsync(); + + response.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1, + "ReadContainerAsync now syncs IndexingPolicy correctly"); + } + + [Fact] + public async Task IndexingPolicy_UpdatedViaReplaceContainerAsync() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var newProperties = new ContainerProperties("test", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + + await container.ReplaceContainerAsync(newProperties); + + container.IndexingPolicy.Automatic.Should().BeFalse(); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public async Task IndexingPolicy_UpdatedViaReplaceContainerAsync_EmulatorBehavior() + { + // ReplaceContainerAsync now syncs IndexingPolicy back to the public property. + var container = new InMemoryContainer("test", "/partitionKey"); + + var newProperties = new ContainerProperties("test", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + + await container.ReplaceContainerAsync(newProperties); + + container.IndexingPolicy.Automatic.Should().BeFalse( + "ReplaceContainerAsync now syncs IndexingPolicy correctly"); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public void IndexingPolicy_DefaultExcludedPaths_Empty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Real Cosmos defaults to /_etag/? in excluded paths + container.IndexingPolicy.ExcludedPaths.Should().ContainSingle() + .Which.Path.Should().Be("/\"_etag\"/?"); + } + + [Fact] + public async Task IndexingPolicy_PreservedWhenCreatedViaDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var properties = new ContainerProperties("testcontainer", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + + var response = await db.CreateContainerAsync(properties); + var container = response.Container as InMemoryContainer; + + container!.IndexingPolicy.Automatic.Should().BeFalse(); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public async Task IndexingPolicy_PreservedWhenCreatedViaDatabase_EmulatorBehavior() + { + // CreateContainerAsync(ContainerProperties) now preserves IndexingPolicy. + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var properties = new ContainerProperties("testcontainer", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + + var response = await db.CreateContainerAsync(properties); + var container = response.Container as InMemoryContainer; + + container!.IndexingPolicy.Automatic.Should().BeFalse( + "IndexingPolicy is now preserved during database container creation"); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -353,101 +353,101 @@ public async Task IndexingPolicy_PreservedWhenCreatedViaDatabase_EmulatorBehavio public class IndexingModeBehaviorTests { - [Fact(Skip = "In real Cosmos DB, IndexingMode.None means queries fail unless " + - "EnableScanInQuery=true. The emulator ignores IndexingMode entirely — queries " + - "always work regardless of mode.")] - public async Task IndexingMode_None_QueriesFailWithoutScanEnabled() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - var act = async () => - { - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task BehavioralDifference_IndexingMode_None_QueriesStillWork() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: IndexingMode.None prevents queries (only point reads work). - // In-Memory Emulator: IndexingMode is stored but not enforced. Queries always scan all items. - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(r => r.Name == "Alice"); - } - - [Fact] - public void IndexingMode_Lazy_IsAccepted() - { + [Fact(Skip = "In real Cosmos DB, IndexingMode.None means queries fail unless " + + "EnableScanInQuery=true. The emulator ignores IndexingMode entirely — queries " + + "always work regardless of mode.")] + public async Task IndexingMode_None_QueriesFailWithoutScanEnabled() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + var act = async () => + { + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task BehavioralDifference_IndexingMode_None_QueriesStillWork() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: IndexingMode.None prevents queries (only point reads work). + // In-Memory Emulator: IndexingMode is stored but not enforced. Queries always scan all items. + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(r => r.Name == "Alice"); + } + + [Fact] + public void IndexingMode_Lazy_IsAccepted() + { #pragma warning disable CS0618 // Lazy is obsolete - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - IndexingMode = IndexingMode.Lazy - }; + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + IndexingMode = IndexingMode.Lazy + }; - container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.Lazy); + container.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.Lazy); #pragma warning restore CS0618 - } - - [Fact] - public async Task BehavioralDifference_IndexingMode_Lazy_QueriesAreImmediate() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: Lazy mode indexes async; queries may see stale results. - // In-Memory Emulator: All queries return complete, immediate results regardless of mode. + } + + [Fact] + public async Task BehavioralDifference_IndexingMode_Lazy_QueriesAreImmediate() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: Lazy mode indexes async; queries may see stale results. + // In-Memory Emulator: All queries return complete, immediate results regardless of mode. #pragma warning disable CS0618 - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - IndexingMode = IndexingMode.Lazy - }; + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + IndexingMode = IndexingMode.Lazy + }; #pragma warning restore CS0618 - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -456,133 +456,135 @@ await container.CreateItemAsync( public class IndexPathEdgeCaseTests { - [Fact] - public void ExcludedPath_EtagPath_CanBeAdded() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/_etag/?" }); - - container.IndexingPolicy.ExcludedPaths.Should().ContainSingle(p => p.Path == "/_etag/?"); - } - - [Fact] - public async Task ExcludedPath_NestedPath_QueriesStillWork() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/nested/*" }); - - await container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "pk", Name = "Test", - Nested = new NestedObject { Description = "deep", Score = 9.5 } - }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.nested.description = 'deep'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact(Skip = "In real Cosmos DB, excluding /* with no included paths means no queries work " + - "(only point reads). The emulator ignores exclusion rules entirely — queries always scan.")] - public void ExcludedAllPaths_QueriesFail() - { - // Placeholder for real Cosmos behavior - } - - [Fact] - public async Task BehavioralDifference_ExcludedAllPaths_QueriesStillWork() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: Excluding /* prevents queries from using any index. - // In-Memory Emulator: Exclusion paths are stored but not enforced. - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.IncludedPaths.Clear(); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public void IncludedPaths_CanBeCleared() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.IncludedPaths.Clear(); - - container.IndexingPolicy.IncludedPaths.Should().BeEmpty(); - } - - [Fact] - public void IncludedPaths_SpecificPath_CanBeSet() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.IncludedPaths.Clear(); - container.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/name/?" }); - - container.IndexingPolicy.IncludedPaths.Should().ContainSingle(p => p.Path == "/name/?"); - } - - [Fact] - public void MultipleExcludedPaths_AllStoredCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/tags/*" }); - - // /_etag/? is auto-added, so total is 3 (auto + 2 user-added) - container.IndexingPolicy.ExcludedPaths.Should().HaveCount(3); - } - - [Fact] - public void ExcludedPath_WildcardVariations_Stored() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/?" }); - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); - - // /_etag/? auto-added + 3 user-added = 4 - container.IndexingPolicy.ExcludedPaths.Should().HaveCount(4); - } - - [Fact] - public async Task ExcludedPath_PointReadsWorkWithModeNone() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk")); - read.Resource.Name.Should().Be("Alice"); - } + [Fact] + public void ExcludedPath_EtagPath_CanBeAdded() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/_etag/?" }); + + container.IndexingPolicy.ExcludedPaths.Should().ContainSingle(p => p.Path == "/_etag/?"); + } + + [Fact] + public async Task ExcludedPath_NestedPath_QueriesStillWork() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/nested/*" }); + + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk", + Name = "Test", + Nested = new NestedObject { Description = "deep", Score = 9.5 } + }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.nested.description = 'deep'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact(Skip = "In real Cosmos DB, excluding /* with no included paths means no queries work " + + "(only point reads). The emulator ignores exclusion rules entirely — queries always scan.")] + public void ExcludedAllPaths_QueriesFail() + { + // Placeholder for real Cosmos behavior + } + + [Fact] + public async Task BehavioralDifference_ExcludedAllPaths_QueriesStillWork() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: Excluding /* prevents queries from using any index. + // In-Memory Emulator: Exclusion paths are stored but not enforced. + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.IncludedPaths.Clear(); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public void IncludedPaths_CanBeCleared() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.IncludedPaths.Clear(); + + container.IndexingPolicy.IncludedPaths.Should().BeEmpty(); + } + + [Fact] + public void IncludedPaths_SpecificPath_CanBeSet() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.IncludedPaths.Clear(); + container.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/name/?" }); + + container.IndexingPolicy.IncludedPaths.Should().ContainSingle(p => p.Path == "/name/?"); + } + + [Fact] + public void MultipleExcludedPaths_AllStoredCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/tags/*" }); + + // /_etag/? is auto-added, so total is 3 (auto + 2 user-added) + container.IndexingPolicy.ExcludedPaths.Should().HaveCount(3); + } + + [Fact] + public void ExcludedPath_WildcardVariations_Stored() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/?" }); + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/name/*" }); + + // /_etag/? auto-added + 3 user-added = 4 + container.IndexingPolicy.ExcludedPaths.Should().HaveCount(4); + } + + [Fact] + public async Task ExcludedPath_PointReadsWorkWithModeNone() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + container.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/*" }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk")); + read.Resource.Name.Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -591,140 +593,140 @@ await container.CreateItemAsync( public class CompositeIndexEdgeCaseTests { - [Fact] - public void CompositeIndex_ThreePaths_CanBeConfigured() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/isActive", Order = CompositePathSortOrder.Descending } - ]); - - container.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(3); - } - - [Fact] - public void CompositeIndex_MultipleSets_CanBeConfigured() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/isActive", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending } - ]); - - container.IndexingPolicy.CompositeIndexes.Should().HaveCount(2); - } - - [Fact(Skip = "In real Cosmos DB, multi-field ORDER BY without a matching composite index " + - "returns an error. The emulator does not validate composite index requirements.")] - public void OrderBy_WithoutCompositeIndex_Fails() - { - // Placeholder for real Cosmos behavior - } - - [Fact] - public async Task BehavioralDifference_OrderBy_MultiField_WorksWithoutCompositeIndex() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: Multi-field ORDER BY requires a composite index. - // In-Memory Emulator: ORDER BY always works regardless of index configuration. - var container = new InMemoryContainer("test", "/partitionKey"); - // NO composite index configured - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 20 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC, c.value ASC"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Value.Should().Be(10); - results[1].Value.Should().Be(20); - } - - [Fact] - public async Task OrderBy_CompositeIndex_MixedSortOrders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Alice", Value = 30 }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(30); - results[1].Name.Should().Be("Alice"); - results[1].Value.Should().Be(10); - results[2].Name.Should().Be("Bob"); - } - - [Fact] - public async Task CompositeIndex_AllDescending() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.CompositeIndexes.Add( - [ - new CompositePath { Path = "/name", Order = CompositePathSortOrder.Descending }, - new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } - ]); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 5 }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name DESC, c.value DESC"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].Name.Should().Be("Bob"); - results[1].Name.Should().Be("Alice"); - } + [Fact] + public void CompositeIndex_ThreePaths_CanBeConfigured() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/isActive", Order = CompositePathSortOrder.Descending } + ]); + + container.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(3); + } + + [Fact] + public void CompositeIndex_MultipleSets_CanBeConfigured() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/isActive", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending } + ]); + + container.IndexingPolicy.CompositeIndexes.Should().HaveCount(2); + } + + [Fact(Skip = "In real Cosmos DB, multi-field ORDER BY without a matching composite index " + + "returns an error. The emulator does not validate composite index requirements.")] + public void OrderBy_WithoutCompositeIndex_Fails() + { + // Placeholder for real Cosmos behavior + } + + [Fact] + public async Task BehavioralDifference_OrderBy_MultiField_WorksWithoutCompositeIndex() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: Multi-field ORDER BY requires a composite index. + // In-Memory Emulator: ORDER BY always works regardless of index configuration. + var container = new InMemoryContainer("test", "/partitionKey"); + // NO composite index configured + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 20 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC, c.value ASC"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Value.Should().Be(10); + results[1].Value.Should().Be(20); + } + + [Fact] + public async Task OrderBy_CompositeIndex_MixedSortOrders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Alice", Value = 30 }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(30); + results[1].Name.Should().Be("Alice"); + results[1].Value.Should().Be(10); + results[2].Name.Should().Be("Bob"); + } + + [Fact] + public async Task CompositeIndex_AllDescending() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.CompositeIndexes.Add( + [ + new CompositePath { Path = "/name", Order = CompositePathSortOrder.Descending }, + new CompositePath { Path = "/value", Order = CompositePathSortOrder.Descending } + ]); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 5 }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name DESC, c.value DESC"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].Name.Should().Be("Bob"); + results[1].Name.Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -733,29 +735,29 @@ await container.CreateItemAsync( public class SpatialIndexEdgeCaseTests { - [Fact] - public void SpatialIndex_WithSpatialTypes_CanBeConfigured() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath - { - Path = "/location/*", - SpatialTypes = { SpatialType.Point, SpatialType.Polygon } - }); - - container.IndexingPolicy.SpatialIndexes.Should().ContainSingle(); - container.IndexingPolicy.SpatialIndexes[0].SpatialTypes.Should().HaveCount(2); - } - - [Fact] - public void SpatialIndex_MultiplePaths_CanBeConfigured() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/location/*" }); - container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/area/*" }); - - container.IndexingPolicy.SpatialIndexes.Should().HaveCount(2); - } + [Fact] + public void SpatialIndex_WithSpatialTypes_CanBeConfigured() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath + { + Path = "/location/*", + SpatialTypes = { SpatialType.Point, SpatialType.Polygon } + }); + + container.IndexingPolicy.SpatialIndexes.Should().ContainSingle(); + container.IndexingPolicy.SpatialIndexes[0].SpatialTypes.Should().HaveCount(2); + } + + [Fact] + public void SpatialIndex_MultiplePaths_CanBeConfigured() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/location/*" }); + container.IndexingPolicy.SpatialIndexes.Add(new SpatialPath { Path = "/area/*" }); + + container.IndexingPolicy.SpatialIndexes.Should().HaveCount(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -764,66 +766,66 @@ public void SpatialIndex_MultiplePaths_CanBeConfigured() public class UniqueKeyPolicyDatabaseCreationTests { - [Fact] - public async Task UniqueKeyPolicy_PreservedWhenCreatedViaDatabase() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var properties = new ContainerProperties("testcontainer", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - - await db.CreateContainerAsync(properties); - var container = db.GetContainer("testcontainer"); - - // Should enforce unique key - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var act = () => container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task UniqueKeyPolicy_PreservedWhenCreatedViaDatabase_EmulatorBehavior() - { - // UniqueKeyPolicy is now preserved when creating containers via Database. - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var properties = new ContainerProperties("testcontainer", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - - await db.CreateContainerAsync(properties); - var container = db.GetContainer("testcontainer"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - // UniqueKeyPolicy IS now enforced — duplicate names should conflict - var act = () => container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } + [Fact] + public async Task UniqueKeyPolicy_PreservedWhenCreatedViaDatabase() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var properties = new ContainerProperties("testcontainer", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + + await db.CreateContainerAsync(properties); + var container = db.GetContainer("testcontainer"); + + // Should enforce unique key + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var act = () => container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task UniqueKeyPolicy_PreservedWhenCreatedViaDatabase_EmulatorBehavior() + { + // UniqueKeyPolicy is now preserved when creating containers via Database. + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var properties = new ContainerProperties("testcontainer", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + + await db.CreateContainerAsync(properties); + var container = db.GetContainer("testcontainer"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + // UniqueKeyPolicy IS now enforced — duplicate names should conflict + var act = () => container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -832,34 +834,34 @@ await act.Should().ThrowAsync() public class FakeCosmosHandlerIndexMetadataTests { - [Fact] - public void FakeCosmosHandler_IndexMetadata_ReflectsContainerPolicy() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - - var handler = new FakeCosmosHandler(container); - handler.Should().NotBeNull(); - } - - [Fact] - public void BehavioralDifference_FakeCosmosHandler_IndexMetadataReflectsPolicy() - { - // FakeCosmosHandler now reads from the container's actual IndexingPolicy. - var container = new InMemoryContainer("test", "/partitionKey"); - container.IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - }; - - var handler = new FakeCosmosHandler(container); - handler.Should().NotBeNull(); - } + [Fact] + public void FakeCosmosHandler_IndexMetadata_ReflectsContainerPolicy() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + + var handler = new FakeCosmosHandler(container); + handler.Should().NotBeNull(); + } + + [Fact] + public void BehavioralDifference_FakeCosmosHandler_IndexMetadataReflectsPolicy() + { + // FakeCosmosHandler now reads from the container's actual IndexingPolicy. + var container = new InMemoryContainer("test", "/partitionKey"); + container.IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + }; + + var handler = new FakeCosmosHandler(container); + handler.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -868,98 +870,98 @@ public void BehavioralDifference_FakeCosmosHandler_IndexMetadataReflectsPolicy() public class OrderByMixedTypeSortingTests { - [Fact] - public async Task OrderBy_NullValues_SortFirst() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", score = 10 }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", score = (int?)null }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", score = 5 }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.score"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("2", "null sorts first"); - } - - [Fact] - public async Task OrderBy_AllNulls_StableSort() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", score = (int?)null }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", score = (int?)null }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", score = (int?)null }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.score"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task OrderBy_UndefinedField_TreatedAsNull() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", score = 10 }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b" }), - new PartitionKey("b")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.score"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.ToString().Should().Be("2", "undefined/missing sorts before values"); - } - - [Fact] - public async Task OrderBy_MixedTypes_FollowsCosmosTypeRanking() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "hello" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 42 }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = true }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results[0]["id"]!.ToString().Should().Be("3", "boolean before number"); - results[1]["id"]!.ToString().Should().Be("2", "number before string"); - results[2]["id"]!.ToString().Should().Be("1", "string last"); - } + [Fact] + public async Task OrderBy_NullValues_SortFirst() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", score = 10 }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", score = (int?)null }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", score = 5 }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.score"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("2", "null sorts first"); + } + + [Fact] + public async Task OrderBy_AllNulls_StableSort() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", score = (int?)null }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", score = (int?)null }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", score = (int?)null }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.score"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task OrderBy_UndefinedField_TreatedAsNull() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", score = 10 }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b" }), + new PartitionKey("b")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.score"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.ToString().Should().Be("2", "undefined/missing sorts before values"); + } + + [Fact] + public async Task OrderBy_MixedTypes_FollowsCosmosTypeRanking() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "hello" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 42 }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = true }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results[0]["id"]!.ToString().Should().Be("3", "boolean before number"); + results[1]["id"]!.ToString().Should().Be("2", "number before string"); + results[2]["id"]!.ToString().Should().Be("1", "string last"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -968,202 +970,202 @@ await container.CreateItemAsync( public class OrderByEdgeCaseTests { - [Fact] - public async Task OrderBy_NestedProperty_SortsCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", nested = new { score = 30 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", nested = new { score = 10 } }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", nested = new { score = 20 } }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.nested.score"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("2"); - results[1]["id"]!.ToString().Should().Be("3"); - results[2]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task OrderBy_SingleField_Descending() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 30 }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = 20 }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val DESC"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("2"); - results[1]["id"]!.ToString().Should().Be("3"); - results[2]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task OrderBy_EmptyCollection_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/pk"); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task OrderBy_SingleItem_ReturnsItem() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 42 }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task OrderBy_LargeDataset_SortsCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - var rng = new Random(42); - var values = Enumerable.Range(0, 100).Select(_ => rng.Next(1000)).ToList(); - - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = values[i] }), - new PartitionKey($"p{i}")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(100); - var sorted = results.Select(r => (long)r["val"]!).ToList(); - sorted.Should().BeInAscendingOrder(); - } - - [Fact] - public async Task OrderBy_WithWhereClause_FiltersAndSorts() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 30, active = true }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 10, active = false }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = 20, active = true }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.active = true ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results[0]["id"]!.ToString().Should().Be("3"); // val=20 - results[1]["id"]!.ToString().Should().Be("1"); // val=30 - } - - [Fact] - public async Task OrderBy_WithTop_ReturnsTopNSorted() - { - var container = new InMemoryContainer("test", "/pk"); - for (var i = 1; i <= 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = i * 10 }), - new PartitionKey($"p{i}")); - - var iterator = container.GetItemQueryIterator( - "SELECT TOP 3 * FROM c ORDER BY c.val DESC"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("10"); // val=100 - results[1]["id"]!.ToString().Should().Be("9"); // val=90 - results[2]["id"]!.ToString().Should().Be("8"); // val=80 - } - - [Fact] - public async Task OrderBy_WithOffsetLimit_ReturnsSortedPage() - { - var container = new InMemoryContainer("test", "/pk"); - for (var i = 1; i <= 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = i }), - new PartitionKey($"p{i}")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val OFFSET 3 LIMIT 3"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["id"]!.ToString().Should().Be("4"); - results[1]["id"]!.ToString().Should().Be("5"); - results[2]["id"]!.ToString().Should().Be("6"); - } - - [Fact] - public async Task OrderBy_DuplicateValues_StableSort() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", val = 10 }), - new PartitionKey("b")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "c", val = 10 }), - new PartitionKey("c")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3, "all items with same sort key returned"); - } + [Fact] + public async Task OrderBy_NestedProperty_SortsCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", nested = new { score = 30 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", nested = new { score = 10 } }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", nested = new { score = 20 } }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.nested.score"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("2"); + results[1]["id"]!.ToString().Should().Be("3"); + results[2]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task OrderBy_SingleField_Descending() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 30 }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = 20 }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val DESC"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("2"); + results[1]["id"]!.ToString().Should().Be("3"); + results[2]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task OrderBy_EmptyCollection_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/pk"); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task OrderBy_SingleItem_ReturnsItem() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 42 }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task OrderBy_LargeDataset_SortsCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + var rng = new Random(42); + var values = Enumerable.Range(0, 100).Select(_ => rng.Next(1000)).ToList(); + + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = values[i] }), + new PartitionKey($"p{i}")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(100); + var sorted = results.Select(r => (long)r["val"]!).ToList(); + sorted.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task OrderBy_WithWhereClause_FiltersAndSorts() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 30, active = true }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 10, active = false }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = 20, active = true }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.active = true ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results[0]["id"]!.ToString().Should().Be("3"); // val=20 + results[1]["id"]!.ToString().Should().Be("1"); // val=30 + } + + [Fact] + public async Task OrderBy_WithTop_ReturnsTopNSorted() + { + var container = new InMemoryContainer("test", "/pk"); + for (var i = 1; i <= 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = i * 10 }), + new PartitionKey($"p{i}")); + + var iterator = container.GetItemQueryIterator( + "SELECT TOP 3 * FROM c ORDER BY c.val DESC"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("10"); // val=100 + results[1]["id"]!.ToString().Should().Be("9"); // val=90 + results[2]["id"]!.ToString().Should().Be("8"); // val=80 + } + + [Fact] + public async Task OrderBy_WithOffsetLimit_ReturnsSortedPage() + { + var container = new InMemoryContainer("test", "/pk"); + for (var i = 1; i <= 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"p{i}", val = i }), + new PartitionKey($"p{i}")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val OFFSET 3 LIMIT 3"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["id"]!.ToString().Should().Be("4"); + results[1]["id"]!.ToString().Should().Be("5"); + results[2]["id"]!.ToString().Should().Be("6"); + } + + [Fact] + public async Task OrderBy_DuplicateValues_StableSort() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", val = 10 }), + new PartitionKey("b")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "c", val = 10 }), + new PartitionKey("c")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3, "all items with same sort key returned"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1172,74 +1174,74 @@ await container.CreateItemAsync( public class IndexPolicyClientCreationTests { - [Fact] - public async Task CreateContainerIfNotExists_PreservesIndexingPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var props = new ContainerProperties("test", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = false, - IndexingMode = IndexingMode.None - } - }; - var response = await db.CreateContainerIfNotExistsAsync(props); - - response.Resource.IndexingPolicy.Automatic.Should().BeFalse(); - response.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); - } - - [Fact] - public async Task CreateContainerIfNotExists_ExistingContainer_DoesNotOverwritePolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var props1 = new ContainerProperties("test", "/pk") - { - IndexingPolicy = new IndexingPolicy { Automatic = false } - }; - await db.CreateContainerIfNotExistsAsync(props1); - - var props2 = new ContainerProperties("test", "/pk") - { - IndexingPolicy = new IndexingPolicy { Automatic = true } - }; - await db.CreateContainerIfNotExistsAsync(props2); - - // Read the container to verify the original policy is preserved - var readResponse = await db.GetContainer("test").ReadContainerAsync(); - readResponse.Resource.IndexingPolicy.Automatic.Should().BeFalse( - "first creation's policy is preserved — second call does not overwrite"); - } - - [Fact] - public async Task CreateContainer_ViaCosmosClient_PreservesPolicy() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var props = new ContainerProperties("test", "/pk") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = true, - IndexingMode = IndexingMode.Consistent, - IncludedPaths = { new IncludedPath { Path = "/name/?" } }, - ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } - } - }; - var response = await db.CreateContainerAsync(props); - - var policy = response.Resource.IndexingPolicy; - policy.Automatic.Should().BeTrue(); - policy.IndexingMode.Should().Be(IndexingMode.Consistent); - policy.IncludedPaths.Should().Contain(p => p.Path == "/name/?"); - policy.ExcludedPaths.Should().Contain(p => p.Path == "/secret/*"); - } + [Fact] + public async Task CreateContainerIfNotExists_PreservesIndexingPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var props = new ContainerProperties("test", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = false, + IndexingMode = IndexingMode.None + } + }; + var response = await db.CreateContainerIfNotExistsAsync(props); + + response.Resource.IndexingPolicy.Automatic.Should().BeFalse(); + response.Resource.IndexingPolicy.IndexingMode.Should().Be(IndexingMode.None); + } + + [Fact] + public async Task CreateContainerIfNotExists_ExistingContainer_DoesNotOverwritePolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var props1 = new ContainerProperties("test", "/pk") + { + IndexingPolicy = new IndexingPolicy { Automatic = false } + }; + await db.CreateContainerIfNotExistsAsync(props1); + + var props2 = new ContainerProperties("test", "/pk") + { + IndexingPolicy = new IndexingPolicy { Automatic = true } + }; + await db.CreateContainerIfNotExistsAsync(props2); + + // Read the container to verify the original policy is preserved + var readResponse = await db.GetContainer("test").ReadContainerAsync(); + readResponse.Resource.IndexingPolicy.Automatic.Should().BeFalse( + "first creation's policy is preserved — second call does not overwrite"); + } + + [Fact] + public async Task CreateContainer_ViaCosmosClient_PreservesPolicy() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var props = new ContainerProperties("test", "/pk") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = true, + IndexingMode = IndexingMode.Consistent, + IncludedPaths = { new IncludedPath { Path = "/name/?" } }, + ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } + } + }; + var response = await db.CreateContainerAsync(props); + + var policy = response.Resource.IndexingPolicy; + policy.Automatic.Should().BeTrue(); + policy.IndexingMode.Should().Be(IndexingMode.Consistent); + policy.IncludedPaths.Should().Contain(p => p.Path == "/name/?"); + policy.ExcludedPaths.Should().Contain(p => p.Path == "/secret/*"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1248,36 +1250,36 @@ public async Task CreateContainer_ViaCosmosClient_PreservesPolicy() public class IndexMetricsTests { - [Fact] - public async Task IndexMetrics_ReturnsUtilizationData() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val > 5", - requestOptions: new QueryRequestOptions { PopulateIndexMetrics = true }); - var response = await iterator.ReadNextAsync(); - - response.IndexMetrics.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task IndexMetrics_WithoutPopulateFlag_ReturnsNull() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val > 5"); - var response = await iterator.ReadNextAsync(); - - response.IndexMetrics.Should().BeNull(); - } + [Fact] + public async Task IndexMetrics_ReturnsUtilizationData() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val > 5", + requestOptions: new QueryRequestOptions { PopulateIndexMetrics = true }); + var response = await iterator.ReadNextAsync(); + + response.IndexMetrics.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task IndexMetrics_WithoutPopulateFlag_ReturnsNull() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val > 5"); + var response = await iterator.ReadNextAsync(); + + response.IndexMetrics.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1286,49 +1288,49 @@ await container.CreateItemAsync( public class EnableScanInQueryTests { - [Fact(Skip = "Real Cosmos DB requires EnableScanInQuery=true to query paths not covered " + - "by an index. The emulator ignores this option entirely — queries always work regardless.")] - public async Task EnableScanInQuery_Required_ForUnindexedPaths() - { - var container = new InMemoryContainer("test", "/pk"); - container.IndexingPolicy = new IndexingPolicy - { - IncludedPaths = { new IncludedPath { Path = "/" } }, - ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } - }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", secret = "hidden" }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.secret = 'hidden'"); - var response = await iterator.ReadNextAsync(); - - response.Count.Should().Be(0, "query on excluded path should fail without EnableScanInQuery"); - } - - [Fact] - public async Task BehavioralDifference_EnableScanInQuery_AlwaysEnabled() - { - // DIVERGENT BEHAVIOR: Real Cosmos DB requires EnableScanInQuery=true - // to query paths excluded from indexing. The emulator always does a - // full scan, so queries work regardless of index configuration. - var container = new InMemoryContainer("test", "/pk"); - container.IndexingPolicy = new IndexingPolicy - { - IncludedPaths = { new IncludedPath { Path = "/" } }, - ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } - }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", secret = "hidden" }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.secret = 'hidden'"); - var response = await iterator.ReadNextAsync(); - - response.Count.Should().Be(1, "emulator always scans — queries work on excluded paths"); - } + [Fact(Skip = "Real Cosmos DB requires EnableScanInQuery=true to query paths not covered " + + "by an index. The emulator ignores this option entirely — queries always work regardless.")] + public async Task EnableScanInQuery_Required_ForUnindexedPaths() + { + var container = new InMemoryContainer("test", "/pk"); + container.IndexingPolicy = new IndexingPolicy + { + IncludedPaths = { new IncludedPath { Path = "/" } }, + ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } + }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", secret = "hidden" }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.secret = 'hidden'"); + var response = await iterator.ReadNextAsync(); + + response.Count.Should().Be(0, "query on excluded path should fail without EnableScanInQuery"); + } + + [Fact] + public async Task BehavioralDifference_EnableScanInQuery_AlwaysEnabled() + { + // DIVERGENT BEHAVIOR: Real Cosmos DB requires EnableScanInQuery=true + // to query paths excluded from indexing. The emulator always does a + // full scan, so queries work regardless of index configuration. + var container = new InMemoryContainer("test", "/pk"); + container.IndexingPolicy = new IndexingPolicy + { + IncludedPaths = { new IncludedPath { Path = "/" } }, + ExcludedPaths = { new ExcludedPath { Path = "/secret/*" } } + }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", secret = "hidden" }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.secret = 'hidden'"); + var response = await iterator.ReadNextAsync(); + + response.Count.Should().Be(1, "emulator always scans — queries work on excluded paths"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1337,56 +1339,56 @@ await container.CreateItemAsync( public class IndexTypeTests { - [Fact] - public void IncludedPath_WithIndexes_AreStored() - { - var container = new InMemoryContainer("test", "/pk"); - container.IndexingPolicy = new IndexingPolicy - { - IncludedPaths = - { - new IncludedPath { Path = "/name/?" }, - new IncludedPath { Path = "/value/?" } - } - }; - - container.IndexingPolicy.IncludedPaths.Should().HaveCount(2); - container.IndexingPolicy.IncludedPaths.Should().Contain(p => p.Path == "/name/?"); - container.IndexingPolicy.IncludedPaths.Should().Contain(p => p.Path == "/value/?"); - } - - [Fact(Skip = "Real Cosmos DB uses RangeIndex for equality and range queries, and HashIndex for " + - "equality-only queries. The emulator stores index kinds but does not enforce them — all " + - "query types work regardless of index kind.")] - public async Task IncludedPath_IndexKind_EnforcedForQueryType() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val > 5"); - var response = await iterator.ReadNextAsync(); - - response.Count.Should().Be(1); - } - - [Fact] - public async Task BehavioralDifference_IndexKind_NotEnforced() - { - // DIVERGENT BEHAVIOR: Real Cosmos DB enforces index kind constraints — - // a HashIndex doesn't support range queries. The emulator stores index - // configuration but queries always work via full scan. - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val > 5"); - var response = await iterator.ReadNextAsync(); - - response.Count.Should().Be(1, "emulator ignores index kind — range query works on any index"); - } + [Fact] + public void IncludedPath_WithIndexes_AreStored() + { + var container = new InMemoryContainer("test", "/pk"); + container.IndexingPolicy = new IndexingPolicy + { + IncludedPaths = + { + new IncludedPath { Path = "/name/?" }, + new IncludedPath { Path = "/value/?" } + } + }; + + container.IndexingPolicy.IncludedPaths.Should().HaveCount(2); + container.IndexingPolicy.IncludedPaths.Should().Contain(p => p.Path == "/name/?"); + container.IndexingPolicy.IncludedPaths.Should().Contain(p => p.Path == "/value/?"); + } + + [Fact(Skip = "Real Cosmos DB uses RangeIndex for equality and range queries, and HashIndex for " + + "equality-only queries. The emulator stores index kinds but does not enforce them — all " + + "query types work regardless of index kind.")] + public async Task IncludedPath_IndexKind_EnforcedForQueryType() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val > 5"); + var response = await iterator.ReadNextAsync(); + + response.Count.Should().Be(1); + } + + [Fact] + public async Task BehavioralDifference_IndexKind_NotEnforced() + { + // DIVERGENT BEHAVIOR: Real Cosmos DB enforces index kind constraints — + // a HashIndex doesn't support range queries. The emulator stores index + // configuration but queries always work via full scan. + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val > 5"); + var response = await iterator.ReadNextAsync(); + + response.Count.Should().Be(1, "emulator ignores index kind — range query works on any index"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18EdgeCaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18EdgeCaseTests.cs index 425bcc8..2c471a3 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18EdgeCaseTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18EdgeCaseTests.cs @@ -14,772 +14,772 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class Issue18EdgeCaseTests { - // ═══════════════════════════════════════════════════════════════════════════ - // 1. FACTORY BEHAVIOUR - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Factory_Returns_CosmosException_WithNullDiagnostics() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "act-1", 1.5); - - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task ConcurrentCreation_PreservesIndividualProperties() - { - const int concurrency = 200; - var exceptions = new CosmosException[concurrency]; - - await Parallel.ForAsync(0, concurrency, async (i, _) => - { - await Task.Yield(); - exceptions[i] = InMemoryCosmosException.Create( - $"msg-{i}", - i % 2 == 0 ? HttpStatusCode.NotFound : HttpStatusCode.Conflict, - i, $"act-{i}", i * 1.0); - }); - - for (int i = 0; i < concurrency; i++) - { - exceptions[i].Message.Should().Contain($"msg-{i}"); - exceptions[i].SubStatusCode.Should().Be(i); - exceptions[i].ActivityId.Should().Be($"act-{i}"); - exceptions[i].RequestCharge.Should().Be(i * 1.0); - - var expectedStatus = i % 2 == 0 ? HttpStatusCode.NotFound : HttpStatusCode.Conflict; - exceptions[i].StatusCode.Should().Be(expectedStatus); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 3. EXCEPTION PROPERTIES — exhaustive verification - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void AllProperties_AreCorrect_AfterCreation() - { - var ex = InMemoryCosmosException.Create( - "Something went wrong", HttpStatusCode.BadRequest, 42, "activity-xyz", 3.14); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.SubStatusCode.Should().Be(42); - ex.ActivityId.Should().Be("activity-xyz"); - ex.RequestCharge.Should().Be(3.14); - ex.Message.Should().Contain("Something went wrong"); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public void ExceptionType_IsExactly_CosmosException() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - - ex.GetType().Should().Be(typeof(CosmosException), - "Must be exactly CosmosException, not a subclass — this is the whole point of Issue #18"); - (ex is CosmosException).Should().BeTrue(); - (ex is Exception).Should().BeTrue(); - } - - [Fact] - public void Message_ContainsStatusCode_InToString() - { - var ex = InMemoryCosmosException.Create("custom msg", HttpStatusCode.NotFound, 0, "", 0); - - // CosmosException.ToString() normally includes status code info - ex.ToString().Should().Contain("NotFound"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 4. CATCH PATTERNS — various C# catch/filter patterns - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void CatchPattern_BareCosmosException() - { - bool caught = false; - try - { - throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - } - catch (CosmosException) - { - caught = true; - } - - caught.Should().BeTrue(); - } - - [Fact] - public void CatchPattern_WithWhenFilter() - { - bool caught = false; - try - { - throw InMemoryCosmosException.Create("test", HttpStatusCode.Conflict, 0, "", 0); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - caught = true; - } - - caught.Should().BeTrue(); - } - - [Fact] - public void CatchPattern_ExceptionWithIsCheck() - { - bool caught = false; - try - { - throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - } - catch (Exception e) when (e is CosmosException) - { - caught = true; - } - - caught.Should().BeTrue(); - } - - [Fact] - public void CatchPattern_PatternMatchingIs() - { - Exception thrown = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "act-1", 1.0); - - if (thrown is CosmosException ce) - { - ce.StatusCode.Should().Be(HttpStatusCode.NotFound); - ce.ActivityId.Should().Be("act-1"); - } - else - { - Assert.Fail("Pattern matching 'is CosmosException' should succeed"); - } - } - - [Fact] - public void CatchPattern_SwitchExpression() - { - Exception thrown = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - - var status = thrown switch - { - CosmosException { StatusCode: HttpStatusCode.NotFound } => "not-found", - CosmosException { StatusCode: HttpStatusCode.Conflict } => "conflict", - CosmosException => "other-cosmos", - _ => "unknown" - }; - - status.Should().Be("not-found"); - } - - [Fact] - public async Task CatchPattern_AssertThrowsAsync_ExactMatch() - { - // This is THE pattern that was broken before Issue #18 fix - var ex = await Assert.ThrowsAsync( - () => Task.FromException( - InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0))); - - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 5. EDGE CASE STATUS CODES - // ═══════════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData(HttpStatusCode.NotFound)] - [InlineData(HttpStatusCode.Conflict)] - [InlineData(HttpStatusCode.BadRequest)] - [InlineData(HttpStatusCode.PreconditionFailed)] - [InlineData(HttpStatusCode.NotModified)] - [InlineData(HttpStatusCode.TooManyRequests)] - [InlineData(HttpStatusCode.InternalServerError)] - [InlineData(HttpStatusCode.ServiceUnavailable)] - [InlineData(HttpStatusCode.RequestEntityTooLarge)] - [InlineData(HttpStatusCode.Forbidden)] - [InlineData(HttpStatusCode.Gone)] - [InlineData(HttpStatusCode.RequestTimeout)] - public void AllStatusCodes_ProduceCorrectCosmosException(HttpStatusCode statusCode) - { - var ex = InMemoryCosmosException.Create($"test-{statusCode}", statusCode, 0, "act", 0); - - ex.GetType().Should().Be(typeof(CosmosException)); - ex.StatusCode.Should().Be(statusCode); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public void SubStatusCode_1003_ContainerNotFound() - { - var ex = InMemoryCosmosException.Create("Container not found", - HttpStatusCode.NotFound, 1003, "act", 0); - - ex.SubStatusCode.Should().Be(1003); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 6. SERIALIZATION - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Exception_CanBeSerializedWithGetObjectData() - { - var ex = InMemoryCosmosException.Create("ser-test", HttpStatusCode.NotFound, 42, "act-ser", 1.5); - - // GetObjectData should not throw — even if BinaryFormatter is obsolete, - // libraries may still call GetObjectData for logging/serialization + // ═══════════════════════════════════════════════════════════════════════════ + // 1. FACTORY BEHAVIOUR + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Factory_Returns_CosmosException_WithNullDiagnostics() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "act-1", 1.5); + + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task ConcurrentCreation_PreservesIndividualProperties() + { + const int concurrency = 200; + var exceptions = new CosmosException[concurrency]; + + await Parallel.ForAsync(0, concurrency, async (i, _) => + { + await Task.Yield(); + exceptions[i] = InMemoryCosmosException.Create( + $"msg-{i}", + i % 2 == 0 ? HttpStatusCode.NotFound : HttpStatusCode.Conflict, + i, $"act-{i}", i * 1.0); + }); + + for (int i = 0; i < concurrency; i++) + { + exceptions[i].Message.Should().Contain($"msg-{i}"); + exceptions[i].SubStatusCode.Should().Be(i); + exceptions[i].ActivityId.Should().Be($"act-{i}"); + exceptions[i].RequestCharge.Should().Be(i * 1.0); + + var expectedStatus = i % 2 == 0 ? HttpStatusCode.NotFound : HttpStatusCode.Conflict; + exceptions[i].StatusCode.Should().Be(expectedStatus); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 3. EXCEPTION PROPERTIES — exhaustive verification + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void AllProperties_AreCorrect_AfterCreation() + { + var ex = InMemoryCosmosException.Create( + "Something went wrong", HttpStatusCode.BadRequest, 42, "activity-xyz", 3.14); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.SubStatusCode.Should().Be(42); + ex.ActivityId.Should().Be("activity-xyz"); + ex.RequestCharge.Should().Be(3.14); + ex.Message.Should().Contain("Something went wrong"); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public void ExceptionType_IsExactly_CosmosException() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + + ex.GetType().Should().Be(typeof(CosmosException), + "Must be exactly CosmosException, not a subclass — this is the whole point of Issue #18"); + (ex is CosmosException).Should().BeTrue(); + (ex is Exception).Should().BeTrue(); + } + + [Fact] + public void Message_ContainsStatusCode_InToString() + { + var ex = InMemoryCosmosException.Create("custom msg", HttpStatusCode.NotFound, 0, "", 0); + + // CosmosException.ToString() normally includes status code info + ex.ToString().Should().Contain("NotFound"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 4. CATCH PATTERNS — various C# catch/filter patterns + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void CatchPattern_BareCosmosException() + { + bool caught = false; + try + { + throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + } + catch (CosmosException) + { + caught = true; + } + + caught.Should().BeTrue(); + } + + [Fact] + public void CatchPattern_WithWhenFilter() + { + bool caught = false; + try + { + throw InMemoryCosmosException.Create("test", HttpStatusCode.Conflict, 0, "", 0); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + caught = true; + } + + caught.Should().BeTrue(); + } + + [Fact] + public void CatchPattern_ExceptionWithIsCheck() + { + bool caught = false; + try + { + throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + } + catch (Exception e) when (e is CosmosException) + { + caught = true; + } + + caught.Should().BeTrue(); + } + + [Fact] + public void CatchPattern_PatternMatchingIs() + { + Exception thrown = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "act-1", 1.0); + + if (thrown is CosmosException ce) + { + ce.StatusCode.Should().Be(HttpStatusCode.NotFound); + ce.ActivityId.Should().Be("act-1"); + } + else + { + Assert.Fail("Pattern matching 'is CosmosException' should succeed"); + } + } + + [Fact] + public void CatchPattern_SwitchExpression() + { + Exception thrown = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + + var status = thrown switch + { + CosmosException { StatusCode: HttpStatusCode.NotFound } => "not-found", + CosmosException { StatusCode: HttpStatusCode.Conflict } => "conflict", + CosmosException => "other-cosmos", + _ => "unknown" + }; + + status.Should().Be("not-found"); + } + + [Fact] + public async Task CatchPattern_AssertThrowsAsync_ExactMatch() + { + // This is THE pattern that was broken before Issue #18 fix + var ex = await Assert.ThrowsAsync( + () => Task.FromException( + InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 5. EDGE CASE STATUS CODES + // ═══════════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Conflict)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.PreconditionFailed)] + [InlineData(HttpStatusCode.NotModified)] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.RequestEntityTooLarge)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.Gone)] + [InlineData(HttpStatusCode.RequestTimeout)] + public void AllStatusCodes_ProduceCorrectCosmosException(HttpStatusCode statusCode) + { + var ex = InMemoryCosmosException.Create($"test-{statusCode}", statusCode, 0, "act", 0); + + ex.GetType().Should().Be(typeof(CosmosException)); + ex.StatusCode.Should().Be(statusCode); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public void SubStatusCode_1003_ContainerNotFound() + { + var ex = InMemoryCosmosException.Create("Container not found", + HttpStatusCode.NotFound, 1003, "act", 0); + + ex.SubStatusCode.Should().Be(1003); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 6. SERIALIZATION + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Exception_CanBeSerializedWithGetObjectData() + { + var ex = InMemoryCosmosException.Create("ser-test", HttpStatusCode.NotFound, 42, "act-ser", 1.5); + + // GetObjectData should not throw — even if BinaryFormatter is obsolete, + // libraries may still call GetObjectData for logging/serialization #pragma warning disable SYSLIB0050, SYSLIB0051 - var info = new SerializationInfo(typeof(CosmosException), new FormatterConverter()); - var context = new StreamingContext(StreamingContextStates.All); + var info = new SerializationInfo(typeof(CosmosException), new FormatterConverter()); + var context = new StreamingContext(StreamingContextStates.All); - var act = () => ex.GetObjectData(info, context); + var act = () => ex.GetObjectData(info, context); #pragma warning restore SYSLIB0050, SYSLIB0051 - act.Should().NotThrow("GetObjectData should work without error"); - } - - [Fact] - public void Exception_StackTrace_IsPreserved() - { - CosmosException? caught = null; - try - { - throw InMemoryCosmosException.Create("stack-test", HttpStatusCode.NotFound, 0, "", 0); - } - catch (CosmosException ex) - { - caught = ex; - } - - caught.Should().NotBeNull(); - caught.StackTrace.Should().NotBeNullOrEmpty("stack trace should be captured when thrown"); - caught.StackTrace.Should().Contain(nameof(Exception_StackTrace_IsPreserved)); - } - - [Fact] - public void Exception_InnerException_IsNull() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - ex.InnerException.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 7. DIAGNOSTICS BEHAVIOUR - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Diagnostics_IsNull_OnCaughtException() - { - CosmosException? caught = null; - try - { - throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - } - catch (CosmosException ex) - { - caught = ex; - } - - caught.Diagnostics.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 8. ETAG / PRECONDITION FAILED - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ETagMismatch_Replace_ThrowsCosmosException_412() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - var created = await container.CreateItemAsync( - new { id = "1", pk = "pk1", value = "a" }, new PartitionKey("pk1")); - - // Replace with wrong ETag - var ex = await Assert.ThrowsAsync(async () => - await container.ReplaceItemAsync( - new { id = "1", pk = "pk1", value = "b" }, "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); - - ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task IfNoneMatch_Star_OnExistingItem_ThrowsCosmosException_304() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - await container.CreateItemAsync( - new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - - // Read with IfNoneMatch="*" — item exists → 304 - var ex = await Assert.ThrowsAsync(async () => - await container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = "*" })); - - ex.StatusCode.Should().Be(HttpStatusCode.NotModified); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task IfNoneMatch_MatchingEtag_ThrowsCosmosException_304() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - var created = await container.CreateItemAsync( - new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - var etag = created.ETag; - - var ex = await Assert.ThrowsAsync(async () => - await container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etag })); - - ex.StatusCode.Should().Be(HttpStatusCode.NotModified); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task ETagMismatch_Patch_ThrowsCosmosException_412() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - await container.CreateItemAsync( - new { id = "1", pk = "pk1", name = "test" }, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(async () => - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "new") }, - new PatchItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); - - ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - ex.Diagnostics.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 9. CONTAINER NOT FOUND - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DeletedContainer_ThrowsCosmosException_404_SubStatus1003() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var containerResp = await db.CreateContainerAsync("items", "/pk"); - var container = containerResp.Container; - - await container.DeleteContainerAsync(); - - var ex = await Assert.ThrowsAsync(async () => - await container.ReadContainerAsync()); - - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.SubStatusCode.Should().Be(1003); - ex.Diagnostics.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 10. MULTIPLE SEQUENTIAL ERRORS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task MultipleSequentialErrors_AllCatchableAsCosmosException() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - - var errors = new List<(HttpStatusCode Status, string Activity)>(); - - // Error 1: Read non-existent → 404 - try - { - await container.ReadItemAsync("nope", new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - errors.Add((ex.StatusCode, ex.ActivityId)); - ex.Diagnostics.Should().BeNull(); - } - - // Error 2: Duplicate create → 409 - try - { - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - errors.Add((ex.StatusCode, ex.ActivityId)); - ex.Diagnostics.Should().BeNull(); - } - - // Error 3: Delete non-existent → 404 - try - { - await container.DeleteItemAsync("nope", new PartitionKey("pk1")); - } - catch (CosmosException ex) - { - errors.Add((ex.StatusCode, ex.ActivityId)); - ex.Diagnostics.Should().BeNull(); - } - - errors.Should().HaveCount(3); - errors[0].Status.Should().Be(HttpStatusCode.NotFound); - errors[1].Status.Should().Be(HttpStatusCode.Conflict); - errors[2].Status.Should().Be(HttpStatusCode.NotFound); - - // Each should have a distinct ActivityId (generated with Guid.NewGuid) - errors.Select(e => e.Activity).Distinct().Should().HaveCount(3, - "Each error should have a unique ActivityId"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 11. VALIDATION ERRORS (400 BadRequest) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task EmptyId_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - var ex = await Assert.ThrowsAsync(async () => - await container.CreateItemAsync(new { id = "", pk = "pk1" }, new PartitionKey("pk1"))); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task PatchTooManyOps_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - await container.CreateItemAsync(new { id = "1", pk = "pk1", a = 0 }, new PartitionKey("pk1")); - - // 11 patch operations exceeds the limit of 10 - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", i)) - .ToArray(); - - var ex = await Assert.ThrowsAsync(async () => - await container.PatchItemAsync("1", new PartitionKey("pk1"), ops)); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task PatchEmptyOps_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - - var ex = await Assert.ThrowsAsync(async () => - await container.PatchItemAsync("1", new PartitionKey("pk1"), - Array.Empty())); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 12. DATABASE-LEVEL ERRORS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DatabaseNameTooLong_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - var longName = new string('x', 256); - - var ex = await Assert.ThrowsAsync(async () => - await client.CreateDatabaseAsync(longName)); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } - - [Fact] - public async Task DatabaseNameWithInvalidChars_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - - var ex = await Assert.ThrowsAsync(async () => - await client.CreateDatabaseAsync("db/name")); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 13. TRANSACTIONAL BATCH ERRORS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task BatchConflict_IsCatchableAsCosmosException() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Pre-create item so batch create conflicts - await container.CreateItemAsync(new { id = "dup", pk = "pk1" }, new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")) - .CreateItem(new { id = "dup", pk = "pk1" }); - - // The batch itself returns a response (not an exception) for per-item failures, - // but the underlying container operation throws, which batch catches internally. - // Let's verify batch response propagates the failure correctly. - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 14. CONCURRENT CONTAINER OPERATIONS RAISING EXCEPTIONS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ConcurrentReadsOfNonExistent_AllThrowCosmosException() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - const int concurrency = 50; - var tasks = Enumerable.Range(0, concurrency).Select(async i => - { - var ex = await Assert.ThrowsAsync(async () => - await container.ReadItemAsync($"nope-{i}", new PartitionKey("pk1"))); - - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Diagnostics.Should().BeNull(); - return ex; - }); - - var results = await Task.WhenAll(tasks); - results.Should().HaveCount(concurrency); - - // Verify each has distinct ActivityId - results.Select(e => e.ActivityId).Distinct().Should().HaveCount(concurrency); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 15. EXCEPTION USED IN AGGREGATE / NESTED SCENARIOS - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task AggregateException_ContainingCosmosException_CanBeUnwrapped() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Wrap in Task.Run to ensure both tasks start as faulted Tasks - // (ReadItemAsync may throw synchronously before returning) - var task1 = Task.Run(() => container.ReadItemAsync("nope1", new PartitionKey("pk1"))); - var task2 = Task.Run(() => container.ReadItemAsync("nope2", new PartitionKey("pk1"))); - - var allTask = Task.WhenAll(task1, task2); - try - { - await allTask; - Assert.Fail("Should have thrown"); - } - catch (CosmosException firstEx) - { - firstEx.Diagnostics.Should().BeNull(); - firstEx.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - task1.IsFaulted.Should().BeTrue(); - task2.IsFaulted.Should().BeTrue(); - - // The AggregateException on the allTask should contain both - allTask.Exception.Should().NotBeNull(); - allTask.Exception!.InnerExceptions.Should().HaveCount(2); - foreach (var inner in allTask.Exception.InnerExceptions) - { - inner.Should().BeOfType(); - ((CosmosException)inner).Diagnostics.Should().BeNull(); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 16. REPLACE WITH WRONG BODY ID - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ReplaceItem_BodyIdMismatch_ThrowsBadRequest() - { - // SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - - var act = () => container.ReplaceItemAsync( - new { id = "DIFFERENT", pk = "pk1" }, "1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 17. UPSERT WITH IF-MATCH ON NON-EXISTENT ITEM - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Upsert_IfMatch_NonExistent_CreatesItem() - { - // Real Cosmos DB creates the item. If-Match is "applicable only on PUT and DELETE" - // per the REST API docs. Upsert uses POST, so If-Match is ignored on insert path. - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - var response = await container.UpsertItemAsync( - new { id = "noexist", pk = "pk1" }, new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 18. EXCEPTION DATA DICTIONARY - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Exception_Data_Dictionary_IsAccessible() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - - // Exception.Data should be accessible (some logging frameworks use it) - ex.Data.Should().NotBeNull(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 19. REQUEST CHARGE EDGE VALUES - // ═══════════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData(0.0)] - [InlineData(1.0)] - [InlineData(99.99)] - [InlineData(double.MaxValue)] - public void RequestCharge_VariousValues_ArePreserved(double charge) - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", charge); - ex.RequestCharge.Should().Be(charge); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 20. ACTIVITY ID EDGE CASES - // ═══════════════════════════════════════════════════════════════════════════ - - [Theory] - [InlineData("simple-id", "simple-id")] - [InlineData("a5d3e7c2-1234-5678-9abc-def012345678", "a5d3e7c2-1234-5678-9abc-def012345678")] - public void ActivityId_NonEmptyValues_ArePreserved(string activityId, string expected) - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, activityId, 0); - ex.ActivityId.Should().Be(expected); - } - - [Fact] - public void ActivityId_EmptyString_IsNormalizedToNull_BySdk() - { - // Cosmos SDK normalizes empty ActivityId to null — this is SDK behavior, not a bug - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - ex.ActivityId.Should().BeNull("Cosmos SDK normalizes empty ActivityId to null"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 21. EXCEPTION AS BASE TYPES - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Exception_IsAssignableTo_AllBaseTypes() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - - (ex is Exception).Should().BeTrue(); - (ex is CosmosException).Should().BeTrue(); - (ex is object).Should().BeTrue(); - - // Should NOT be assignable to any other derived type - typeof(CosmosException).IsAssignableFrom(ex!.GetType()).Should().BeTrue(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 22. DIAGNOSTICS AFTER EXCEPTION TRAVERSES ASYNC STATE MACHINE - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Diagnostics_IsNull_AfterAsyncAwait() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - CosmosException? caught = null; - try - { - await NestedAsyncCall(container); - } - catch (CosmosException ex) - { - caught = ex; - } - - caught.Should().NotBeNull(); - caught!.StatusCode.Should().Be(HttpStatusCode.NotFound); - caught.Diagnostics.Should().BeNull(); - } - - private static async Task NestedAsyncCall(Container container) - { - await Task.Yield(); // force async - await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // 23. CONTAINER REPLACE WITH WRONG ID - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ContainerReplace_WrongId_ThrowsCosmosException_400() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - var ex = await Assert.ThrowsAsync(async () => - await container.ReplaceContainerAsync(new ContainerProperties("wrong-name", "/pk"))); - - ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Diagnostics.Should().BeNull(); - } + act.Should().NotThrow("GetObjectData should work without error"); + } + + [Fact] + public void Exception_StackTrace_IsPreserved() + { + CosmosException? caught = null; + try + { + throw InMemoryCosmosException.Create("stack-test", HttpStatusCode.NotFound, 0, "", 0); + } + catch (CosmosException ex) + { + caught = ex; + } + + caught.Should().NotBeNull(); + caught.StackTrace.Should().NotBeNullOrEmpty("stack trace should be captured when thrown"); + caught.StackTrace.Should().Contain(nameof(Exception_StackTrace_IsPreserved)); + } + + [Fact] + public void Exception_InnerException_IsNull() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + ex.InnerException.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 7. DIAGNOSTICS BEHAVIOUR + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Diagnostics_IsNull_OnCaughtException() + { + CosmosException? caught = null; + try + { + throw InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + } + catch (CosmosException ex) + { + caught = ex; + } + + caught.Diagnostics.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 8. ETAG / PRECONDITION FAILED + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ETagMismatch_Replace_ThrowsCosmosException_412() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + var created = await container.CreateItemAsync( + new { id = "1", pk = "pk1", value = "a" }, new PartitionKey("pk1")); + + // Replace with wrong ETag + var ex = await Assert.ThrowsAsync(async () => + await container.ReplaceItemAsync( + new { id = "1", pk = "pk1", value = "b" }, "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task IfNoneMatch_Star_OnExistingItem_ThrowsCosmosException_304() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + await container.CreateItemAsync( + new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + + // Read with IfNoneMatch="*" — item exists → 304 + var ex = await Assert.ThrowsAsync(async () => + await container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = "*" })); + + ex.StatusCode.Should().Be(HttpStatusCode.NotModified); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task IfNoneMatch_MatchingEtag_ThrowsCosmosException_304() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + var created = await container.CreateItemAsync( + new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + var etag = created.ETag; + + var ex = await Assert.ThrowsAsync(async () => + await container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etag })); + + ex.StatusCode.Should().Be(HttpStatusCode.NotModified); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task ETagMismatch_Patch_ThrowsCosmosException_412() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + await container.CreateItemAsync( + new { id = "1", pk = "pk1", name = "test" }, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(async () => + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "new") }, + new PatchItemRequestOptions { IfMatchEtag = "\"wrong-etag\"" })); + + ex.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + ex.Diagnostics.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 9. CONTAINER NOT FOUND + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeletedContainer_ThrowsCosmosException_404_SubStatus1003() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var containerResp = await db.CreateContainerAsync("items", "/pk"); + var container = containerResp.Container; + + await container.DeleteContainerAsync(); + + var ex = await Assert.ThrowsAsync(async () => + await container.ReadContainerAsync()); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.SubStatusCode.Should().Be(1003); + ex.Diagnostics.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 10. MULTIPLE SEQUENTIAL ERRORS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task MultipleSequentialErrors_AllCatchableAsCosmosException() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + + var errors = new List<(HttpStatusCode Status, string Activity)>(); + + // Error 1: Read non-existent → 404 + try + { + await container.ReadItemAsync("nope", new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + errors.Add((ex.StatusCode, ex.ActivityId)); + ex.Diagnostics.Should().BeNull(); + } + + // Error 2: Duplicate create → 409 + try + { + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + errors.Add((ex.StatusCode, ex.ActivityId)); + ex.Diagnostics.Should().BeNull(); + } + + // Error 3: Delete non-existent → 404 + try + { + await container.DeleteItemAsync("nope", new PartitionKey("pk1")); + } + catch (CosmosException ex) + { + errors.Add((ex.StatusCode, ex.ActivityId)); + ex.Diagnostics.Should().BeNull(); + } + + errors.Should().HaveCount(3); + errors[0].Status.Should().Be(HttpStatusCode.NotFound); + errors[1].Status.Should().Be(HttpStatusCode.Conflict); + errors[2].Status.Should().Be(HttpStatusCode.NotFound); + + // Each should have a distinct ActivityId (generated with Guid.NewGuid) + errors.Select(e => e.Activity).Distinct().Should().HaveCount(3, + "Each error should have a unique ActivityId"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 11. VALIDATION ERRORS (400 BadRequest) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task EmptyId_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + var ex = await Assert.ThrowsAsync(async () => + await container.CreateItemAsync(new { id = "", pk = "pk1" }, new PartitionKey("pk1"))); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task PatchTooManyOps_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + await container.CreateItemAsync(new { id = "1", pk = "pk1", a = 0 }, new PartitionKey("pk1")); + + // 11 patch operations exceeds the limit of 10 + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", i)) + .ToArray(); + + var ex = await Assert.ThrowsAsync(async () => + await container.PatchItemAsync("1", new PartitionKey("pk1"), ops)); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task PatchEmptyOps_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + + var ex = await Assert.ThrowsAsync(async () => + await container.PatchItemAsync("1", new PartitionKey("pk1"), + Array.Empty())); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 12. DATABASE-LEVEL ERRORS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DatabaseNameTooLong_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + var longName = new string('x', 256); + + var ex = await Assert.ThrowsAsync(async () => + await client.CreateDatabaseAsync(longName)); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } + + [Fact] + public async Task DatabaseNameWithInvalidChars_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + + var ex = await Assert.ThrowsAsync(async () => + await client.CreateDatabaseAsync("db/name")); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 13. TRANSACTIONAL BATCH ERRORS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task BatchConflict_IsCatchableAsCosmosException() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Pre-create item so batch create conflicts + await container.CreateItemAsync(new { id = "dup", pk = "pk1" }, new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")) + .CreateItem(new { id = "dup", pk = "pk1" }); + + // The batch itself returns a response (not an exception) for per-item failures, + // but the underlying container operation throws, which batch catches internally. + // Let's verify batch response propagates the failure correctly. + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 14. CONCURRENT CONTAINER OPERATIONS RAISING EXCEPTIONS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConcurrentReadsOfNonExistent_AllThrowCosmosException() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + const int concurrency = 50; + var tasks = Enumerable.Range(0, concurrency).Select(async i => + { + var ex = await Assert.ThrowsAsync(async () => + await container.ReadItemAsync($"nope-{i}", new PartitionKey("pk1"))); + + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Diagnostics.Should().BeNull(); + return ex; + }); + + var results = await Task.WhenAll(tasks); + results.Should().HaveCount(concurrency); + + // Verify each has distinct ActivityId + results.Select(e => e.ActivityId).Distinct().Should().HaveCount(concurrency); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 15. EXCEPTION USED IN AGGREGATE / NESTED SCENARIOS + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task AggregateException_ContainingCosmosException_CanBeUnwrapped() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Wrap in Task.Run to ensure both tasks start as faulted Tasks + // (ReadItemAsync may throw synchronously before returning) + var task1 = Task.Run(() => container.ReadItemAsync("nope1", new PartitionKey("pk1"))); + var task2 = Task.Run(() => container.ReadItemAsync("nope2", new PartitionKey("pk1"))); + + var allTask = Task.WhenAll(task1, task2); + try + { + await allTask; + Assert.Fail("Should have thrown"); + } + catch (CosmosException firstEx) + { + firstEx.Diagnostics.Should().BeNull(); + firstEx.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + task1.IsFaulted.Should().BeTrue(); + task2.IsFaulted.Should().BeTrue(); + + // The AggregateException on the allTask should contain both + allTask.Exception.Should().NotBeNull(); + allTask.Exception!.InnerExceptions.Should().HaveCount(2); + foreach (var inner in allTask.Exception.InnerExceptions) + { + inner.Should().BeOfType(); + ((CosmosException)inner).Diagnostics.Should().BeNull(); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 16. REPLACE WITH WRONG BODY ID + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ReplaceItem_BodyIdMismatch_ThrowsBadRequest() + { + // SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + + var act = () => container.ReplaceItemAsync( + new { id = "DIFFERENT", pk = "pk1" }, "1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 17. UPSERT WITH IF-MATCH ON NON-EXISTENT ITEM + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Upsert_IfMatch_NonExistent_CreatesItem() + { + // Real Cosmos DB creates the item. If-Match is "applicable only on PUT and DELETE" + // per the REST API docs. Upsert uses POST, so If-Match is ignored on insert path. + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + var response = await container.UpsertItemAsync( + new { id = "noexist", pk = "pk1" }, new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 18. EXCEPTION DATA DICTIONARY + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Exception_Data_Dictionary_IsAccessible() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + + // Exception.Data should be accessible (some logging frameworks use it) + ex.Data.Should().NotBeNull(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 19. REQUEST CHARGE EDGE VALUES + // ═══════════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData(0.0)] + [InlineData(1.0)] + [InlineData(99.99)] + [InlineData(double.MaxValue)] + public void RequestCharge_VariousValues_ArePreserved(double charge) + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", charge); + ex.RequestCharge.Should().Be(charge); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 20. ACTIVITY ID EDGE CASES + // ═══════════════════════════════════════════════════════════════════════════ + + [Theory] + [InlineData("simple-id", "simple-id")] + [InlineData("a5d3e7c2-1234-5678-9abc-def012345678", "a5d3e7c2-1234-5678-9abc-def012345678")] + public void ActivityId_NonEmptyValues_ArePreserved(string activityId, string expected) + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, activityId, 0); + ex.ActivityId.Should().Be(expected); + } + + [Fact] + public void ActivityId_EmptyString_IsNormalizedToNull_BySdk() + { + // Cosmos SDK normalizes empty ActivityId to null — this is SDK behavior, not a bug + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + ex.ActivityId.Should().BeNull("Cosmos SDK normalizes empty ActivityId to null"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 21. EXCEPTION AS BASE TYPES + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Exception_IsAssignableTo_AllBaseTypes() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + + (ex is Exception).Should().BeTrue(); + (ex is CosmosException).Should().BeTrue(); + (ex is object).Should().BeTrue(); + + // Should NOT be assignable to any other derived type + typeof(CosmosException).IsAssignableFrom(ex!.GetType()).Should().BeTrue(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 22. DIAGNOSTICS AFTER EXCEPTION TRAVERSES ASYNC STATE MACHINE + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Diagnostics_IsNull_AfterAsyncAwait() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + CosmosException? caught = null; + try + { + await NestedAsyncCall(container); + } + catch (CosmosException ex) + { + caught = ex; + } + + caught.Should().NotBeNull(); + caught!.StatusCode.Should().Be(HttpStatusCode.NotFound); + caught.Diagnostics.Should().BeNull(); + } + + private static async Task NestedAsyncCall(Container container) + { + await Task.Yield(); // force async + await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 23. CONTAINER REPLACE WITH WRONG ID + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ContainerReplace_WrongId_ThrowsCosmosException_400() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + var ex = await Assert.ThrowsAsync(async () => + await container.ReplaceContainerAsync(new ContainerProperties("wrong-name", "/pk"))); + + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Diagnostics.Should().BeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18ReproductionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18ReproductionTests.cs index d40f861..f6810c2 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18ReproductionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue18ReproductionTests.cs @@ -11,160 +11,160 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class Issue18ReproductionTests { - [Fact] - public async Task ReadItemAsync_NonExistent_CatchCosmosException_Works() - { - // Arrange — exact scenario from issue - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Act & Assert — catch (CosmosException) with when clause - bool caughtAsCosmosException = false; - try - { - await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtAsCosmosException = true; - } - - caughtAsCosmosException.Should().BeTrue("InMemoryCosmosException should be catchable as CosmosException"); - } - - [Fact] - public async Task ReadItemAsync_NonExistent_AssertThrowsCosmosException_Works() - { - // Arrange - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Act & Assert — Assert.ThrowsAsync pattern from issue - var exception = await Assert.ThrowsAsync(async () => - await container.ReadItemAsync("nonexistent", new PartitionKey("pk1"))); - - exception.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task CreateItemAsync_Duplicate_CatchCosmosException_Works() - { - // Arrange - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - - // Act & Assert — duplicate create should throw 409 - bool caughtConflict = false; - try - { - await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - caughtConflict = true; - } - - caughtConflict.Should().BeTrue("Duplicate create should be catchable as CosmosException with 409"); - } - - [Fact] - public async Task DeleteItemAsync_NonExistent_CatchCosmosException_Works() - { - // Arrange - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Act & Assert - bool caughtNotFound = false; - try - { - await container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue("Delete of nonexistent item should be catchable as CosmosException with 404"); - } - - [Fact] - public async Task ReplaceItemAsync_NonExistent_CatchCosmosException_Works() - { - // Arrange - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Act & Assert - bool caughtNotFound = false; - try - { - await container.ReplaceItemAsync(new { id = "nonexistent", pk = "pk1" }, "nonexistent", new PartitionKey("pk1")); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue("Replace of nonexistent item should be catchable as CosmosException with 404"); - } - - [Fact] - public async Task PatchItemAsync_NonExistent_CatchCosmosException_Works() - { - // Arrange - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; - var container = (await db.CreateContainerAsync("items", "/pk")).Container; - - // Act & Assert - bool caughtNotFound = false; - try - { - await container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "test") }); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - caughtNotFound = true; - } - - caughtNotFound.Should().BeTrue("Patch of nonexistent item should be catchable as CosmosException with 404"); - } - - [Fact] - public void InMemoryCosmosException_Creates_CosmosException() - { - var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); - (ex is CosmosException).Should().BeTrue(); - ex.GetType().Should().Be(typeof(CosmosException), "thrown exception should be exactly CosmosException, not a subclass"); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Diagnostics.Should().BeNull("Diagnostics is null because the SDK does not expose a public way to set it"); - } - - [Fact] - public async Task CreateDatabaseAsync_Duplicate_CatchCosmosException_Works() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("testdb"); - - bool caughtConflict = false; - try - { - await client.CreateDatabaseAsync("testdb"); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - caughtConflict = true; - } - - caughtConflict.Should().BeTrue("Duplicate database creation should throw CosmosException with 409"); - } + [Fact] + public async Task ReadItemAsync_NonExistent_CatchCosmosException_Works() + { + // Arrange — exact scenario from issue + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Act & Assert — catch (CosmosException) with when clause + bool caughtAsCosmosException = false; + try + { + await container.ReadItemAsync("nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtAsCosmosException = true; + } + + caughtAsCosmosException.Should().BeTrue("InMemoryCosmosException should be catchable as CosmosException"); + } + + [Fact] + public async Task ReadItemAsync_NonExistent_AssertThrowsCosmosException_Works() + { + // Arrange + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Act & Assert — Assert.ThrowsAsync pattern from issue + var exception = await Assert.ThrowsAsync(async () => + await container.ReadItemAsync("nonexistent", new PartitionKey("pk1"))); + + exception.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateItemAsync_Duplicate_CatchCosmosException_Works() + { + // Arrange + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + + // Act & Assert — duplicate create should throw 409 + bool caughtConflict = false; + try + { + await container.CreateItemAsync(new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + caughtConflict = true; + } + + caughtConflict.Should().BeTrue("Duplicate create should be catchable as CosmosException with 409"); + } + + [Fact] + public async Task DeleteItemAsync_NonExistent_CatchCosmosException_Works() + { + // Arrange + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Act & Assert + bool caughtNotFound = false; + try + { + await container.DeleteItemAsync("nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue("Delete of nonexistent item should be catchable as CosmosException with 404"); + } + + [Fact] + public async Task ReplaceItemAsync_NonExistent_CatchCosmosException_Works() + { + // Arrange + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Act & Assert + bool caughtNotFound = false; + try + { + await container.ReplaceItemAsync(new { id = "nonexistent", pk = "pk1" }, "nonexistent", new PartitionKey("pk1")); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue("Replace of nonexistent item should be catchable as CosmosException with 404"); + } + + [Fact] + public async Task PatchItemAsync_NonExistent_CatchCosmosException_Works() + { + // Arrange + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + var container = (await db.CreateContainerAsync("items", "/pk")).Container; + + // Act & Assert + bool caughtNotFound = false; + try + { + await container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "test") }); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + caughtNotFound = true; + } + + caughtNotFound.Should().BeTrue("Patch of nonexistent item should be catchable as CosmosException with 404"); + } + + [Fact] + public void InMemoryCosmosException_Creates_CosmosException() + { + var ex = InMemoryCosmosException.Create("test", HttpStatusCode.NotFound, 0, "", 0); + (ex is CosmosException).Should().BeTrue(); + ex.GetType().Should().Be(typeof(CosmosException), "thrown exception should be exactly CosmosException, not a subclass"); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Diagnostics.Should().BeNull("Diagnostics is null because the SDK does not expose a public way to set it"); + } + + [Fact] + public async Task CreateDatabaseAsync_Duplicate_CatchCosmosException_Works() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("testdb"); + + bool caughtConflict = false; + try + { + await client.CreateDatabaseAsync("testdb"); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + caughtConflict = true; + } + + caughtConflict.Should().BeTrue("Duplicate database creation should throw CosmosException with 409"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue59_CountBracketNotationTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue59_CountBracketNotationTests.cs index 4a40452..2b4e9a9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue59_CountBracketNotationTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/Issue59_CountBracketNotationTests.cs @@ -1,8 +1,8 @@ +using AwesomeAssertions; using CosmosDB.InMemoryEmulator; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; using Xunit; -using AwesomeAssertions; namespace CosmosDB.InMemoryEmulator.Tests; @@ -13,77 +13,77 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class Issue59_CountBracketNotationTests { - [Fact] - public async Task Sum_WithBracketNotation_OnReservedWordField_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("transactions", "/id") - .Build(); + [Fact] + public async Task Sum_WithBracketNotation_OnReservedWordField_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("transactions", "/id") + .Build(); - var container = cosmos.Containers["transactions"]; - await SeedTransactions(container); + var container = cosmos.Containers["transactions"]; + await SeedTransactions(container); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE SUM(c.grossSettlementValue[\"value\"]) FROM c"); - var page = await iterator.ReadNextAsync(); - page.First().Should().Be(100m); - } + var iterator = container.GetItemQueryIterator( + "SELECT VALUE SUM(c.grossSettlementValue[\"value\"]) FROM c"); + var page = await iterator.ReadNextAsync(); + page.First().Should().Be(100m); + } - [Fact] - public async Task Count_WithBracketNotationAndTernary_OnReservedWordField_DoesNotThrow() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("transactions", "/id") - .Build(); + [Fact] + public async Task Count_WithBracketNotationAndTernary_OnReservedWordField_DoesNotThrow() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("transactions", "/id") + .Build(); - var container = cosmos.Containers["transactions"]; - await SeedTransactions(container); + var container = cosmos.Containers["transactions"]; + await SeedTransactions(container); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE COUNT(c.grossSettlementValue[\"value\"] > 0 ? 1 : undefined) FROM c"); + var iterator = container.GetItemQueryIterator( + "SELECT VALUE COUNT(c.grossSettlementValue[\"value\"] > 0 ? 1 : undefined) FROM c"); - var page = await iterator.ReadNextAsync(); - page.First().Should().Be(1); - } + var page = await iterator.ReadNextAsync(); + page.First().Should().Be(1); + } - [Fact] - public async Task Combined_CountAndSum_WithBracketNotation_OnReservedWordField_Works() - { - using var cosmos = InMemoryCosmos.Builder() - .AddContainer("transactions", "/id") - .Build(); + [Fact] + public async Task Combined_CountAndSum_WithBracketNotation_OnReservedWordField_Works() + { + using var cosmos = InMemoryCosmos.Builder() + .AddContainer("transactions", "/id") + .Build(); - var container = cosmos.Containers["transactions"]; - await SeedTransactions(container); - await container.CreateItemAsync( - new - { - id = "2", - grossSettlementValue = new { value = -50m, currencyCode = "GBP" }, - transactionType = "Settlement" - }, - new PartitionKey("2")); + var container = cosmos.Containers["transactions"]; + await SeedTransactions(container); + await container.CreateItemAsync( + new + { + id = "2", + grossSettlementValue = new { value = -50m, currencyCode = "GBP" }, + transactionType = "Settlement" + }, + new PartitionKey("2")); - var iterator = container.GetItemQueryIterator( - "SELECT COUNT(c.grossSettlementValue[\"value\"] > 0 ? 1 : undefined) AS NumberTransactions, " + - "SUM(c.grossSettlementValue[\"value\"]) AS CreditTotal " + - "FROM c WHERE c.transactionType = 'Settlement'"); + var iterator = container.GetItemQueryIterator( + "SELECT COUNT(c.grossSettlementValue[\"value\"] > 0 ? 1 : undefined) AS NumberTransactions, " + + "SUM(c.grossSettlementValue[\"value\"]) AS CreditTotal " + + "FROM c WHERE c.transactionType = 'Settlement'"); - var page = await iterator.ReadNextAsync(); - var row = page.First(); - row["NumberTransactions"]!.Value().Should().Be(1); - row["CreditTotal"]!.Value().Should().Be(50m); - } + var page = await iterator.ReadNextAsync(); + var row = page.First(); + row["NumberTransactions"]!.Value().Should().Be(1); + row["CreditTotal"]!.Value().Should().Be(50m); + } - private static async Task SeedTransactions(Container container) - { - await container.CreateItemAsync( - new - { - id = "1", - grossSettlementValue = new { value = 100m, currencyCode = "GBP" }, - transactionType = "Settlement" - }, - new PartitionKey("1")); - } + private static async Task SeedTransactions(Container container) + { + await container.CreateItemAsync( + new + { + id = "1", + grossSettlementValue = new { value = 100m, currencyCode = "GBP" }, + transactionType = "Settlement" + }, + new PartitionKey("1")); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ItemResponseProxyNet10Tests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ItemResponseProxyNet10Tests.cs index 36711f0..a8b73d9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ItemResponseProxyNet10Tests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ItemResponseProxyNet10Tests.cs @@ -13,66 +13,66 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class ItemResponseProxyNet10Tests { - private class SimpleDocument - { - [JsonProperty("id")] - public string Id { get; set; } = default!; + private class SimpleDocument + { + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("pk")] - public string Pk { get; set; } = default!; + [JsonProperty("pk")] + public string Pk { get; set; } = default!; - [JsonProperty("name")] - public string Name { get; set; } = default!; - } + [JsonProperty("name")] + public string Name { get; set; } = default!; + } - [Fact] - public async Task CreateItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); - var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); - var container = containerResponse.Container; + [Fact] + public async Task CreateItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); + var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); + var container = containerResponse.Container; - var item = new SimpleDocument { Id = "1", Pk = "pk1", Name = "test" }; - var response = await container.CreateItemAsync(item, new PartitionKey("pk1")); + var item = new SimpleDocument { Id = "1", Pk = "pk1", Name = "test" }; + var response = await container.CreateItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("1"); - response.Resource.Name.Should().Be("test"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("1"); + response.Resource.Name.Should().Be("test"); + } - [Fact] - public async Task UpsertItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); - var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); - var container = containerResponse.Container; + [Fact] + public async Task UpsertItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); + var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); + var container = containerResponse.Container; - var item = new SimpleDocument { Id = "2", Pk = "pk1", Name = "upsert-test" }; - var response = await container.UpsertItemAsync(item, new PartitionKey("pk1")); + var item = new SimpleDocument { Id = "2", Pk = "pk1", Name = "upsert-test" }; + var response = await container.UpsertItemAsync(item, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - response.Resource.Name.Should().Be("upsert-test"); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + response.Resource.Name.Should().Be("upsert-test"); + } - [Fact] - public async Task ReadItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() - { - var client = new InMemoryCosmosClient(); - var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); - var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); - var container = containerResponse.Container; + [Fact] + public async Task ReadItemAsync_ViaInMemoryCosmosClient_ShouldSucceed() + { + var client = new InMemoryCosmosClient(); + var dbResponse = await client.CreateDatabaseIfNotExistsAsync("testdb"); + var containerResponse = await dbResponse.Database.CreateContainerIfNotExistsAsync("testcontainer", "/pk"); + var container = containerResponse.Container; - var item = new SimpleDocument { Id = "3", Pk = "pk1", Name = "read-test" }; - await container.CreateItemAsync(item, new PartitionKey("pk1")); + var item = new SimpleDocument { Id = "3", Pk = "pk1", Name = "read-test" }; + await container.CreateItemAsync(item, new PartitionKey("pk1")); - var response = await container.ReadItemAsync("3", new PartitionKey("pk1")); + var response = await container.ReadItemAsync("3", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - response.Resource.Name.Should().Be("read-test"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + response.Resource.Name.Should().Be("read-test"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsSprocQueryResponseOptionsBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsSprocQueryResponseOptionsBugTests.cs index 9cb83b1..11da143 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsSprocQueryResponseOptionsBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsSprocQueryResponseOptionsBugTests.cs @@ -15,26 +15,26 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class JsSprocQueryResponseOptionsBugTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task QueryDocuments_Callback_ShouldReceiveResponseOptions() - { - _container.UseJsStoredProcedures(); - - // Insert some test documents - for (int i = 0; i < 3; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"doc{i}", pk = "a", value = i }), - new PartitionKey("a")); - } - - // This stored procedure checks whether responseOptions is provided in the callback. - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spCheckResponseOptions", - Body = @"function() { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task QueryDocuments_Callback_ShouldReceiveResponseOptions() + { + _container.UseJsStoredProcedures(); + + // Insert some test documents + for (int i = 0; i < 3; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"doc{i}", pk = "a", value = i }), + new PartitionKey("a")); + } + + // This stored procedure checks whether responseOptions is provided in the callback. + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spCheckResponseOptions", + Body = @"function() { var collection = getContext().getCollection(); collection.queryDocuments( collection.getSelfLink(), @@ -51,40 +51,40 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(JSON.stringify(result)); }); }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCheckResponseOptions", new PartitionKey("a"), Array.Empty()); - - var result = JObject.Parse(response.Resource); - - result["docCount"]!.Value().Should().Be(3); - result["hasResponseOptions"]!.Value().Should().BeTrue( - "real Cosmos DB always passes responseOptions as the third argument to the queryDocuments callback"); - result["responseOptionsType"]!.Value().Should().Be("object", - "responseOptions should be an object, not undefined or null"); - result["hasContinuation"]!.Value().Should().BeTrue( - "responseOptions should have a 'continuation' property for pagination support"); - } - - [Fact] - public async Task QueryDocuments_BulkDeletePattern_ShouldWorkWithContinuation() - { - _container.UseJsStoredProcedures(); - - // Insert test documents - for (int i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"doc{i}", pk = "a", status = "old" }), - new PartitionKey("a")); - } - - // Eveneum-style BulkDelete pattern that relies on responseOptions.continuation - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spBulkDeleteWithContinuation", - Body = @"function(query) { + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCheckResponseOptions", new PartitionKey("a"), Array.Empty()); + + var result = JObject.Parse(response.Resource); + + result["docCount"]!.Value().Should().Be(3); + result["hasResponseOptions"]!.Value().Should().BeTrue( + "real Cosmos DB always passes responseOptions as the third argument to the queryDocuments callback"); + result["responseOptionsType"]!.Value().Should().Be("object", + "responseOptions should be an object, not undefined or null"); + result["hasContinuation"]!.Value().Should().BeTrue( + "responseOptions should have a 'continuation' property for pagination support"); + } + + [Fact] + public async Task QueryDocuments_BulkDeletePattern_ShouldWorkWithContinuation() + { + _container.UseJsStoredProcedures(); + + // Insert test documents + for (int i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"doc{i}", pk = "a", status = "old" }), + new PartitionKey("a")); + } + + // Eveneum-style BulkDelete pattern that relies on responseOptions.continuation + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spBulkDeleteWithContinuation", + Body = @"function(query) { var collection = getContext().getCollection(); var response = getContext().getResponse(); var deletedCount = 0; @@ -114,24 +114,24 @@ function doQuery(continuation) { doQuery(undefined); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spBulkDeleteWithContinuation", new PartitionKey("a"), - new dynamic[] { "SELECT * FROM c WHERE c.status = 'old'" }); - - result.Resource.Should().Be("5", - "all 5 documents should be deleted via the continuation-based bulk delete pattern"); - - // Verify all documents were deleted - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.pk = 'a'")); - var remaining = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - remaining.AddRange(page); - } - remaining.Should().BeEmpty("all documents should have been deleted by the stored procedure"); - } + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spBulkDeleteWithContinuation", new PartitionKey("a"), + new dynamic[] { "SELECT * FROM c WHERE c.status = 'old'" }); + + result.Resource.Should().Be("5", + "all 5 documents should be deleted via the continuation-based bulk delete pattern"); + + // Verify all documents were deleted + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.pk = 'a'")); + var remaining = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + remaining.AddRange(page); + } + remaining.Should().BeEmpty("all documents should have been deleted by the stored procedure"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerDeepDiveTests.cs index 7c8200b..723dca7 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerDeepDiveTests.cs @@ -15,212 +15,212 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class JsUdfEngineTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - public JsUdfEngineTests() - { - _container.UseJsTriggers(); - } - - private async Task SeedItem(string id, string pk, JObject? extra = null) - { - var item = new JObject { ["id"] = id, ["pk"] = pk }; - if (extra != null) item.Merge(extra); - await _container.CreateItemAsync(item, new PartitionKey(pk)); - } - - [Fact] - public async Task T1_1_JsUdf_SimpleFunction_ReturnsResult() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "tax", Body = "function tax(x) { return x * 0.2; }" }); - await SeedItem("1", "p", JObject.FromObject(new { price = 100 })); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, udf.tax(c.price) AS taxed FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - results.Should().ContainSingle(); - ((double)results[0]["taxed"]!).Should().Be(20.0); - } - - [Fact] - public async Task T1_2_JsUdf_MultipleArgs_Works() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "add", Body = "function add(a, b) { return a + b; }" }); - await SeedItem("1", "p", JObject.FromObject(new { x = 10, y = 20 })); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.add(c.x, c.y) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(30); - } - - [Fact] - public async Task T1_3_JsUdf_NullArgs_HandledGracefully() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "isnull", Body = "function isnull(x) { return x === null; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.isnull(null) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task T1_4_JsUdf_ReturnsObject_DeserializesCorrectly() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "mkobj", Body = "function mkobj(k) { return {key: k}; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.mkobj('hello') FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["key"]!.ToString().Should().Be("hello"); - } - - [Fact] - public async Task T1_5_JsUdf_ReturnsArray_DeserializesCorrectly() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "mkarr", Body = "function mkarr() { return [1, 2, 3]; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.mkarr() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle(); - var arr = results[0] as JArray; - arr.Should().NotBeNull(); - arr!.Select(t => (int)t).Should().Equal(1, 2, 3); - } - - [Fact] - public async Task T1_6_JsUdf_ThrowsError_WrappedAsCosmosException() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "bad", Body = "function bad() { throw new Error('boom'); }" }); - await SeedItem("1", "p"); - - var act = async () => - { - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.bad() FROM c")); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - }; - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T1_7_JsUdf_SyntaxError_WrappedAsCosmosException() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "syntax", Body = "function syntax( { broken }" }); - await SeedItem("1", "p"); - - var act = async () => - { - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.syntax() FROM c")); - while (iter.HasMoreResults) await iter.ReadNextAsync(); - }; - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T1_8_JsUdf_AnonymousFunction_ExecutesViaElseBranch() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "anon", Body = "function(x) { return x + 1; }" }); - await SeedItem("1", "p", JObject.FromObject(new { val = 5 })); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.anon(c.val) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(6); - } - - [Fact] - public async Task T1_9_JsUdf_WithoutJsTriggers_FallsBackToCSharpHandler() - { - var container = new InMemoryContainer("no-js", "/pk"); - container.RegisterUdf("myudf", args => Convert.ToInt64(args[0]) * 2); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", val = 5 }), new PartitionKey("p")); - - var iter = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.myudf(c.val) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(10L); - } - - [Fact] - public async Task T1_10_JsUdf_CSharpHandlerPriority_OverJsBody() - { - _container.RegisterUdf("over", args => 999); - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "over", Body = "function over() { return 1; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.over() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(999); - } - - [Fact] - public async Task T1_11_JsUdf_BooleanArg_ConvertsCorrectly() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "flip", Body = "function flip(b) { return !b; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.flip(true) FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task T1_14_JsUdf_ReturnsNull_HandledCorrectly() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "retnull", Body = "function retnull() { return null; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.retnull() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task T1_15_JsUdf_ReturnsBool_ConvertedCorrectly() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "rettrue", Body = "function rettrue() { return true; }" }); - await SeedItem("1", "p"); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.rettrue() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + public JsUdfEngineTests() + { + _container.UseJsTriggers(); + } + + private async Task SeedItem(string id, string pk, JObject? extra = null) + { + var item = new JObject { ["id"] = id, ["pk"] = pk }; + if (extra != null) item.Merge(extra); + await _container.CreateItemAsync(item, new PartitionKey(pk)); + } + + [Fact] + public async Task T1_1_JsUdf_SimpleFunction_ReturnsResult() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "tax", Body = "function tax(x) { return x * 0.2; }" }); + await SeedItem("1", "p", JObject.FromObject(new { price = 100 })); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, udf.tax(c.price) AS taxed FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().ContainSingle(); + ((double)results[0]["taxed"]!).Should().Be(20.0); + } + + [Fact] + public async Task T1_2_JsUdf_MultipleArgs_Works() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "add", Body = "function add(a, b) { return a + b; }" }); + await SeedItem("1", "p", JObject.FromObject(new { x = 10, y = 20 })); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.add(c.x, c.y) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(30); + } + + [Fact] + public async Task T1_3_JsUdf_NullArgs_HandledGracefully() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "isnull", Body = "function isnull(x) { return x === null; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.isnull(null) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task T1_4_JsUdf_ReturnsObject_DeserializesCorrectly() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "mkobj", Body = "function mkobj(k) { return {key: k}; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.mkobj('hello') FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle(); + results[0]["key"]!.ToString().Should().Be("hello"); + } + + [Fact] + public async Task T1_5_JsUdf_ReturnsArray_DeserializesCorrectly() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "mkarr", Body = "function mkarr() { return [1, 2, 3]; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.mkarr() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle(); + var arr = results[0] as JArray; + arr.Should().NotBeNull(); + arr!.Select(t => (int)t).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task T1_6_JsUdf_ThrowsError_WrappedAsCosmosException() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "bad", Body = "function bad() { throw new Error('boom'); }" }); + await SeedItem("1", "p"); + + var act = async () => + { + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.bad() FROM c")); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + }; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T1_7_JsUdf_SyntaxError_WrappedAsCosmosException() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "syntax", Body = "function syntax( { broken }" }); + await SeedItem("1", "p"); + + var act = async () => + { + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.syntax() FROM c")); + while (iter.HasMoreResults) await iter.ReadNextAsync(); + }; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T1_8_JsUdf_AnonymousFunction_ExecutesViaElseBranch() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "anon", Body = "function(x) { return x + 1; }" }); + await SeedItem("1", "p", JObject.FromObject(new { val = 5 })); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.anon(c.val) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(6); + } + + [Fact] + public async Task T1_9_JsUdf_WithoutJsTriggers_FallsBackToCSharpHandler() + { + var container = new InMemoryContainer("no-js", "/pk"); + container.RegisterUdf("myudf", args => Convert.ToInt64(args[0]) * 2); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", val = 5 }), new PartitionKey("p")); + + var iter = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.myudf(c.val) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(10L); + } + + [Fact] + public async Task T1_10_JsUdf_CSharpHandlerPriority_OverJsBody() + { + _container.RegisterUdf("over", args => 999); + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "over", Body = "function over() { return 1; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.over() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(999); + } + + [Fact] + public async Task T1_11_JsUdf_BooleanArg_ConvertsCorrectly() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "flip", Body = "function flip(b) { return !b; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.flip(true) FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task T1_14_JsUdf_ReturnsNull_HandledCorrectly() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "retnull", Body = "function retnull() { return null; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.retnull() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task T1_15_JsUdf_ReturnsBool_ConvertedCorrectly() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "rettrue", Body = "function rettrue() { return true; }" }); + await SeedItem("1", "p"); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.rettrue() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -229,137 +229,137 @@ await _container.Scripts.CreateUserDefinedFunctionAsync( public class JsSprocEngineTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - public JsSprocEngineTests() - { - _container.UseJsStoredProcedures(); - _container.UseJsTriggers(); - } - - [Fact] - public async Task T2_1_JsSproc_SimpleSetBody_ReturnsResult() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "sp1", - Body = "function run() { getContext().getResponse().setBody('hello'); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("hello"); - } - - [Fact] - public async Task T2_2_JsSproc_ConsoleLog_CapturedInLogs() - { - _container.UseJsStoredProcedures(); - var engine = _container.SprocEngine as JintSprocEngine; - engine.Should().NotBeNull(); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spLog", - Body = "function run() { console.log('hello world'); getContext().getResponse().setBody('ok'); }" - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spLog", new PartitionKey("p"), Array.Empty()); - - engine!.CapturedLogs.Should().Contain("hello world"); - } - - [Fact] - public async Task T2_3_JsSproc_MultipleConsoleLogs_AllCaptured() - { - _container.UseJsStoredProcedures(); - var engine = _container.SprocEngine as JintSprocEngine; - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spMulti", - Body = "function run() { console.log('a'); console.log('b'); console.log('c'); getContext().getResponse().setBody('ok'); }" - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spMulti", new PartitionKey("p"), Array.Empty()); - - engine!.CapturedLogs.Should().HaveCount(3); - engine!.CapturedLogs.Should().Equal("a", "b", "c"); - } - - [Fact] - public async Task T2_4_JsSproc_ThrowsError_WrappedAsCosmosException() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spErr", - Body = "function run() { throw new Error('sproc boom'); }" - }); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spErr", new PartitionKey("p"), Array.Empty()); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task T2_5_JsSproc_WithCollectionContext_CreateDocument() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spCreate", - Body = @"function run() { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + public JsSprocEngineTests() + { + _container.UseJsStoredProcedures(); + _container.UseJsTriggers(); + } + + [Fact] + public async Task T2_1_JsSproc_SimpleSetBody_ReturnsResult() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "sp1", + Body = "function run() { getContext().getResponse().setBody('hello'); }" + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("hello"); + } + + [Fact] + public async Task T2_2_JsSproc_ConsoleLog_CapturedInLogs() + { + _container.UseJsStoredProcedures(); + var engine = _container.SprocEngine as JintSprocEngine; + engine.Should().NotBeNull(); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spLog", + Body = "function run() { console.log('hello world'); getContext().getResponse().setBody('ok'); }" + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spLog", new PartitionKey("p"), Array.Empty()); + + engine!.CapturedLogs.Should().Contain("hello world"); + } + + [Fact] + public async Task T2_3_JsSproc_MultipleConsoleLogs_AllCaptured() + { + _container.UseJsStoredProcedures(); + var engine = _container.SprocEngine as JintSprocEngine; + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spMulti", + Body = "function run() { console.log('a'); console.log('b'); console.log('c'); getContext().getResponse().setBody('ok'); }" + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spMulti", new PartitionKey("p"), Array.Empty()); + + engine!.CapturedLogs.Should().HaveCount(3); + engine!.CapturedLogs.Should().Equal("a", "b", "c"); + } + + [Fact] + public async Task T2_4_JsSproc_ThrowsError_WrappedAsCosmosException() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spErr", + Body = "function run() { throw new Error('sproc boom'); }" + }); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spErr", new PartitionKey("p"), Array.Empty()); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task T2_5_JsSproc_WithCollectionContext_CreateDocument() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spCreate", + Body = @"function run() { var context = getContext(); var collection = context.getCollection(); collection.createDocument(collection.getSelfLink(), {id: 'created', pk: 'p', value: 42}); context.getResponse().setBody('done'); }" - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spCreate", new PartitionKey("p"), Array.Empty()); - - var item = await _container.ReadItemAsync("created", new PartitionKey("p")); - ((int)item.Resource["value"]!).Should().Be(42); - } - - [Fact] - public async Task T2_6_JsSproc_WithCollectionContext_QueryDocuments() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "q1", pk = "p", val = 10 }), new PartitionKey("p")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "q2", pk = "p", val = 20 }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spQuery", - Body = @"function run() { + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spCreate", new PartitionKey("p"), Array.Empty()); + + var item = await _container.ReadItemAsync("created", new PartitionKey("p")); + ((int)item.Resource["value"]!).Should().Be(42); + } + + [Fact] + public async Task T2_6_JsSproc_WithCollectionContext_QueryDocuments() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "q1", pk = "p", val = 10 }), new PartitionKey("p")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "q2", pk = "p", val = 20 }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spQuery", + Body = @"function run() { var context = getContext(); var coll = context.getCollection(); coll.queryDocuments(coll.getSelfLink(), 'SELECT * FROM c', function(err, docs) { context.getResponse().setBody(JSON.stringify(docs.length)); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spQuery", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("2"); - } - - [Fact] - public async Task T2_7_JsSproc_WithCollectionContext_ReplaceDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "r1", pk = "p", val = 1 }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spQuery", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("2"); + } + + [Fact] + public async Task T2_7_JsSproc_WithCollectionContext_ReplaceDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "r1", pk = "p", val = 1 }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = @"function run() { var coll = getContext().getCollection(); coll.readDocument(coll.getSelfLink() + '/docs/r1', function(err, doc) { doc.val = 999; @@ -367,119 +367,119 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie }); getContext().getResponse().setBody('done'); }" - }); + }); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spReplace", new PartitionKey("p"), Array.Empty()); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spReplace", new PartitionKey("p"), Array.Empty()); - var item = await _container.ReadItemAsync("r1", new PartitionKey("p")); - ((int)item.Resource["val"]!).Should().Be(999); - } + var item = await _container.ReadItemAsync("r1", new PartitionKey("p")); + ((int)item.Resource["val"]!).Should().Be(999); + } - [Fact] - public async Task T2_8_JsSproc_WithCollectionContext_DeleteDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "d1", pk = "p", val = 1 }), new PartitionKey("p")); + [Fact] + public async Task T2_8_JsSproc_WithCollectionContext_DeleteDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "d1", pk = "p", val = 1 }), new PartitionKey("p")); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDelete", - Body = @"function run() { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDelete", + Body = @"function run() { var coll = getContext().getCollection(); coll.deleteDocument(coll.getSelfLink() + '/docs/d1'); getContext().getResponse().setBody('deleted'); }" - }); + }); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spDelete", new PartitionKey("p"), Array.Empty()); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spDelete", new PartitionKey("p"), Array.Empty()); - var act = () => _container.ReadItemAsync("d1", new PartitionKey("p")); - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + var act = () => _container.ReadItemAsync("d1", new PartitionKey("p")); + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } - [Fact] - public async Task T2_9_JsSproc_WithCollectionContext_ReadDocument() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "rd1", pk = "p", val = 42 }), new PartitionKey("p")); + [Fact] + public async Task T2_9_JsSproc_WithCollectionContext_ReadDocument() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "rd1", pk = "p", val = 42 }), new PartitionKey("p")); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spRead", - Body = @"function run() { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spRead", + Body = @"function run() { var coll = getContext().getCollection(); coll.readDocument(coll.getSelfLink() + '/docs/rd1', function(err, doc) { getContext().getResponse().setBody(JSON.stringify(doc.val)); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spRead", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("42"); - } - - [Fact] - public async Task T2_10_JsSproc_AnonymousFunction_WithArgs() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spAnon", - Body = "(function(a, b) { getContext().getResponse().setBody(a + ' ' + b); })" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spAnon", new PartitionKey("p"), new dynamic[] { "hello", "world" }); - result.Resource.Should().Be("hello world"); - } - - [Fact] - public async Task T2_11_JsSproc_SetBody_ObjectResult() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spObj", - Body = "function run() { getContext().getResponse().setBody({key: 'val', num: 42}); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spObj", new PartitionKey("p"), Array.Empty()); - result.Resource["key"]!.ToString().Should().Be("val"); - ((int)result.Resource["num"]!).Should().Be(42); - } - - [Fact] - public async Task T2_14_JsSproc_WithArgs_MultipleTypes() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spArgs", - Body = @"function run(s, n, b) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spRead", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("42"); + } + + [Fact] + public async Task T2_10_JsSproc_AnonymousFunction_WithArgs() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spAnon", + Body = "(function(a, b) { getContext().getResponse().setBody(a + ' ' + b); })" + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spAnon", new PartitionKey("p"), new dynamic[] { "hello", "world" }); + result.Resource.Should().Be("hello world"); + } + + [Fact] + public async Task T2_11_JsSproc_SetBody_ObjectResult() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spObj", + Body = "function run() { getContext().getResponse().setBody({key: 'val', num: 42}); }" + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spObj", new PartitionKey("p"), Array.Empty()); + result.Resource["key"]!.ToString().Should().Be("val"); + ((int)result.Resource["num"]!).Should().Be(42); + } + + [Fact] + public async Task T2_14_JsSproc_WithArgs_MultipleTypes() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spArgs", + Body = @"function run(s, n, b) { getContext().getResponse().setBody(s + '-' + n + '-' + b); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spArgs", new PartitionKey("p"), new dynamic[] { "hi", 42, true }); - result.Resource.Should().Be("hi-42-true"); - } - - [Fact] - public async Task T2_15_JsSproc_CSharpHandlerPriority_OverJsBody() - { - _container.RegisterStoredProcedure("spover", (pk, args) => "csharp-wins"); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spover", - Body = "function run() { getContext().getResponse().setBody('js-body'); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spover", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("csharp-wins"); - } + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spArgs", new PartitionKey("p"), new dynamic[] { "hi", 42, true }); + result.Resource.Should().Be("hi-42-true"); + } + + [Fact] + public async Task T2_15_JsSproc_CSharpHandlerPriority_OverJsBody() + { + _container.RegisterStoredProcedure("spover", (pk, args) => "csharp-wins"); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spover", + Body = "function run() { getContext().getResponse().setBody('js-body'); }" + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spover", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("csharp-wins"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -488,153 +488,153 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class CollectionContextEdgeCaseTests { - private readonly InMemoryContainer _container = new("ctx-container", "/pk"); - - [Fact] - public async Task T3_1_CollectionContext_CreateDocument_ReturnsDoc() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spEnrich", - Body = @"function run() { + private readonly InMemoryContainer _container = new("ctx-container", "/pk"); + + [Fact] + public async Task T3_1_CollectionContext_CreateDocument_ReturnsDoc() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spEnrich", + Body = @"function run() { var coll = getContext().getCollection(); coll.createDocument(coll.getSelfLink(), {id: 'e1', pk: 'p'}, function(err, doc) { getContext().getResponse().setBody(JSON.stringify({hasId: doc.id !== undefined})); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spEnrich", new PartitionKey("p"), Array.Empty()); - ((bool)result.Resource["hasId"]!).Should().BeTrue(); - - // The persisted document should have system properties - var persisted = await _container.ReadItemAsync("e1", new PartitionKey("p")); - persisted.Resource.ContainsKey("_ts").Should().BeTrue(); - persisted.Resource.ContainsKey("_etag").Should().BeTrue(); - } - - [Fact] - public async Task T3_2_CollectionContext_ReadDocument_NotFound_ThrowsException() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReadMissing", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spEnrich", new PartitionKey("p"), Array.Empty()); + ((bool)result.Resource["hasId"]!).Should().BeTrue(); + + // The persisted document should have system properties + var persisted = await _container.ReadItemAsync("e1", new PartitionKey("p")); + persisted.Resource.ContainsKey("_ts").Should().BeTrue(); + persisted.Resource.ContainsKey("_etag").Should().BeTrue(); + } + + [Fact] + public async Task T3_2_CollectionContext_ReadDocument_NotFound_ThrowsException() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReadMissing", + Body = @"function run() { var coll = getContext().getCollection(); coll.readDocument(coll.getSelfLink() + '/docs/nonexistent', function(err, doc) { getContext().getResponse().setBody('ok'); }); }" - }); - - // ReadDocument on non-existent will throw inside the sproc - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spReadMissing", new PartitionKey("p"), Array.Empty()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T3_3_CollectionContext_QueryDocuments_EmptyResult() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spQueryEmpty", - Body = @"function run() { + }); + + // ReadDocument on non-existent will throw inside the sproc + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spReadMissing", new PartitionKey("p"), Array.Empty()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T3_3_CollectionContext_QueryDocuments_EmptyResult() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spQueryEmpty", + Body = @"function run() { var coll = getContext().getCollection(); coll.queryDocuments(coll.getSelfLink(), 'SELECT * FROM c WHERE c.id = ""nonexistent""', function(err, docs) { getContext().getResponse().setBody(JSON.stringify(docs.length)); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spQueryEmpty", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("0"); - } - - [Fact] - public async Task T3_4_CollectionContext_ReplaceDocument_NotFound_ThrowsException() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplaceNotFound", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spQueryEmpty", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("0"); + } + + [Fact] + public async Task T3_4_CollectionContext_ReplaceDocument_NotFound_ThrowsException() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplaceNotFound", + Body = @"function run() { var coll = getContext().getCollection(); coll.replaceDocument(coll.getSelfLink() + '/docs/nope', {id: 'nope', pk: 'p'}); getContext().getResponse().setBody('done'); }" - }); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spReplaceNotFound", new PartitionKey("p"), Array.Empty()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T3_5_CollectionContext_DeleteDocument_NotFound_ThrowsException() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDelNotFound", - Body = @"function run() { + }); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spReplaceNotFound", new PartitionKey("p"), Array.Empty()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T3_5_CollectionContext_DeleteDocument_NotFound_ThrowsException() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDelNotFound", + Body = @"function run() { var coll = getContext().getCollection(); coll.deleteDocument(coll.getSelfLink() + '/docs/nope'); getContext().getResponse().setBody('done'); }" - }); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spDelNotFound", new PartitionKey("p"), Array.Empty()); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T3_6_CollectionContext_SelfLink_MatchesExpectedFormat() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spLink", - Body = @"function run() { + }); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spDelNotFound", new PartitionKey("p"), Array.Empty()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T3_6_CollectionContext_SelfLink_MatchesExpectedFormat() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spLink", + Body = @"function run() { var coll = getContext().getCollection(); getContext().getResponse().setBody(coll.getSelfLink()); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spLink", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Contain("colls/ctx-container"); - } - - [Fact] - public async Task T3_7_CollectionContext_QueryDocuments_MultiplePages() - { - _container.UseJsStoredProcedures(); - for (int i = 0; i < 10; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"item{i}", pk = "p", val = i }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spQueryMulti", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spLink", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Contain("colls/ctx-container"); + } + + [Fact] + public async Task T3_7_CollectionContext_QueryDocuments_MultiplePages() + { + _container.UseJsStoredProcedures(); + for (int i = 0; i < 10; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"item{i}", pk = "p", val = i }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spQueryMulti", + Body = @"function run() { var coll = getContext().getCollection(); coll.queryDocuments(coll.getSelfLink(), 'SELECT * FROM c', function(err, docs) { getContext().getResponse().setBody(JSON.stringify(docs.length)); }); }" - }); + }); - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spQueryMulti", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("10"); - } + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spQueryMulti", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("10"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -643,68 +643,68 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class JsUdfConvertResultTests { - private readonly InMemoryContainer _container = new("convert-container", "/pk"); - - public JsUdfConvertResultTests() - { - _container.UseJsTriggers(); - } - - [Fact] - public async Task T4_1_JsUdf_ReturnsUndefined_ConvertsToNull() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "undef", Body = "function undef() { return undefined; }" }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.undef() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task T4_2_JsUdf_ReturnsInteger_ConvertsToNumber() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "intfn", Body = "function intfn() { return 42; }" }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.intfn() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(42); - } - - [Fact] - public async Task T4_3_JsUdf_ReturnsFloat_ConvertsToDouble() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "fltfn", Body = "function fltfn() { return 3.14; }" }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.fltfn() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().BeApproximately(3.14, 0.001); - } - - [Fact] - public async Task T4_4_JsUdf_ReturnsString_PassesThrough() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "strfn", Body = "function strfn() { return 'hello'; }" }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE udf.strfn() FROM c")); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be("hello"); - } + private readonly InMemoryContainer _container = new("convert-container", "/pk"); + + public JsUdfConvertResultTests() + { + _container.UseJsTriggers(); + } + + [Fact] + public async Task T4_1_JsUdf_ReturnsUndefined_ConvertsToNull() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "undef", Body = "function undef() { return undefined; }" }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.undef() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task T4_2_JsUdf_ReturnsInteger_ConvertsToNumber() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "intfn", Body = "function intfn() { return 42; }" }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.intfn() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(42); + } + + [Fact] + public async Task T4_3_JsUdf_ReturnsFloat_ConvertsToDouble() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "fltfn", Body = "function fltfn() { return 3.14; }" }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.fltfn() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().BeApproximately(3.14, 0.001); + } + + [Fact] + public async Task T4_4_JsUdf_ReturnsString_PassesThrough() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "strfn", Body = "function strfn() { return 'hello'; }" }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE udf.strfn() FROM c")); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be("hello"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -713,144 +713,144 @@ await _container.Scripts.CreateUserDefinedFunctionAsync( public class TriggerExecutionEdgeCaseDeepTests { - private readonly InMemoryContainer _container = new("trig-edge", "/pk"); - - public TriggerExecutionEdgeCaseDeepTests() - { - _container.UseJsTriggers(); - } - - [Fact] - public async Task T5_2_PreTrigger_Js_PreservesSystemProperties() - { - // Pre-trigger can't clobber system properties — enrichment happens after trigger - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "overwrite", - Body = "function overwrite() { var req = getContext().getRequest(); var doc = req.getBody(); doc._ts = 0; doc._etag = 'fake'; req.setBody(doc); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "overwrite" } }); - - // System enrichment should override the trigger's attempted values - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - var ts = (long)item.Resource["_ts"]!; - ts.Should().BeGreaterThan(0); // Not the 0 set by trigger - } - - [Fact] - public async Task T5_3_PreTrigger_Js_EmptyPreTriggersList_NoEffect() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "shouldNotFire", - Body = "function shouldNotFire() { var req = getContext().getRequest(); var doc = req.getBody(); doc.triggerFired = true; req.setBody(doc); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List() }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - item.Resource.ContainsKey("triggerFired").Should().BeFalse(); - } - - [Fact] - public async Task T5_6_PostTrigger_Js_EmptyPostTriggersList_NoEffect() - { - var counter = 0; - _container.RegisterTrigger("postCount", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => Interlocked.Increment(ref counter))); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PostTriggers = new List() }); - - counter.Should().Be(0); - } - - [Fact] - public async Task T5_10_PostTrigger_Js_ThrowCosmosException_PropagatesDirectly() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "postThrow", - Body = "function postThrow() { throw new Error('post boom'); }", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All - }); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PostTriggers = new List { "postThrow" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task T5_11_CSharpPostHandler_TakesPriority_OverJsBody() - { - var csharpFired = false; - _container.RegisterTrigger("priority", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => csharpFired = true)); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "priority", - Body = "function priority() { throw new Error('should not run'); }", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PostTriggers = new List { "priority" } }); - - csharpFired.Should().BeTrue(); - } - - [Fact] - public async Task T5_12_PreTrigger_CSharp_ThrowsException_BlocksWrite() - { - _container.RegisterTrigger("preBlock", - TriggerType.Pre, TriggerOperation.All, - (Func)(doc => throw new InvalidOperationException("blocked"))); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "preBlock" } }); - - await act.Should().ThrowAsync(); - // Item should not exist - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("p")); - await readAct.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task T5_13_PostTrigger_CSharp_ThrowsException_RollsBackWrite() - { - _container.RegisterTrigger("postRollback", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => throw new InvalidOperationException("rollback"))); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PostTriggers = new List { "postRollback" } }); - - await act.Should().ThrowAsync(); - // Item should have been rolled back - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("p")); - await readAct.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("trig-edge", "/pk"); + + public TriggerExecutionEdgeCaseDeepTests() + { + _container.UseJsTriggers(); + } + + [Fact] + public async Task T5_2_PreTrigger_Js_PreservesSystemProperties() + { + // Pre-trigger can't clobber system properties — enrichment happens after trigger + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "overwrite", + Body = "function overwrite() { var req = getContext().getRequest(); var doc = req.getBody(); doc._ts = 0; doc._etag = 'fake'; req.setBody(doc); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "overwrite" } }); + + // System enrichment should override the trigger's attempted values + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + var ts = (long)item.Resource["_ts"]!; + ts.Should().BeGreaterThan(0); // Not the 0 set by trigger + } + + [Fact] + public async Task T5_3_PreTrigger_Js_EmptyPreTriggersList_NoEffect() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "shouldNotFire", + Body = "function shouldNotFire() { var req = getContext().getRequest(); var doc = req.getBody(); doc.triggerFired = true; req.setBody(doc); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List() }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + item.Resource.ContainsKey("triggerFired").Should().BeFalse(); + } + + [Fact] + public async Task T5_6_PostTrigger_Js_EmptyPostTriggersList_NoEffect() + { + var counter = 0; + _container.RegisterTrigger("postCount", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => Interlocked.Increment(ref counter))); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PostTriggers = new List() }); + + counter.Should().Be(0); + } + + [Fact] + public async Task T5_10_PostTrigger_Js_ThrowCosmosException_PropagatesDirectly() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "postThrow", + Body = "function postThrow() { throw new Error('post boom'); }", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All + }); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PostTriggers = new List { "postThrow" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task T5_11_CSharpPostHandler_TakesPriority_OverJsBody() + { + var csharpFired = false; + _container.RegisterTrigger("priority", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => csharpFired = true)); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "priority", + Body = "function priority() { throw new Error('should not run'); }", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PostTriggers = new List { "priority" } }); + + csharpFired.Should().BeTrue(); + } + + [Fact] + public async Task T5_12_PreTrigger_CSharp_ThrowsException_BlocksWrite() + { + _container.RegisterTrigger("preBlock", + TriggerType.Pre, TriggerOperation.All, + (Func)(doc => throw new InvalidOperationException("blocked"))); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "preBlock" } }); + + await act.Should().ThrowAsync(); + // Item should not exist + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("p")); + await readAct.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task T5_13_PostTrigger_CSharp_ThrowsException_RollsBackWrite() + { + _container.RegisterTrigger("postRollback", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => throw new InvalidOperationException("rollback"))); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PostTriggers = new List { "postRollback" } }); + + await act.Should().ThrowAsync(); + // Item should have been rolled back + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("p")); + await readAct.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -859,40 +859,40 @@ public async Task T5_13_PostTrigger_CSharp_ThrowsException_RollsBackWrite() public class WireCollectionContextEdgeCaseTests { - private readonly InMemoryContainer _container = new("wire-ctx", "/pk"); - - public WireCollectionContextEdgeCaseTests() - { - _container.UseJsStoredProcedures(); - } - - [Fact] - public async Task T6_1_WireCollectionContext_NullContext_ProvidesStubbedCollection() - { - // When sproc executes without collection context, getSelfLink returns "" - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spNull", - Body = @"function run() { + private readonly InMemoryContainer _container = new("wire-ctx", "/pk"); + + public WireCollectionContextEdgeCaseTests() + { + _container.UseJsStoredProcedures(); + } + + [Fact] + public async Task T6_1_WireCollectionContext_NullContext_ProvidesStubbedCollection() + { + // When sproc executes without collection context, getSelfLink returns "" + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spNull", + Body = @"function run() { var coll = getContext().getCollection(); getContext().getResponse().setBody(coll.getSelfLink()); }" - }); - - // For a sproc with UseJsStoredProcedures, context IS provided, - // so getSelfLink() should return the container self link - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spNull", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Contain("wire-ctx"); - } - - [Fact] - public async Task T6_2_WireCollectionContext_CreateDocument_CallbackAsThirdArg() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spCb3", - Body = @"function run() { + }); + + // For a sproc with UseJsStoredProcedures, context IS provided, + // so getSelfLink() should return the container self link + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spNull", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Contain("wire-ctx"); + } + + [Fact] + public async Task T6_2_WireCollectionContext_CreateDocument_CallbackAsThirdArg() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spCb3", + Body = @"function run() { var coll = getContext().getCollection(); var created; coll.createDocument(coll.getSelfLink(), {id: 'cb3', pk: 'p'}, function(err, doc) { @@ -900,80 +900,80 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie }); getContext().getResponse().setBody(JSON.stringify({id: created.id})); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCb3", new PartitionKey("p"), Array.Empty()); - result.Resource["id"]!.ToString().Should().Be("cb3"); - } - - [Fact] - public async Task T6_3_WireCollectionContext_ReplaceDocument_UsesDocId() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "rep1", pk = "p", val = 1 }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spRepId", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCb3", new PartitionKey("p"), Array.Empty()); + result.Resource["id"]!.ToString().Should().Be("cb3"); + } + + [Fact] + public async Task T6_3_WireCollectionContext_ReplaceDocument_UsesDocId() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "rep1", pk = "p", val = 1 }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spRepId", + Body = @"function run() { var coll = getContext().getCollection(); coll.replaceDocument(coll.getSelfLink() + '/docs/rep1', {id: 'rep1', pk: 'p', val: 100}); getContext().getResponse().setBody('done'); }" - }); + }); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spRepId", new PartitionKey("p"), Array.Empty()); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spRepId", new PartitionKey("p"), Array.Empty()); - var item = await _container.ReadItemAsync("rep1", new PartitionKey("p")); - ((int)item.Resource["val"]!).Should().Be(100); - } + var item = await _container.ReadItemAsync("rep1", new PartitionKey("p")); + ((int)item.Resource["val"]!).Should().Be(100); + } - [Fact] - public async Task T6_4_WireCollectionContext_ReadDocument_ExtractsIdFromLink() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "read1", pk = "p", msg = "hi" }), new PartitionKey("p")); + [Fact] + public async Task T6_4_WireCollectionContext_ReadDocument_ExtractsIdFromLink() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "read1", pk = "p", msg = "hi" }), new PartitionKey("p")); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReadLink", - Body = @"function run() { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReadLink", + Body = @"function run() { var coll = getContext().getCollection(); coll.readDocument(coll.getSelfLink() + '/docs/read1', function(err, doc) { getContext().getResponse().setBody(doc.msg); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spReadLink", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("hi"); - } - - [Fact] - public async Task T6_5_WireCollectionContext_DeleteDocument_ExtractsIdFromLink() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "del1", pk = "p" }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDelLink", - Body = @"function run() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spReadLink", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("hi"); + } + + [Fact] + public async Task T6_5_WireCollectionContext_DeleteDocument_ExtractsIdFromLink() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "del1", pk = "p" }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDelLink", + Body = @"function run() { var coll = getContext().getCollection(); coll.deleteDocument(coll.getSelfLink() + '/docs/del1'); getContext().getResponse().setBody('ok'); }" - }); + }); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spDelLink", new PartitionKey("p"), Array.Empty()); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spDelLink", new PartitionKey("p"), Array.Empty()); - var act = () => _container.ReadItemAsync("del1", new PartitionKey("p")); - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + var act = () => _container.ReadItemAsync("del1", new PartitionKey("p")); + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -982,140 +982,146 @@ await _container.Scripts.ExecuteStoredProcedureAsync( public class TriggerCrudStreamApiTests { - private readonly InMemoryContainer _container = new("stream-trig", "/pk"); - - [Fact] - public async Task T7_1_CreateTriggerStreamAsync_ReturnsCreatedStatus() - { - var props = new TriggerProperties - { - Id = "t1", - Body = "function t1() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }; - - var response = await _container.Scripts.CreateTriggerStreamAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task T7_2_ReadTriggerStreamAsync_ReturnsOk() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "tRead", - Body = "function tRead() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var response = await _container.Scripts.ReadTriggerStreamAsync("tRead"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task T7_3_ReplaceTriggerStreamAsync_ReturnsOk() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "tRep", - Body = "function tRep() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var response = await _container.Scripts.ReplaceTriggerStreamAsync(new TriggerProperties - { - Id = "tRep", - Body = "function tRep() { /* updated */ }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task T7_4_DeleteTriggerStreamAsync_ReturnsNoContent() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "tDel", - Body = "function tDel() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var response = await _container.Scripts.DeleteTriggerStreamAsync("tDel"); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task T7_5_CreateTriggerStreamAsync_Duplicate_ReturnsConflict() - { - var props = new TriggerProperties - { - Id = "tDup", - Body = "function tDup() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }; - - await _container.Scripts.CreateTriggerStreamAsync(props); - var response = await _container.Scripts.CreateTriggerStreamAsync(props); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task T7_6_ReadTriggerStreamAsync_NotFound_ReturnsNotFound() - { - var response = await _container.Scripts.ReadTriggerStreamAsync("nonexistent"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task T7_7_ReplaceTriggerStreamAsync_NotFound_ReturnsNotFound() - { - var response = await _container.Scripts.ReplaceTriggerStreamAsync(new TriggerProperties - { - Id = "nonexistent", - Body = "function x() {}", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task T7_8_DeleteTriggerStreamAsync_NotFound_ReturnsNotFound() - { - var response = await _container.Scripts.DeleteTriggerStreamAsync("nonexistent"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task T7_9_GetTriggerQueryIterator_ReturnsAll() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "tA", Body = "function tA() {}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All - }); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "tB", Body = "function tB() {}", TriggerType = TriggerType.Post, TriggerOperation = TriggerOperation.All - }); - - var iterator = _container.Scripts.GetTriggerQueryIterator(); - var all = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - all.AddRange(page); - } - - all.Should().HaveCount(2); - all.Select(t => t.Id).Should().BeEquivalentTo("tA", "tB"); - } + private readonly InMemoryContainer _container = new("stream-trig", "/pk"); + + [Fact] + public async Task T7_1_CreateTriggerStreamAsync_ReturnsCreatedStatus() + { + var props = new TriggerProperties + { + Id = "t1", + Body = "function t1() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }; + + var response = await _container.Scripts.CreateTriggerStreamAsync(props); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task T7_2_ReadTriggerStreamAsync_ReturnsOk() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "tRead", + Body = "function tRead() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var response = await _container.Scripts.ReadTriggerStreamAsync("tRead"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task T7_3_ReplaceTriggerStreamAsync_ReturnsOk() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "tRep", + Body = "function tRep() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var response = await _container.Scripts.ReplaceTriggerStreamAsync(new TriggerProperties + { + Id = "tRep", + Body = "function tRep() { /* updated */ }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task T7_4_DeleteTriggerStreamAsync_ReturnsNoContent() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "tDel", + Body = "function tDel() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var response = await _container.Scripts.DeleteTriggerStreamAsync("tDel"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task T7_5_CreateTriggerStreamAsync_Duplicate_ReturnsConflict() + { + var props = new TriggerProperties + { + Id = "tDup", + Body = "function tDup() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }; + + await _container.Scripts.CreateTriggerStreamAsync(props); + var response = await _container.Scripts.CreateTriggerStreamAsync(props); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task T7_6_ReadTriggerStreamAsync_NotFound_ReturnsNotFound() + { + var response = await _container.Scripts.ReadTriggerStreamAsync("nonexistent"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task T7_7_ReplaceTriggerStreamAsync_NotFound_ReturnsNotFound() + { + var response = await _container.Scripts.ReplaceTriggerStreamAsync(new TriggerProperties + { + Id = "nonexistent", + Body = "function x() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task T7_8_DeleteTriggerStreamAsync_NotFound_ReturnsNotFound() + { + var response = await _container.Scripts.DeleteTriggerStreamAsync("nonexistent"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task T7_9_GetTriggerQueryIterator_ReturnsAll() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "tA", + Body = "function tA() {}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "tB", + Body = "function tB() {}", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All + }); + + var iterator = _container.Scripts.GetTriggerQueryIterator(); + var all = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + all.AddRange(page); + } + + all.Should().HaveCount(2); + all.Select(t => t.Id).Should().BeEquivalentTo("tA", "tB"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1124,55 +1130,55 @@ await _container.Scripts.CreateTriggerAsync(new TriggerProperties public class UseJsExtensionDeepTests { - [Fact] - public void T8_1_UseJsStoredProcedures_SetsSprocEngine() - { - var container = new InMemoryContainer("ext1", "/pk"); - container.SprocEngine.Should().BeNull(); - - container.UseJsStoredProcedures(); - container.SprocEngine.Should().NotBeNull(); - container.SprocEngine.Should().BeOfType(); - } - - [Fact] - public void T8_2_UseJsStoredProcedures_ReturnsSameContainer() - { - var container = new InMemoryContainer("ext2", "/pk"); - var returned = container.UseJsStoredProcedures(); - returned.Should().BeSameAs(container); - } - - [Fact] - public void T8_3_UseJsTriggers_CalledTwice_ReplacesEngine() - { - var container = new InMemoryContainer("ext3", "/pk"); - container.UseJsTriggers(); - var first = container.JsTriggerEngine; - container.UseJsTriggers(); - var second = container.JsTriggerEngine; - first.Should().NotBeSameAs(second); - } - - [Fact] - public void T8_4_UseJsStoredProcedures_CalledTwice_ReplacesEngine() - { - var container = new InMemoryContainer("ext4", "/pk"); - container.UseJsStoredProcedures(); - var first = container.SprocEngine; - container.UseJsStoredProcedures(); - var second = container.SprocEngine; - first.Should().NotBeSameAs(second); - } - - [Fact] - public void T8_5_UseJsTriggers_AlsoSetsAsUdfEngine() - { - var container = new InMemoryContainer("ext5", "/pk"); - container.UseJsTriggers(); - // JintTriggerEngine implements IJsUdfEngine - container.JsTriggerEngine.Should().BeAssignableTo(); - } + [Fact] + public void T8_1_UseJsStoredProcedures_SetsSprocEngine() + { + var container = new InMemoryContainer("ext1", "/pk"); + container.SprocEngine.Should().BeNull(); + + container.UseJsStoredProcedures(); + container.SprocEngine.Should().NotBeNull(); + container.SprocEngine.Should().BeOfType(); + } + + [Fact] + public void T8_2_UseJsStoredProcedures_ReturnsSameContainer() + { + var container = new InMemoryContainer("ext2", "/pk"); + var returned = container.UseJsStoredProcedures(); + returned.Should().BeSameAs(container); + } + + [Fact] + public void T8_3_UseJsTriggers_CalledTwice_ReplacesEngine() + { + var container = new InMemoryContainer("ext3", "/pk"); + container.UseJsTriggers(); + var first = container.JsTriggerEngine; + container.UseJsTriggers(); + var second = container.JsTriggerEngine; + first.Should().NotBeSameAs(second); + } + + [Fact] + public void T8_4_UseJsStoredProcedures_CalledTwice_ReplacesEngine() + { + var container = new InMemoryContainer("ext4", "/pk"); + container.UseJsStoredProcedures(); + var first = container.SprocEngine; + container.UseJsStoredProcedures(); + var second = container.SprocEngine; + first.Should().NotBeSameAs(second); + } + + [Fact] + public void T8_5_UseJsTriggers_AlsoSetsAsUdfEngine() + { + var container = new InMemoryContainer("ext5", "/pk"); + container.UseJsTriggers(); + // JintTriggerEngine implements IJsUdfEngine + container.JsTriggerEngine.Should().BeAssignableTo(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1181,82 +1187,82 @@ public void T8_5_UseJsTriggers_AlsoSetsAsUdfEngine() public class JsTriggerDivergentDeepTests { - private readonly InMemoryContainer _container = new("div-trig", "/pk"); - - public JsTriggerDivergentDeepTests() - { - _container.UseJsTriggers(); - } - - [Fact] - public async Task T9_1_Divergent_PreTrigger_Js_GetResponse_ThrowsError() - { - // In the emulator, getResponse() in pre-trigger throws an error - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "preResp", - Body = "function preResp() { getContext().getResponse(); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "preResp" } }); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task T9_3_Divergent_MultiplePreTriggers_OnlyFirstFires() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pre1", - Body = "function pre1() { var r = getContext().getRequest(); var d = r.getBody(); d.pre1 = true; r.setBody(d); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pre2", - Body = "function pre2() { var r = getContext().getRequest(); var d = r.getBody(); d.pre2 = true; r.setBody(d); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - // Only first trigger in the list fires in emulator - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "pre1", "pre2" } }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - item.Resource.ContainsKey("pre1").Should().BeTrue(); - // pre2 may or may not fire depending on emulator implementation - } - - [Fact] - public async Task T9_5_Divergent_PatchTrigger_FiresAsReplace() - { - // Patch triggers fire on replace-only trigger since patch uses "Replace" operation - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "onReplace", - Body = "function onReplace() { var r = getContext().getRequest(); var d = r.getBody(); d.triggered = true; r.setBody(d); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Replace - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); - - await _container.PatchItemAsync("1", new PartitionKey("p"), - new[] { PatchOperation.Set("/val", 2) }, - new PatchItemRequestOptions { PreTriggers = new List { "onReplace" } }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - ((int)item.Resource["val"]!).Should().Be(2); - } + private readonly InMemoryContainer _container = new("div-trig", "/pk"); + + public JsTriggerDivergentDeepTests() + { + _container.UseJsTriggers(); + } + + [Fact] + public async Task T9_1_Divergent_PreTrigger_Js_GetResponse_ThrowsError() + { + // In the emulator, getResponse() in pre-trigger throws an error + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "preResp", + Body = "function preResp() { getContext().getResponse(); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "preResp" } }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task T9_3_Divergent_MultiplePreTriggers_OnlyFirstFires() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pre1", + Body = "function pre1() { var r = getContext().getRequest(); var d = r.getBody(); d.pre1 = true; r.setBody(d); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pre2", + Body = "function pre2() { var r = getContext().getRequest(); var d = r.getBody(); d.pre2 = true; r.setBody(d); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + // Only first trigger in the list fires in emulator + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "pre1", "pre2" } }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + item.Resource.ContainsKey("pre1").Should().BeTrue(); + // pre2 may or may not fire depending on emulator implementation + } + + [Fact] + public async Task T9_5_Divergent_PatchTrigger_FiresAsReplace() + { + // Patch triggers fire on replace-only trigger since patch uses "Replace" operation + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "onReplace", + Body = "function onReplace() { var r = getContext().getRequest(); var d = r.getBody(); d.triggered = true; r.setBody(d); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Replace + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); + + await _container.PatchItemAsync("1", new PartitionKey("p"), + new[] { PatchOperation.Set("/val", 2) }, + new PatchItemRequestOptions { PreTriggers = new List { "onReplace" } }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + ((int)item.Resource["val"]!).Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1266,25 +1272,25 @@ await _container.CreateItemAsync( public class JsSprocQueryResponseOptionsTests { - private readonly InMemoryContainer _container = new("query-response-opts", "/pk"); - - public JsSprocQueryResponseOptionsTests() - { - _container.UseJsStoredProcedures(); - } - - [Fact] - public async Task Issue22_QueryDocuments_ResponseOptions_IsNotNull() - { - // Seed a document - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 10 }), new PartitionKey("p")); - - // SP that accesses responseOptions.continuation — should NOT throw - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "checkResponseOpts", - Body = @"function checkResponseOpts() { + private readonly InMemoryContainer _container = new("query-response-opts", "/pk"); + + public JsSprocQueryResponseOptionsTests() + { + _container.UseJsStoredProcedures(); + } + + [Fact] + public async Task Issue22_QueryDocuments_ResponseOptions_IsNotNull() + { + // Seed a document + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 10 }), new PartitionKey("p")); + + // SP that accesses responseOptions.continuation — should NOT throw + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "checkResponseOpts", + Body = @"function checkResponseOpts() { var coll = getContext().getCollection(); coll.queryDocuments( coll.getSelfLink(), @@ -1304,30 +1310,30 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie } ); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "checkResponseOpts", new PartitionKey("p"), Array.Empty()); - - var parsed = JObject.Parse(result.Resource); - ((int)parsed["docCount"]!).Should().Be(1); - ((bool)parsed["hasContinuation"]!).Should().BeTrue(); - ((bool)parsed["continuationIsNull"]!).Should().BeTrue(); - } - - [Fact] - public async Task Issue22_QueryDocuments_WithOptsObject_CallbackReceivesResponseOptions() - { - // The 4-argument form: queryDocuments(link, query, opts, callback) - await _container.CreateItemAsync( - JObject.FromObject(new { id = "a", pk = "p" }), new PartitionKey("p")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "b", pk = "p" }), new PartitionKey("p")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "withOpts", - Body = @"function withOpts() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "checkResponseOpts", new PartitionKey("p"), Array.Empty()); + + var parsed = JObject.Parse(result.Resource); + ((int)parsed["docCount"]!).Should().Be(1); + ((bool)parsed["hasContinuation"]!).Should().BeTrue(); + ((bool)parsed["continuationIsNull"]!).Should().BeTrue(); + } + + [Fact] + public async Task Issue22_QueryDocuments_WithOptsObject_CallbackReceivesResponseOptions() + { + // The 4-argument form: queryDocuments(link, query, opts, callback) + await _container.CreateItemAsync( + JObject.FromObject(new { id = "a", pk = "p" }), new PartitionKey("p")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "b", pk = "p" }), new PartitionKey("p")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "withOpts", + Body = @"function withOpts() { var coll = getContext().getCollection(); coll.queryDocuments( coll.getSelfLink(), @@ -1344,26 +1350,26 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie } ); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "withOpts", new PartitionKey("p"), Array.Empty()); - result.Resource.Should().Be("2"); - } - - [Fact] - public async Task Issue23_BulkDelete_WithRecursion_CompletesSuccessfully() - { - // Seed 50 documents (enough to validate recursion pattern works) - for (int i = 0; i < 50; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"item{i}", pk = "p" }), new PartitionKey("p")); - - // Standard Cosmos DB recursive bulk delete SP pattern - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "bulkDelete", - Body = @"function bulkDelete(query) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "withOpts", new PartitionKey("p"), Array.Empty()); + result.Resource.Should().Be("2"); + } + + [Fact] + public async Task Issue23_BulkDelete_WithRecursion_CompletesSuccessfully() + { + // Seed 50 documents (enough to validate recursion pattern works) + for (int i = 0; i < 50; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"item{i}", pk = "p" }), new PartitionKey("p")); + + // Standard Cosmos DB recursive bulk delete SP pattern + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "bulkDelete", + Body = @"function bulkDelete(query) { var context = getContext(); var collection = context.getCollection(); var response = context.getResponse(); @@ -1394,34 +1400,34 @@ function tryDelete(continuation) { } tryDelete(null); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "bulkDelete", new PartitionKey("p"), new dynamic[] { "SELECT * FROM c" }); - - result.Resource.Should().Be("50"); - - // Verify all documents are deleted - var iter = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var remaining = new List(); - while (iter.HasMoreResults) remaining.AddRange(await iter.ReadNextAsync()); - remaining.Should().BeEmpty(); - } - - [Fact] - public async Task Issue23_BulkDelete_200Documents_NoStackOverflow() - { - // Seed 200 documents — the exact scenario from the issue - for (int i = 0; i < 200; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "p1" }), new PartitionKey("p1")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "BulkDelete", - Body = @"function bulkDelete(query) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "bulkDelete", new PartitionKey("p"), new dynamic[] { "SELECT * FROM c" }); + + result.Resource.Should().Be("50"); + + // Verify all documents are deleted + var iter = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var remaining = new List(); + while (iter.HasMoreResults) remaining.AddRange(await iter.ReadNextAsync()); + remaining.Should().BeEmpty(); + } + + [Fact] + public async Task Issue23_BulkDelete_200Documents_NoStackOverflow() + { + // Seed 200 documents — the exact scenario from the issue + for (int i = 0; i < 200; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "p1" }), new PartitionKey("p1")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "BulkDelete", + Body = @"function bulkDelete(query) { var collection = getContext().getCollection(); var response = getContext().getResponse(); var count = 0; @@ -1448,14 +1454,14 @@ function tryDelete(continuation) { } tryDelete(null); }" - }); + }); - // This should NOT throw StackOverflowException - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "BulkDelete", new PartitionKey("p1"), new dynamic[] { "SELECT * FROM c" }); + // This should NOT throw StackOverflowException + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "BulkDelete", new PartitionKey("p1"), new dynamic[] { "SELECT * FROM c" }); - result.Resource.Should().Be("200"); - } + result.Resource.Should().Be("200"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1464,106 +1470,106 @@ function tryDelete(continuation) { public class JsTriggerBugFixTests { - private readonly InMemoryContainer _container = new("bugfix-trig", "/pk"); - - [Fact] - public async Task T10_1_PreTrigger_Js_VarFunction_NotInvoked() - { - // The InvokeFirstFunction regex requires `function name(`, so - // `var f = function() {}` won't be invoked - _container.UseJsTriggers(); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "varFn", - Body = "var f = function() { var r = getContext().getRequest(); var d = r.getBody(); d.triggered = true; r.setBody(d); };", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "varFn" } }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - item.Resource.ContainsKey("triggered").Should().BeFalse(); - } - - [Fact] - public async Task T10_2_PostTrigger_Reference_PreOnlyTrigger_Succeeds() - { - // Emulator behavior: referencing a pre-only C# trigger in PostTriggers - // doesn't throw — it silently skips (the C# handler type check passes it over) - _container.RegisterTrigger("preOnly", - TriggerType.Pre, TriggerOperation.All, - (Func)(doc => doc)); - - // Should not throw — the pre-only trigger is skipped for post execution - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PostTriggers = new List { "preOnly" } }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - item.Resource["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task T10_3_PreTrigger_Reference_PostOnlyTrigger_Succeeds() - { - // Emulator behavior: referencing a post-only C# trigger in PreTriggers - // doesn't throw — it silently skips - _container.RegisterTrigger("postOnly", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => { })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "postOnly" } }); - - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - item.Resource["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task T10_5_PreTrigger_StreamApi_EmptyBody_NoOp() - { - _container.UseJsTriggers(); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "emptyBody", - Body = "", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - // Empty JS body should be a no-op (or gracefully handled) - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), - new ItemRequestOptions { PreTriggers = new List { "emptyBody" } }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task T10_6_JsSproc_ConcurrentExecution_LogsNotCorrupted() - { - // BUG: JintSprocEngine._capturedLogs is replaced per call, - // so concurrent calls could lose logs - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spLog", - Body = "function run(id) { console.log('log-' + id); getContext().getResponse().setBody(id); }" - }); - - var tasks = Enumerable.Range(1, 10).Select(i => - _container.Scripts.ExecuteStoredProcedureAsync( - "spLog", new PartitionKey("p"), new dynamic[] { i.ToString() })); - - var results = await Task.WhenAll(tasks); - // All should complete without error, even if logs are mixed - results.Should().HaveCount(10); - results.Select(r => r.Resource).Should().OnlyHaveUniqueItems(); - } + private readonly InMemoryContainer _container = new("bugfix-trig", "/pk"); + + [Fact] + public async Task T10_1_PreTrigger_Js_VarFunction_NotInvoked() + { + // The InvokeFirstFunction regex requires `function name(`, so + // `var f = function() {}` won't be invoked + _container.UseJsTriggers(); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "varFn", + Body = "var f = function() { var r = getContext().getRequest(); var d = r.getBody(); d.triggered = true; r.setBody(d); };", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "varFn" } }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + item.Resource.ContainsKey("triggered").Should().BeFalse(); + } + + [Fact] + public async Task T10_2_PostTrigger_Reference_PreOnlyTrigger_Succeeds() + { + // Emulator behavior: referencing a pre-only C# trigger in PostTriggers + // doesn't throw — it silently skips (the C# handler type check passes it over) + _container.RegisterTrigger("preOnly", + TriggerType.Pre, TriggerOperation.All, + (Func)(doc => doc)); + + // Should not throw — the pre-only trigger is skipped for post execution + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PostTriggers = new List { "preOnly" } }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + item.Resource["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task T10_3_PreTrigger_Reference_PostOnlyTrigger_Succeeds() + { + // Emulator behavior: referencing a post-only C# trigger in PreTriggers + // doesn't throw — it silently skips + _container.RegisterTrigger("postOnly", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => { })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "postOnly" } }); + + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + item.Resource["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task T10_5_PreTrigger_StreamApi_EmptyBody_NoOp() + { + _container.UseJsTriggers(); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "emptyBody", + Body = "", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + // Empty JS body should be a no-op (or gracefully handled) + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p"), + new ItemRequestOptions { PreTriggers = new List { "emptyBody" } }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task T10_6_JsSproc_ConcurrentExecution_LogsNotCorrupted() + { + // BUG: JintSprocEngine._capturedLogs is replaced per call, + // so concurrent calls could lose logs + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spLog", + Body = "function run(id) { console.log('log-' + id); getContext().getResponse().setBody(id); }" + }); + + var tasks = Enumerable.Range(1, 10).Select(i => + _container.Scripts.ExecuteStoredProcedureAsync( + "spLog", new PartitionKey("p"), new dynamic[] { i.ToString() })); + + var results = await Task.WhenAll(tasks); + // All should complete without error, even if logs are mixed + results.Should().HaveCount(10); + results.Select(r => r.Resource).Should().OnlyHaveUniqueItems(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1572,106 +1578,106 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class PatchStreamTriggerDeepTests { - private readonly InMemoryContainer _container = new("patch-trig", "/pk"); - - public PatchStreamTriggerDeepTests() - { - _container.UseJsTriggers(); - } - - [Fact] - public async Task T11_1_PatchStream_WithPreTrigger_FiresTrigger() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "prePatch", - Body = "function prePatch() { var r = getContext().getRequest(); var d = r.getBody(); d.prePatched = true; r.setBody(d); }", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); - - var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("p"), - new[] { PatchOperation.Set("/val", 2) }, - new PatchItemRequestOptions { PreTriggers = new List { "prePatch" } }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - ((bool)item.Resource["prePatched"]!).Should().BeTrue(); - } - - [Fact] - public async Task T11_2_PatchStream_WithPostTrigger_FiresTrigger() - { - var postFired = false; - _container.RegisterTrigger("postPatch", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => postFired = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); - - await _container.PatchItemStreamAsync( - "1", new PartitionKey("p"), - new[] { PatchOperation.Set("/val", 2) }, - new PatchItemRequestOptions { PostTriggers = new List { "postPatch" } }); - - postFired.Should().BeTrue(); - } - - [Fact] - public async Task T11_3_PatchStream_PostTrigger_Throws_RollsBack() - { - _container.RegisterTrigger("postFail", - TriggerType.Post, TriggerOperation.All, - (Action)(doc => throw new InvalidOperationException("fail"))); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); - - try - { - await _container.PatchItemStreamAsync( - "1", new PartitionKey("p"), - new[] { PatchOperation.Set("/val", 2) }, - new PatchItemRequestOptions { PostTriggers = new List { "postFail" } }); - } - catch { /* expected */ } - - // Value should be rolled back to 1 - var item = await _container.ReadItemAsync("1", new PartitionKey("p")); - ((int)item.Resource["val"]!).Should().Be(1); - } - - [Fact] - public async Task T11_4_Patch_WithPostTrigger_SetBody_ModifiesResponse() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "postBody", - Body = @"function postBody() { + private readonly InMemoryContainer _container = new("patch-trig", "/pk"); + + public PatchStreamTriggerDeepTests() + { + _container.UseJsTriggers(); + } + + [Fact] + public async Task T11_1_PatchStream_WithPreTrigger_FiresTrigger() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "prePatch", + Body = "function prePatch() { var r = getContext().getRequest(); var d = r.getBody(); d.prePatched = true; r.setBody(d); }", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); + + var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("p"), + new[] { PatchOperation.Set("/val", 2) }, + new PatchItemRequestOptions { PreTriggers = new List { "prePatch" } }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + ((bool)item.Resource["prePatched"]!).Should().BeTrue(); + } + + [Fact] + public async Task T11_2_PatchStream_WithPostTrigger_FiresTrigger() + { + var postFired = false; + _container.RegisterTrigger("postPatch", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => postFired = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); + + await _container.PatchItemStreamAsync( + "1", new PartitionKey("p"), + new[] { PatchOperation.Set("/val", 2) }, + new PatchItemRequestOptions { PostTriggers = new List { "postPatch" } }); + + postFired.Should().BeTrue(); + } + + [Fact] + public async Task T11_3_PatchStream_PostTrigger_Throws_RollsBack() + { + _container.RegisterTrigger("postFail", + TriggerType.Post, TriggerOperation.All, + (Action)(doc => throw new InvalidOperationException("fail"))); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); + + try + { + await _container.PatchItemStreamAsync( + "1", new PartitionKey("p"), + new[] { PatchOperation.Set("/val", 2) }, + new PatchItemRequestOptions { PostTriggers = new List { "postFail" } }); + } + catch { /* expected */ } + + // Value should be rolled back to 1 + var item = await _container.ReadItemAsync("1", new PartitionKey("p")); + ((int)item.Resource["val"]!).Should().Be(1); + } + + [Fact] + public async Task T11_4_Patch_WithPostTrigger_SetBody_ModifiesResponse() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "postBody", + Body = @"function postBody() { var resp = getContext().getResponse(); var body = resp.getBody(); body.modified = true; resp.setBody(body); }", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); - - var response = await _container.PatchItemAsync( - "1", new PartitionKey("p"), - new[] { PatchOperation.Set("/val", 2) }, - new PatchItemRequestOptions { PostTriggers = new List { "postBody" } }); - - // setBody in post-trigger modifies response but not persisted version - var persisted = await _container.ReadItemAsync("1", new PartitionKey("p")); - ((int)persisted.Resource["val"]!).Should().Be(2); - } + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", val = 1 }), new PartitionKey("p")); + + var response = await _container.PatchItemAsync( + "1", new PartitionKey("p"), + new[] { PatchOperation.Set("/val", 2) }, + new PatchItemRequestOptions { PostTriggers = new List { "postBody" } }); + + // setBody in post-trigger modifies response but not persisted version + var persisted = await _container.ReadItemAsync("1", new PartitionKey("p")); + ((int)persisted.Resource["val"]!).Should().Be(2); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerTests.cs index 728b879..94b3c41 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/JsTriggerTests.cs @@ -15,31 +15,31 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class JsTriggerTests { - // ─── Pre-Trigger JS Execution ──────────────────────────────────────── - - public class PreTriggerJsTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task RegisterJsPreTrigger(string id, string jsBody, - TriggerOperation op = TriggerOperation.All) - { - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = id, - TriggerType = TriggerType.Pre, - TriggerOperation = op, - Body = jsBody - }); - } - - [Fact] - public async Task PreTrigger_Js_SetsTimestamp() - { - _container.UseJsTriggers(); - - await RegisterJsPreTrigger("addTimestamp", """ + // ─── Pre-Trigger JS Execution ──────────────────────────────────────── + + public class PreTriggerJsTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task RegisterJsPreTrigger(string id, string jsBody, + TriggerOperation op = TriggerOperation.All) + { + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = id, + TriggerType = TriggerType.Pre, + TriggerOperation = op, + Body = jsBody + }); + } + + [Fact] + public async Task PreTrigger_Js_SetsTimestamp() + { + _container.UseJsTriggers(); + + await RegisterJsPreTrigger("addTimestamp", """ function addTimestamp() { var context = getContext(); var request = context.getRequest(); @@ -49,21 +49,21 @@ function addTimestamp() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addTimestamp" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addTimestamp" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["timestamp"]!.Value().Should().Be("2024-01-01T00:00:00Z"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["timestamp"]!.Value().Should().Be("2024-01-01T00:00:00Z"); + } - [Fact] - public async Task PreTrigger_Js_ModifiesExistingField() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_ModifiesExistingField() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("upperName", """ + await RegisterJsPreTrigger("upperName", """ function upperName() { var context = getContext(); var request = context.getRequest(); @@ -73,21 +73,21 @@ function upperName() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "upperName" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "upperName" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["name"]!.Value().Should().Be("HELLO"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["name"]!.Value().Should().Be("HELLO"); + } - [Fact] - public async Task PreTrigger_Js_AddsMultipleFields() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_AddsMultipleFields() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("enrich", """ + await RegisterJsPreTrigger("enrich", """ function enrich() { var context = getContext(); var request = context.getRequest(); @@ -98,22 +98,22 @@ function enrich() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "enrich" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "enrich" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["createdAt"]!.Value().Should().Be("2024-01-01"); - item["version"]!.Value().Should().Be(1); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["createdAt"]!.Value().Should().Be("2024-01-01"); + item["version"]!.Value().Should().Be(1); + } - [Fact] - public async Task PreTrigger_Js_WithoutSetBody_DocumentUnchanged() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_WithoutSetBody_DocumentUnchanged() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("noop", """ + await RegisterJsPreTrigger("noop", """ function noop() { var context = getContext(); var request = context.getRequest(); @@ -122,21 +122,21 @@ function noop() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "original" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "noop" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "original" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "noop" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["name"]!.Value().Should().Be("original"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["name"]!.Value().Should().Be("original"); + } - [Fact] - public async Task PreTrigger_Js_OperationMismatch_NotFired() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_OperationMismatch_NotFired() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("createOnly", """ + await RegisterJsPreTrigger("createOnly", """ function createOnly() { var context = getContext(); var request = context.getRequest(); @@ -146,26 +146,26 @@ function createOnly() { } """, TriggerOperation.Create); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - // Replace with a Create-only trigger — should NOT fire - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "createOnly" } }); + // Replace with a Create-only trigger — should NOT fire + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "createOnly" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item.ContainsKey("triggered").Should().BeFalse(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item.ContainsKey("triggered").Should().BeFalse(); + } - [Fact] - public async Task PreTrigger_Js_FiresOnUpsert() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_FiresOnUpsert() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("stamp", """ + await RegisterJsPreTrigger("stamp", """ function stamp() { var context = getContext(); var request = context.getRequest(); @@ -175,21 +175,21 @@ function stamp() { } """); - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp" } }); + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamped"]!.Value().Should().BeTrue(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamped"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task PreTrigger_Js_StreamApi_ModifiesDocument() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_StreamApi_ModifiesDocument() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("addField", """ + await RegisterJsPreTrigger("addField", """ function addField() { var context = getContext(); var request = context.getRequest(); @@ -199,22 +199,22 @@ function addField() { } """); - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["added"]!.Value().Should().Be("yes"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["added"]!.Value().Should().Be("yes"); + } - [Fact] - public async Task PreTrigger_MultipleChainedJsTriggers() - { - // Real Cosmos only fires the first matching trigger from the list. - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_MultipleChainedJsTriggers() + { + // Real Cosmos only fires the first matching trigger from the list. + _container.UseJsTriggers(); - await RegisterJsPreTrigger("first", """ + await RegisterJsPreTrigger("first", """ function first() { var context = getContext(); var request = context.getRequest(); @@ -224,7 +224,7 @@ function first() { } """); - await RegisterJsPreTrigger("second", """ + await RegisterJsPreTrigger("second", """ function second() { var context = getContext(); var request = context.getRequest(); @@ -234,85 +234,85 @@ function second() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "first", "second" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "first", "second" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["step1"]!.Value().Should().BeTrue("first trigger should fire"); - item.ContainsKey("step2").Should().BeFalse("second trigger should not fire — real Cosmos only fires first"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["step1"]!.Value().Should().BeTrue("first trigger should fire"); + item.ContainsKey("step2").Should().BeFalse("second trigger should not fire — real Cosmos only fires first"); + } - [Fact] - public async Task PreTrigger_Js_ThrowingTrigger_ThrowsCosmosException() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_ThrowingTrigger_ThrowsCosmosException() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("fail", """ + await RegisterJsPreTrigger("fail", """ function fail() { throw new Error("trigger validation failed"); } """); - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PreTrigger_Js_WithoutUseJsTriggers_ThrowsBadRequest() - { - // NOTE: No UseJsTriggers() call here — the engine is not configured - - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function myTrigger() { }" - }); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Which.Message.Should().Contain("UseJsTriggers"); - } - } - - // ─── Post-Trigger JS Execution ─────────────────────────────────────── - - public class PostTriggerJsTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task RegisterJsPostTrigger(string id, string jsBody, - TriggerOperation op = TriggerOperation.All) - { - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = id, - TriggerType = TriggerType.Post, - TriggerOperation = op, - Body = jsBody - }); - } - - [Fact] - public async Task PostTrigger_Js_CanReadCommittedDocument() - { - _container.UseJsTriggers(); - - await RegisterJsPostTrigger("createAudit", """ + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PreTrigger_Js_WithoutUseJsTriggers_ThrowsBadRequest() + { + // NOTE: No UseJsTriggers() call here — the engine is not configured + + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function myTrigger() { }" + }); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Which.Message.Should().Contain("UseJsTriggers"); + } + } + + // ─── Post-Trigger JS Execution ─────────────────────────────────────── + + public class PostTriggerJsTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task RegisterJsPostTrigger(string id, string jsBody, + TriggerOperation op = TriggerOperation.All) + { + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = id, + TriggerType = TriggerType.Post, + TriggerOperation = op, + Body = jsBody + }); + } + + [Fact] + public async Task PostTrigger_Js_CanReadCommittedDocument() + { + _container.UseJsTriggers(); + + await RegisterJsPostTrigger("createAudit", """ function createAudit() { var context = getContext(); var response = context.getResponse(); @@ -320,45 +320,45 @@ function createAudit() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "createAudit" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "createAudit" } }); - // Verify the item was created successfully (post-trigger ran without error) - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } + // Verify the item was created successfully (post-trigger ran without error) + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } - [Fact] - public async Task PostTrigger_Js_ThrowingTrigger_RollsBackWrite() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_ThrowingTrigger_RollsBackWrite() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("failPost", """ + await RegisterJsPostTrigger("failPost", """ function failPost() { throw new Error("post-trigger failed"); } """); - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failPost" } }); + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failPost" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should NOT exist — write rolled back - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); - await readAct.Should().ThrowAsync(); - } + // Item should NOT exist — write rolled back + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); + await readAct.Should().ThrowAsync(); + } - [Fact] - public async Task PostTrigger_Js_FiresAfterUpsert() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_FiresAfterUpsert() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("readDoc", """ + await RegisterJsPostTrigger("readDoc", """ function readDoc() { var context = getContext(); var response = context.getResponse(); @@ -366,23 +366,23 @@ function readDoc() { } """); - // Should not throw — verifies post-trigger executed successfully - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); - } + // Should not throw — verifies post-trigger executed successfully + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); + } - [Fact] - public async Task PostTrigger_Js_FiresAfterReplace() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_FiresAfterReplace() + { + _container.UseJsTriggers(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - await RegisterJsPostTrigger("readDoc", """ + await RegisterJsPostTrigger("readDoc", """ function readDoc() { var context = getContext(); var response = context.getResponse(); @@ -390,65 +390,65 @@ function readDoc() { } """); - // Should not throw - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); - } + // Should not throw + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); + } - [Fact] - public async Task PostTrigger_Js_OperationMismatch_NotFired() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_OperationMismatch_NotFired() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("createOnly", """ + await RegisterJsPostTrigger("createOnly", """ function createOnly() { throw new Error("should not run"); } """, TriggerOperation.Create); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Replace with Create-only post-trigger — should not fire (and not throw) - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "createOnly" } }); - } - - [Fact] - public async Task PostTrigger_Js_WithoutUseJsTriggers_ThrowsBadRequest() - { - // NOTE: No UseJsTriggers() call - - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myPost", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function myPost() { }" - }); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "myPost" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - ex.Which.Message.Should().Contain("UseJsTriggers"); - } - - [Fact] - public async Task PostTrigger_Js_OnStream_Fires() - { - _container.UseJsTriggers(); - - await RegisterJsPostTrigger("readDoc", """ + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Replace with Create-only post-trigger — should not fire (and not throw) + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "createOnly" } }); + } + + [Fact] + public async Task PostTrigger_Js_WithoutUseJsTriggers_ThrowsBadRequest() + { + // NOTE: No UseJsTriggers() call + + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myPost", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function myPost() { }" + }); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "myPost" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Which.Message.Should().Contain("UseJsTriggers"); + } + + [Fact] + public async Task PostTrigger_Js_OnStream_Fires() + { + _container.UseJsTriggers(); + + await RegisterJsPostTrigger("readDoc", """ function readDoc() { var context = getContext(); var response = context.getResponse(); @@ -456,42 +456,42 @@ function readDoc() { } """); - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - // Should not throw - await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); - } - } - - // ─── Mixed C# handler + JS triggers ────────────────────────────────── - - public class MixedTriggerTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task CSharpHandler_TakesPriority_OverJsBody() - { - _container.UseJsTriggers(); - - // Register a C# handler - _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["source"] = "csharp"; - return doc; - })); - - // Also register a JS body for the same trigger name - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + // Should not throw + await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); + } + } + + // ─── Mixed C# handler + JS triggers ────────────────────────────────── + + public class MixedTriggerTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task CSharpHandler_TakesPriority_OverJsBody() + { + _container.UseJsTriggers(); + + // Register a C# handler + _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["source"] = "csharp"; + return doc; + })); + + // Also register a JS body for the same trigger name + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function myTrigger() { var ctx = getContext(); var req = ctx.getRequest(); @@ -500,44 +500,44 @@ function myTrigger() { req.setBody(doc); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - - // C# handler should win — read the stored document to verify - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["source"]!.Value().Should().Be("csharp"); - } - } - - // ─── Additional Pre-Trigger Coverage ───────────────────────────────── - - public class PreTriggerJsAdditionalTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task RegisterJsPreTrigger(string id, string jsBody, - TriggerOperation op = TriggerOperation.All) - { - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = id, - TriggerType = TriggerType.Pre, - TriggerOperation = op, - Body = jsBody - }); - } - - [Fact] - public async Task PreTrigger_Js_FiresOnReplace() - { - _container.UseJsTriggers(); - - await RegisterJsPreTrigger("stamp", """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + + // C# handler should win — read the stored document to verify + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["source"]!.Value().Should().Be("csharp"); + } + } + + // ─── Additional Pre-Trigger Coverage ───────────────────────────────── + + public class PreTriggerJsAdditionalTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task RegisterJsPreTrigger(string id, string jsBody, + TriggerOperation op = TriggerOperation.All) + { + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = id, + TriggerType = TriggerType.Pre, + TriggerOperation = op, + Body = jsBody + }); + } + + [Fact] + public async Task PreTrigger_Js_FiresOnReplace() + { + _container.UseJsTriggers(); + + await RegisterJsPreTrigger("stamp", """ function stamp() { var context = getContext(); var request = context.getRequest(); @@ -547,25 +547,25 @@ function stamp() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp" } }); + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["replaced"]!.Value().Should().BeTrue(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["replaced"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task PreTrigger_Js_OperationSpecific_Replace_DoesNotFireOnCreate() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_OperationSpecific_Replace_DoesNotFireOnCreate() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("replaceOnly", """ + await RegisterJsPreTrigger("replaceOnly", """ function replaceOnly() { var context = getContext(); var request = context.getRequest(); @@ -575,23 +575,23 @@ function replaceOnly() { } """, TriggerOperation.Replace); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "replaceOnly" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "replaceOnly" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item.ContainsKey("triggered").Should().BeFalse(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item.ContainsKey("triggered").Should().BeFalse(); + } - [Fact] - public async Task PreTrigger_Js_OperationSpecific_Upsert_DoesNotFireOnCreate() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_OperationSpecific_Upsert_DoesNotFireOnCreate() + { + _container.UseJsTriggers(); - // Register as Upsert-only (note: TriggerOperation has no Upsert value in - // older SDK versions; if this doesn't compile, the test is inherently N/A). - await RegisterJsPreTrigger("upsertOnly", """ + // Register as Upsert-only (note: TriggerOperation has no Upsert value in + // older SDK versions; if this doesn't compile, the test is inherently N/A). + await RegisterJsPreTrigger("upsertOnly", """ function upsertOnly() { var context = getContext(); var request = context.getRequest(); @@ -601,59 +601,59 @@ function upsertOnly() { } """, TriggerOperation.Create); // Use Create as proxy; real test is below - // When the trigger is Create-only, a Replace should NOT fire it - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + // When the trigger is Create-only, a Replace should NOT fire it + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "upsertOnly" } }); + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "upsertOnly" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item.ContainsKey("triggered").Should().BeFalse(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item.ContainsKey("triggered").Should().BeFalse(); + } - [Fact] - public async Task PreTrigger_Js_NonExistentTriggerId_ThrowsBadRequest() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_NonExistentTriggerId_ThrowsBadRequest() + { + _container.UseJsTriggers(); - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "doesNotExist" } }); + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "doesNotExist" } }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task PreTrigger_Js_SyntaxError_ThrowsCosmosException() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_SyntaxError_ThrowsCosmosException() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("badSyntax", """ + await RegisterJsPreTrigger("badSyntax", """ function badSyntax() { var x = ; // syntax error } """); - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "badSyntax" } }); + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "badSyntax" } }); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task PreTrigger_Js_SetsNestedFields() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_SetsNestedFields() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("addNested", """ + await RegisterJsPreTrigger("addNested", """ function addNested() { var context = getContext(); var request = context.getRequest(); @@ -663,22 +663,22 @@ function addNested() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addNested" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addNested" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["address"]!["city"]!.Value().Should().Be("London"); - item["address"]!["zip"]!.Value().Should().Be("SW1"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["address"]!["city"]!.Value().Should().Be("London"); + item["address"]!["zip"]!.Value().Should().Be("SW1"); + } - [Fact] - public async Task PreTrigger_Js_RemovesField() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_RemovesField() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("removeField", """ + await RegisterJsPreTrigger("removeField", """ function removeField() { var context = getContext(); var request = context.getRequest(); @@ -688,21 +688,21 @@ function removeField() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", toRemove = "gone" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "removeField" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", toRemove = "gone" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "removeField" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item.ContainsKey("toRemove").Should().BeFalse(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item.ContainsKey("toRemove").Should().BeFalse(); + } - [Fact] - public async Task PreTrigger_Js_SetsNullValue() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_SetsNullValue() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("setNull", """ + await RegisterJsPreTrigger("setNull", """ function setNull() { var context = getContext(); var request = context.getRequest(); @@ -712,21 +712,21 @@ function setNull() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "setNull" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "setNull" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["nullField"]!.Type.Should().Be(JTokenType.Null); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["nullField"]!.Type.Should().Be(JTokenType.Null); + } - [Fact] - public async Task PreTrigger_Js_WorksWithArrays() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_WorksWithArrays() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("addArray", """ + await RegisterJsPreTrigger("addArray", """ function addArray() { var context = getContext(); var request = context.getRequest(); @@ -736,21 +736,21 @@ function addArray() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addArray" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addArray" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["tags"]!.ToObject().Should().BeEquivalentTo(new[] { "alpha", "beta" }); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["tags"]!.ToObject().Should().BeEquivalentTo(new[] { "alpha", "beta" }); + } - [Fact] - public async Task PreTrigger_Js_ConditionalLogic() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_ConditionalLogic() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("conditional", """ + await RegisterJsPreTrigger("conditional", """ function conditional() { var context = getContext(); var request = context.getRequest(); @@ -762,21 +762,21 @@ function conditional() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", status = "draft" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "conditional" } }); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", status = "draft" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "conditional" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["status"]!.Value().Should().Be("pending"); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["status"]!.Value().Should().Be("pending"); + } - [Fact] - public async Task PreTrigger_Js_FiresOnReplaceStream() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_FiresOnReplaceStream() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("stamp", """ + await RegisterJsPreTrigger("stamp", """ function stamp() { var context = getContext(); var request = context.getRequest(); @@ -786,27 +786,27 @@ function stamp() { } """); - // Create initial item - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + // Create initial item + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - // Replace via stream API - var json = """{"id":"1","pk":"a","name":"replaced"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp" } }); + // Replace via stream API + var json = """{"id":"1","pk":"a","name":"replaced"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["preRan"]!.Value().Should().BeTrue(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["preRan"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task PreTrigger_Js_FiresOnUpsertStream() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_FiresOnUpsertStream() + { + _container.UseJsTriggers(); - await RegisterJsPreTrigger("stamp", """ + await RegisterJsPreTrigger("stamp", """ function stamp() { var context = getContext(); var request = context.getRequest(); @@ -816,70 +816,70 @@ function stamp() { } """); - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp" } }); + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp" } }); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["preRan"]!.Value().Should().BeTrue(); - } + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["preRan"]!.Value().Should().BeTrue(); + } - [Fact] - public async Task PreTrigger_Js_DeleteOperation_Fires() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreTrigger_Js_DeleteOperation_Fires() + { + _container.UseJsTriggers(); - // To verify the pre-trigger actually fires on Delete, we register one - // that throws. If triggers fire, the delete should fail. If triggers - // DON'T fire, the delete would silently succeed. - await RegisterJsPreTrigger("blockDelete", """ + // To verify the pre-trigger actually fires on Delete, we register one + // that throws. If triggers fire, the delete should fail. If triggers + // DON'T fire, the delete would silently succeed. + await RegisterJsPreTrigger("blockDelete", """ function blockDelete() { throw new Error("pre-trigger blocked the delete"); } """, TriggerOperation.Delete); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Delete with throwing pre-trigger — should throw CosmosException - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "blockDelete" } }); - - await act.Should().ThrowAsync(); - - // Item should still exist because the pre-trigger blocked the delete - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } - } - - // ─── Additional Post-Trigger Coverage ───────────────────────────────── - - public class PostTriggerJsAdditionalTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task RegisterJsPostTrigger(string id, string jsBody, - TriggerOperation op = TriggerOperation.All) - { - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = id, - TriggerType = TriggerType.Post, - TriggerOperation = op, - Body = jsBody - }); - } - - [Fact] - public async Task PostTrigger_Js_MultipleChainedPostTriggers() - { - _container.UseJsTriggers(); - - await RegisterJsPostTrigger("postA", """ + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Delete with throwing pre-trigger — should throw CosmosException + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "blockDelete" } }); + + await act.Should().ThrowAsync(); + + // Item should still exist because the pre-trigger blocked the delete + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } + } + + // ─── Additional Post-Trigger Coverage ───────────────────────────────── + + public class PostTriggerJsAdditionalTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task RegisterJsPostTrigger(string id, string jsBody, + TriggerOperation op = TriggerOperation.All) + { + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = id, + TriggerType = TriggerType.Post, + TriggerOperation = op, + Body = jsBody + }); + } + + [Fact] + public async Task PostTrigger_Js_MultipleChainedPostTriggers() + { + _container.UseJsTriggers(); + + await RegisterJsPostTrigger("postA", """ function postA() { var context = getContext(); var response = context.getResponse(); @@ -887,7 +887,7 @@ function postA() { } """); - await RegisterJsPostTrigger("postB", """ + await RegisterJsPostTrigger("postB", """ function postB() { var context = getContext(); var response = context.getResponse(); @@ -895,56 +895,56 @@ function postB() { } """); - // Should not throw — both triggers execute successfully - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "postA", "postB" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task PostTrigger_Js_NonExistentTriggerId_ThrowsBadRequest() - { - _container.UseJsTriggers(); - - // Create the item first so the write itself succeeds - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "nonExistent" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PostTrigger_Js_SyntaxError_ThrowsCosmosException() - { - _container.UseJsTriggers(); - - await RegisterJsPostTrigger("badJs", """ + // Should not throw — both triggers execute successfully + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "postA", "postB" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task PostTrigger_Js_NonExistentTriggerId_ThrowsBadRequest() + { + _container.UseJsTriggers(); + + // Create the item first so the write itself succeeds + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "nonExistent" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostTrigger_Js_SyntaxError_ThrowsCosmosException() + { + _container.UseJsTriggers(); + + await RegisterJsPostTrigger("badJs", """ function badJs() { var x = ; // syntax error } """); - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "badJs" } }); + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "badJs" } }); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task PostTrigger_Js_FiresOnReplaceStream() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_FiresOnReplaceStream() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("readDoc", """ + await RegisterJsPostTrigger("readDoc", """ function readDoc() { var context = getContext(); var response = context.getResponse(); @@ -952,24 +952,24 @@ function readDoc() { } """); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - var json = """{"id":"1","pk":"a","name":"replaced"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var json = """{"id":"1","pk":"a","name":"replaced"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - // Should not throw - await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); - } + // Should not throw + await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); + } - [Fact] - public async Task PostTrigger_Js_FiresOnUpsertStream() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_FiresOnUpsertStream() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("readDoc", """ + await RegisterJsPostTrigger("readDoc", """ function readDoc() { var context = getContext(); var response = context.getResponse(); @@ -977,135 +977,135 @@ function readDoc() { } """); - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - // Should not throw - await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); - } + // Should not throw + await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "readDoc" } }); + } - [Fact] - public async Task PostTrigger_Js_RollsBack_OnExceptionDuringReplace() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_RollsBack_OnExceptionDuringReplace() + { + _container.UseJsTriggers(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", version = 1 }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", version = 1 }), + new PartitionKey("a")); - await RegisterJsPostTrigger("failPost", """ + await RegisterJsPostTrigger("failPost", """ function failPost() { throw new Error("post-trigger failed on replace"); } """); - var act = () => _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", version = 2 }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failPost" } }); + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", version = 2 }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failPost" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Original item should still exist with version = 1 - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["version"]!.Value().Should().Be(1); - } + // Original item should still exist with version = 1 + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["version"]!.Value().Should().Be(1); + } - [Fact] - public async Task PostTrigger_Js_RollsBack_OnExceptionDuringUpsert() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_RollsBack_OnExceptionDuringUpsert() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("failPost", """ + await RegisterJsPostTrigger("failPost", """ function failPost() { throw new Error("post-trigger failed on upsert"); } """); - var act = () => _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failPost" } }); + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failPost" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should NOT exist — upsert rolled back - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); - await readAct.Should().ThrowAsync(); - } + // Item should NOT exist — upsert rolled back + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); + await readAct.Should().ThrowAsync(); + } - [Fact] - public async Task PostTrigger_Js_DeleteOperation_Fires() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_DeleteOperation_Fires() + { + _container.UseJsTriggers(); - // To verify the post-trigger actually fires on Delete, we register one - // that throws. If triggers fire, the delete should be rolled back. - await RegisterJsPostTrigger("failAfterDelete", """ + // To verify the post-trigger actually fires on Delete, we register one + // that throws. If triggers fire, the delete should be rolled back. + await RegisterJsPostTrigger("failAfterDelete", """ function failAfterDelete() { throw new Error("post-trigger rejects the delete"); } """, TriggerOperation.Delete); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - // Delete with throwing post-trigger — should throw, rolling back - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failAfterDelete" } }); + // Delete with throwing post-trigger — should throw, rolling back + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failAfterDelete" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should still exist — post-trigger rolled back the delete - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } + // Item should still exist — post-trigger rolled back the delete + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } - [Fact] - public async Task PostTrigger_Js_ThrowOnDelete_RollsBackDelete() - { - _container.UseJsTriggers(); + [Fact] + public async Task PostTrigger_Js_ThrowOnDelete_RollsBackDelete() + { + _container.UseJsTriggers(); - await RegisterJsPostTrigger("failDelete", """ + await RegisterJsPostTrigger("failDelete", """ function failDelete() { throw new Error("post-trigger rejects delete"); } """, TriggerOperation.Delete); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failDelete" } }); + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failDelete" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should still exist — delete rolled back - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } - } + // Item should still exist — delete rolled back + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } + } - // ─── Combined Pre+Post Trigger Scenarios ───────────────────────────── + // ─── Combined Pre+Post Trigger Scenarios ───────────────────────────── - public class CombinedTriggerTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + public class CombinedTriggerTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task PreAndPostTrigger_Js_BothFireOnSameOperation() - { - _container.UseJsTriggers(); + [Fact] + public async Task PreAndPostTrigger_Js_BothFireOnSameOperation() + { + _container.UseJsTriggers(); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pre", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pre", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function pre() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1114,47 +1114,47 @@ function pre() { req.setBody(doc); } """ - }); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = """ function post() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions - { - PreTriggers = new List { "pre" }, - PostTriggers = new List { "post" } - }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["preRan"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreAndPostTrigger_Js_PostSeesPreModifiedDoc() - { - _container.UseJsTriggers(); - - // Pre-trigger adds a field - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addField", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions + { + PreTriggers = new List { "pre" }, + PostTriggers = new List { "post" } + }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["preRan"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreAndPostTrigger_Js_PostSeesPreModifiedDoc() + { + _container.UseJsTriggers(); + + // Pre-trigger adds a field + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addField", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function addField() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1163,115 +1163,115 @@ function addField() { req.setBody(doc); } """ - }); - - // Post-trigger reads the committed doc — if pre's change is persisted, - // "addedByPre" will be in the committed document - var postSawField = false; - _container.RegisterTrigger("verifyPost", TriggerType.Post, TriggerOperation.All, - (Action)(doc => - { - postSawField = doc["addedByPre"]?.Value() == "yes"; - })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions - { - PreTriggers = new List { "addField" }, - PostTriggers = new List { "verifyPost" } - }); - - postSawField.Should().BeTrue(); - } - - [Fact] - public async Task CSharpPreTrigger_JsPostTrigger_BothFire() - { - _container.UseJsTriggers(); - - // C# pre-trigger - _container.RegisterTrigger("csharpPre", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["preSrc"] = "csharp"; - return doc; - })); - - // JS post-trigger - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "jsPost", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + // Post-trigger reads the committed doc — if pre's change is persisted, + // "addedByPre" will be in the committed document + var postSawField = false; + _container.RegisterTrigger("verifyPost", TriggerType.Post, TriggerOperation.All, + (Action)(doc => + { + postSawField = doc["addedByPre"]?.Value() == "yes"; + })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions + { + PreTriggers = new List { "addField" }, + PostTriggers = new List { "verifyPost" } + }); + + postSawField.Should().BeTrue(); + } + + [Fact] + public async Task CSharpPreTrigger_JsPostTrigger_BothFire() + { + _container.UseJsTriggers(); + + // C# pre-trigger + _container.RegisterTrigger("csharpPre", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["preSrc"] = "csharp"; + return doc; + })); + + // JS post-trigger + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "jsPost", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = """ function jsPost() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions - { - PreTriggers = new List { "csharpPre" }, - PostTriggers = new List { "jsPost" } - }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["preSrc"]!.Value().Should().Be("csharp"); - } - } - - // ─── Scripts CRUD Integration ──────────────────────────────────────── - - public class TriggerScriptsCrudTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task ReadTriggerAsync_ReturnsRegisteredTrigger() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function myTrigger() { }" - }); - - var response = await _container.Scripts.ReadTriggerAsync("myTrigger"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("myTrigger"); - response.Resource.TriggerType.Should().Be(TriggerType.Pre); - response.Resource.TriggerOperation.Should().Be(TriggerOperation.Create); - } - - [Fact] - public async Task ReadTriggerAsync_NotFound_ThrowsCosmosException() - { - var act = () => _container.Scripts.ReadTriggerAsync("nonExistent"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceTriggerAsync_UpdatesBody_NewBodyFiresOnNextOp() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "evolving", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions + { + PreTriggers = new List { "csharpPre" }, + PostTriggers = new List { "jsPost" } + }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["preSrc"]!.Value().Should().Be("csharp"); + } + } + + // ─── Scripts CRUD Integration ──────────────────────────────────────── + + public class TriggerScriptsCrudTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task ReadTriggerAsync_ReturnsRegisteredTrigger() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function myTrigger() { }" + }); + + var response = await _container.Scripts.ReadTriggerAsync("myTrigger"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("myTrigger"); + response.Resource.TriggerType.Should().Be(TriggerType.Pre); + response.Resource.TriggerOperation.Should().Be(TriggerOperation.Create); + } + + [Fact] + public async Task ReadTriggerAsync_NotFound_ThrowsCosmosException() + { + var act = () => _container.Scripts.ReadTriggerAsync("nonExistent"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceTriggerAsync_UpdatesBody_NewBodyFiresOnNextOp() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "evolving", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function evolving() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1280,15 +1280,15 @@ function evolving() { req.setBody(doc); } """ - }); - - // Replace the trigger body with a new version - await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "evolving", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + // Replace the trigger body with a new version + await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "evolving", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function evolving() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1297,43 +1297,43 @@ function evolving() { req.setBody(doc); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "evolving" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["version"]!.Value().Should().Be("v2"); - } - - [Fact] - public async Task ReplaceTriggerAsync_NotFound_ThrowsCosmosException() - { - var act = () => _container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "nonExistent", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function x() { }" - }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteTriggerAsync_RemovesTrigger() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "toDelete", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "evolving" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["version"]!.Value().Should().Be("v2"); + } + + [Fact] + public async Task ReplaceTriggerAsync_NotFound_ThrowsCosmosException() + { + var act = () => _container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "nonExistent", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function x() { }" + }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteTriggerAsync_RemovesTrigger() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "toDelete", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function toDelete() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1342,95 +1342,95 @@ function toDelete() { req.setBody(doc); } """ - }); - - await _container.Scripts.DeleteTriggerAsync("toDelete"); - - // Now referencing the deleted trigger should throw - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "toDelete" } }); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteTriggerAsync_NotFound_ThrowsCosmosException() - { - var act = () => _container.Scripts.DeleteTriggerAsync("nonExistent"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteTriggerAsync_AlsoRemovesCSharpHandler() - { - // Register both a C# handler and a JS body for the same trigger - _container.RegisterTrigger("hybrid", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["csharp"] = true; - return doc; - })); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "hybrid", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function hybrid() { }" - }); - - // Delete via Scripts API — should remove both - await _container.Scripts.DeleteTriggerAsync("hybrid"); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "hybrid" } }); - - await act.Should().ThrowAsync(); - } - } - - // ─── Divergent Behavior Tests ──────────────────────────────────────── - // - // These test pairs document known behavioral differences between the - // in-memory emulator and real Azure Cosmos DB. Each "skip" test describes - // real Cosmos behavior that we don't implement, with a sister test that - // documents what the emulator actually does. - - public class JsTriggerDivergentBehaviorTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - // ── Divergence: Post-trigger response.setBody() ────────────────── - // - // Real Cosmos DB: Post-triggers can call getContext().getResponse().setBody(doc) - // to modify the response body returned to the client. This allows post-triggers - // to alter what the client sees without changing the persisted document. - // - // Emulator: The IJsTriggerEngine.ExecutePostTrigger returns void. The Jint - // engine wires up getResponse().getBody() but NOT setBody(). Any call to - // setBody() in a post-trigger is silently ignored. Implementing this would - // require changing the IJsTriggerEngine interface to return optional modified - // response body, threading that through ExecutePostTriggers, and altering the - // response construction in every write path — a significant interface change - // that's unlikely to affect most unit test scenarios. - - [Fact] - public async Task PostTrigger_Js_SetBody_ModifiesResponse() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "modifyResponse", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.Scripts.DeleteTriggerAsync("toDelete"); + + // Now referencing the deleted trigger should throw + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "toDelete" } }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteTriggerAsync_NotFound_ThrowsCosmosException() + { + var act = () => _container.Scripts.DeleteTriggerAsync("nonExistent"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteTriggerAsync_AlsoRemovesCSharpHandler() + { + // Register both a C# handler and a JS body for the same trigger + _container.RegisterTrigger("hybrid", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["csharp"] = true; + return doc; + })); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "hybrid", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function hybrid() { }" + }); + + // Delete via Scripts API — should remove both + await _container.Scripts.DeleteTriggerAsync("hybrid"); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "hybrid" } }); + + await act.Should().ThrowAsync(); + } + } + + // ─── Divergent Behavior Tests ──────────────────────────────────────── + // + // These test pairs document known behavioral differences between the + // in-memory emulator and real Azure Cosmos DB. Each "skip" test describes + // real Cosmos behavior that we don't implement, with a sister test that + // documents what the emulator actually does. + + public class JsTriggerDivergentBehaviorTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + // ── Divergence: Post-trigger response.setBody() ────────────────── + // + // Real Cosmos DB: Post-triggers can call getContext().getResponse().setBody(doc) + // to modify the response body returned to the client. This allows post-triggers + // to alter what the client sees without changing the persisted document. + // + // Emulator: The IJsTriggerEngine.ExecutePostTrigger returns void. The Jint + // engine wires up getResponse().getBody() but NOT setBody(). Any call to + // setBody() in a post-trigger is silently ignored. Implementing this would + // require changing the IJsTriggerEngine interface to return optional modified + // response body, threading that through ExecutePostTriggers, and altering the + // response construction in every write path — a significant interface change + // that's unlikely to affect most unit test scenarios. + + [Fact] + public async Task PostTrigger_Js_SetBody_ModifiesResponse() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "modifyResponse", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = """ function modifyResponse() { var ctx = getContext(); var resp = ctx.getResponse(); @@ -1439,166 +1439,170 @@ function modifyResponse() { resp.setBody(doc); } """ - }); - - var response = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "modifyResponse" } }); - - // setBody modifies the response body — client sees the override - response.Resource["modifiedByPost"]!.Value().Should().BeTrue(); - - // But the persisted document is NOT affected by setBody - var persisted = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - persisted.ContainsKey("modifiedByPost").Should().BeFalse(); - } - - // ── Divergence: Pre-trigger getResponse() ──────────────────────── - // - // Real Cosmos DB: Calling getContext().getResponse() inside a pre-trigger - // throws a runtime error because the response doesn't exist yet (the operation - // hasn't executed). - // - // Emulator: The Jint pre-trigger API only wires getContext().getRequest(). - // getResponse() is simply not defined on the context object, so calling it - // returns undefined rather than throwing. This is functionally equivalent - // (the trigger can't use the response) but the failure mode differs. - - [Fact] - public async Task PreTrigger_Js_GetResponse_Throws_RealCosmos() - { - // Real Cosmos DB throws a runtime error when a pre-trigger calls - // getContext().getResponse() because the response doesn't exist yet. - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "callGetResponse", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + var response = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "modifyResponse" } }); + + // setBody modifies the response body — client sees the override + response.Resource["modifiedByPost"]!.Value().Should().BeTrue(); + + // But the persisted document is NOT affected by setBody + var persisted = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + persisted.ContainsKey("modifiedByPost").Should().BeFalse(); + } + + // ── Divergence: Pre-trigger getResponse() ──────────────────────── + // + // Real Cosmos DB: Calling getContext().getResponse() inside a pre-trigger + // throws a runtime error because the response doesn't exist yet (the operation + // hasn't executed). + // + // Emulator: The Jint pre-trigger API only wires getContext().getRequest(). + // getResponse() is simply not defined on the context object, so calling it + // returns undefined rather than throwing. This is functionally equivalent + // (the trigger can't use the response) but the failure mode differs. + + [Fact] + public async Task PreTrigger_Js_GetResponse_Throws_RealCosmos() + { + // Real Cosmos DB throws a runtime error when a pre-trigger calls + // getContext().getResponse() because the response doesn't exist yet. + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "callGetResponse", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function callGetResponse() { var ctx = getContext(); var resp = ctx.getResponse(); } """ - }); - - var act = async () => await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "callGetResponse" } }); - - await act.Should().ThrowAsync() - .WithMessage("*getResponse*"); - } - - // ── Divergence: Multiple triggers per operation ────────────────── - // - // Real Cosmos DB: The official documentation states "you can still run only one - // trigger per operation" despite the SDK accepting a list of trigger names. - // If multiple trigger names are provided, only one executes. - // - // Emulator: All listed triggers are chained and executed in order. This is - // actually more permissive than real Cosmos and may mask issues where code - // relies on multiple triggers executing when only one would in production. - - [Fact] - public async Task MultipleTriggers_RealCosmosOnlyExecutesOne() - { - // Real Cosmos DB only fires the FIRST listed trigger, even when multiple are specified. - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addA", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All, - Body = "function addA() { var r = getContext().getRequest(); var d = r.getBody(); d['a'] = true; r.setBody(d); }" - }); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addB", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All, - Body = "function addB() { var r = getContext().getRequest(); var d = r.getBody(); d['b'] = true; r.setBody(d); }" - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addA", "addB" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["a"]!.Value().Should().BeTrue("first trigger should fire"); - item.ContainsKey("b").Should().BeFalse("second trigger should NOT fire — real Cosmos only fires first"); - } - - [Fact] - public async Task MultipleTriggers_OnlyFirstCSharpHandlerExecuted() - { - // With multiple pre-triggers listed, only the first matching one executes. - var aFired = false; - var bFired = false; - - _container.RegisterTrigger("tA", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { aFired = true; doc["a"] = true; return doc; })); - _container.RegisterTrigger("tB", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { bFired = true; doc["b"] = true; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "tA", "tB" } }); - - aFired.Should().BeTrue("first trigger should fire"); - bFired.Should().BeFalse("second trigger should NOT fire"); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["a"]!.Value().Should().BeTrue(); - item.ContainsKey("b").Should().BeFalse(); - } - - [Fact] - public async Task MultiplePostTriggers_OnlyFirstExecuted() - { - var aFired = false; - var bFired = false; - - _container.RegisterTrigger("postA", TriggerType.Post, TriggerOperation.All, - (Action)(_ => aFired = true)); - _container.RegisterTrigger("postB", TriggerType.Post, TriggerOperation.All, - (Action)(_ => bFired = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "postA", "postB" } }); - - aFired.Should().BeTrue("first post-trigger should fire"); - bFired.Should().BeFalse("second post-trigger should NOT fire"); - } - - // ── Divergence: getCollection() not supported ──────────────────── - // - // Real Cosmos DB: Triggers can access other documents in the same collection - // and partition via getContext().getCollection(). This enables patterns like - // creating audit documents, updating counters, or cascading deletes — all - // within the same transaction. - // - // Emulator: getContext().getCollection() is not wired in the Jint engine. - // The context object only provides getRequest() (pre-triggers) or getResponse() - // (post-triggers). Implementing collection access would require a major - // refactor to pass container state into the JS engine and support - // createDocument/readDocument/queryDocuments within the JS sandbox. - - [Fact] - public async Task Trigger_GetCollection_Available() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "checkCollection", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + var act = async () => await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "callGetResponse" } }); + + await act.Should().ThrowAsync() + .WithMessage("*getResponse*"); + } + + // ── Divergence: Multiple triggers per operation ────────────────── + // + // Real Cosmos DB: The official documentation states "you can still run only one + // trigger per operation" despite the SDK accepting a list of trigger names. + // If multiple trigger names are provided, only one executes. + // + // Emulator: All listed triggers are chained and executed in order. This is + // actually more permissive than real Cosmos and may mask issues where code + // relies on multiple triggers executing when only one would in production. + + [Fact] + public async Task MultipleTriggers_RealCosmosOnlyExecutesOne() + { + // Real Cosmos DB only fires the FIRST listed trigger, even when multiple are specified. + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addA", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function addA() { var r = getContext().getRequest(); var d = r.getBody(); d['a'] = true; r.setBody(d); }" + }); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addB", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function addB() { var r = getContext().getRequest(); var d = r.getBody(); d['b'] = true; r.setBody(d); }" + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addA", "addB" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["a"]!.Value().Should().BeTrue("first trigger should fire"); + item.ContainsKey("b").Should().BeFalse("second trigger should NOT fire — real Cosmos only fires first"); + } + + [Fact] + public async Task MultipleTriggers_OnlyFirstCSharpHandlerExecuted() + { + // With multiple pre-triggers listed, only the first matching one executes. + var aFired = false; + var bFired = false; + + _container.RegisterTrigger("tA", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { aFired = true; doc["a"] = true; return doc; })); + _container.RegisterTrigger("tB", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { bFired = true; doc["b"] = true; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "tA", "tB" } }); + + aFired.Should().BeTrue("first trigger should fire"); + bFired.Should().BeFalse("second trigger should NOT fire"); + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["a"]!.Value().Should().BeTrue(); + item.ContainsKey("b").Should().BeFalse(); + } + + [Fact] + public async Task MultiplePostTriggers_OnlyFirstExecuted() + { + var aFired = false; + var bFired = false; + + _container.RegisterTrigger("postA", TriggerType.Post, TriggerOperation.All, + (Action)(_ => aFired = true)); + _container.RegisterTrigger("postB", TriggerType.Post, TriggerOperation.All, + (Action)(_ => bFired = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "postA", "postB" } }); + + aFired.Should().BeTrue("first post-trigger should fire"); + bFired.Should().BeFalse("second post-trigger should NOT fire"); + } + + // ── Divergence: getCollection() not supported ──────────────────── + // + // Real Cosmos DB: Triggers can access other documents in the same collection + // and partition via getContext().getCollection(). This enables patterns like + // creating audit documents, updating counters, or cascading deletes — all + // within the same transaction. + // + // Emulator: getContext().getCollection() is not wired in the Jint engine. + // The context object only provides getRequest() (pre-triggers) or getResponse() + // (post-triggers). Implementing collection access would require a major + // refactor to pass container state into the JS engine and support + // createDocument/readDocument/queryDocuments within the JS sandbox. + + [Fact] + public async Task Trigger_GetCollection_Available() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "checkCollection", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function checkCollection() { var ctx = getContext(); var hasCollection = typeof ctx.getCollection === "function"; @@ -1608,37 +1612,37 @@ function checkCollection() { req.setBody(doc); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "checkCollection" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["hasCollection"]!.Value().Should().BeTrue(); - } - - // ── Divergence: getRequest() in post-triggers ──────────────────── - // - // Real Cosmos DB: getContext().getRequest() is available in post-triggers too, - // allowing the trigger to inspect the original request (e.g., check headers or - // the original operation body). - // - // Emulator: The Jint post-trigger API only wires getContext().getResponse(). - // getRequest() is not defined, so it returns undefined. - - [Fact] - public async Task PostTrigger_Js_GetRequest_Available() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "checkRequest", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "checkCollection" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["hasCollection"]!.Value().Should().BeTrue(); + } + + // ── Divergence: getRequest() in post-triggers ──────────────────── + // + // Real Cosmos DB: getContext().getRequest() is available in post-triggers too, + // allowing the trigger to inspect the original request (e.g., check headers or + // the original operation body). + // + // Emulator: The Jint post-trigger API only wires getContext().getResponse(). + // getRequest() is not defined, so it returns undefined. + + [Fact] + public async Task PostTrigger_Js_GetRequest_Available() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "checkRequest", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = """ function checkRequest() { var ctx = getContext(); var hasRequest = typeof ctx.getRequest === "function"; @@ -1648,40 +1652,40 @@ function checkRequest() { } } """ - }); - - // Should not throw — getRequest is available in post-triggers - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "checkRequest" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } - } - - // ─── JintTriggerEngine Edge Cases ──────────────────────────────────── - - public class JintTriggerEngineEdgeCaseTests - { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PreTrigger_Js_HelperFunctionBeforeMain_InvokesFirstFunction() - { - // The JintTriggerEngine uses InvokeFirstFunction which picks the first - // `function ()` via regex. If a helper function is defined before - // the main trigger function, the helper runs instead. - // This documents the current behavior — it's a known quirk. - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "helperFirst", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + // Should not throw — getRequest is available in post-triggers + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "checkRequest" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } + } + + // ─── JintTriggerEngine Edge Cases ──────────────────────────────────── + + public class JintTriggerEngineEdgeCaseTests + { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PreTrigger_Js_HelperFunctionBeforeMain_InvokesFirstFunction() + { + // The JintTriggerEngine uses InvokeFirstFunction which picks the first + // `function ()` via regex. If a helper function is defined before + // the main trigger function, the helper runs instead. + // This documents the current behavior — it's a known quirk. + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "helperFirst", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function helper() { // This is a helper that gets invoked first due to regex ordering. // It does nothing — no getContext/setBody. @@ -1694,29 +1698,29 @@ function main() { req.setBody(doc); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "helperFirst" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - // "mainRan" is NOT set because InvokeFirstFunction invoked "helper", not "main" - item.ContainsKey("mainRan").Should().BeFalse(); - } - - [Fact] - public async Task PreTrigger_Js_LargeDocument_HandledCorrectly() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addField", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "helperFirst" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + // "mainRan" is NOT set because InvokeFirstFunction invoked "helper", not "main" + item.ContainsKey("mainRan").Should().BeFalse(); + } + + [Fact] + public async Task PreTrigger_Js_LargeDocument_HandledCorrectly() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addField", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function addField() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1725,35 +1729,35 @@ function addField() { req.setBody(doc); } """ - }); - - // Create a document with 100+ fields - var jObj = new JObject { ["id"] = "1", ["pk"] = "a" }; - for (int i = 0; i < 150; i++) - { - jObj[$"field_{i}"] = $"value_{i}"; - } - - await _container.CreateItemAsync(jObj, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["triggerRan"]!.Value().Should().BeTrue(); - item["field_0"]!.Value().Should().Be("value_0"); - item["field_149"]!.Value().Should().Be("value_149"); - } - - [Fact] - public async Task PreTrigger_Js_UnicodeContent() - { - _container.UseJsTriggers(); - - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "addUnicode", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = """ + }); + + // Create a document with 100+ fields + var jObj = new JObject { ["id"] = "1", ["pk"] = "a" }; + for (int i = 0; i < 150; i++) + { + jObj[$"field_{i}"] = $"value_{i}"; + } + + await _container.CreateItemAsync(jObj, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["triggerRan"]!.Value().Should().BeTrue(); + item["field_0"]!.Value().Should().Be("value_0"); + item["field_149"]!.Value().Should().Be("value_149"); + } + + [Fact] + public async Task PreTrigger_Js_UnicodeContent() + { + _container.UseJsTriggers(); + + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "addUnicode", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = """ function addUnicode() { var ctx = getContext(); var req = ctx.getRequest(); @@ -1763,43 +1767,43 @@ function addUnicode() { req.setBody(doc); } """ - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addUnicode" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["emoji"]!.Value().Should().Be("😀"); - item["japanese"]!.Value().Should().Be("東京"); - } - } - - // ─── UseJsTriggers extension ───────────────────────────────────────── - - public class UseJsTriggersExtensionTests - { - [Fact] - public void UseJsTriggers_SetsJsTriggerEngine() - { - var container = new InMemoryContainer("test", "/pk"); - container.JsTriggerEngine.Should().BeNull(); - - container.UseJsTriggers(); - - container.JsTriggerEngine.Should().NotBeNull(); - container.JsTriggerEngine.Should().BeOfType(); - } - - [Fact] - public void UseJsTriggers_ReturnsSameContainer() - { - var container = new InMemoryContainer("test", "/pk"); - var result = container.UseJsTriggers(); - result.Should().BeSameAs(container); - } - } + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addUnicode" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["emoji"]!.Value().Should().Be("😀"); + item["japanese"]!.Value().Should().Be("東京"); + } + } + + // ─── UseJsTriggers extension ───────────────────────────────────────── + + public class UseJsTriggersExtensionTests + { + [Fact] + public void UseJsTriggers_SetsJsTriggerEngine() + { + var container = new InMemoryContainer("test", "/pk"); + container.JsTriggerEngine.Should().BeNull(); + + container.UseJsTriggers(); + + container.JsTriggerEngine.Should().NotBeNull(); + container.JsTriggerEngine.Should().BeOfType(); + } + + [Fact] + public void UseJsTriggers_ReturnsSameContainer() + { + var container = new InMemoryContainer("test", "/pk"); + var result = container.UseJsTriggers(); + result.Should().BeSameAs(container); + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1808,71 +1812,71 @@ public void UseJsTriggers_ReturnsSameContainer() public class TriggerCrudEdgeCaseTests { - [Fact] - public async Task CreateTriggerAsync_DuplicateId_ThrowsConflict() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var scripts = container.Scripts; - - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'ok'; req.setBody(doc); }" - }); - - var act = () => scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run2() { }" - }); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReplaceTriggerAsync_ChangesOperation_AffectsFiring() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var scripts = container.Scripts; - - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'v1'; req.setBody(doc); }" - }); - - // Create an item to replace later - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "trig1" } }); - - // Now change trigger to fire on All (including Replace) - await scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'v2'; req.setBody(doc); }" - }); - - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "trig1" } }); - - var readBack = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - readBack["stamp"]!.ToString().Should().Be("v2"); - } + [Fact] + public async Task CreateTriggerAsync_DuplicateId_ThrowsConflict() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var scripts = container.Scripts; + + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'ok'; req.setBody(doc); }" + }); + + var act = () => scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run2() { }" + }); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceTriggerAsync_ChangesOperation_AffectsFiring() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var scripts = container.Scripts; + + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'v1'; req.setBody(doc); }" + }); + + // Create an item to replace later + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "trig1" } }); + + // Now change trigger to fire on All (including Replace) + await scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'v2'; req.setBody(doc); }" + }); + + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "trig1" } }); + + var readBack = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + readBack["stamp"]!.ToString().Should().Be("v2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1881,152 +1885,152 @@ await container.ReplaceItemAsync( public class TriggerOperationFilteringTests { - [Fact] - public async Task PreTrigger_DeleteOnly_DoesNotFireOnCreate() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "del-only", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Delete, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" - }); - - var result = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "del-only" } }); - - result.Resource["stamp"].Should().BeNull("delete-only trigger should not fire on create"); - } - - [Fact] - public async Task PreTrigger_DeleteOnly_DoesNotFireOnReplace() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "del-only", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Delete, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var result = await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "del-only" } }); - - result.Resource["stamp"].Should().BeNull("delete-only trigger should not fire on replace"); - } - - [Fact] - public async Task PostTrigger_CreateOnly_DoesNotFireOnReplace() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var called = false; - container.RegisterTrigger("create-only", TriggerType.Post, TriggerOperation.Create, - _ => called = true); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - called = false; // reset after create - - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", extra = "val" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "create-only" } }); - - called.Should().BeFalse("create-only post-trigger should not fire on replace"); - } - - [Fact] - public async Task PreTrigger_DeleteOnly_DoesNotFireOnUpsert() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "del-only", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Delete, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" - }); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "del-only" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamp"].Should().BeNull("delete-only trigger should not fire on upsert"); - } - - [Fact] - public async Task PostTrigger_DeleteOnly_DoesNotFireOnCreate() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var called = false; - container.RegisterTrigger("del-only-post", TriggerType.Post, TriggerOperation.Delete, - _ => called = true); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "del-only-post" } }); - - called.Should().BeFalse("delete-only post-trigger should not fire on create"); - } - - [Fact] - public async Task PreTrigger_UpsertOnly_FiresOnUpsert() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "upsert-only", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, // Upsert maps to Create/Replace in emulator; use All to cover - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'upserted'; req.setBody(doc); }" - }); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "upsert-only" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamp"]!.ToString().Should().Be("upserted"); - } - - [Fact] - public async Task PostTrigger_All_FiresOnUpsert() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var called = false; - container.RegisterTrigger("all-post", TriggerType.Post, TriggerOperation.All, - _ => called = true); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "all-post" } }); - - called.Should().BeTrue("All-operation post-trigger should fire on upsert"); - } + [Fact] + public async Task PreTrigger_DeleteOnly_DoesNotFireOnCreate() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "del-only", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Delete, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" + }); + + var result = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "del-only" } }); + + result.Resource["stamp"].Should().BeNull("delete-only trigger should not fire on create"); + } + + [Fact] + public async Task PreTrigger_DeleteOnly_DoesNotFireOnReplace() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "del-only", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Delete, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var result = await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "del-only" } }); + + result.Resource["stamp"].Should().BeNull("delete-only trigger should not fire on replace"); + } + + [Fact] + public async Task PostTrigger_CreateOnly_DoesNotFireOnReplace() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var called = false; + container.RegisterTrigger("create-only", TriggerType.Post, TriggerOperation.Create, + _ => called = true); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + called = false; // reset after create + + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", extra = "val" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "create-only" } }); + + called.Should().BeFalse("create-only post-trigger should not fire on replace"); + } + + [Fact] + public async Task PreTrigger_DeleteOnly_DoesNotFireOnUpsert() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "del-only", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Delete, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'fired'; req.setBody(doc); }" + }); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "del-only" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamp"].Should().BeNull("delete-only trigger should not fire on upsert"); + } + + [Fact] + public async Task PostTrigger_DeleteOnly_DoesNotFireOnCreate() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var called = false; + container.RegisterTrigger("del-only-post", TriggerType.Post, TriggerOperation.Delete, + _ => called = true); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "del-only-post" } }); + + called.Should().BeFalse("delete-only post-trigger should not fire on create"); + } + + [Fact] + public async Task PreTrigger_UpsertOnly_FiresOnUpsert() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "upsert-only", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, // Upsert maps to Create/Replace in emulator; use All to cover + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'upserted'; req.setBody(doc); }" + }); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "upsert-only" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamp"]!.ToString().Should().Be("upserted"); + } + + [Fact] + public async Task PostTrigger_All_FiresOnUpsert() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var called = false; + container.RegisterTrigger("all-post", TriggerType.Post, TriggerOperation.All, + _ => called = true); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "all-post" } }); + + called.Should().BeTrue("All-operation post-trigger should fire on upsert"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2035,77 +2039,77 @@ await container.UpsertItemAsync( public class PreTriggerRollbackTests { - [Fact] - public async Task PreTrigger_Js_Throws_ItemNotCreated() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('intentional failure'); }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "item should not be created when pre-trigger throws"); - } - - [Fact] - public async Task PreTrigger_Js_Throws_ItemNotReplaced() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('intentional failure'); }" - }); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "changed" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); - - await act.Should().ThrowAsync(); - - var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); - readBack.Resource["val"]!.ToString().Should().Be("original"); - } - - [Fact] - public async Task PreTrigger_Js_Throws_ItemNotUpserted() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('intentional failure'); }" - }); - - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "item should not be created when pre-trigger throws on upsert"); - } + [Fact] + public async Task PreTrigger_Js_Throws_ItemNotCreated() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('intentional failure'); }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "item should not be created when pre-trigger throws"); + } + + [Fact] + public async Task PreTrigger_Js_Throws_ItemNotReplaced() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('intentional failure'); }" + }); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "changed" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); + + await act.Should().ThrowAsync(); + + var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); + readBack.Resource["val"]!.ToString().Should().Be("original"); + } + + [Fact] + public async Task PreTrigger_Js_Throws_ItemNotUpserted() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('intentional failure'); }" + }); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "throw-trigger" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "item should not be created when pre-trigger throws on upsert"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2114,130 +2118,130 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties public class PostTriggerRollbackVerificationTests { - [Fact] - public async Task PostTrigger_Js_RollsBack_Create_ItemRemoved() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "item should be rolled back when post-trigger throws"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_Replace_RestoresOriginal() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "replaced" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - - var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); - readBack.Resource["val"]!.ToString().Should().Be("original"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_Delete_RestoresItem() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.DeleteItemAsync( - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(1, "item should be restored when post-trigger throws on delete"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_UpsertNew_RemovesItem() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "new upsert should be rolled back"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_UpsertExisting_RestoresOriginal() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "updated" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - - var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); - readBack.Resource["val"]!.ToString().Should().Be("original", "existing upsert should restore original on rollback"); - } + [Fact] + public async Task PostTrigger_Js_RollsBack_Create_ItemRemoved() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "item should be rolled back when post-trigger throws"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_Replace_RestoresOriginal() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "replaced" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + + var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); + readBack.Resource["val"]!.ToString().Should().Be("original"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_Delete_RestoresItem() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.DeleteItemAsync( + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(1, "item should be restored when post-trigger throws on delete"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_UpsertNew_RemovesItem() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "new upsert should be rolled back"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_UpsertExisting_RestoresOriginal() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "updated" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + + var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); + readBack.Resource["val"]!.ToString().Should().Be("original", "existing upsert should restore original on rollback"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2246,389 +2250,389 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties public class JintTriggerEngineAdditionalEdgeCaseTests { - private readonly InMemoryContainer _container; - - public JintTriggerEngineAdditionalEdgeCaseTests() - { - _container = new InMemoryContainer("test", "/pk"); - _container.UseJsTriggers(); - } - - [Fact] - public async Task PreTrigger_Js_MultipleSetBody_LastWins() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "multi-set", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.val = 'first'; req.setBody(doc); " + - "doc.val = 'second'; req.setBody(doc); }" - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "multi-set" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["val"]!.ToString().Should().Be("second"); - } - - [Fact] - public async Task PreTrigger_Js_MathFunctions_Work() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "math-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.rounded = Math.floor(3.7); doc.max = Math.max(1, 5, 3); " + - "req.setBody(doc); }" - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "math-trigger" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - ((long)item["rounded"]!).Should().Be(3); - ((long)item["max"]!).Should().Be(5); - } - - [Fact] - public async Task PreTrigger_Js_StringManipulation_Works() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "string-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.upper = doc.name.toUpperCase(); doc.trimmed = ' hello '.trim(); " + - "req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "test" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "string-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["upper"]!.ToString().Should().Be("TEST"); - item["trimmed"]!.ToString().Should().Be("hello"); - } - - [Fact] - public async Task PreTrigger_Js_JsonParse_InTrigger() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "json-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "var parsed = JSON.parse('{\"key\":\"value\"}'); doc.parsedKey = parsed.key; " + - "req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "json-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["parsedKey"]!.ToString().Should().Be("value"); - } - - [Fact] - public async Task PreTrigger_Js_EmptyBody_NoOp() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "empty-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "empty-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["val"]!.ToString().Should().Be("original", "empty trigger body should not modify document"); - } - - [Fact] - public async Task PostTrigger_Js_EmptyBody_NoOp() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "empty-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "empty-post" } }); - - container.ItemCount.Should().Be(1, "empty post-trigger body should not cause failure"); - } - - [Fact] - public async Task PreTrigger_Js_ArrowFunction_NotInvoked() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "arrow-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "var f = () => { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.arrow = true; req.setBody(doc); };" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "arrow-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["arrow"].Should().BeNull("arrow functions are not invoked by InvokeFirstFunction regex"); - } - - [Fact] - public async Task PreTrigger_Js_AnonymousFunction_NotInvoked() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "anon-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "(function() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.anon = true; req.setBody(doc); })();" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "anon-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - // Anonymous IIFE executes during engine.Execute(), so it DOES run before InvokeFirstFunction - // The regex won't match it, but the IIFE already executed - item["anon"]!.Value().Should().BeTrue("IIFE executes during engine.Execute() phase"); - } - - [Fact] - public async Task PreTrigger_Js_NumberPrecision() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "precision-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.sum = 0.1 + 0.2; " + - "req.setBody(doc); }" - }); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "precision-trigger" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - var sum = (double)item["sum"]!; - sum.Should().NotBe(0.3, "JavaScript floating-point: 0.1 + 0.2 !== 0.3"); - sum.Should().BeApproximately(0.3, 0.0001); - } - - [Fact] - public async Task PreTrigger_Js_DateObject() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "date-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.hasDate = typeof new Date().getTime() === 'number'; " + - "req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "date-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["hasDate"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_Js_RegexUsage() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "regex-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.isEmail = /^[^@]+@[^@]+$/.test(doc.email); " + - "req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "user@example.com" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "regex-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["isEmail"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_Js_JsonStringify_InTrigger() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "stringify-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.nested = JSON.stringify({a: 1, b: 2}); " + - "req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stringify-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["nested"]!.ToString().Should().Contain("\"a\""); - item["nested"]!.ToString().Should().Contain("\"b\""); - } - - [Fact] - public async Task PostTrigger_Js_LargeDocument_HandledCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "large-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); }" - }); - - var doc = new JObject { ["id"] = "1", ["pk"] = "a" }; - for (var i = 0; i < 150; i++) doc[$"field{i}"] = $"value{i}"; - - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "large-post" } }); - - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task PostTrigger_Js_UnicodeInResponse() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "unicode-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "日本語テスト 🎉" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "unicode-post" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["name"]!.ToString().Should().Be("日本語テスト 🎉"); - } - - [Fact(Skip = "Jint timeout is 5 seconds — test would take too long for CI. " + - "Verified that Jint.Engine TimeoutInterval is configured to 5s in JintTriggerEngine.")] - public async Task PreTrigger_Js_InfiniteLoop_TimesOut() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "loop-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { while(true) {} }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "loop-trigger" } }); - - await act.Should().ThrowAsync(); - } - - [Fact(Skip = "MaxStatements(10000) may take too long to trigger in CI. " + - "Verified that Jint.Engine MaxStatements is configured to 10000 in JintTriggerEngine.")] - public async Task PreTrigger_Js_MaxStatements_Exceeded() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "stmt-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var i = 0; while(i < 100000) { i++; } }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stmt-trigger" } }); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container; + + public JintTriggerEngineAdditionalEdgeCaseTests() + { + _container = new InMemoryContainer("test", "/pk"); + _container.UseJsTriggers(); + } + + [Fact] + public async Task PreTrigger_Js_MultipleSetBody_LastWins() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "multi-set", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.val = 'first'; req.setBody(doc); " + + "doc.val = 'second'; req.setBody(doc); }" + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "multi-set" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["val"]!.ToString().Should().Be("second"); + } + + [Fact] + public async Task PreTrigger_Js_MathFunctions_Work() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "math-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.rounded = Math.floor(3.7); doc.max = Math.max(1, 5, 3); " + + "req.setBody(doc); }" + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "math-trigger" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + ((long)item["rounded"]!).Should().Be(3); + ((long)item["max"]!).Should().Be(5); + } + + [Fact] + public async Task PreTrigger_Js_StringManipulation_Works() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "string-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.upper = doc.name.toUpperCase(); doc.trimmed = ' hello '.trim(); " + + "req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "test" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "string-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["upper"]!.ToString().Should().Be("TEST"); + item["trimmed"]!.ToString().Should().Be("hello"); + } + + [Fact] + public async Task PreTrigger_Js_JsonParse_InTrigger() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "json-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "var parsed = JSON.parse('{\"key\":\"value\"}'); doc.parsedKey = parsed.key; " + + "req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "json-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["parsedKey"]!.ToString().Should().Be("value"); + } + + [Fact] + public async Task PreTrigger_Js_EmptyBody_NoOp() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "empty-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "empty-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["val"]!.ToString().Should().Be("original", "empty trigger body should not modify document"); + } + + [Fact] + public async Task PostTrigger_Js_EmptyBody_NoOp() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "empty-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "empty-post" } }); + + container.ItemCount.Should().Be(1, "empty post-trigger body should not cause failure"); + } + + [Fact] + public async Task PreTrigger_Js_ArrowFunction_NotInvoked() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "arrow-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "var f = () => { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.arrow = true; req.setBody(doc); };" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "arrow-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["arrow"].Should().BeNull("arrow functions are not invoked by InvokeFirstFunction regex"); + } + + [Fact] + public async Task PreTrigger_Js_AnonymousFunction_NotInvoked() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "anon-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "(function() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.anon = true; req.setBody(doc); })();" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "anon-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + // Anonymous IIFE executes during engine.Execute(), so it DOES run before InvokeFirstFunction + // The regex won't match it, but the IIFE already executed + item["anon"]!.Value().Should().BeTrue("IIFE executes during engine.Execute() phase"); + } + + [Fact] + public async Task PreTrigger_Js_NumberPrecision() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "precision-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.sum = 0.1 + 0.2; " + + "req.setBody(doc); }" + }); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "precision-trigger" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + var sum = (double)item["sum"]!; + sum.Should().NotBe(0.3, "JavaScript floating-point: 0.1 + 0.2 !== 0.3"); + sum.Should().BeApproximately(0.3, 0.0001); + } + + [Fact] + public async Task PreTrigger_Js_DateObject() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "date-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.hasDate = typeof new Date().getTime() === 'number'; " + + "req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "date-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["hasDate"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_Js_RegexUsage() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "regex-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.isEmail = /^[^@]+@[^@]+$/.test(doc.email); " + + "req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "user@example.com" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "regex-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["isEmail"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_Js_JsonStringify_InTrigger() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "stringify-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.nested = JSON.stringify({a: 1, b: 2}); " + + "req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stringify-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["nested"]!.ToString().Should().Contain("\"a\""); + item["nested"]!.ToString().Should().Contain("\"b\""); + } + + [Fact] + public async Task PostTrigger_Js_LargeDocument_HandledCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "large-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); }" + }); + + var doc = new JObject { ["id"] = "1", ["pk"] = "a" }; + for (var i = 0; i < 150; i++) doc[$"field{i}"] = $"value{i}"; + + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "large-post" } }); + + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task PostTrigger_Js_UnicodeInResponse() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "unicode-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var resp = ctx.getResponse(); var doc = resp.getBody(); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "日本語テスト 🎉" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "unicode-post" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["name"]!.ToString().Should().Be("日本語テスト 🎉"); + } + + [Fact(Skip = "Jint timeout is 5 seconds — test would take too long for CI. " + + "Verified that Jint.Engine TimeoutInterval is configured to 5s in JintTriggerEngine.")] + public async Task PreTrigger_Js_InfiniteLoop_TimesOut() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "loop-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { while(true) {} }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "loop-trigger" } }); + + await act.Should().ThrowAsync(); + } + + [Fact(Skip = "MaxStatements(10000) may take too long to trigger in CI. " + + "Verified that Jint.Engine MaxStatements is configured to 10000 in JintTriggerEngine.")] + public async Task PreTrigger_Js_MaxStatements_Exceeded() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "stmt-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var i = 0; while(i < 100000) { i++; } }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stmt-trigger" } }); + + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2637,177 +2641,177 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties public class TriggerFeatureInteractionTests { - [Fact] - public async Task PreTrigger_Js_WithTTL_SetsItemTTL() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 3600; - container.UseJsTriggers(); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "ttl-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { " + - "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + - "doc.ttl = 60; req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "ttl-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - ((long)item["ttl"]!).Should().Be(60); - } - - [Fact] - public async Task PostTrigger_Js_VerifyChangeFeedRecorded() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "log-trigger", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { /* no-op post trigger */ }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "log-trigger" } }); - - var cfIterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - var results = new List(); - while (cfIterator.HasMoreResults) - { - var batch = await cfIterator.ReadNextAsync(); - if (batch.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(batch); - } - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PreTrigger_Js_WithETagCondition_BothApply() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "stamp-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'triggered'; req.setBody(doc); }" - }); - - var created = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "v1" }), - new PartitionKey("a")); - var etag = created.ETag; - - // Replace with correct ETag + pre-trigger - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "v2" }), - "1", new PartitionKey("a"), - new ItemRequestOptions - { - IfMatchEtag = etag, - PreTriggers = new List { "stamp-trigger" } - }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamp"]!.ToString().Should().Be("triggered"); - item["val"]!.ToString().Should().Be("v2"); - } - - [Fact] - public async Task PreTrigger_Js_WithUniqueKeyPolicy() - { - var properties = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "email-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.email = doc.email.toLowerCase(); req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", email = "USER@TEST.COM" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "email-trigger" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["email"]!.ToString().Should().Be("user@test.com"); - - // Second item with same email (after trigger lowercasing) should conflict - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", email = "User@Test.Com" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "email-trigger" } }); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PreTrigger_Js_ModifiesIdField_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "id-changer", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.id = 'modified-id'; req.setBody(doc); }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "original-id", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "id-changer" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PreTrigger_Js_ModifiesPartitionKey_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pk-changer", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.pk = 'modified-pk'; req.setBody(doc); }" - }); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pk-changer" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task PreTrigger_Js_WithTTL_SetsItemTTL() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 3600; + container.UseJsTriggers(); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "ttl-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { " + + "var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); " + + "doc.ttl = 60; req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "ttl-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + ((long)item["ttl"]!).Should().Be(60); + } + + [Fact] + public async Task PostTrigger_Js_VerifyChangeFeedRecorded() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "log-trigger", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { /* no-op post trigger */ }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "log-trigger" } }); + + var cfIterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + var results = new List(); + while (cfIterator.HasMoreResults) + { + var batch = await cfIterator.ReadNextAsync(); + if (batch.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(batch); + } + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PreTrigger_Js_WithETagCondition_BothApply() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "stamp-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'triggered'; req.setBody(doc); }" + }); + + var created = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "v1" }), + new PartitionKey("a")); + var etag = created.ETag; + + // Replace with correct ETag + pre-trigger + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "v2" }), + "1", new PartitionKey("a"), + new ItemRequestOptions + { + IfMatchEtag = etag, + PreTriggers = new List { "stamp-trigger" } + }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamp"]!.ToString().Should().Be("triggered"); + item["val"]!.ToString().Should().Be("v2"); + } + + [Fact] + public async Task PreTrigger_Js_WithUniqueKeyPolicy() + { + var properties = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "email-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.email = doc.email.toLowerCase(); req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "USER@TEST.COM" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "email-trigger" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["email"]!.ToString().Should().Be("user@test.com"); + + // Second item with same email (after trigger lowercasing) should conflict + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "User@Test.Com" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "email-trigger" } }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PreTrigger_Js_ModifiesIdField_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "id-changer", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.id = 'modified-id'; req.setBody(doc); }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "original-id", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "id-changer" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PreTrigger_Js_ModifiesPartitionKey_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pk-changer", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.pk = 'modified-pk'; req.setBody(doc); }" + }); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pk-changer" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2816,136 +2820,136 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties public class TriggerPatchBatchDivergentTests { - [Fact] - public async Task Trigger_Js_PatchOperation_FiresTrigger() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "patch-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - await container.PatchItemAsync( - "1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/val", 20) }, - new PatchItemRequestOptions { PreTriggers = new List { "patch-trigger" } }); - } - - [Fact] - public async Task DivergentBehavior_PatchTriggerFiresWhenRequested() - { - // Triggers on patch now fire when explicitly requested via PreTriggers/PostTriggers. - // When not requested, triggers are not fired (same as real Cosmos DB). - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "stamp-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamped = true; req.setBody(doc); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 10 }), - new PartitionKey("a")); - - var patched = await container.PatchItemAsync( - "1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/val", 20) }); - - patched.Resource["stamped"].Should().BeNull("triggers not requested in this call"); - } - - [Fact] - public async Task ChangeFeed_RolledBack_OnPostTriggerFailure() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('fail'); }" - }); - - try - { - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - } - catch { } - - var cfIterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - var results = new List(); - while (cfIterator.HasMoreResults) - { - var batch = await cfIterator.ReadNextAsync(); - if (batch.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(batch); - } - - results.Should().BeEmpty("failed transaction should not appear in change feed"); - } - - [Fact] - public async Task Trigger_Js_TransactionalBatch_WithPropertiesHeader() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "batch-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var doc = ctx.getRequest().getBody(); doc.triggered = true; ctx.getRequest().setBody(doc); }" - }); - - var batch = container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), - new TransactionalBatchItemRequestOptions - { - Properties = new Dictionary - { - ["x-ms-pre-trigger-include"] = new[] { "batch-trigger" } - } - }); - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - ((bool)read["triggered"]!).Should().BeTrue(); - } - - [Fact] - public async Task TransactionalBatch_WithoutTriggerProperties_DoesNotFireTriggers() - { - // Triggers should only fire when explicitly specified via Properties headers. - // Without Properties, registered triggers are NOT automatically invoked for batch ops. - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var called = false; - container.RegisterTrigger("batch-post", TriggerType.Post, TriggerOperation.All, - _ => called = true); - - var batch = container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" })); - await batch.ExecuteAsync(); - - called.Should().BeFalse("batch operations do not invoke registered triggers unless specified via Properties headers"); - } + [Fact] + public async Task Trigger_Js_PatchOperation_FiresTrigger() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "patch-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + await container.PatchItemAsync( + "1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/val", 20) }, + new PatchItemRequestOptions { PreTriggers = new List { "patch-trigger" } }); + } + + [Fact] + public async Task DivergentBehavior_PatchTriggerFiresWhenRequested() + { + // Triggers on patch now fire when explicitly requested via PreTriggers/PostTriggers. + // When not requested, triggers are not fired (same as real Cosmos DB). + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "stamp-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamped = true; req.setBody(doc); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 10 }), + new PartitionKey("a")); + + var patched = await container.PatchItemAsync( + "1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/val", 20) }); + + patched.Resource["stamped"].Should().BeNull("triggers not requested in this call"); + } + + [Fact] + public async Task ChangeFeed_RolledBack_OnPostTriggerFailure() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('fail'); }" + }); + + try + { + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + } + catch { } + + var cfIterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + var results = new List(); + while (cfIterator.HasMoreResults) + { + var batch = await cfIterator.ReadNextAsync(); + if (batch.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(batch); + } + + results.Should().BeEmpty("failed transaction should not appear in change feed"); + } + + [Fact] + public async Task Trigger_Js_TransactionalBatch_WithPropertiesHeader() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "batch-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var doc = ctx.getRequest().getBody(); doc.triggered = true; ctx.getRequest().setBody(doc); }" + }); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), + new TransactionalBatchItemRequestOptions + { + Properties = new Dictionary + { + ["x-ms-pre-trigger-include"] = new[] { "batch-trigger" } + } + }); + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + ((bool)read["triggered"]!).Should().BeTrue(); + } + + [Fact] + public async Task TransactionalBatch_WithoutTriggerProperties_DoesNotFireTriggers() + { + // Triggers should only fire when explicitly specified via Properties headers. + // Without Properties, registered triggers are NOT automatically invoked for batch ops. + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var called = false; + container.RegisterTrigger("batch-post", TriggerType.Post, TriggerOperation.All, + _ => called = true); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" })); + await batch.ExecuteAsync(); + + called.Should().BeFalse("batch operations do not invoke registered triggers unless specified via Properties headers"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2954,95 +2958,95 @@ public async Task TransactionalBatch_WithoutTriggerProperties_DoesNotFireTrigger public class TriggerScriptsStatusCodeTests { - [Fact] - public async Task CreateTriggerAsync_ReturnsCreatedStatus() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var result = await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { }" - }); - - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReplaceTriggerAsync_ReturnsOkStatus() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { }" - }); - - var result = await container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run2() { }" - }); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteTriggerAsync_ReturnsNoContentStatus() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { }" - }); - - var result = await container.Scripts.DeleteTriggerAsync("trig1"); - - result.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task ReplaceTriggerAsync_ChangesType_FromPreToPost() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'pre'; req.setBody(doc); }" - }); - - // Replace to change from Pre to Post - await container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "trig1", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { /* now a post trigger */ }" - }); - - // It should no longer fire as a pre-trigger - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "trig1" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamp"].Should().BeNull("trigger was changed from Pre to Post — should not fire as pre-trigger"); - } + [Fact] + public async Task CreateTriggerAsync_ReturnsCreatedStatus() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var result = await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { }" + }); + + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReplaceTriggerAsync_ReturnsOkStatus() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { }" + }); + + var result = await container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run2() { }" + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteTriggerAsync_ReturnsNoContentStatus() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { }" + }); + + var result = await container.Scripts.DeleteTriggerAsync("trig1"); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task ReplaceTriggerAsync_ChangesType_FromPreToPost() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'pre'; req.setBody(doc); }" + }); + + // Replace to change from Pre to Post + await container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "trig1", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { /* now a post trigger */ }" + }); + + // It should no longer fire as a pre-trigger + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "trig1" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamp"].Should().BeNull("trigger was changed from Pre to Post — should not fire as pre-trigger"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -3051,161 +3055,161 @@ await container.CreateItemAsync( public class StreamTriggerRollbackTests { - [Fact] - public async Task PostTrigger_Js_RollsBack_CreateStream() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var json = JObject.FromObject(new { id = "1", pk = "a" }).ToString(); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - var act = () => container.CreateItemStreamAsync( - stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "stream create should be rolled back on post-trigger failure"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_ReplaceStream() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var json = JObject.FromObject(new { id = "1", pk = "a", val = "replaced" }).ToString(); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - var act = () => container.ReplaceItemStreamAsync( - stream, "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); - readBack.Resource["val"]!.ToString().Should().Be("original"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_UpsertStream_New() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var json = JObject.FromObject(new { id = "1", pk = "a" }).ToString(); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - var act = () => container.UpsertItemStreamAsync( - stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(0, "new stream upsert should be rolled back"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_UpsertStream_Existing() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = "original" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var json = JObject.FromObject(new { id = "1", pk = "a", val = "updated" }).ToString(); - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - - var act = () => container.UpsertItemStreamAsync( - stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); - readBack.Resource["val"]!.ToString().Should().Be("original"); - } - - [Fact] - public async Task PostTrigger_Js_RollsBack_DeleteStream() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-post", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('post failure'); }" - }); - - var act = () => container.DeleteItemStreamAsync( - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(1, "item should be restored when stream delete post-trigger fails"); - } - - [Fact] - public async Task PreTrigger_Js_DeleteStream_FiresAndBlocksDelete() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "throw-pre", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { throw new Error('pre failure'); }" - }); - - var act = () => container.DeleteItemStreamAsync( - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "throw-pre" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(1, "item should not be deleted when pre-trigger throws"); - } + [Fact] + public async Task PostTrigger_Js_RollsBack_CreateStream() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var json = JObject.FromObject(new { id = "1", pk = "a" }).ToString(); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + var act = () => container.CreateItemStreamAsync( + stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "stream create should be rolled back on post-trigger failure"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_ReplaceStream() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var json = JObject.FromObject(new { id = "1", pk = "a", val = "replaced" }).ToString(); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + var act = () => container.ReplaceItemStreamAsync( + stream, "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); + readBack.Resource["val"]!.ToString().Should().Be("original"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_UpsertStream_New() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var json = JObject.FromObject(new { id = "1", pk = "a" }).ToString(); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + var act = () => container.UpsertItemStreamAsync( + stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(0, "new stream upsert should be rolled back"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_UpsertStream_Existing() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = "original" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var json = JObject.FromObject(new { id = "1", pk = "a", val = "updated" }).ToString(); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + var act = () => container.UpsertItemStreamAsync( + stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + var readBack = await container.ReadItemAsync("1", new PartitionKey("a")); + readBack.Resource["val"]!.ToString().Should().Be("original"); + } + + [Fact] + public async Task PostTrigger_Js_RollsBack_DeleteStream() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-post", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('post failure'); }" + }); + + var act = () => container.DeleteItemStreamAsync( + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "throw-post" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(1, "item should be restored when stream delete post-trigger fails"); + } + + [Fact] + public async Task PreTrigger_Js_DeleteStream_FiresAndBlocksDelete() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "throw-pre", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { throw new Error('pre failure'); }" + }); + + var act = () => container.DeleteItemStreamAsync( + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "throw-pre" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(1, "item should not be deleted when pre-trigger throws"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -3214,55 +3218,55 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties public class TriggerConcurrencyTests { - [Fact] - public async Task PreTrigger_Js_ConcurrentCreates_AllTriggered() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "stamp-trigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.All, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'triggered'; req.setBody(doc); }" - }); - - var tasks = Enumerable.Range(0, 10).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"item-{i}", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp-trigger" } })); - - await Task.WhenAll(tasks); - - container.ItemCount.Should().Be(10); - for (var i = 0; i < 10; i++) - { - var item = (await container.ReadItemAsync($"item-{i}", new PartitionKey("a"))).Resource; - item["stamp"]!.ToString().Should().Be("triggered"); - } - } - - [Fact] - public async Task PostTrigger_Js_ConcurrentCreates_AllTriggered() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var callCount = 0; - container.RegisterTrigger("count-trigger", TriggerType.Post, TriggerOperation.All, - _ => Interlocked.Increment(ref callCount)); - - var tasks = Enumerable.Range(0, 10).Select(i => - container.CreateItemAsync( - JObject.FromObject(new { id = $"item-{i}", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "count-trigger" } })); - - await Task.WhenAll(tasks); - - container.ItemCount.Should().Be(10); - callCount.Should().Be(10); - } + [Fact] + public async Task PreTrigger_Js_ConcurrentCreates_AllTriggered() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "stamp-trigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.stamp = 'triggered'; req.setBody(doc); }" + }); + + var tasks = Enumerable.Range(0, 10).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"item-{i}", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp-trigger" } })); + + await Task.WhenAll(tasks); + + container.ItemCount.Should().Be(10); + for (var i = 0; i < 10; i++) + { + var item = (await container.ReadItemAsync($"item-{i}", new PartitionKey("a"))).Resource; + item["stamp"]!.ToString().Should().Be("triggered"); + } + } + + [Fact] + public async Task PostTrigger_Js_ConcurrentCreates_AllTriggered() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var callCount = 0; + container.RegisterTrigger("count-trigger", TriggerType.Post, TriggerOperation.All, + _ => Interlocked.Increment(ref callCount)); + + var tasks = Enumerable.Range(0, 10).Select(i => + container.CreateItemAsync( + JObject.FromObject(new { id = $"item-{i}", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "count-trigger" } })); + + await Task.WhenAll(tasks); + + container.ItemCount.Should().Be(10); + callCount.Should().Be(10); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -3271,60 +3275,62 @@ public async Task PostTrigger_Js_ConcurrentCreates_AllTriggered() public class JsTriggerEngineEdgeCaseDeepTests { - [Fact] - public async Task PreTrigger_Js_EmptyBody_DoesNotThrow() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "empty", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "" - }); - - // Empty JS body executes without error — document is stored unmodified - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "empty" } }); - - var stored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - stored["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task PostTrigger_Js_LargeDocument_HandledCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var postFired = false; - container.RegisterTrigger("lg", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => postFired = true)); - - var largeDoc = JObject.FromObject(new { id = "1", pk = "a", data = new string('X', 50000) }); - await container.CreateItemAsync(largeDoc, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "lg" } }); - - postFired.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_Js_UnicodeContent() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - JObject? received = null; - container.RegisterTrigger("uni", TriggerType.Post, TriggerOperation.Create, - (Action)(doc => received = doc)); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "héllo wörld 日本語" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "uni" } }); - - received.Should().NotBeNull(); - received!["name"]!.Value().Should().Be("héllo wörld 日本語"); - } + [Fact] + public async Task PreTrigger_Js_EmptyBody_DoesNotThrow() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "empty", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "" + }); + + // Empty JS body executes without error — document is stored unmodified + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "empty" } }); + + var stored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + stored["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task PostTrigger_Js_LargeDocument_HandledCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var postFired = false; + container.RegisterTrigger("lg", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => postFired = true)); + + var largeDoc = JObject.FromObject(new { id = "1", pk = "a", data = new string('X', 50000) }); + await container.CreateItemAsync(largeDoc, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "lg" } }); + + postFired.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_Js_UnicodeContent() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + JObject? received = null; + container.RegisterTrigger("uni", TriggerType.Post, TriggerOperation.Create, + (Action)(doc => received = doc)); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "héllo wörld 日本語" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "uni" } }); + + received.Should().NotBeNull(); + received!["name"]!.Value().Should().Be("héllo wörld 日本語"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -3333,74 +3339,82 @@ await container.CreateItemAsync( public class JsTriggerJsonRoundTripTests { - [Fact] - public async Task PreTrigger_Js_DateValues_PreservedAsStrings() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "noop", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" - }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", createdAt = "2024-01-15T13:45:00Z" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "noop" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["createdAt"]!.Value().Should().Be("2024-01-15T13:45:00Z"); - } - - [Fact] - public async Task PreTrigger_Js_DeepNestedObject_Preserved() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "noop", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" - }); - - var doc = JObject.FromObject(new { id = "1", pk = "a", level1 = new { level2 = new { level3 = new { value = 42 } } } }); - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "noop" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item.SelectToken("level1.level2.level3.value")!.Value().Should().Be(42); - } - - [Fact] - public async Task PreTrigger_Js_EmptyArray_Preserved() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "noop", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" - }); - - var doc = new JObject { ["id"] = "1", ["pk"] = "a", ["tags"] = new JArray() }; - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "noop" } }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["tags"]!.Should().BeOfType(); - ((JArray)item["tags"]!).Should().BeEmpty(); - } - - [Fact] - public async Task PreTrigger_Js_SetBody_CalledMultipleTimes_LastWins() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "multi-set", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = @"function run() { + [Fact] + public async Task PreTrigger_Js_DateValues_PreservedAsStrings() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "noop", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" + }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", createdAt = "2024-01-15T13:45:00Z" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "noop" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["createdAt"]!.Value().Should().Be("2024-01-15T13:45:00Z"); + } + + [Fact] + public async Task PreTrigger_Js_DeepNestedObject_Preserved() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "noop", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" + }); + + var doc = JObject.FromObject(new { id = "1", pk = "a", level1 = new { level2 = new { level3 = new { value = 42 } } } }); + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "noop" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item.SelectToken("level1.level2.level3.value")!.Value().Should().Be(42); + } + + [Fact] + public async Task PreTrigger_Js_EmptyArray_Preserved() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "noop", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() { var ctx = getContext(); var req = ctx.getRequest(); req.setBody(req.getBody()); }" + }); + + var doc = new JObject { ["id"] = "1", ["pk"] = "a", ["tags"] = new JArray() }; + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "noop" } }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["tags"]!.Should().BeOfType(); + ((JArray)item["tags"]!).Should().BeEmpty(); + } + + [Fact] + public async Task PreTrigger_Js_SetBody_CalledMultipleTimes_LastWins() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "multi-set", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = @"function run() { var ctx = getContext(); var req = ctx.getRequest(); var doc = req.getBody(); doc.tag = 'first'; @@ -3408,16 +3422,16 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties doc.tag = 'second'; req.setBody(doc); }" - }); + }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "multi-set" } }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "multi-set" } }); - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["tag"]!.Value().Should().Be("second"); - } + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["tag"]!.Value().Should().Be("second"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -3426,108 +3440,114 @@ await container.CreateItemAsync( public class JsTriggerDeleteStreamTests { - [Fact] - public async Task PostTrigger_Js_DeleteStream_Fires() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var postFired = false; - container.RegisterTrigger("post-del", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => postFired = true)); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-del" } }); - - postFired.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_Js_DeleteStream_ThrowRollsBack() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - container.RegisterTrigger("fail-del", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail-del" } }); - - await act.Should().ThrowAsync(); - container.ItemCount.Should().Be(1); // Rolled back - } - - [Fact] - public async Task PreTrigger_Js_DeleteStream_Fires() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - var preFired = false; - container.RegisterTrigger("pre-del", TriggerType.Pre, TriggerOperation.Delete, - (Func)(doc => { preFired = true; return doc; })); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - await container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre-del" } }); - - preFired.Should().BeTrue(); - } - - // ── Gap 5: Trigger stream query iterator ────────────────────────── - - [Fact] - public async Task GetTriggerQueryStreamIterator_ReturnsSerializedProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All, Body = "function(){}" - }); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t2", TriggerType = TriggerType.Post, TriggerOperation = TriggerOperation.Create, Body = "function(){}" - }); - - var iterator = container.Scripts.GetTriggerQueryStreamIterator(); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("t1"); - json.Should().Contain("t2"); - } - - // ── Gap 1: Trigger Collection Access ────────────────────────────── - - [Fact] - public async Task PreTrigger_Js_CollectionAccess_QueryDocuments() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - - // Seed a reference doc - await container.CreateItemAsync( - JObject.FromObject(new { id = "ref1", pk = "a", category = "premium" }), - new PartitionKey("a")); - - // Pre-trigger reads the reference doc and stamps the incoming doc - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pre-enrich", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = @"function enrich() { + [Fact] + public async Task PostTrigger_Js_DeleteStream_Fires() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var postFired = false; + container.RegisterTrigger("post-del", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => postFired = true)); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-del" } }); + + postFired.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_Js_DeleteStream_ThrowRollsBack() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + container.RegisterTrigger("fail-del", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail-del" } }); + + await act.Should().ThrowAsync(); + container.ItemCount.Should().Be(1); // Rolled back + } + + [Fact] + public async Task PreTrigger_Js_DeleteStream_Fires() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + var preFired = false; + container.RegisterTrigger("pre-del", TriggerType.Pre, TriggerOperation.Delete, + (Func)(doc => { preFired = true; return doc; })); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + await container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre-del" } }); + + preFired.Should().BeTrue(); + } + + // ── Gap 5: Trigger stream query iterator ────────────────────────── + + [Fact] + public async Task GetTriggerQueryStreamIterator_ReturnsSerializedProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All, + Body = "function(){}" + }); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t2", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.Create, + Body = "function(){}" + }); + + var iterator = container.Scripts.GetTriggerQueryStreamIterator(); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("t1"); + json.Should().Contain("t2"); + } + + // ── Gap 1: Trigger Collection Access ────────────────────────────── + + [Fact] + public async Task PreTrigger_Js_CollectionAccess_QueryDocuments() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + + // Seed a reference doc + await container.CreateItemAsync( + JObject.FromObject(new { id = "ref1", pk = "a", category = "premium" }), + new PartitionKey("a")); + + // Pre-trigger reads the reference doc and stamps the incoming doc + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pre-enrich", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = @"function enrich() { var ctx = getContext(); var request = ctx.getRequest(); var body = request.getBody(); @@ -3540,28 +3560,28 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties request.setBody(body); }); }" - }); - - var doc = JObject.FromObject(new { id = "new1", pk = "a" }); - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre-enrich" } }); - - var result = await container.ReadItemAsync("new1", new PartitionKey("a")); - result.Resource["enrichedFrom"]!.Value().Should().Be("premium"); - } - - [Fact] - public async Task PostTrigger_Js_CollectionAccess_CreateAuditDoc() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "post-audit", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.Create, - Body = @"function audit() { + }); + + var doc = JObject.FromObject(new { id = "new1", pk = "a" }); + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre-enrich" } }); + + var result = await container.ReadItemAsync("new1", new PartitionKey("a")); + result.Resource["enrichedFrom"]!.Value().Should().Be("premium"); + } + + [Fact] + public async Task PostTrigger_Js_CollectionAccess_CreateAuditDoc() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "post-audit", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.Create, + Body = @"function audit() { var ctx = getContext(); var doc = ctx.getResponse().getBody(); var collection = ctx.getCollection(); @@ -3569,33 +3589,33 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties { id: 'audit-' + doc.id, pk: doc.pk, action: 'created', sourceId: doc.id }, {}, function(err) { if (err) throw err; }); }" - }); - - var doc = JObject.FromObject(new { id = "order1", pk = "a", total = 100 }); - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-audit" } }); - - var audit = await container.ReadItemAsync("audit-order1", new PartitionKey("a")); - audit.Resource["action"]!.Value().Should().Be("created"); - audit.Resource["sourceId"]!.Value().Should().Be("order1"); - } - - [Fact] - public async Task Trigger_Js_CollectionScoped_SamePartition() - { - var container = new InMemoryContainer("test", "/pk"); - container.UseJsTriggers(); - - // Seed docs in two partitions - await container.CreateItemAsync(JObject.FromObject(new { id = "a1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "b1", pk = "b" }), new PartitionKey("b")); - - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "pre-count", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = @"function countNeighbors() { + }); + + var doc = JObject.FromObject(new { id = "order1", pk = "a", total = 100 }); + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-audit" } }); + + var audit = await container.ReadItemAsync("audit-order1", new PartitionKey("a")); + audit.Resource["action"]!.Value().Should().Be("created"); + audit.Resource["sourceId"]!.Value().Should().Be("order1"); + } + + [Fact] + public async Task Trigger_Js_CollectionScoped_SamePartition() + { + var container = new InMemoryContainer("test", "/pk"); + container.UseJsTriggers(); + + // Seed docs in two partitions + await container.CreateItemAsync(JObject.FromObject(new { id = "a1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "b1", pk = "b" }), new PartitionKey("b")); + + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "pre-count", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = @"function countNeighbors() { var ctx = getContext(); var request = ctx.getRequest(); var body = request.getBody(); @@ -3608,14 +3628,14 @@ await container.Scripts.CreateTriggerAsync(new TriggerProperties request.setBody(body); }); }" - }); + }); - // Create in partition 'a' — should only see the 1 existing doc in partition 'a' - var doc = JObject.FromObject(new { id = "a2", pk = "a" }); - await container.CreateItemAsync(doc, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre-count" } }); + // Create in partition 'a' — should only see the 1 existing doc in partition 'a' + var doc = JObject.FromObject(new { id = "a2", pk = "a" }); + await container.CreateItemAsync(doc, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre-count" } }); - var result = await container.ReadItemAsync("a2", new PartitionKey("a")); - result.Resource["neighborCount"]!.Value().Should().Be(1); - } + var result = await container.ReadItemAsync("a2", new PartitionKey("a")); + result.Resource["neighborCount"]!.Value().Should().Be(1); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqFeedIteratorDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqFeedIteratorDeepDiveTests.cs index fd7b6f6..c0435ee 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqFeedIteratorDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqFeedIteratorDeepDiveTests.cs @@ -1,11 +1,11 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using CosmosDB.InMemoryEmulator.ProductionExtensions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -17,116 +17,116 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection("FeedIteratorSetup")] public class LinqPaginationDeepTests : IDisposable { - private readonly InMemoryContainer _container = new("pag-container", "/partitionKey"); - - public LinqPaginationDeepTests() - { - InMemoryFeedIteratorSetup.Register(); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - } - - private async Task SeedItems(int count) - { - for (int i = 1; i <= count; i++) - await _container.CreateItemAsync( - new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}", Value = i }, - new PartitionKey("pk")); - } - - [Fact] - public async Task Pagination_MultiPage_ContinuationTokensFlowCorrectly() - { - await SeedItems(5); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var iterator = queryable.ToFeedIteratorOverridable(); - - var allItems = new List(); - int pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - pageCount++; - } - - allItems.Should().HaveCount(5); - pageCount.Should().BeGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task Pagination_WithWhereFilter_PagesFilteredResults() - { - await SeedItems(6); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) - .Where(d => d.Value > 3); - var iterator = queryable.ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); // Items 4, 5, 6 - } - - [Fact] - public async Task Pagination_WithOrderBy_MaintainsOrderAcrossPages() - { - await SeedItems(5); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) - .OrderByDescending(d => d.Value); - var iterator = queryable.ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Select(r => r.Value).Should().BeInDescendingOrder(); - } - - [Fact] - public async Task Pagination_MaxItemCountLargerThanTotal_SinglePage() - { - await SeedItems(3); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); - var iterator = queryable.ToFeedIteratorOverridable(); - - var page = await iterator.ReadNextAsync(); - page.Should().HaveCount(3); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task Pagination_MaxItemCountZero_ReturnsAllInOnePage() - { - // Divergent: MaxItemCount=0 treated same as null (all items) - await SeedItems(3); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 0 }); - var iterator = queryable.ToFeedIteratorOverridable(); - - var page = await iterator.ReadNextAsync(); - page.Should().HaveCount(3); - } - - [Fact] - public async Task Pagination_MaxItemCountNegative_ReturnsAllInOnePage() - { - await SeedItems(3); - var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = -1 }); - var iterator = queryable.ToFeedIteratorOverridable(); - - var page = await iterator.ReadNextAsync(); - page.Should().HaveCount(3); - } + private readonly InMemoryContainer _container = new("pag-container", "/partitionKey"); + + public LinqPaginationDeepTests() + { + InMemoryFeedIteratorSetup.Register(); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + } + + private async Task SeedItems(int count) + { + for (int i = 1; i <= count; i++) + await _container.CreateItemAsync( + new TestDocument { Id = i.ToString(), PartitionKey = "pk", Name = $"Item{i}", Value = i }, + new PartitionKey("pk")); + } + + [Fact] + public async Task Pagination_MultiPage_ContinuationTokensFlowCorrectly() + { + await SeedItems(5); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var iterator = queryable.ToFeedIteratorOverridable(); + + var allItems = new List(); + int pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + pageCount++; + } + + allItems.Should().HaveCount(5); + pageCount.Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task Pagination_WithWhereFilter_PagesFilteredResults() + { + await SeedItems(6); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) + .Where(d => d.Value > 3); + var iterator = queryable.ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); // Items 4, 5, 6 + } + + [Fact] + public async Task Pagination_WithOrderBy_MaintainsOrderAcrossPages() + { + await SeedItems(5); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 2 }) + .OrderByDescending(d => d.Value); + var iterator = queryable.ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Select(r => r.Value).Should().BeInDescendingOrder(); + } + + [Fact] + public async Task Pagination_MaxItemCountLargerThanTotal_SinglePage() + { + await SeedItems(3); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 100 }); + var iterator = queryable.ToFeedIteratorOverridable(); + + var page = await iterator.ReadNextAsync(); + page.Should().HaveCount(3); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task Pagination_MaxItemCountZero_ReturnsAllInOnePage() + { + // Divergent: MaxItemCount=0 treated same as null (all items) + await SeedItems(3); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = 0 }); + var iterator = queryable.ToFeedIteratorOverridable(); + + var page = await iterator.ReadNextAsync(); + page.Should().HaveCount(3); + } + + [Fact] + public async Task Pagination_MaxItemCountNegative_ReturnsAllInOnePage() + { + await SeedItems(3); + var queryable = _container.GetItemLinqQueryable(requestOptions: new QueryRequestOptions { MaxItemCount = -1 }); + var iterator = queryable.ToFeedIteratorOverridable(); + + var page = await iterator.ReadNextAsync(); + page.Should().HaveCount(3); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -136,47 +136,47 @@ public async Task Pagination_MaxItemCountNegative_ReturnsAllInOnePage() [Collection("FeedIteratorSetup")] public class LinqResponseShapeTests : IDisposable { - private readonly InMemoryContainer _container = new("resp-container", "/partitionKey"); - - public LinqResponseShapeTests() - { - InMemoryFeedIteratorSetup.Register(); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - } - - [Fact] - public async Task Response_Resource_MatchesEnumeration() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, - new PartitionKey("pk")); - - var queryable = _container.GetItemLinqQueryable(); - var iterator = queryable.ToFeedIteratorOverridable(); - var page = await iterator.ReadNextAsync(); - - var enumerated = page.ToList(); - var fromResource = page.Resource.ToList(); - enumerated.Should().HaveCount(fromResource.Count); - } - - [Fact] - public async Task Response_CancellationToken_IsAccepted() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, - new PartitionKey("pk")); - - var queryable = _container.GetItemLinqQueryable(); - var iterator = queryable.ToFeedIteratorOverridable(); - - var page = await iterator.ReadNextAsync(CancellationToken.None); - page.Should().HaveCount(1); - } + private readonly InMemoryContainer _container = new("resp-container", "/partitionKey"); + + public LinqResponseShapeTests() + { + InMemoryFeedIteratorSetup.Register(); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + } + + [Fact] + public async Task Response_Resource_MatchesEnumeration() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, + new PartitionKey("pk")); + + var queryable = _container.GetItemLinqQueryable(); + var iterator = queryable.ToFeedIteratorOverridable(); + var page = await iterator.ReadNextAsync(); + + var enumerated = page.ToList(); + var fromResource = page.Resource.ToList(); + enumerated.Should().HaveCount(fromResource.Count); + } + + [Fact] + public async Task Response_CancellationToken_IsAccepted() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, + new PartitionKey("pk")); + + var queryable = _container.GetItemLinqQueryable(); + var iterator = queryable.ToFeedIteratorOverridable(); + + var page = await iterator.ReadNextAsync(CancellationToken.None); + page.Should().HaveCount(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -186,48 +186,48 @@ await _container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class LinqDisposeSafetyTests : IDisposable { - private readonly InMemoryContainer _container = new("disp-container", "/partitionKey"); - - public LinqDisposeSafetyTests() - { - InMemoryFeedIteratorSetup.Register(); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - } - - [Fact] - public async Task FeedIterator_Dispose_DoesNotThrow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, - new PartitionKey("pk")); - - var queryable = _container.GetItemLinqQueryable(); - var iterator = queryable.ToFeedIteratorOverridable(); - await iterator.ReadNextAsync(); - - var act = () => iterator.Dispose(); - act.Should().NotThrow(); - } - - [Fact] - public async Task FeedIterator_DoubleDispose_DoesNotThrow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, - new PartitionKey("pk")); - - var queryable = _container.GetItemLinqQueryable(); - var iterator = queryable.ToFeedIteratorOverridable(); - await iterator.ReadNextAsync(); - - iterator.Dispose(); - var act = () => iterator.Dispose(); - act.Should().NotThrow(); - } + private readonly InMemoryContainer _container = new("disp-container", "/partitionKey"); + + public LinqDisposeSafetyTests() + { + InMemoryFeedIteratorSetup.Register(); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + } + + [Fact] + public async Task FeedIterator_Dispose_DoesNotThrow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, + new PartitionKey("pk")); + + var queryable = _container.GetItemLinqQueryable(); + var iterator = queryable.ToFeedIteratorOverridable(); + await iterator.ReadNextAsync(); + + var act = () => iterator.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public async Task FeedIterator_DoubleDispose_DoesNotThrow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 1 }, + new PartitionKey("pk")); + + var queryable = _container.GetItemLinqQueryable(); + var iterator = queryable.ToFeedIteratorOverridable(); + await iterator.ReadNextAsync(); + + iterator.Dispose(); + var act = () => iterator.Dispose(); + act.Should().NotThrow(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -237,133 +237,133 @@ await _container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class LinqDivergentOperatorTests : IDisposable { - private readonly InMemoryContainer _container = new("div-ops", "/partitionKey"); - - public LinqDivergentOperatorTests() - { - InMemoryFeedIteratorSetup.Register(); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - } - - private async Task SeedItems() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", Value = 1 }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B", Value = 2 }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk", Name = "C", Value = 3 }, new PartitionKey("pk")); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where Concat is natively supported. Real Cosmos SDK throws NotSupportedException.")] - public async Task Linq_Concat_RealCosmos_ShouldThrowNotSupported() - { - await SeedItems(); - var q1 = _container.GetItemLinqQueryable().Where(d => d.Value == 1); - var q2 = _container.GetItemLinqQueryable().Where(d => d.Value == 2); - var act = () => q1.Concat(q2).ToList(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_Concat_WorksInMemory_DivergentBehavior() - { - await SeedItems(); - var q1 = _container.GetItemLinqQueryable().Where(d => d.Value == 1); - var q2 = _container.GetItemLinqQueryable().Where(d => d.Value == 2); - var result = q1.Concat(q2).ToList(); - result.Should().HaveCount(2); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where Union is natively supported. Real Cosmos SDK throws NotSupportedException.")] - public async Task Linq_Union_RealCosmos_ShouldThrowNotSupported() - { - await SeedItems(); - var q1 = _container.GetItemLinqQueryable().Where(d => d.Value <= 2); - var q2 = _container.GetItemLinqQueryable().Where(d => d.Value >= 2); - var act = () => q1.Union(q2).ToList(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_Union_WorksInMemory_DivergentBehavior() - { - await SeedItems(); - var q1 = _container.GetItemLinqQueryable().Where(d => d.Value <= 2); - var q2 = _container.GetItemLinqQueryable().Where(d => d.Value >= 2); - var result = q1.Union(q2).ToList(); - result.Should().HaveCount(4); // LINQ-to-Objects Union uses reference equality, so objects aren't deduped - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where DefaultIfEmpty is natively supported. Real Cosmos SDK throws NotSupportedException.")] - public void Linq_DefaultIfEmpty_RealCosmos_ShouldThrowNotSupported() - { - var act = () => _container.GetItemLinqQueryable() - .Where(d => d.Value > 100).DefaultIfEmpty().ToList(); - act.Should().Throw(); - } - - [Fact] - public void Linq_DefaultIfEmpty_WorksInMemory_DivergentBehavior() - { - var result = _container.GetItemLinqQueryable() - .Where(d => d.Value > 100).DefaultIfEmpty().ToList(); - result.Should().ContainSingle().Which.Should().BeNull(); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where ElementAt is natively supported. Real Cosmos SDK throws NotSupportedException.")] - public async Task Linq_ElementAt_RealCosmos_ShouldThrowNotSupported() - { - await SeedItems(); - var act = () => _container.GetItemLinqQueryable().ElementAt(0); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_ElementAt_WorksInMemory_DivergentBehavior() - { - await SeedItems(); - var item = _container.GetItemLinqQueryable() - .OrderBy(d => d.Value).ElementAt(1); - item.Value.Should().Be(2); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where ElementAtOrDefault is natively supported. Real Cosmos SDK throws NotSupportedException.")] - public async Task Linq_ElementAtOrDefault_RealCosmos_ShouldThrowNotSupported() - { - await SeedItems(); - var act = () => _container.GetItemLinqQueryable().ElementAtOrDefault(100); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_ElementAtOrDefault_WorksInMemory_DivergentBehavior() - { - await SeedItems(); - var item = _container.GetItemLinqQueryable().ElementAtOrDefault(100); - item.Should().BeNull(); - } - - [Fact] - public async Task Linq_ToDictionary_WorksInMemory() - { - await SeedItems(); - var dict = _container.GetItemLinqQueryable() - .ToDictionary(d => d.Id); - dict.Should().HaveCount(3); - dict.Should().ContainKey("1"); - } - - [Fact] - public async Task Linq_ToHashSet_WorksInMemory() - { - await SeedItems(); - var set = _container.GetItemLinqQueryable() - .Select(d => d.Name).ToHashSet(); - set.Should().HaveCount(3); - set.Should().Contain("A"); - } + private readonly InMemoryContainer _container = new("div-ops", "/partitionKey"); + + public LinqDivergentOperatorTests() + { + InMemoryFeedIteratorSetup.Register(); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + } + + private async Task SeedItems() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", Value = 1 }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B", Value = 2 }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk", Name = "C", Value = 3 }, new PartitionKey("pk")); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where Concat is natively supported. Real Cosmos SDK throws NotSupportedException.")] + public async Task Linq_Concat_RealCosmos_ShouldThrowNotSupported() + { + await SeedItems(); + var q1 = _container.GetItemLinqQueryable().Where(d => d.Value == 1); + var q2 = _container.GetItemLinqQueryable().Where(d => d.Value == 2); + var act = () => q1.Concat(q2).ToList(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_Concat_WorksInMemory_DivergentBehavior() + { + await SeedItems(); + var q1 = _container.GetItemLinqQueryable().Where(d => d.Value == 1); + var q2 = _container.GetItemLinqQueryable().Where(d => d.Value == 2); + var result = q1.Concat(q2).ToList(); + result.Should().HaveCount(2); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where Union is natively supported. Real Cosmos SDK throws NotSupportedException.")] + public async Task Linq_Union_RealCosmos_ShouldThrowNotSupported() + { + await SeedItems(); + var q1 = _container.GetItemLinqQueryable().Where(d => d.Value <= 2); + var q2 = _container.GetItemLinqQueryable().Where(d => d.Value >= 2); + var act = () => q1.Union(q2).ToList(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_Union_WorksInMemory_DivergentBehavior() + { + await SeedItems(); + var q1 = _container.GetItemLinqQueryable().Where(d => d.Value <= 2); + var q2 = _container.GetItemLinqQueryable().Where(d => d.Value >= 2); + var result = q1.Union(q2).ToList(); + result.Should().HaveCount(4); // LINQ-to-Objects Union uses reference equality, so objects aren't deduped + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where DefaultIfEmpty is natively supported. Real Cosmos SDK throws NotSupportedException.")] + public void Linq_DefaultIfEmpty_RealCosmos_ShouldThrowNotSupported() + { + var act = () => _container.GetItemLinqQueryable() + .Where(d => d.Value > 100).DefaultIfEmpty().ToList(); + act.Should().Throw(); + } + + [Fact] + public void Linq_DefaultIfEmpty_WorksInMemory_DivergentBehavior() + { + var result = _container.GetItemLinqQueryable() + .Where(d => d.Value > 100).DefaultIfEmpty().ToList(); + result.Should().ContainSingle().Which.Should().BeNull(); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where ElementAt is natively supported. Real Cosmos SDK throws NotSupportedException.")] + public async Task Linq_ElementAt_RealCosmos_ShouldThrowNotSupported() + { + await SeedItems(); + var act = () => _container.GetItemLinqQueryable().ElementAt(0); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_ElementAt_WorksInMemory_DivergentBehavior() + { + await SeedItems(); + var item = _container.GetItemLinqQueryable() + .OrderBy(d => d.Value).ElementAt(1); + item.Value.Should().Be(2); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects where ElementAtOrDefault is natively supported. Real Cosmos SDK throws NotSupportedException.")] + public async Task Linq_ElementAtOrDefault_RealCosmos_ShouldThrowNotSupported() + { + await SeedItems(); + var act = () => _container.GetItemLinqQueryable().ElementAtOrDefault(100); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_ElementAtOrDefault_WorksInMemory_DivergentBehavior() + { + await SeedItems(); + var item = _container.GetItemLinqQueryable().ElementAtOrDefault(100); + item.Should().BeNull(); + } + + [Fact] + public async Task Linq_ToDictionary_WorksInMemory() + { + await SeedItems(); + var dict = _container.GetItemLinqQueryable() + .ToDictionary(d => d.Id); + dict.Should().HaveCount(3); + dict.Should().ContainKey("1"); + } + + [Fact] + public async Task Linq_ToHashSet_WorksInMemory() + { + await SeedItems(); + var set = _container.GetItemLinqQueryable() + .Select(d => d.Name).ToHashSet(); + set.Should().HaveCount(3); + set.Should().Contain("A"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -372,39 +372,39 @@ public async Task Linq_ToHashSet_WorksInMemory() public class LinqNumericEdgeCaseTests { - private readonly InMemoryContainer _container = new("num-edge", "/partitionKey"); - - [Fact] - public void Linq_Min_OnEmpty_ThrowsInvalidOperationException() - { - var act = () => _container.GetItemLinqQueryable() - .Select(d => d.Value).Min(); - act.Should().Throw(); - } - - [Fact] - public void Linq_Max_OnEmpty_ThrowsInvalidOperationException() - { - var act = () => _container.GetItemLinqQueryable() - .Select(d => d.Value).Max(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_NegativeValues_InAggregates() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Value = -10 }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Value = -20 }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk", Value = 5 }, new PartitionKey("pk")); - - var min = _container.GetItemLinqQueryable().Select(d => d.Value).Min(); - var max = _container.GetItemLinqQueryable().Select(d => d.Value).Max(); - var sum = _container.GetItemLinqQueryable().Select(d => d.Value).Sum(); - - min.Should().Be(-20); - max.Should().Be(5); - sum.Should().Be(-25); - } + private readonly InMemoryContainer _container = new("num-edge", "/partitionKey"); + + [Fact] + public void Linq_Min_OnEmpty_ThrowsInvalidOperationException() + { + var act = () => _container.GetItemLinqQueryable() + .Select(d => d.Value).Min(); + act.Should().Throw(); + } + + [Fact] + public void Linq_Max_OnEmpty_ThrowsInvalidOperationException() + { + var act = () => _container.GetItemLinqQueryable() + .Select(d => d.Value).Max(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_NegativeValues_InAggregates() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Value = -10 }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Value = -20 }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk", Value = 5 }, new PartitionKey("pk")); + + var min = _container.GetItemLinqQueryable().Select(d => d.Value).Min(); + var max = _container.GetItemLinqQueryable().Select(d => d.Value).Max(); + var sum = _container.GetItemLinqQueryable().Select(d => d.Value).Sum(); + + min.Should().Be(-20); + max.Should().Be(5); + sum.Should().Be(-25); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -413,52 +413,52 @@ public async Task Linq_NegativeValues_InAggregates() public class LinqSerializationEdgeCaseTests { - private readonly InMemoryContainer _container = new("ser-edge", "/partitionKey"); - - [Fact] - public async Task Linq_UnicodeCharacters_InValues_Queryable() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "こんにちは" }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "🎉" }, new PartitionKey("pk")); - - var results = _container.GetItemLinqQueryable() - .Where(d => d.Name == "こんにちは").ToList(); - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task Linq_EmptyStringProperty_Queryable() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "" }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "test" }, new PartitionKey("pk")); - - var results = _container.GetItemLinqQueryable() - .Where(d => d.Name == "").ToList(); - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task Linq_NullStringProperty_Queryable() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = null! }, new PartitionKey("pk")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "test" }, new PartitionKey("pk")); - - var results = _container.GetItemLinqQueryable() - .Where(d => d.Name == null).ToList(); - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task Linq_SpecialJsonCharacters_Queryable() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "he said \"hello\"" }, - new PartitionKey("pk")); - - var results = _container.GetItemLinqQueryable() - .Where(d => d.Name.Contains("hello")).ToList(); - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("ser-edge", "/partitionKey"); + + [Fact] + public async Task Linq_UnicodeCharacters_InValues_Queryable() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "こんにちは" }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "🎉" }, new PartitionKey("pk")); + + var results = _container.GetItemLinqQueryable() + .Where(d => d.Name == "こんにちは").ToList(); + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task Linq_EmptyStringProperty_Queryable() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = "" }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "test" }, new PartitionKey("pk")); + + var results = _container.GetItemLinqQueryable() + .Where(d => d.Name == "").ToList(); + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task Linq_NullStringProperty_Queryable() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk", Name = null! }, new PartitionKey("pk")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk", Name = "test" }, new PartitionKey("pk")); + + var results = _container.GetItemLinqQueryable() + .Where(d => d.Name == null).ToList(); + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task Linq_SpecialJsonCharacters_Queryable() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "he said \"hello\"" }, + new PartitionKey("pk")); + + var results = _container.GetItemLinqQueryable() + .Where(d => d.Name.Contains("hello")).ToList(); + results.Should().ContainSingle(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -467,40 +467,40 @@ await _container.CreateItemAsync( public class LinqPartitionKeyEdgeCaseTests { - [Fact] - public async Task Linq_WithPartitionKeyNone_ReturnsAllItems() - { - var container = new InMemoryContainer("pk-none", "/partitionKey"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.None }) - .ToList(); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Linq_WithHierarchicalPartitionKey_FiltersCorrectly() - { - var container = new InMemoryContainer("pk-hier", new List { "/region", "/city" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", region = "US", city = "NYC", val = 1 }), - new PartitionKeyBuilder().Add("US").Add("NYC").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", region = "US", city = "LA", val = 2 }), - new PartitionKeyBuilder().Add("US").Add("LA").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", region = "UK", city = "LON", val = 3 }), - new PartitionKeyBuilder().Add("UK").Add("LON").Build()); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions - { - PartitionKey = new PartitionKeyBuilder().Add("US").Add("NYC").Build() - }).ToList(); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task Linq_WithPartitionKeyNone_ReturnsAllItems() + { + var container = new InMemoryContainer("pk-none", "/partitionKey"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.None }) + .ToList(); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Linq_WithHierarchicalPartitionKey_FiltersCorrectly() + { + var container = new InMemoryContainer("pk-hier", new List { "/region", "/city" }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", region = "US", city = "NYC", val = 1 }), + new PartitionKeyBuilder().Add("US").Add("NYC").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", region = "US", city = "LA", val = 2 }), + new PartitionKeyBuilder().Add("US").Add("LA").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", region = "UK", city = "LON", val = 3 }), + new PartitionKeyBuilder().Add("UK").Add("LON").Build()); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions + { + PartitionKey = new PartitionKeyBuilder().Add("US").Add("NYC").Build() + }).ToList(); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -510,49 +510,49 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class LinqStreamFeedIteratorTests : IDisposable { - private readonly InMemoryContainer _container = new("stream-linq", "/partitionKey"); - - public LinqStreamFeedIteratorTests() - { - InMemoryFeedIteratorSetup.Register(); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - } - - [Fact] - public async Task StreamFeedIterator_ReturnsStreamResponse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 42 }, - new PartitionKey("pk")); - - // Use GetItemQueryStreamIterator directly since ToStreamIterator requires real SDK - var iterator = _container.GetItemQueryStreamIterator("SELECT * FROM c"); - - iterator.HasMoreResults.Should().BeTrue(); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Test"); - } - - [Fact] - public async Task StreamFeedIterator_HasCorrectStatusCode() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "X" }, - new PartitionKey("pk")); - - var iterator = _container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iterator.ReadNextAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.IsSuccessStatusCode.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("stream-linq", "/partitionKey"); + + public LinqStreamFeedIteratorTests() + { + InMemoryFeedIteratorSetup.Register(); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + } + + [Fact] + public async Task StreamFeedIterator_ReturnsStreamResponse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test", Value = 42 }, + new PartitionKey("pk")); + + // Use GetItemQueryStreamIterator directly since ToStreamIterator requires real SDK + var iterator = _container.GetItemQueryStreamIterator("SELECT * FROM c"); + + iterator.HasMoreResults.Should().BeTrue(); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Test"); + } + + [Fact] + public async Task StreamFeedIterator_HasCorrectStatusCode() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "X" }, + new PartitionKey("pk")); + + var iterator = _container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iterator.ReadNextAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.IsSuccessStatusCode.Should().BeTrue(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqToFeedIteratorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqToFeedIteratorTests.cs index 2fe5dc3..1053318 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqToFeedIteratorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/LinqToFeedIteratorTests.cs @@ -1,11 +1,11 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using CosmosDB.InMemoryEmulator.ProductionExtensions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; -using Xunit; -using System.Net; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -17,281 +17,281 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection("FeedIteratorSetup")] public class LinqToFeedIteratorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ToFeedIterator_ThrowsWhenUsedWithInMemoryContainer() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable() - .Where(document => document.Name == "Alice"); - - var act = () => queryable.ToFeedIterator(); - - act.Should().Throw() - .WithMessage("*ToFeedIterator is only supported on Cosmos LINQ query operations*"); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WorksWithInMemoryContainer() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .Where(document => document.Value > 10) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithOrderBy_ReturnsOrderedResults() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithSelect_ReturnsProjectedResults() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .Select(document => document.Name) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Should().Be("Alice"); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithNoMatchingItems_ReturnsEmpty() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .Where(document => document.Name == "Nobody") - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().BeEmpty(); - } - - [Fact] - public void Deregister_ClearsFactory() - { - InMemoryFeedIteratorSetup.Register(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); - - InMemoryFeedIteratorSetup.Deregister(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); - } - - [Fact] - public void Deregister_AllowsReRegister() - { - InMemoryFeedIteratorSetup.Register(); - InMemoryFeedIteratorSetup.Deregister(); - InMemoryFeedIteratorSetup.Register(); - - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithRegister_WorksWithFilter() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .Where(document => document.Value > 10) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithRegister_OrderByWorks() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ToFeedIterator_ThrowsWhenUsedWithInMemoryContainer() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable() + .Where(document => document.Name == "Alice"); + + var act = () => queryable.ToFeedIterator(); + + act.Should().Throw() + .WithMessage("*ToFeedIterator is only supported on Cosmos LINQ query operations*"); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WorksWithInMemoryContainer() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .Where(document => document.Value > 10) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithOrderBy_ReturnsOrderedResults() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithSelect_ReturnsProjectedResults() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .Select(document => document.Name) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Should().Be("Alice"); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithNoMatchingItems_ReturnsEmpty() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .Where(document => document.Name == "Nobody") + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().BeEmpty(); + } + + [Fact] + public void Deregister_ClearsFactory() + { + InMemoryFeedIteratorSetup.Register(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); + + InMemoryFeedIteratorSetup.Deregister(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); + } + + [Fact] + public void Deregister_AllowsReRegister() + { + InMemoryFeedIteratorSetup.Register(); + InMemoryFeedIteratorSetup.Deregister(); + InMemoryFeedIteratorSetup.Register(); + + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithRegister_WorksWithFilter() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .Where(document => document.Value > 10) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithRegister_OrderByWorks() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie"); + } } public class LinqGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task SeedItems() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["urgent"] }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 10, IsActive = true, Tags = ["urgent", "important"] }, new PartitionKey("pk2")); - } + private async Task SeedItems() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["urgent"] }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 10, IsActive = true, Tags = ["urgent", "important"] }, new PartitionKey("pk2")); + } - [Fact] - public async Task Linq_Where_CompoundConditions() - { - await SeedItems(); + [Fact] + public async Task Linq_Where_CompoundConditions() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.Where(doc => doc.Value > 15 && doc.IsActive).ToList(); + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.Where(doc => doc.Value > 15 && doc.IsActive).ToList(); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } - [Fact] - public async Task Linq_OrderBy_ThenByDescending() - { - await SeedItems(); + [Fact] + public async Task Linq_OrderBy_ThenByDescending() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.OrderBy(doc => doc.IsActive).ThenByDescending(doc => doc.Value).ToList(); + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.OrderBy(doc => doc.IsActive).ThenByDescending(doc => doc.Value).ToList(); - // false sorts before true; within active=false, highest value first - results[0].Name.Should().Be("Bob"); - } + // false sorts before true; within active=false, highest value first + results[0].Name.Should().Be("Bob"); + } - [Fact] - public async Task Linq_Skip_Take_Pagination() - { - await SeedItems(); + [Fact] + public async Task Linq_Skip_Take_Pagination() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.OrderBy(doc => doc.Value).Skip(1).Take(1).ToList(); + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.OrderBy(doc => doc.Value).Skip(1).Take(1).ToList(); - results.Should().ContainSingle().Which.Value.Should().Be(20); - } + results.Should().ContainSingle().Which.Value.Should().Be(20); + } - [Fact] - public async Task Linq_Count_Aggregate() - { - await SeedItems(); + [Fact] + public async Task Linq_Count_Aggregate() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var count = queryable.Count(); + var queryable = _container.GetItemLinqQueryable(true); + var count = queryable.Count(); - count.Should().Be(3); - } + count.Should().Be(3); + } - [Fact] - public async Task Linq_FirstOrDefault() - { - await SeedItems(); + [Fact] + public async Task Linq_FirstOrDefault() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var result = queryable.FirstOrDefault(doc => doc.Name == "Charlie"); + var queryable = _container.GetItemLinqQueryable(true); + var result = queryable.FirstOrDefault(doc => doc.Name == "Charlie"); - result.Should().NotBeNull(); - result!.Value.Should().Be(10); - } + result.Should().NotBeNull(); + result!.Value.Should().Be(10); + } - [Fact] - public async Task Linq_Any_ExistenceCheck() - { - await SeedItems(); + [Fact] + public async Task Linq_Any_ExistenceCheck() + { + await SeedItems(); - var queryable = _container.GetItemLinqQueryable(true); - var hasUrgent = queryable.Any(doc => doc.Tags.Contains("urgent")); + var queryable = _container.GetItemLinqQueryable(true); + var hasUrgent = queryable.Any(doc => doc.Tags.Contains("urgent")); - hasUrgent.Should().BeTrue(); - } + hasUrgent.Should().BeTrue(); + } } @@ -303,42 +303,42 @@ public async Task Linq_Any_ExistenceCheck() /// public class LinqQueryableParameterTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Linq_WithContinuationToken_DoesNotThrow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Should not throw when passing a continuation token - var queryable = _container.GetItemLinqQueryable( - allowSynchronousQueryExecution: true, - continuationToken: "some-token"); - - queryable.ToList().Should().ContainSingle(); - } - - [Fact] - public async Task Linq_WithLinqSerializerOptions_DoesNotThrow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var options = new CosmosLinqSerializerOptions - { - PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase - }; - - // Should not throw when passing serializer options - var queryable = _container.GetItemLinqQueryable( - allowSynchronousQueryExecution: true, - linqSerializerOptions: options); - - queryable.ToList().Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Linq_WithContinuationToken_DoesNotThrow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Should not throw when passing a continuation token + var queryable = _container.GetItemLinqQueryable( + allowSynchronousQueryExecution: true, + continuationToken: "some-token"); + + queryable.ToList().Should().ContainSingle(); + } + + [Fact] + public async Task Linq_WithLinqSerializerOptions_DoesNotThrow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var options = new CosmosLinqSerializerOptions + { + PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase + }; + + // Should not throw when passing serializer options + var queryable = _container.GetItemLinqQueryable( + allowSynchronousQueryExecution: true, + linqSerializerOptions: options); + + queryable.ToList().Should().ContainSingle(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -347,109 +347,109 @@ await _container.CreateItemAsync( public class LinqOperatorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 10, IsActive = true }, new PartitionKey("pk2")); - await _container.CreateItemAsync(new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true }, new PartitionKey("pk2")); - } - - [Fact] - public async Task Linq_OrderByDescending() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var results = q.OrderByDescending(d => d.Value).ToList(); - - results.Select(d => d.Value).Should().BeInDescendingOrder(); - } - - [Fact] - public async Task Linq_ThenBy_AfterOrderBy() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var results = q.OrderBy(d => d.IsActive).ThenBy(d => d.Value).ToList(); - - // false < true, then ascending value - results[0].Name.Should().Be("Bob"); // IsActive=false, Value=20 - results[1].Value.Should().BeLessThanOrEqualTo(results[2].Value); - } - - [Fact] - public async Task Linq_Sum_Aggregate() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var sum = q.Sum(d => d.Value); - - sum.Should().Be(100); // 30+20+10+40 - } - - [Fact] - public async Task Linq_Average_Aggregate() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var avg = q.Average(d => d.Value); - - avg.Should().Be(25.0); // 100/4 - } - - [Fact] - public async Task Linq_Min_Max_Aggregates() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - - q.Min(d => d.Value).Should().Be(10); - q.Max(d => d.Value).Should().Be(40); - } - - [Fact] - public async Task Linq_Distinct_RemovesDuplicateProjections() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var activeFlags = q.Select(d => d.IsActive).Distinct().ToList(); - - activeFlags.Should().HaveCount(2); - activeFlags.Should().Contain(true); - activeFlags.Should().Contain(false); - } - - [Fact] - public async Task Linq_GroupBy_ByPartitionKey() - { - await SeedItems(); - var q = _container.GetItemLinqQueryable(true); - var groups = q.GroupBy(d => d.PartitionKey).ToList(); - - groups.Should().HaveCount(2); - groups.SelectMany(g => g).Should().HaveCount(4); - } - - [Fact] - public async Task Linq_SelectMany_FlattenTags() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Tags = ["x", "y"] }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Tags = ["y", "z"] }, - new PartitionKey("pk1")); - - var q = container.GetItemLinqQueryable(true); - var allTags = q.SelectMany(d => d.Tags).ToList(); - - allTags.Should().HaveCount(4); - allTags.Should().Contain("x"); - allTags.Should().Contain("z"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 10, IsActive = true }, new PartitionKey("pk2")); + await _container.CreateItemAsync(new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true }, new PartitionKey("pk2")); + } + + [Fact] + public async Task Linq_OrderByDescending() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var results = q.OrderByDescending(d => d.Value).ToList(); + + results.Select(d => d.Value).Should().BeInDescendingOrder(); + } + + [Fact] + public async Task Linq_ThenBy_AfterOrderBy() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var results = q.OrderBy(d => d.IsActive).ThenBy(d => d.Value).ToList(); + + // false < true, then ascending value + results[0].Name.Should().Be("Bob"); // IsActive=false, Value=20 + results[1].Value.Should().BeLessThanOrEqualTo(results[2].Value); + } + + [Fact] + public async Task Linq_Sum_Aggregate() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var sum = q.Sum(d => d.Value); + + sum.Should().Be(100); // 30+20+10+40 + } + + [Fact] + public async Task Linq_Average_Aggregate() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var avg = q.Average(d => d.Value); + + avg.Should().Be(25.0); // 100/4 + } + + [Fact] + public async Task Linq_Min_Max_Aggregates() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + + q.Min(d => d.Value).Should().Be(10); + q.Max(d => d.Value).Should().Be(40); + } + + [Fact] + public async Task Linq_Distinct_RemovesDuplicateProjections() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var activeFlags = q.Select(d => d.IsActive).Distinct().ToList(); + + activeFlags.Should().HaveCount(2); + activeFlags.Should().Contain(true); + activeFlags.Should().Contain(false); + } + + [Fact] + public async Task Linq_GroupBy_ByPartitionKey() + { + await SeedItems(); + var q = _container.GetItemLinqQueryable(true); + var groups = q.GroupBy(d => d.PartitionKey).ToList(); + + groups.Should().HaveCount(2); + groups.SelectMany(g => g).Should().HaveCount(4); + } + + [Fact] + public async Task Linq_SelectMany_FlattenTags() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Tags = ["x", "y"] }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Tags = ["y", "z"] }, + new PartitionKey("pk1")); + + var q = container.GetItemLinqQueryable(true); + var allTags = q.SelectMany(d => d.Tags).ToList(); + + allTags.Should().HaveCount(4); + allTags.Should().Contain("x"); + allTags.Should().Contain("z"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -459,28 +459,28 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class LinqRegistrationLifecycleTests { - [Fact] - public void Register_SetsStaticFallbackFactory() - { - InMemoryFeedIteratorSetup.Register(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); - } - - [Fact] - public void Register_IsIdempotent_CallingTwiceDoesNotThrow() - { - InMemoryFeedIteratorSetup.Register(); - InMemoryFeedIteratorSetup.Register(); // second call should not throw - - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); - } - - [Fact] - public void ToFeedIteratorOverridable_WithoutRegister_ThrowsOrReturnsNull() - { - InMemoryFeedIteratorSetup.Deregister(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); - } + [Fact] + public void Register_SetsStaticFallbackFactory() + { + InMemoryFeedIteratorSetup.Register(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); + } + + [Fact] + public void Register_IsIdempotent_CallingTwiceDoesNotThrow() + { + InMemoryFeedIteratorSetup.Register(); + InMemoryFeedIteratorSetup.Register(); // second call should not throw + + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); + } + + [Fact] + public void ToFeedIteratorOverridable_WithoutRegister_ThrowsOrReturnsNull() + { + InMemoryFeedIteratorSetup.Deregister(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -490,207 +490,207 @@ public void ToFeedIteratorOverridable_WithoutRegister_ThrowsOrReturnsNull() [Collection("FeedIteratorSetup")] public class LinqFeedIteratorIntegrationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ToFeedIteratorOverridable_WithSkipTake_ReturnsPaginatedResults() - { - InMemoryFeedIteratorSetup.Register(); - - for (var i = 0; i < 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .OrderBy(d => d.Value) - .Skip(3) - .Take(4) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(4); - results.Select(d => d.Value).Should().ContainInConsecutiveOrder(3, 4, 5, 6); - } - - [Fact] - public async Task ToFeedIteratorOverridable_MultipleEnumerations_ReturnSameResults() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - // First enumeration - var iter1 = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIteratorOverridable(); - var results1 = new List(); - while (iter1.HasMoreResults) - { - var page = await iter1.ReadNextAsync(); - results1.AddRange(page); - } - - // Second enumeration - var iter2 = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIteratorOverridable(); - var results2 = new List(); - while (iter2.HasMoreResults) - { - var page = await iter2.ReadNextAsync(); - results2.AddRange(page); - } - - results1.Should().HaveCount(1); - results2.Should().HaveCount(1); - results1[0].Id.Should().Be(results2[0].Id); - } - - [Fact] - public async Task ToFeedIteratorOverridable_OrderByDescending_ReturnsDescendingResults() - { - InMemoryFeedIteratorSetup.Register(); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 30 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 20 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemLinqQueryable() - .OrderByDescending(d => d.Value) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Select(d => d.Value).Should().ContainInConsecutiveOrder(30, 20, 10); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ToFeedIteratorOverridable_WithSkipTake_ReturnsPaginatedResults() + { + InMemoryFeedIteratorSetup.Register(); + + for (var i = 0; i < 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .OrderBy(d => d.Value) + .Skip(3) + .Take(4) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(4); + results.Select(d => d.Value).Should().ContainInConsecutiveOrder(3, 4, 5, 6); + } + + [Fact] + public async Task ToFeedIteratorOverridable_MultipleEnumerations_ReturnSameResults() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + // First enumeration + var iter1 = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIteratorOverridable(); + var results1 = new List(); + while (iter1.HasMoreResults) + { + var page = await iter1.ReadNextAsync(); + results1.AddRange(page); + } + + // Second enumeration + var iter2 = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIteratorOverridable(); + var results2 = new List(); + while (iter2.HasMoreResults) + { + var page = await iter2.ReadNextAsync(); + results2.AddRange(page); + } + + results1.Should().HaveCount(1); + results2.Should().HaveCount(1); + results1[0].Id.Should().Be(results2[0].Id); + } + + [Fact] + public async Task ToFeedIteratorOverridable_OrderByDescending_ReturnsDescendingResults() + { + InMemoryFeedIteratorSetup.Register(); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 30 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 20 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemLinqQueryable() + .OrderByDescending(d => d.Value) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Select(d => d.Value).Should().ContainInConsecutiveOrder(30, 20, 10); + } } public class LinqGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["urgent"] }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, Tags = ["important"] }, new PartitionKey("pk2")); - } - - [Fact] - public async Task Linq_WithPartitionKey_FiltersCorrectly() - { - await SeedItems(); - - var queryable = _container.GetItemLinqQueryable(true, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = queryable.ToList(); - - results.Should().HaveCount(2); - results.Should().OnlyContain(item => item.PartitionKey == "pk1"); - } - - [Fact] - public async Task Linq_Contains_OnCollection_InStyleQuery() - { - await SeedItems(); - - var queryable = _container.GetItemLinqQueryable(true); - var validNames = new[] { "Alice", "Charlie" }; - var results = queryable.Where(doc => validNames.Contains(doc.Name)).ToList(); - - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["urgent"] }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, Tags = ["important"] }, new PartitionKey("pk2")); + } + + [Fact] + public async Task Linq_WithPartitionKey_FiltersCorrectly() + { + await SeedItems(); + + var queryable = _container.GetItemLinqQueryable(true, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = queryable.ToList(); + + results.Should().HaveCount(2); + results.Should().OnlyContain(item => item.PartitionKey == "pk1"); + } + + [Fact] + public async Task Linq_Contains_OnCollection_InStyleQuery() + { + await SeedItems(); + + var queryable = _container.GetItemLinqQueryable(true); + var validNames = new[] { "Alice", "Charlie" }; + var results = queryable.Where(doc => validNames.Contains(doc.Name)).ToList(); + + results.Should().HaveCount(2); + } } public class LinqGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Linq_Where_EqualityFilter() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.Where(d => d.Name == "Alice").ToList(); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_OrderBy() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bravo", Value = 2 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alpha", Value = 1 }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.OrderBy(d => d.Value).ToList(); - - results[0].Name.Should().Be("Alpha"); - results[1].Name.Should().Be("Bravo"); - } - - [Fact] - public async Task Linq_Select_Projection() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var results = queryable.Select(d => new { d.Name, d.Value }).ToList(); - - results.Should().ContainSingle(); - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(42); - } - - [Fact] - public async Task Linq_Count() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var count = queryable.Count(); - - count.Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Linq_Where_EqualityFilter() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.Where(d => d.Name == "Alice").ToList(); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_OrderBy() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bravo", Value = 2 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alpha", Value = 1 }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.OrderBy(d => d.Value).ToList(); + + results[0].Name.Should().Be("Alpha"); + results[1].Name.Should().Be("Bravo"); + } + + [Fact] + public async Task Linq_Select_Projection() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var results = queryable.Select(d => new { d.Name, d.Value }).ToList(); + + results.Should().ContainSingle(); + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(42); + } + + [Fact] + public async Task Linq_Count() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var count = queryable.Count(); + + count.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -699,190 +699,190 @@ await _container.CreateItemAsync( public class LinqOperatorGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedTestData() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["a", "b"], Nested = new NestedObject { Description = "D1", Score = 8.5 } }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["c"], Nested = null }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 40, IsActive = true, Tags = ["a"] }, new PartitionKey("pk2")); - } - - [Fact] - public async Task Linq_Where_NullComparison_FiltersNullNested() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Nested == null).ToList(); - results.Should().HaveCount(2); // Bob and Charlie (Nested default is null) - results.Should().Contain(r => r.Name == "Bob"); - } - - [Fact] - public async Task Linq_Where_NestedPropertyAccess() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Nested != null && d.Nested.Score > 5.0).ToList(); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_Where_StringContains() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.Contains("li")).ToList(); - results.Should().HaveCount(2); // Alice and Charlie both contain "li" - results.Should().Contain(r => r.Name == "Alice"); - } - - [Fact] - public async Task Linq_Where_StringStartsWith() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.StartsWith("A")).ToList(); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_Where_StringEndsWith() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.EndsWith("ce")).ToList(); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_Where_OrCondition() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Name == "Alice" || d.Name == "Bob").ToList(); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Linq_Where_NotCondition() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => !d.IsActive).ToList(); - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task Linq_Where_ArithmeticExpression() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.Value * 2 > 50).ToList(); - results.Should().HaveCount(2); // Alice (60) and Charlie (80) - } - - [Fact] - public async Task Linq_Where_MultipleChainedWhereClauses() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.IsActive).Where(d => d.Value > 25).ToList(); - results.Should().HaveCount(2); // Alice(30) and Charlie(40) - } - - [Fact] - public async Task Linq_Single_WithOneMatch() - { - await SeedTestData(); - var result = _container.GetItemLinqQueryable(true).Single(d => d.Name == "Alice"); - result.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Linq_SingleOrDefault_WithNoMatch_ReturnsNull() - { - await SeedTestData(); - var result = _container.GetItemLinqQueryable(true).SingleOrDefault(d => d.Name == "Nobody"); - result.Should().BeNull(); - } - - [Fact] - public async Task Linq_Single_WithMultipleMatches_Throws() - { - await SeedTestData(); - var act = () => _container.GetItemLinqQueryable(true).Single(d => d.IsActive); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_All_PredicateCheck() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).All(d => d.Value > 0).Should().BeTrue(); - _container.GetItemLinqQueryable(true).All(d => d.IsActive).Should().BeFalse(); - } - - [Fact] - public async Task Linq_Take_Zero_ReturnsEmpty() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).Take(0).ToList().Should().BeEmpty(); - } - - [Fact] - public async Task Linq_Take_MoreThanAvailable_ReturnsAll() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).Take(100).ToList().Should().HaveCount(3); - } - - [Fact] - public async Task Linq_Skip_Zero_ReturnsAll() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).Skip(0).ToList().Should().HaveCount(3); - } - - [Fact] - public async Task Linq_Select_ToAnonymousType_WithComputed() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true) - .Select(d => new { d.Name, d.Value }).ToList(); - results.Should().Contain(r => r.Name == "Alice" && r.Value == 30); - } - - [Fact] - public async Task Linq_Select_ToDtoType() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true) - .AsEnumerable() // materialize to avoid expression tree limitations - .Select(d => (d.Name, d.Value)).ToList(); - results.Should().Contain(("Alice", 30)); - } - - [Fact] - public async Task Linq_Where_BooleanProperty_DirectCheck() - { - await SeedTestData(); - var results = _container.GetItemLinqQueryable(true).Where(d => d.IsActive).ToList(); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Linq_CountWithPredicate() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).Count(d => d.IsActive).Should().Be(2); - } - - [Fact] - public async Task Linq_LongCount() - { - await SeedTestData(); - _container.GetItemLinqQueryable(true).LongCount().Should().Be(3L); - } - - [Fact] - public async Task Linq_CaseSensitive_StringComparison() - { - await SeedTestData(); - // LINQ-to-Objects is case-sensitive; "alice" != "Alice" - _container.GetItemLinqQueryable(true).Where(d => d.Name == "alice").ToList().Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedTestData() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true, Tags = ["a", "b"], Nested = new NestedObject { Description = "D1", Score = 8.5 } }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["c"], Nested = null }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 40, IsActive = true, Tags = ["a"] }, new PartitionKey("pk2")); + } + + [Fact] + public async Task Linq_Where_NullComparison_FiltersNullNested() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Nested == null).ToList(); + results.Should().HaveCount(2); // Bob and Charlie (Nested default is null) + results.Should().Contain(r => r.Name == "Bob"); + } + + [Fact] + public async Task Linq_Where_NestedPropertyAccess() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Nested != null && d.Nested.Score > 5.0).ToList(); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_Where_StringContains() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.Contains("li")).ToList(); + results.Should().HaveCount(2); // Alice and Charlie both contain "li" + results.Should().Contain(r => r.Name == "Alice"); + } + + [Fact] + public async Task Linq_Where_StringStartsWith() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.StartsWith("A")).ToList(); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_Where_StringEndsWith() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Name.EndsWith("ce")).ToList(); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_Where_OrCondition() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Name == "Alice" || d.Name == "Bob").ToList(); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Linq_Where_NotCondition() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => !d.IsActive).ToList(); + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task Linq_Where_ArithmeticExpression() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.Value * 2 > 50).ToList(); + results.Should().HaveCount(2); // Alice (60) and Charlie (80) + } + + [Fact] + public async Task Linq_Where_MultipleChainedWhereClauses() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.IsActive).Where(d => d.Value > 25).ToList(); + results.Should().HaveCount(2); // Alice(30) and Charlie(40) + } + + [Fact] + public async Task Linq_Single_WithOneMatch() + { + await SeedTestData(); + var result = _container.GetItemLinqQueryable(true).Single(d => d.Name == "Alice"); + result.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Linq_SingleOrDefault_WithNoMatch_ReturnsNull() + { + await SeedTestData(); + var result = _container.GetItemLinqQueryable(true).SingleOrDefault(d => d.Name == "Nobody"); + result.Should().BeNull(); + } + + [Fact] + public async Task Linq_Single_WithMultipleMatches_Throws() + { + await SeedTestData(); + var act = () => _container.GetItemLinqQueryable(true).Single(d => d.IsActive); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_All_PredicateCheck() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).All(d => d.Value > 0).Should().BeTrue(); + _container.GetItemLinqQueryable(true).All(d => d.IsActive).Should().BeFalse(); + } + + [Fact] + public async Task Linq_Take_Zero_ReturnsEmpty() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).Take(0).ToList().Should().BeEmpty(); + } + + [Fact] + public async Task Linq_Take_MoreThanAvailable_ReturnsAll() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).Take(100).ToList().Should().HaveCount(3); + } + + [Fact] + public async Task Linq_Skip_Zero_ReturnsAll() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).Skip(0).ToList().Should().HaveCount(3); + } + + [Fact] + public async Task Linq_Select_ToAnonymousType_WithComputed() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true) + .Select(d => new { d.Name, d.Value }).ToList(); + results.Should().Contain(r => r.Name == "Alice" && r.Value == 30); + } + + [Fact] + public async Task Linq_Select_ToDtoType() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true) + .AsEnumerable() // materialize to avoid expression tree limitations + .Select(d => (d.Name, d.Value)).ToList(); + results.Should().Contain(("Alice", 30)); + } + + [Fact] + public async Task Linq_Where_BooleanProperty_DirectCheck() + { + await SeedTestData(); + var results = _container.GetItemLinqQueryable(true).Where(d => d.IsActive).ToList(); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Linq_CountWithPredicate() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).Count(d => d.IsActive).Should().Be(2); + } + + [Fact] + public async Task Linq_LongCount() + { + await SeedTestData(); + _container.GetItemLinqQueryable(true).LongCount().Should().Be(3L); + } + + [Fact] + public async Task Linq_CaseSensitive_StringComparison() + { + await SeedTestData(); + // LINQ-to-Objects is case-sensitive; "alice" != "Alice" + _container.GetItemLinqQueryable(true).Where(d => d.Name == "alice").ToList().Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -892,104 +892,104 @@ public async Task Linq_CaseSensitive_StringComparison() [Collection("FeedIteratorSetup")] public class LinqFeedIteratorIntegrationGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task ToFeedIteratorOverridable_EmptyContainer_NoResults() - { - InMemoryFeedIteratorSetup.Register(); - var queryable = _container.GetItemLinqQueryable(true); - var iterator = queryable.ToFeedIteratorOverridable(); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().BeEmpty(); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithPartitionKeyFilter_RespectsPartition() - { - InMemoryFeedIteratorSetup.Register(); - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var queryable = _container.GetItemLinqQueryable(true, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var iterator = queryable.ToFeedIteratorOverridable(); - var results = new List(); - while (iterator.HasMoreResults) - { - results.AddRange(await iterator.ReadNextAsync()); - } - results.Should().ContainSingle().Which.Name.Should().Be("A"); - } - - [Fact] - public async Task ToFeedIteratorOverridable_Response_HasCorrectMetadata() - { - InMemoryFeedIteratorSetup.Register(); - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var iterator = queryable.ToFeedIteratorOverridable(); - var page = await iterator.ReadNextAsync(); - - page.StatusCode.Should().Be(HttpStatusCode.OK); - page.RequestCharge.Should().Be(1); - page.Count.Should().Be(1); - page.Diagnostics.Should().NotBeNull(); - page.ActivityId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ToFeedIteratorOverridable_Response_ContinuationToken_NullOnLastPage() - { - InMemoryFeedIteratorSetup.Register(); - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var iterator = queryable.ToFeedIteratorOverridable(); - var page = await iterator.ReadNextAsync(); - page.ContinuationToken.Should().BeNull(); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task ToFeedIteratorOverridable_CalledTwiceOnSameQueryable_EachIteratorIndependent() - { - InMemoryFeedIteratorSetup.Register(); - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true); - var iter1 = queryable.ToFeedIteratorOverridable(); - var iter2 = queryable.ToFeedIteratorOverridable(); - - var results1 = new List(); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - var results2 = new List(); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - - results1.Should().HaveCount(1); - results2.Should().HaveCount(1); - } - - [Fact] - public async Task ToFeedIteratorOverridable_WithDistinct_ReturnsUniqueItems() - { - InMemoryFeedIteratorSetup.Register(); - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var queryable = _container.GetItemLinqQueryable(true).Select(d => d.Name).Distinct(); - var iterator = queryable.ToFeedIteratorOverridable(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEquivalentTo(["A", "B"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task ToFeedIteratorOverridable_EmptyContainer_NoResults() + { + InMemoryFeedIteratorSetup.Register(); + var queryable = _container.GetItemLinqQueryable(true); + var iterator = queryable.ToFeedIteratorOverridable(); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().BeEmpty(); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithPartitionKeyFilter_RespectsPartition() + { + InMemoryFeedIteratorSetup.Register(); + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var queryable = _container.GetItemLinqQueryable(true, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var iterator = queryable.ToFeedIteratorOverridable(); + var results = new List(); + while (iterator.HasMoreResults) + { + results.AddRange(await iterator.ReadNextAsync()); + } + results.Should().ContainSingle().Which.Name.Should().Be("A"); + } + + [Fact] + public async Task ToFeedIteratorOverridable_Response_HasCorrectMetadata() + { + InMemoryFeedIteratorSetup.Register(); + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var iterator = queryable.ToFeedIteratorOverridable(); + var page = await iterator.ReadNextAsync(); + + page.StatusCode.Should().Be(HttpStatusCode.OK); + page.RequestCharge.Should().Be(1); + page.Count.Should().Be(1); + page.Diagnostics.Should().NotBeNull(); + page.ActivityId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ToFeedIteratorOverridable_Response_ContinuationToken_NullOnLastPage() + { + InMemoryFeedIteratorSetup.Register(); + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var iterator = queryable.ToFeedIteratorOverridable(); + var page = await iterator.ReadNextAsync(); + page.ContinuationToken.Should().BeNull(); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task ToFeedIteratorOverridable_CalledTwiceOnSameQueryable_EachIteratorIndependent() + { + InMemoryFeedIteratorSetup.Register(); + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true); + var iter1 = queryable.ToFeedIteratorOverridable(); + var iter2 = queryable.ToFeedIteratorOverridable(); + + var results1 = new List(); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + var results2 = new List(); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + + results1.Should().HaveCount(1); + results2.Should().HaveCount(1); + } + + [Fact] + public async Task ToFeedIteratorOverridable_WithDistinct_ReturnsUniqueItems() + { + InMemoryFeedIteratorSetup.Register(); + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var queryable = _container.GetItemLinqQueryable(true).Select(d => d.Name).Distinct(); + var iterator = queryable.ToFeedIteratorOverridable(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEquivalentTo(["A", "B"]); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -999,25 +999,25 @@ public async Task ToFeedIteratorOverridable_WithDistinct_ReturnsUniqueItems() [Collection("FeedIteratorSetup")] public class LinqRegistrationLifecycleGapTests { - [Fact] - public async Task Deregister_ThenRegister_StillWorks() - { - InMemoryFeedIteratorSetup.Register(); - InMemoryFeedIteratorSetup.Deregister(); - InMemoryFeedIteratorSetup.Register(); - - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var iterator = container.GetItemLinqQueryable(true).ToFeedIteratorOverridable(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - - InMemoryFeedIteratorSetup.Deregister(); - } + [Fact] + public async Task Deregister_ThenRegister_StillWorks() + { + InMemoryFeedIteratorSetup.Register(); + InMemoryFeedIteratorSetup.Deregister(); + InMemoryFeedIteratorSetup.Register(); + + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var iterator = container.GetItemLinqQueryable(true).ToFeedIteratorOverridable(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle(); + + InMemoryFeedIteratorSetup.Deregister(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1026,152 +1026,152 @@ await container.CreateItemAsync( public class LinqEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void Linq_EmptyContainer_AllOperators_ReturnEmptyOrDefaults() - { - var q = _container.GetItemLinqQueryable(true); - q.Where(d => d.IsActive).ToList().Should().BeEmpty(); - q.Select(d => d.Name).ToList().Should().BeEmpty(); - q.OrderBy(d => d.Name).ToList().Should().BeEmpty(); - q.Count().Should().Be(0); - q.FirstOrDefault().Should().BeNull(); - q.Any().Should().BeFalse(); - q.All(d => d.Value > 0).Should().BeTrue(); // vacuous truth - } - - [Fact] - public async Task Linq_SingleItem_AllOperatorsWork() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - - var q = _container.GetItemLinqQueryable(true); - q.Where(d => d.IsActive).ToList().Should().ContainSingle(); - q.First().Name.Should().Be("Alice"); - q.Single().Name.Should().Be("Alice"); - q.Count().Should().Be(1); - q.Any().Should().BeTrue(); - q.Sum(d => d.Value).Should().Be(10); - q.Min(d => d.Value).Should().Be(10); - q.Max(d => d.Value).Should().Be(10); - q.Average(d => d.Value).Should().Be(10); - } - - [Fact] - public async Task Linq_LargeDataset_1000Items_HandlesCorrectly() - { - for (int i = 0; i < 1000; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - } - - var q = _container.GetItemLinqQueryable(true); - q.Count().Should().Be(1000); - q.Where(d => d.Value >= 500).ToList().Should().HaveCount(500); - q.Sum(d => d.Value).Should().Be(499500); - } - - [Fact] - public async Task Linq_AfterDelete_ItemNotReturnedInQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - _container.GetItemLinqQueryable(true).ToList().Should().BeEmpty(); - } - - [Fact] - public async Task Linq_AfterUpsert_QueryReturnsUpdatedItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - var result = _container.GetItemLinqQueryable(true).Single(); - result.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Linq_AfterReplace_QueryReturnsReplacedItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v1" }, - new PartitionKey("pk1")); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v2" }, "1", - new PartitionKey("pk1")); - - var result = _container.GetItemLinqQueryable(true).Single(); - result.Name.Should().Be("v2"); - } - - [Fact] - public async Task Linq_WithJObjectType_ReturnsJObjects() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var results = _container.GetItemLinqQueryable(true).ToList(); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Linq_ConcurrentReads_DontCorrupt() - { - for (int i = 0; i < 20; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var tasks = Enumerable.Range(0, 10) - .Select(_ => Task.Run(() => _container.GetItemLinqQueryable(true).ToList())) - .ToArray(); - - var allResults = await Task.WhenAll(tasks); - foreach (var results in allResults) - { - results.Should().HaveCount(20); - } - } - - [Fact] - public async Task Linq_MultiPartitionKey_CrossPartitionQuery() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - // No partition key in request options → cross-partition - var results = _container.GetItemLinqQueryable(true).ToList(); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Linq_WithRequestOptions_MaxItemCount_IsIgnored() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }, new PartitionKey("pk1")); - - // MaxItemCount=1 has no effect on LINQ queryable — all items returned - var results = _container.GetItemLinqQueryable(true, - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }) - .ToList(); - results.Should().HaveCount(3); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public void Linq_EmptyContainer_AllOperators_ReturnEmptyOrDefaults() + { + var q = _container.GetItemLinqQueryable(true); + q.Where(d => d.IsActive).ToList().Should().BeEmpty(); + q.Select(d => d.Name).ToList().Should().BeEmpty(); + q.OrderBy(d => d.Name).ToList().Should().BeEmpty(); + q.Count().Should().Be(0); + q.FirstOrDefault().Should().BeNull(); + q.Any().Should().BeFalse(); + q.All(d => d.Value > 0).Should().BeTrue(); // vacuous truth + } + + [Fact] + public async Task Linq_SingleItem_AllOperatorsWork() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + + var q = _container.GetItemLinqQueryable(true); + q.Where(d => d.IsActive).ToList().Should().ContainSingle(); + q.First().Name.Should().Be("Alice"); + q.Single().Name.Should().Be("Alice"); + q.Count().Should().Be(1); + q.Any().Should().BeTrue(); + q.Sum(d => d.Value).Should().Be(10); + q.Min(d => d.Value).Should().Be(10); + q.Max(d => d.Value).Should().Be(10); + q.Average(d => d.Value).Should().Be(10); + } + + [Fact] + public async Task Linq_LargeDataset_1000Items_HandlesCorrectly() + { + for (int i = 0; i < 1000; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + } + + var q = _container.GetItemLinqQueryable(true); + q.Count().Should().Be(1000); + q.Where(d => d.Value >= 500).ToList().Should().HaveCount(500); + q.Sum(d => d.Value).Should().Be(499500); + } + + [Fact] + public async Task Linq_AfterDelete_ItemNotReturnedInQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + _container.GetItemLinqQueryable(true).ToList().Should().BeEmpty(); + } + + [Fact] + public async Task Linq_AfterUpsert_QueryReturnsUpdatedItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + var result = _container.GetItemLinqQueryable(true).Single(); + result.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Linq_AfterReplace_QueryReturnsReplacedItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v1" }, + new PartitionKey("pk1")); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "v2" }, "1", + new PartitionKey("pk1")); + + var result = _container.GetItemLinqQueryable(true).Single(); + result.Name.Should().Be("v2"); + } + + [Fact] + public async Task Linq_WithJObjectType_ReturnsJObjects() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var results = _container.GetItemLinqQueryable(true).ToList(); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Linq_ConcurrentReads_DontCorrupt() + { + for (int i = 0; i < 20; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var tasks = Enumerable.Range(0, 10) + .Select(_ => Task.Run(() => _container.GetItemLinqQueryable(true).ToList())) + .ToArray(); + + var allResults = await Task.WhenAll(tasks); + foreach (var results in allResults) + { + results.Should().HaveCount(20); + } + } + + [Fact] + public async Task Linq_MultiPartitionKey_CrossPartitionQuery() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + // No partition key in request options → cross-partition + var results = _container.GetItemLinqQueryable(true).ToList(); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Linq_WithRequestOptions_MaxItemCount_IsIgnored() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }, new PartitionKey("pk1")); + + // MaxItemCount=1 has no effect on LINQ queryable — all items returned + var results = _container.GetItemLinqQueryable(true, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }) + .ToList(); + results.Should().HaveCount(3); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1181,160 +1181,160 @@ public async Task Linq_WithRequestOptions_MaxItemCount_IsIgnored() [Collection("FeedIteratorSetup")] public class LinqDivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedData() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, new PartitionKey("pk2")); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + - "GroupBy is natively supported by System.Linq. Real Cosmos SDK's CosmosLinqQueryProvider " + - "cannot translate GroupBy to SQL and throws NotSupportedException. This divergence only " + - "affects Approach 1 (direct InMemoryContainer). With Approach 3 (CosmosClient + " + - "FakeCosmosHandler), the real SDK correctly rejects GroupBy — see " + - "RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_GroupBy_SdkRejectsTranslation.")] - public async Task Linq_GroupBy_RealCosmos_ShouldThrowNotSupported() - { - await SeedData(); - var act = () => _container.GetItemLinqQueryable(true) - .GroupBy(d => d.PartitionKey).ToList(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_GroupBy_WorksInMemory_DivergentBehavior() - { - // DIVERGENT: GroupBy works in-memory via LINQ-to-Objects. Real Cosmos rejects it. - await SeedData(); - var groups = _container.GetItemLinqQueryable(true) - .GroupBy(d => d.PartitionKey).ToList(); - groups.Should().HaveCount(2); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + - "Last()/LastOrDefault() work natively. Cosmos SQL has no LAST equivalent, so the real " + - "SDK rejects these. This divergence only affects Approach 1 (direct InMemoryContainer). " + - "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + - "Last() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Last_SdkRejectsTranslation.")] - public async Task Linq_Last_RealCosmos_ShouldThrowNotSupported() - { - await SeedData(); - var act = () => _container.GetItemLinqQueryable(true).Last(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_Last_WorksInMemory_DivergentBehavior() - { - // DIVERGENT: Last() works in-memory. Real Cosmos rejects it. - await SeedData(); - var result = _container.GetItemLinqQueryable(true).Last(); - result.Should().NotBeNull(); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + - "custom Aggregate() works natively. The real SDK only translates Sum/Average/Count/Min/Max " + - "to Cosmos SQL. This divergence only affects Approach 1 (direct InMemoryContainer). " + - "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + - "Aggregate() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Aggregate_SdkRejectsTranslation.")] - public async Task Linq_Aggregate_RealCosmos_ShouldThrowNotSupported() - { - await SeedData(); - var act = () => _container.GetItemLinqQueryable(true) - .Aggregate(0, (acc, d) => acc + d.Value); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_Aggregate_WorksInMemory_DivergentBehavior() - { - // DIVERGENT: Custom Aggregate works in-memory. Real Cosmos rejects it. - await SeedData(); - var total = _container.GetItemLinqQueryable(true) - .Aggregate(0, (acc, d) => acc + d.Value); - total.Should().Be(60); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + - "Reverse() works natively. Cosmos SQL has no REVERSE ordering clause, so the real SDK " + - "rejects it. This divergence only affects Approach 1 (direct InMemoryContainer). " + - "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + - "Reverse() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Reverse_SdkRejectsTranslation.")] - public async Task Linq_Reverse_RealCosmos_ShouldThrowNotSupported() - { - await SeedData(); - var act = () => _container.GetItemLinqQueryable(true).Reverse().ToList(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_Reverse_WorksInMemory_DivergentBehavior() - { - // DIVERGENT: Reverse() works in-memory. Real Cosmos rejects it. - await SeedData(); - var results = _container.GetItemLinqQueryable(true).Reverse().ToList(); - results.Should().HaveCount(3); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer ignores allowSynchronousQueryExecution " + - "because the queryable is always synchronously enumerable via LINQ-to-Objects. The real " + - "SDK enforces this flag by throwing on ToList()/foreach, forcing ToFeedIterator(). This " + - "divergence only affects Approach 1 (direct InMemoryContainer). With Approach 3 " + - "(CosmosClient + FakeCosmosHandler), the real SDK correctly enforces async — see " + - "RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_AllowSynchronousQueryExecution_False_SdkEnforcesAsync.")] - public async Task Linq_AllowSynchronousQueryExecution_False_ShouldThrow() - { - await SeedData(); - var act = () => _container.GetItemLinqQueryable(false).ToList(); - act.Should().Throw(); - } - - [Fact] - public async Task Linq_AllowSynchronousQueryExecution_False_StillWorksInMemory_DivergentBehavior() - { - // DIVERGENT: allowSynchronousQueryExecution=false is ignored. Sync enumeration always works. - await SeedData(); - var results = _container.GetItemLinqQueryable(false).ToList(); - results.Should().HaveCount(3); - } - - [Fact(Skip = "APPROACH 1 PERMISSIVENESS (L4): InMemoryContainer accepts continuationToken " + - "on GetItemLinqQueryable but ignores it — all items are always materialized via " + - "LINQ-to-Objects. Real Cosmos uses continuation tokens to resume from a server-side " + - "checkpoint. This divergence only affects Approach 1 (direct InMemoryContainer). " + - "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK handles continuation " + - "tokens through the HTTP pipeline.")] - public async Task Linq_ContinuationToken_ShouldResumeFromCheckpoint() - { - await SeedData(); - // With a continuation token, should resume from midpoint - var results = _container.GetItemLinqQueryable(true, "some-token").ToList(); - results.Should().HaveCountLessThan(3); - } - - [Fact] - public async Task Linq_ContinuationToken_IsIgnored_DivergentBehavior() - { - // DIVERGENT (L4): continuationToken is ignored — all items always returned - await SeedData(); - var results = _container.GetItemLinqQueryable(true, "some-token").ToList(); - results.Should().HaveCount(3); - } - - [Fact] - public async Task ToFeedIteratorOverridable_MaxItemCount_ShouldPaginate() - { - await SeedData(); - InMemoryFeedIteratorSetup.Register(); - var q = _container.GetItemLinqQueryable(true, - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var iterator = q.ToFeedIteratorOverridable(); - var pageCount = 0; - while (iterator.HasMoreResults) { await iterator.ReadNextAsync(); pageCount++; } - pageCount.Should().Be(3, "should page through 3 items one at a time"); - InMemoryFeedIteratorSetup.Deregister(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedData() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, new PartitionKey("pk2")); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + + "GroupBy is natively supported by System.Linq. Real Cosmos SDK's CosmosLinqQueryProvider " + + "cannot translate GroupBy to SQL and throws NotSupportedException. This divergence only " + + "affects Approach 1 (direct InMemoryContainer). With Approach 3 (CosmosClient + " + + "FakeCosmosHandler), the real SDK correctly rejects GroupBy — see " + + "RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_GroupBy_SdkRejectsTranslation.")] + public async Task Linq_GroupBy_RealCosmos_ShouldThrowNotSupported() + { + await SeedData(); + var act = () => _container.GetItemLinqQueryable(true) + .GroupBy(d => d.PartitionKey).ToList(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_GroupBy_WorksInMemory_DivergentBehavior() + { + // DIVERGENT: GroupBy works in-memory via LINQ-to-Objects. Real Cosmos rejects it. + await SeedData(); + var groups = _container.GetItemLinqQueryable(true) + .GroupBy(d => d.PartitionKey).ToList(); + groups.Should().HaveCount(2); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + + "Last()/LastOrDefault() work natively. Cosmos SQL has no LAST equivalent, so the real " + + "SDK rejects these. This divergence only affects Approach 1 (direct InMemoryContainer). " + + "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + + "Last() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Last_SdkRejectsTranslation.")] + public async Task Linq_Last_RealCosmos_ShouldThrowNotSupported() + { + await SeedData(); + var act = () => _container.GetItemLinqQueryable(true).Last(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_Last_WorksInMemory_DivergentBehavior() + { + // DIVERGENT: Last() works in-memory. Real Cosmos rejects it. + await SeedData(); + var result = _container.GetItemLinqQueryable(true).Last(); + result.Should().NotBeNull(); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + + "custom Aggregate() works natively. The real SDK only translates Sum/Average/Count/Min/Max " + + "to Cosmos SQL. This divergence only affects Approach 1 (direct InMemoryContainer). " + + "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + + "Aggregate() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Aggregate_SdkRejectsTranslation.")] + public async Task Linq_Aggregate_RealCosmos_ShouldThrowNotSupported() + { + await SeedData(); + var act = () => _container.GetItemLinqQueryable(true) + .Aggregate(0, (acc, d) => acc + d.Value); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_Aggregate_WorksInMemory_DivergentBehavior() + { + // DIVERGENT: Custom Aggregate works in-memory. Real Cosmos rejects it. + await SeedData(); + var total = _container.GetItemLinqQueryable(true) + .Aggregate(0, (acc, d) => acc + d.Value); + total.Should().Be(60); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer uses LINQ-to-Objects, where " + + "Reverse() works natively. Cosmos SQL has no REVERSE ordering clause, so the real SDK " + + "rejects it. This divergence only affects Approach 1 (direct InMemoryContainer). " + + "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK correctly rejects " + + "Reverse() — see RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_Reverse_SdkRejectsTranslation.")] + public async Task Linq_Reverse_RealCosmos_ShouldThrowNotSupported() + { + await SeedData(); + var act = () => _container.GetItemLinqQueryable(true).Reverse().ToList(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_Reverse_WorksInMemory_DivergentBehavior() + { + // DIVERGENT: Reverse() works in-memory. Real Cosmos rejects it. + await SeedData(); + var results = _container.GetItemLinqQueryable(true).Reverse().ToList(); + results.Should().HaveCount(3); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS: InMemoryContainer ignores allowSynchronousQueryExecution " + + "because the queryable is always synchronously enumerable via LINQ-to-Objects. The real " + + "SDK enforces this flag by throwing on ToList()/foreach, forcing ToFeedIterator(). This " + + "divergence only affects Approach 1 (direct InMemoryContainer). With Approach 3 " + + "(CosmosClient + FakeCosmosHandler), the real SDK correctly enforces async — see " + + "RealToFeedIteratorLinqDeepDiveTests.ToFeedIterator_AllowSynchronousQueryExecution_False_SdkEnforcesAsync.")] + public async Task Linq_AllowSynchronousQueryExecution_False_ShouldThrow() + { + await SeedData(); + var act = () => _container.GetItemLinqQueryable(false).ToList(); + act.Should().Throw(); + } + + [Fact] + public async Task Linq_AllowSynchronousQueryExecution_False_StillWorksInMemory_DivergentBehavior() + { + // DIVERGENT: allowSynchronousQueryExecution=false is ignored. Sync enumeration always works. + await SeedData(); + var results = _container.GetItemLinqQueryable(false).ToList(); + results.Should().HaveCount(3); + } + + [Fact(Skip = "APPROACH 1 PERMISSIVENESS (L4): InMemoryContainer accepts continuationToken " + + "on GetItemLinqQueryable but ignores it — all items are always materialized via " + + "LINQ-to-Objects. Real Cosmos uses continuation tokens to resume from a server-side " + + "checkpoint. This divergence only affects Approach 1 (direct InMemoryContainer). " + + "With Approach 3 (CosmosClient + FakeCosmosHandler), the real SDK handles continuation " + + "tokens through the HTTP pipeline.")] + public async Task Linq_ContinuationToken_ShouldResumeFromCheckpoint() + { + await SeedData(); + // With a continuation token, should resume from midpoint + var results = _container.GetItemLinqQueryable(true, "some-token").ToList(); + results.Should().HaveCountLessThan(3); + } + + [Fact] + public async Task Linq_ContinuationToken_IsIgnored_DivergentBehavior() + { + // DIVERGENT (L4): continuationToken is ignored — all items always returned + await SeedData(); + var results = _container.GetItemLinqQueryable(true, "some-token").ToList(); + results.Should().HaveCount(3); + } + + [Fact] + public async Task ToFeedIteratorOverridable_MaxItemCount_ShouldPaginate() + { + await SeedData(); + InMemoryFeedIteratorSetup.Register(); + var q = _container.GetItemLinqQueryable(true, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var iterator = q.ToFeedIteratorOverridable(); + var pageCount = 0; + while (iterator.HasMoreResults) { await iterator.ReadNextAsync(); pageCount++; } + pageCount.Should().Be(3, "should page through 3 items one at a time"); + InMemoryFeedIteratorSetup.Deregister(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/NestedFunctionBugTest.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/NestedFunctionBugTest.cs index c67e5ea..b63e7f6 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/NestedFunctionBugTest.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/NestedFunctionBugTest.cs @@ -7,50 +7,50 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class NestedFunctionBugTest { - [Fact] - public async Task ContainsLower_DirectContainer_Works() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - new { id = "1", pk = "p1", name = "T1000" }, - new PartitionKey("p1")); + [Fact] + public async Task ContainsLower_DirectContainer_Works() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + new { id = "1", pk = "p1", name = "T1000" }, + new PartitionKey("p1")); - var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") - .WithParameter("@val", "t1000"); - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(1); - } + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "t1000"); + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(1); + } - [Fact] - public async Task ContainsLower_ViaFakeCosmosHandler_Works() - { - using var cosmos = InMemoryCosmos.Create("test-nested", "/pk"); - var container = cosmos.Container; - var handler = cosmos.Handler; + [Fact] + public async Task ContainsLower_ViaFakeCosmosHandler_Works() + { + using var cosmos = InMemoryCosmos.Create("test-nested", "/pk"); + var container = cosmos.Container; + var handler = cosmos.Handler; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p1", name = "T1000" }), - new PartitionKey("p1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p1", name = "T1000" }), + new PartitionKey("p1")); - var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") - .WithParameter("@val", "t1000"); - var results = new List(); - using var iterator = container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p1") }); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "t1000"); + var results = new List(); + using var iterator = container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p1") }); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - // Debug: show the query that was actually sent - var queryLog = handler.QueryLog; - results.Should().HaveCount(1, $"Query log: [{string.Join("; ", queryLog)}]"); - } + // Debug: show the query that was actually sent + var queryLog = handler.QueryLog; + results.Should().HaveCount(1, $"Query log: [{string.Join("; ", queryLog)}]"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ParameterizedQueryBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ParameterizedQueryBugTests.cs index f765fcb..df17f7c 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ParameterizedQueryBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ParameterizedQueryBugTests.cs @@ -12,95 +12,95 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class ParameterizedQueryBugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", status = "Completed", amount = 50 }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk1", status = "Completed", amount = 30 }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", partitionKey = "pk1", status = "Failed", amount = 20 }), - new PartitionKey("pk1")); - } - - // ── Bug 1: IS_NULL(@param) with null parameter ────────────────────────── - - [Fact] - public async Task IsNull_WithParameterizedNull_ReturnsTrue() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT IS_NULL(@status) AS isNullResult FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", "pk1") - .WithParameter("@status", null); - - var results = await DrainQuery(query); - - results.Should().HaveCount(3); - results[0]["isNullResult"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task IsNull_OptionalFilterPattern_WithNullParam_ReturnsAllItems() - { - await SeedItems(); - - // Common Cosmos pattern: (IS_NULL(@filter) OR c.field = @filter) - // When @filter is null, IS_NULL(@filter) should be true, so all items match - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.partitionKey = @pk AND (IS_NULL(@status) OR c.status = @status)") - .WithParameter("@pk", "pk1") - .WithParameter("@status", null); - - var results = await DrainQuery(query); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task IsNull_OptionalFilterPattern_WithNonNullParam_FiltersCorrectly() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.partitionKey = @pk AND (IS_NULL(@status) OR c.status = @status)") - .WithParameter("@pk", "pk1") - .WithParameter("@status", "Completed"); - - var results = await DrainQuery(query); - - results.Should().HaveCount(2); - } - - // ── Bug 2: SUM(IIF(field = @param, 1, 0)) ────────────────────────────── - - [Fact] - public async Task SumIif_WithParameterizedValue_ReturnsCorrectCount() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT SUM(IIF(c.status = @completedStatus, 1, 0)) AS filesCompleted FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", "pk1") - .WithParameter("@completedStatus", "Completed"); - - var results = await DrainQuery(query); - - results.Should().ContainSingle(); - results[0]["filesCompleted"]!.Value().Should().Be(2); - } - - [Fact] - public async Task SumIif_FullAggregation_WithParameters_ReturnsCorrectCounts() - { - await SeedItems(); - - var query = new QueryDefinition(@" + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", status = "Completed", amount = 50 }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk1", status = "Completed", amount = 30 }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", partitionKey = "pk1", status = "Failed", amount = 20 }), + new PartitionKey("pk1")); + } + + // ── Bug 1: IS_NULL(@param) with null parameter ────────────────────────── + + [Fact] + public async Task IsNull_WithParameterizedNull_ReturnsTrue() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT IS_NULL(@status) AS isNullResult FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", "pk1") + .WithParameter("@status", null); + + var results = await DrainQuery(query); + + results.Should().HaveCount(3); + results[0]["isNullResult"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task IsNull_OptionalFilterPattern_WithNullParam_ReturnsAllItems() + { + await SeedItems(); + + // Common Cosmos pattern: (IS_NULL(@filter) OR c.field = @filter) + // When @filter is null, IS_NULL(@filter) should be true, so all items match + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.partitionKey = @pk AND (IS_NULL(@status) OR c.status = @status)") + .WithParameter("@pk", "pk1") + .WithParameter("@status", null); + + var results = await DrainQuery(query); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task IsNull_OptionalFilterPattern_WithNonNullParam_FiltersCorrectly() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.partitionKey = @pk AND (IS_NULL(@status) OR c.status = @status)") + .WithParameter("@pk", "pk1") + .WithParameter("@status", "Completed"); + + var results = await DrainQuery(query); + + results.Should().HaveCount(2); + } + + // ── Bug 2: SUM(IIF(field = @param, 1, 0)) ────────────────────────────── + + [Fact] + public async Task SumIif_WithParameterizedValue_ReturnsCorrectCount() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT SUM(IIF(c.status = @completedStatus, 1, 0)) AS filesCompleted FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", "pk1") + .WithParameter("@completedStatus", "Completed"); + + var results = await DrainQuery(query); + + results.Should().ContainSingle(); + results[0]["filesCompleted"]!.Value().Should().Be(2); + } + + [Fact] + public async Task SumIif_FullAggregation_WithParameters_ReturnsCorrectCounts() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT COUNT(1) AS totalFiles, SUM(c.amount) AS totalAmount, @@ -108,31 +108,31 @@ public async Task SumIif_FullAggregation_WithParameters_ReturnsCorrectCounts() SUM(IIF(c.status = @failedStatus, 1, 0)) AS filesFailed FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", "pk1") - .WithParameter("@completedStatus", "Completed") - .WithParameter("@failedStatus", "Failed"); - - var results = await DrainQuery(query); - - results.Should().ContainSingle(); - var result = results[0]; - result["totalFiles"]!.Value().Should().Be(3); - result["totalAmount"]!.Value().Should().Be(100); - result["filesCompleted"]!.Value().Should().Be(2); - result["filesFailed"]!.Value().Should().Be(1); - } - - private async Task> DrainQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + .WithParameter("@pk", "pk1") + .WithParameter("@completedStatus", "Completed") + .WithParameter("@failedStatus", "Failed"); + + var results = await DrainQuery(query); + + results.Should().ContainSingle(); + var result = results[0]; + result["totalFiles"]!.Value().Should().Be(3); + result["totalAmount"]!.Value().Should().Be(100); + result["filesCompleted"]!.Value().Should().Be(2); + result["filesFailed"]!.Value().Should().Be(1); + } + + private async Task> DrainQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } /// @@ -141,419 +141,419 @@ private async Task> DrainQuery(QueryDefinition query) /// public class ParameterizedQueryExtendedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task SeedItems() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", status = "Active", category = "A", price = 10.0, score = 5 }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", status = "Active", category = "A", price = 20.0, score = 8 }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", status = "Inactive", category = "B", price = 30.0, score = 3 }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "4", pk = "a", status = "Active", category = "B", price = 40.0 }), - new PartitionKey("a")); - } - - // ── IS_DEFINED with @param ────────────────────────────────────────────── - - [Fact] - public async Task IsDefined_WithParameterizedNonNull_ReturnsTrue() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT IS_DEFINED(@val) AS result FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", "something"); - - var results = await DrainQuery(query); - results.Should().HaveCount(4); - results[0]["result"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task IsDefined_WithParameterizedNull_ReturnsTrue() - { - // IS_DEFINED(null) returns true in real Cosmos (null is defined, just null) - await SeedItems(); - - var query = new QueryDefinition( - "SELECT IS_DEFINED(@val) AS result FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", null); - - var results = await DrainQuery(query); - results.Should().HaveCount(4); - // null is a defined value — IS_DEFINED(null) = true - results[0]["result"]!.Value().Should().BeTrue(); - } - - // ── IS_NULL variants ──────────────────────────────────────────────────── - - [Fact] - public async Task IsNull_WithParameterizedString_ReturnsFalse() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT IS_NULL(@val) AS result FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", "notNull"); - - var results = await DrainQuery(query); - results[0]["result"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsNull_WithParameterizedInteger_ReturnsFalse() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT IS_NULL(@val) AS result FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", 42); - - var results = await DrainQuery(query); - results[0]["result"]!.Value().Should().BeFalse(); - } - - // ── SUM with parameterized arithmetic ─────────────────────────────────── - - [Fact] - public async Task Sum_WithParameterizedMultiplier_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT SUM(c.price * @multiplier) AS total FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@multiplier", 2); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(200.0); - } - - [Fact] - public async Task Avg_WithParameterizedExpression_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT AVG(c.price + @bonus) AS avgPrice FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@bonus", 10); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - // Prices: 10, 20, 30, 40. With +10 bonus: 20, 30, 40, 50. Avg = 35 - results[0]["avgPrice"]!.Value().Should().Be(35.0); - } - - // ── Multiple IIF aggregates in one query ──────────────────────────────── - - [Fact] - public async Task SumIif_MultipleDifferentParams_AllResolve() - { - await SeedItems(); - - var query = new QueryDefinition(@" + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task SeedItems() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", status = "Active", category = "A", price = 10.0, score = 5 }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", status = "Active", category = "A", price = 20.0, score = 8 }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", status = "Inactive", category = "B", price = 30.0, score = 3 }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "4", pk = "a", status = "Active", category = "B", price = 40.0 }), + new PartitionKey("a")); + } + + // ── IS_DEFINED with @param ────────────────────────────────────────────── + + [Fact] + public async Task IsDefined_WithParameterizedNonNull_ReturnsTrue() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT IS_DEFINED(@val) AS result FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", "something"); + + var results = await DrainQuery(query); + results.Should().HaveCount(4); + results[0]["result"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task IsDefined_WithParameterizedNull_ReturnsTrue() + { + // IS_DEFINED(null) returns true in real Cosmos (null is defined, just null) + await SeedItems(); + + var query = new QueryDefinition( + "SELECT IS_DEFINED(@val) AS result FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", null); + + var results = await DrainQuery(query); + results.Should().HaveCount(4); + // null is a defined value — IS_DEFINED(null) = true + results[0]["result"]!.Value().Should().BeTrue(); + } + + // ── IS_NULL variants ──────────────────────────────────────────────────── + + [Fact] + public async Task IsNull_WithParameterizedString_ReturnsFalse() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT IS_NULL(@val) AS result FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", "notNull"); + + var results = await DrainQuery(query); + results[0]["result"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsNull_WithParameterizedInteger_ReturnsFalse() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT IS_NULL(@val) AS result FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", 42); + + var results = await DrainQuery(query); + results[0]["result"]!.Value().Should().BeFalse(); + } + + // ── SUM with parameterized arithmetic ─────────────────────────────────── + + [Fact] + public async Task Sum_WithParameterizedMultiplier_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT SUM(c.price * @multiplier) AS total FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@multiplier", 2); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(200.0); + } + + [Fact] + public async Task Avg_WithParameterizedExpression_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT AVG(c.price + @bonus) AS avgPrice FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@bonus", 10); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + // Prices: 10, 20, 30, 40. With +10 bonus: 20, 30, 40, 50. Avg = 35 + results[0]["avgPrice"]!.Value().Should().Be(35.0); + } + + // ── Multiple IIF aggregates in one query ──────────────────────────────── + + [Fact] + public async Task SumIif_MultipleDifferentParams_AllResolve() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT SUM(IIF(c.status = @active, 1, 0)) AS activeCount, SUM(IIF(c.status = @inactive, 1, 0)) AS inactiveCount, SUM(IIF(c.status = @unknown, 1, 0)) AS unknownCount FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@active", "Active") - .WithParameter("@inactive", "Inactive") - .WithParameter("@unknown", "Unknown"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["activeCount"]!.Value().Should().Be(3); - results[0]["inactiveCount"]!.Value().Should().Be(1); - results[0]["unknownCount"]!.Value().Should().Be(0); - } - - // ── SUM(IIF) with conditional amount ──────────────────────────────────── - - [Fact] - public async Task SumIif_ConditionalAmount_WithParameter() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT SUM(IIF(c.status = @status, c.price, 0)) AS totalActive FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@status", "Active"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - // Active items: price 10 + 20 + 40 = 70 - results[0]["totalActive"]!.Value().Should().Be(70.0); - } - - // ── GROUP BY with parameterized aggregates ────────────────────────────── - - [Fact] - public async Task GroupBy_SumIif_WithParameter() - { - await SeedItems(); - - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@active", "Active") + .WithParameter("@inactive", "Inactive") + .WithParameter("@unknown", "Unknown"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["activeCount"]!.Value().Should().Be(3); + results[0]["inactiveCount"]!.Value().Should().Be(1); + results[0]["unknownCount"]!.Value().Should().Be(0); + } + + // ── SUM(IIF) with conditional amount ──────────────────────────────────── + + [Fact] + public async Task SumIif_ConditionalAmount_WithParameter() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT SUM(IIF(c.status = @status, c.price, 0)) AS totalActive FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@status", "Active"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + // Active items: price 10 + 20 + 40 = 70 + results[0]["totalActive"]!.Value().Should().Be(70.0); + } + + // ── GROUP BY with parameterized aggregates ────────────────────────────── + + [Fact] + public async Task GroupBy_SumIif_WithParameter() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT c.category, SUM(IIF(c.status = @activeStatus, 1, 0)) AS activeCount FROM c WHERE c.pk = @pk GROUP BY c.category") - .WithParameter("@pk", "a") - .WithParameter("@activeStatus", "Active"); + .WithParameter("@pk", "a") + .WithParameter("@activeStatus", "Active"); - var results = await DrainQuery(query); - results.Should().HaveCount(2); + var results = await DrainQuery(query); + results.Should().HaveCount(2); - var catA = results.First(r => r["category"]!.Value() == "A"); - var catB = results.First(r => r["category"]!.Value() == "B"); + var catA = results.First(r => r["category"]!.Value() == "A"); + var catB = results.First(r => r["category"]!.Value() == "B"); - catA["activeCount"]!.Value().Should().Be(2); - catB["activeCount"]!.Value().Should().Be(1); - } + catA["activeCount"]!.Value().Should().Be(2); + catB["activeCount"]!.Value().Should().Be(1); + } - [Fact] - public async Task GroupBy_Sum_WithParameterizedMultiplier() - { - await SeedItems(); + [Fact] + public async Task GroupBy_Sum_WithParameterizedMultiplier() + { + await SeedItems(); - var query = new QueryDefinition(@" + var query = new QueryDefinition(@" SELECT c.category, SUM(c.price * @factor) AS adjustedTotal FROM c WHERE c.pk = @pk GROUP BY c.category") - .WithParameter("@pk", "a") - .WithParameter("@factor", 1.1); + .WithParameter("@pk", "a") + .WithParameter("@factor", 1.1); - var results = await DrainQuery(query); - results.Should().HaveCount(2); + var results = await DrainQuery(query); + results.Should().HaveCount(2); - var catA = results.First(r => r["category"]!.Value() == "A"); - // Cat A prices: 10, 20 → * 1.1 → 11 + 22 = 33 - catA["adjustedTotal"]!.Value().Should().BeApproximately(33.0, 0.001); - } + var catA = results.First(r => r["category"]!.Value() == "A"); + // Cat A prices: 10, 20 → * 1.1 → 11 + 22 = 33 + catA["adjustedTotal"]!.Value().Should().BeApproximately(33.0, 0.001); + } - // ── GROUP BY HAVING with parameterized threshold ──────────────────────── + // ── GROUP BY HAVING with parameterized threshold ──────────────────────── - [Fact] - public async Task GroupBy_Having_WithParameterizedThreshold() - { - await SeedItems(); + [Fact] + public async Task GroupBy_Having_WithParameterizedThreshold() + { + await SeedItems(); - var query = new QueryDefinition(@" + var query = new QueryDefinition(@" SELECT c.category, SUM(c.price) AS total FROM c WHERE c.pk = @pk GROUP BY c.category HAVING SUM(c.price) > @threshold") - .WithParameter("@pk", "a") - .WithParameter("@threshold", 50); - - var results = await DrainQuery(query); - // Cat A: 10+20=30 (below 50), Cat B: 30+40=70 (above 50) - results.Should().ContainSingle(); - results[0]["category"]!.Value().Should().Be("B"); - results[0]["total"]!.Value().Should().Be(70.0); - } - - // ── IS_NULL in WHERE combined with other conditions ────────────────────── - - [Fact] - public async Task IsNull_InWhereWithAnd_WithNullParam() - { - await SeedItems(); - - // Items with id=4 has no "score" property - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND (IS_NULL(@filter) OR c.category = @filter)") - .WithParameter("@pk", "a") - .WithParameter("@filter", null); - - var results = await DrainQuery(query); - results.Should().HaveCount(4); // All items returned when filter is null - } - - [Fact] - public async Task IsNull_InWhereWithAnd_WithNonNullParam() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND (IS_NULL(@filter) OR c.category = @filter)") - .WithParameter("@pk", "a") - .WithParameter("@filter", "A"); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); // Only category A items - } - - // ── MIN/MAX with parameterized expressions ────────────────────────────── - - [Fact] - public async Task Min_WithParameterizedExpression_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT MIN(c.price + @offset) AS minAdjusted FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@offset", 5); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - // Min price is 10, +5 = 15 - results[0]["minAdjusted"]!.Value().Should().Be(15.0); - } - - [Fact] - public async Task Max_WithParameterizedExpression_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT MAX(c.price * @factor) AS maxAdjusted FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@factor", 0.5); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - // Max price is 40, *0.5 = 20 - results[0]["maxAdjusted"]!.Value().Should().Be(20.0); - } - - // ── Nested IIF with multiple param references ─────────────────────────── - - [Fact] - public async Task Iif_NestedWithMultipleParams_InSelect() - { - await SeedItems(); - - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@threshold", 50); + + var results = await DrainQuery(query); + // Cat A: 10+20=30 (below 50), Cat B: 30+40=70 (above 50) + results.Should().ContainSingle(); + results[0]["category"]!.Value().Should().Be("B"); + results[0]["total"]!.Value().Should().Be(70.0); + } + + // ── IS_NULL in WHERE combined with other conditions ────────────────────── + + [Fact] + public async Task IsNull_InWhereWithAnd_WithNullParam() + { + await SeedItems(); + + // Items with id=4 has no "score" property + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND (IS_NULL(@filter) OR c.category = @filter)") + .WithParameter("@pk", "a") + .WithParameter("@filter", null); + + var results = await DrainQuery(query); + results.Should().HaveCount(4); // All items returned when filter is null + } + + [Fact] + public async Task IsNull_InWhereWithAnd_WithNonNullParam() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND (IS_NULL(@filter) OR c.category = @filter)") + .WithParameter("@pk", "a") + .WithParameter("@filter", "A"); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); // Only category A items + } + + // ── MIN/MAX with parameterized expressions ────────────────────────────── + + [Fact] + public async Task Min_WithParameterizedExpression_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT MIN(c.price + @offset) AS minAdjusted FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@offset", 5); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + // Min price is 10, +5 = 15 + results[0]["minAdjusted"]!.Value().Should().Be(15.0); + } + + [Fact] + public async Task Max_WithParameterizedExpression_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT MAX(c.price * @factor) AS maxAdjusted FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@factor", 0.5); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + // Max price is 40, *0.5 = 20 + results[0]["maxAdjusted"]!.Value().Should().Be(20.0); + } + + // ── Nested IIF with multiple param references ─────────────────────────── + + [Fact] + public async Task Iif_NestedWithMultipleParams_InSelect() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT c.id, IIF(c.status = @active, @activeLabel, @inactiveLabel) AS label FROM c WHERE c.pk = @pk ORDER BY c.id") - .WithParameter("@pk", "a") - .WithParameter("@active", "Active") - .WithParameter("@activeLabel", "ON") - .WithParameter("@inactiveLabel", "OFF"); - - var results = await DrainQuery(query); - results.Should().HaveCount(4); - results[0]["label"]!.Value().Should().Be("ON"); // id=1, Active - results[2]["label"]!.Value().Should().Be("OFF"); // id=3, Inactive - } - - // ── Combined IS_NULL + SUM(IIF) pattern (production use case) ─────────── - - [Fact] - public async Task ProductionPattern_OptionalFilterWithConditionalAggregation() - { - await SeedItems(); - - // Real-world pattern: optional status filter + conditional counting - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@active", "Active") + .WithParameter("@activeLabel", "ON") + .WithParameter("@inactiveLabel", "OFF"); + + var results = await DrainQuery(query); + results.Should().HaveCount(4); + results[0]["label"]!.Value().Should().Be("ON"); // id=1, Active + results[2]["label"]!.Value().Should().Be("OFF"); // id=3, Inactive + } + + // ── Combined IS_NULL + SUM(IIF) pattern (production use case) ─────────── + + [Fact] + public async Task ProductionPattern_OptionalFilterWithConditionalAggregation() + { + await SeedItems(); + + // Real-world pattern: optional status filter + conditional counting + var query = new QueryDefinition(@" SELECT COUNT(1) AS total, SUM(IIF(c.status = @targetStatus, 1, 0)) AS targetCount FROM c WHERE c.pk = @pk AND (IS_NULL(@categoryFilter) OR c.category = @categoryFilter)") - .WithParameter("@pk", "a") - .WithParameter("@targetStatus", "Active") - .WithParameter("@categoryFilter", null); // No filter — all items - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(4); - results[0]["targetCount"]!.Value().Should().Be(3); - } - - [Fact] - public async Task ProductionPattern_WithCategoryFilter() - { - await SeedItems(); - - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@targetStatus", "Active") + .WithParameter("@categoryFilter", null); // No filter — all items + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(4); + results[0]["targetCount"]!.Value().Should().Be(3); + } + + [Fact] + public async Task ProductionPattern_WithCategoryFilter() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT COUNT(1) AS total, SUM(IIF(c.status = @targetStatus, 1, 0)) AS targetCount FROM c WHERE c.pk = @pk AND (IS_NULL(@categoryFilter) OR c.category = @categoryFilter)") - .WithParameter("@pk", "a") - .WithParameter("@targetStatus", "Active") - .WithParameter("@categoryFilter", "B"); // Filter to category B only - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(2); // 2 items in cat B - results[0]["targetCount"]!.Value().Should().Be(1); // 1 active in cat B - } - - // ── IS_STRING/IS_NUMBER/IS_BOOL with parameters ───────────────────────── - - [Fact] - public async Task IsString_WithParameterizedValue_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT VALUE IS_STRING(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", "hello"); - - var results = await DrainQuery(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsNumber_WithParameterizedValue_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT VALUE IS_NUMBER(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", 42); - - var results = await DrainQuery(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsBool_WithParameterizedValue_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT VALUE IS_BOOL(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") - .WithParameter("@pk", "a") - .WithParameter("@val", true); - - var results = await DrainQuery(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - private async Task> DrainQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + .WithParameter("@pk", "a") + .WithParameter("@targetStatus", "Active") + .WithParameter("@categoryFilter", "B"); // Filter to category B only + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(2); // 2 items in cat B + results[0]["targetCount"]!.Value().Should().Be(1); // 1 active in cat B + } + + // ── IS_STRING/IS_NUMBER/IS_BOOL with parameters ───────────────────────── + + [Fact] + public async Task IsString_WithParameterizedValue_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT VALUE IS_STRING(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", "hello"); + + var results = await DrainQuery(query); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsNumber_WithParameterizedValue_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT VALUE IS_NUMBER(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", 42); + + var results = await DrainQuery(query); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsBool_WithParameterizedValue_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT VALUE IS_BOOL(@val) FROM c WHERE c.id = '1' AND c.pk = @pk") + .WithParameter("@pk", "a") + .WithParameter("@val", true); + + var results = await DrainQuery(query); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + private async Task> DrainQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } /// @@ -562,423 +562,423 @@ private async Task> DrainQuery(QueryDefinition query) /// public class ParameterizedQueryEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task SeedItems() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice", tags = new[] { "admin", "user" }, score = 85, nullableField = (string?)null }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "Bob", tags = new[] { "user" }, score = 72 }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", name = "Charlie", tags = new[] { "admin", "moderator" }, score = 91 }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "4", pk = "a", name = "Diana", tags = new object?[] { "user", null }, score = 68 }), - new PartitionKey("a")); - } - - // ── ARRAY_CONTAINS with parameters ────────────────────────────────────── - - [Fact] - public async Task ArrayContains_WithParameterizedSearchValue_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(c.tags, @tag)") - .WithParameter("@pk", "a") - .WithParameter("@tag", "admin"); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); // Alice and Charlie - } - - [Fact] - public async Task ArrayContains_WithParameterizedArray_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(@adminTags, c.name)") - .WithParameter("@pk", "a") - .WithParameter("@adminTags", new JArray("Alice", "Charlie")); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); - } - - [Fact] - public async Task ArrayContains_NullSearchValue_FindsNullElements() - { - await SeedItems(); - - // Item 4 has tags: ["user", null] — ARRAY_CONTAINS should find the null element - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(c.tags, null)") - .WithParameter("@pk", "a"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("4"); - } - - // ── BETWEEN with parameters ───────────────────────────────────────────── - - [Fact] - public async Task Between_WithParameterizedBounds_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.score BETWEEN @low AND @high") - .WithParameter("@pk", "a") - .WithParameter("@low", 70) - .WithParameter("@high", 90); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); // Bob (72) and Alice (85) - } - - // ── IN with parameters ────────────────────────────────────────────────── - - [Fact] - public async Task In_WithParameterizedValues_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.name IN (@n1, @n2)") - .WithParameter("@pk", "a") - .WithParameter("@n1", "Alice") - .WithParameter("@n2", "Charlie"); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); - } - - [Fact] - public async Task In_WithNullParameter_MatchesNullField() - { - await SeedItems(); - - // Item 1 has nullableField = null - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.nullableField IN (@val)") - .WithParameter("@pk", "a") - .WithParameter("@val", null); - - var results = await DrainQuery(query); - // Item 1 has explicitly null field, others have undefined field - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── String functions with parameters ──────────────────────────────────── - - [Fact] - public async Task StartsWith_WithParameterizedPrefix_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND STARTSWITH(c.name, @prefix)") - .WithParameter("@pk", "a") - .WithParameter("@prefix", "Al"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task EndsWith_WithParameterizedSuffix_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND ENDSWITH(c.name, @suffix)") - .WithParameter("@pk", "a") - .WithParameter("@suffix", "ob"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task Contains_WithParameterizedSubstring_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND CONTAINS(c.name, @sub)") - .WithParameter("@pk", "a") - .WithParameter("@sub", "li"); - - var results = await DrainQuery(query); - results.Should().HaveCount(2); // Alice and Charlie - } - - [Fact] - public async Task StartsWith_WithNullParam_ReturnsNoResults() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND STARTSWITH(c.name, @prefix)") - .WithParameter("@pk", "a") - .WithParameter("@prefix", null); - - var results = await DrainQuery(query); - results.Should().BeEmpty(); - } - - // ── Null coalesce (??) with parameters ────────────────────────────────── - - [Fact] - public async Task NullCoalesce_WithParameterizedDefault_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id, c.nullableField ?? @default AS resolved FROM c WHERE c.pk = @pk AND c.id = '1'") - .WithParameter("@pk", "a") - .WithParameter("@default", "fallback"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["resolved"]!.Value().Should().Be("fallback"); - } - - [Fact] - public async Task NullCoalesce_WhenFieldHasValue_UsesFieldValue() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id, c.name ?? @default AS resolved FROM c WHERE c.pk = @pk AND c.id = '1'") - .WithParameter("@pk", "a") - .WithParameter("@default", "fallback"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["resolved"]!.Value().Should().Be("Alice"); - } - - // ── Comparison with null parameters ───────────────────────────────────── - - [Fact] - public async Task Equals_WithNullParam_DoesNotMatchNonNullField() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.name = @val") - .WithParameter("@pk", "a") - .WithParameter("@val", null); - - var results = await DrainQuery(query); - results.Should().BeEmpty(); // No name is null - } - - [Fact] - public async Task NotEquals_WithNullParam_MatchesNonNullFields() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.name != @val") - .WithParameter("@pk", "a") - .WithParameter("@val", null); - - var results = await DrainQuery(query); - // In Cosmos, x != null returns false for all x (three-value logic) - // But the emulator may differ — this documents the actual behavior - results.Should().HaveCount(4); - } - - // ── Ternary (IIF) with parameterized conditions ───────────────────────── - - [Fact] - public async Task Iif_WithParameterizedConditionValue_Works() - { - await SeedItems(); - - var query = new QueryDefinition(@" + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task SeedItems() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice", tags = new[] { "admin", "user" }, score = 85, nullableField = (string?)null }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "Bob", tags = new[] { "user" }, score = 72 }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", name = "Charlie", tags = new[] { "admin", "moderator" }, score = 91 }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "4", pk = "a", name = "Diana", tags = new object?[] { "user", null }, score = 68 }), + new PartitionKey("a")); + } + + // ── ARRAY_CONTAINS with parameters ────────────────────────────────────── + + [Fact] + public async Task ArrayContains_WithParameterizedSearchValue_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(c.tags, @tag)") + .WithParameter("@pk", "a") + .WithParameter("@tag", "admin"); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); // Alice and Charlie + } + + [Fact] + public async Task ArrayContains_WithParameterizedArray_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(@adminTags, c.name)") + .WithParameter("@pk", "a") + .WithParameter("@adminTags", new JArray("Alice", "Charlie")); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); + } + + [Fact] + public async Task ArrayContains_NullSearchValue_FindsNullElements() + { + await SeedItems(); + + // Item 4 has tags: ["user", null] — ARRAY_CONTAINS should find the null element + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND ARRAY_CONTAINS(c.tags, null)") + .WithParameter("@pk", "a"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("4"); + } + + // ── BETWEEN with parameters ───────────────────────────────────────────── + + [Fact] + public async Task Between_WithParameterizedBounds_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.score BETWEEN @low AND @high") + .WithParameter("@pk", "a") + .WithParameter("@low", 70) + .WithParameter("@high", 90); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); // Bob (72) and Alice (85) + } + + // ── IN with parameters ────────────────────────────────────────────────── + + [Fact] + public async Task In_WithParameterizedValues_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.name IN (@n1, @n2)") + .WithParameter("@pk", "a") + .WithParameter("@n1", "Alice") + .WithParameter("@n2", "Charlie"); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); + } + + [Fact] + public async Task In_WithNullParameter_MatchesNullField() + { + await SeedItems(); + + // Item 1 has nullableField = null + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.nullableField IN (@val)") + .WithParameter("@pk", "a") + .WithParameter("@val", null); + + var results = await DrainQuery(query); + // Item 1 has explicitly null field, others have undefined field + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── String functions with parameters ──────────────────────────────────── + + [Fact] + public async Task StartsWith_WithParameterizedPrefix_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND STARTSWITH(c.name, @prefix)") + .WithParameter("@pk", "a") + .WithParameter("@prefix", "Al"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task EndsWith_WithParameterizedSuffix_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND ENDSWITH(c.name, @suffix)") + .WithParameter("@pk", "a") + .WithParameter("@suffix", "ob"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task Contains_WithParameterizedSubstring_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND CONTAINS(c.name, @sub)") + .WithParameter("@pk", "a") + .WithParameter("@sub", "li"); + + var results = await DrainQuery(query); + results.Should().HaveCount(2); // Alice and Charlie + } + + [Fact] + public async Task StartsWith_WithNullParam_ReturnsNoResults() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND STARTSWITH(c.name, @prefix)") + .WithParameter("@pk", "a") + .WithParameter("@prefix", null); + + var results = await DrainQuery(query); + results.Should().BeEmpty(); + } + + // ── Null coalesce (??) with parameters ────────────────────────────────── + + [Fact] + public async Task NullCoalesce_WithParameterizedDefault_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id, c.nullableField ?? @default AS resolved FROM c WHERE c.pk = @pk AND c.id = '1'") + .WithParameter("@pk", "a") + .WithParameter("@default", "fallback"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["resolved"]!.Value().Should().Be("fallback"); + } + + [Fact] + public async Task NullCoalesce_WhenFieldHasValue_UsesFieldValue() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id, c.name ?? @default AS resolved FROM c WHERE c.pk = @pk AND c.id = '1'") + .WithParameter("@pk", "a") + .WithParameter("@default", "fallback"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["resolved"]!.Value().Should().Be("Alice"); + } + + // ── Comparison with null parameters ───────────────────────────────────── + + [Fact] + public async Task Equals_WithNullParam_DoesNotMatchNonNullField() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.name = @val") + .WithParameter("@pk", "a") + .WithParameter("@val", null); + + var results = await DrainQuery(query); + results.Should().BeEmpty(); // No name is null + } + + [Fact] + public async Task NotEquals_WithNullParam_MatchesNonNullFields() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.name != @val") + .WithParameter("@pk", "a") + .WithParameter("@val", null); + + var results = await DrainQuery(query); + // In Cosmos, x != null returns false for all x (three-value logic) + // But the emulator may differ — this documents the actual behavior + results.Should().HaveCount(4); + } + + // ── Ternary (IIF) with parameterized conditions ───────────────────────── + + [Fact] + public async Task Iif_WithParameterizedConditionValue_Works() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT c.id, IIF(c.score >= @threshold, 'pass', 'fail') AS result FROM c WHERE c.pk = @pk ORDER BY c.id") - .WithParameter("@pk", "a") - .WithParameter("@threshold", 80); - - var results = await DrainQuery(query); - results.Should().HaveCount(4); - results[0]["result"]!.Value().Should().Be("pass"); // id=1, score=85 - results[1]["result"]!.Value().Should().Be("fail"); // id=2, score=72 - results[2]["result"]!.Value().Should().Be("pass"); // id=3, score=91 - results[3]["result"]!.Value().Should().Be("fail"); // id=4, score=68 - } - - // ── CONCAT with parameters ────────────────────────────────────────────── - - [Fact] - public async Task Concat_WithParameterizedValues_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT CONCAT(@prefix, c.name, @suffix) AS greeting FROM c WHERE c.pk = @pk AND c.id = '1'") - .WithParameter("@pk", "a") - .WithParameter("@prefix", "Hello, ") - .WithParameter("@suffix", "!"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["greeting"]!.Value().Should().Be("Hello, Alice!"); - } - - // ── Mathematical functions with parameters ────────────────────────────── - - [Fact] - public async Task Power_WithParameterizedExponent_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT POWER(c.score, @exp) AS result FROM c WHERE c.pk = @pk AND c.id = '1'") - .WithParameter("@pk", "a") - .WithParameter("@exp", 2); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().Be(7225.0); // 85^2 - } - - // ── LIKE with parameters ──────────────────────────────────────────────── - - [Fact] - public async Task Like_WithParameterizedPattern_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id FROM c WHERE c.pk = @pk AND c.name LIKE @pattern") - .WithParameter("@pk", "a") - .WithParameter("@pattern", "A%"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); // Alice - } - - // ── Complex WHERE combining multiple param patterns ───────────────────── - - [Fact] - public async Task ComplexWhere_MultipleParamPatterns_Works() - { - await SeedItems(); - - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@threshold", 80); + + var results = await DrainQuery(query); + results.Should().HaveCount(4); + results[0]["result"]!.Value().Should().Be("pass"); // id=1, score=85 + results[1]["result"]!.Value().Should().Be("fail"); // id=2, score=72 + results[2]["result"]!.Value().Should().Be("pass"); // id=3, score=91 + results[3]["result"]!.Value().Should().Be("fail"); // id=4, score=68 + } + + // ── CONCAT with parameters ────────────────────────────────────────────── + + [Fact] + public async Task Concat_WithParameterizedValues_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT CONCAT(@prefix, c.name, @suffix) AS greeting FROM c WHERE c.pk = @pk AND c.id = '1'") + .WithParameter("@pk", "a") + .WithParameter("@prefix", "Hello, ") + .WithParameter("@suffix", "!"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["greeting"]!.Value().Should().Be("Hello, Alice!"); + } + + // ── Mathematical functions with parameters ────────────────────────────── + + [Fact] + public async Task Power_WithParameterizedExponent_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT POWER(c.score, @exp) AS result FROM c WHERE c.pk = @pk AND c.id = '1'") + .WithParameter("@pk", "a") + .WithParameter("@exp", 2); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().Be(7225.0); // 85^2 + } + + // ── LIKE with parameters ──────────────────────────────────────────────── + + [Fact] + public async Task Like_WithParameterizedPattern_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id FROM c WHERE c.pk = @pk AND c.name LIKE @pattern") + .WithParameter("@pk", "a") + .WithParameter("@pattern", "A%"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); // Alice + } + + // ── Complex WHERE combining multiple param patterns ───────────────────── + + [Fact] + public async Task ComplexWhere_MultipleParamPatterns_Works() + { + await SeedItems(); + + var query = new QueryDefinition(@" SELECT c.id, c.name, c.score FROM c WHERE c.pk = @pk AND c.score BETWEEN @minScore AND @maxScore AND ARRAY_CONTAINS(c.tags, @requiredTag) AND STARTSWITH(c.name, @prefix)") - .WithParameter("@pk", "a") - .WithParameter("@minScore", 80) - .WithParameter("@maxScore", 100) - .WithParameter("@requiredTag", "admin") - .WithParameter("@prefix", "C"); - - var results = await DrainQuery(query); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Charlie"); - } - - // ── GROUP BY HAVING with parameterized aggregate expression ───────────── - - [Fact] - public async Task GroupBy_Having_SumIifWithParameter() - { - await SeedItems(); - - // Group by first tag, keep groups where count >= minCount - var query = new QueryDefinition(@" + .WithParameter("@pk", "a") + .WithParameter("@minScore", 80) + .WithParameter("@maxScore", 100) + .WithParameter("@requiredTag", "admin") + .WithParameter("@prefix", "C"); + + var results = await DrainQuery(query); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Charlie"); + } + + // ── GROUP BY HAVING with parameterized aggregate expression ───────────── + + [Fact] + public async Task GroupBy_Having_SumIifWithParameter() + { + await SeedItems(); + + // Group by first tag, keep groups where count >= minCount + var query = new QueryDefinition(@" SELECT c.tags[0] AS firstTag, COUNT(1) AS cnt FROM c WHERE c.pk = @pk GROUP BY c.tags[0] HAVING COUNT(1) >= @minCount") - .WithParameter("@pk", "a") - .WithParameter("@minCount", 2); - - var results = await DrainQuery(query); - // "user" appears in items 2 and 4 → cnt=2, "admin" in items 1 and 3 → cnt=2 - results.Should().HaveCount(2); - } - - // ── VALUE queries with parameters ─────────────────────────────────────── - - [Fact] - public async Task SelectValue_WithParameterizedExpression() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT VALUE c.score + @bonus FROM c WHERE c.pk = @pk AND c.id = '1'") - .WithParameter("@pk", "a") - .WithParameter("@bonus", 15); - - var results = await DrainQuery(query); - results.Should().ContainSingle().Which.Should().Be(100); - } - - // ── ORDER BY with parameterized expression ────────────────────────────── - - [Fact] - public async Task OrderBy_SimpleFieldWithParamInWhere_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT c.id, c.score FROM c WHERE c.pk = @pk AND c.score > @minScore ORDER BY c.score DESC") - .WithParameter("@pk", "a") - .WithParameter("@minScore", 70); - - var results = await DrainQuery(query); - results.Should().HaveCount(3); // 91, 85, 72 - results[0]["score"]!.Value().Should().Be(91); - results[1]["score"]!.Value().Should().Be(85); - results[2]["score"]!.Value().Should().Be(72); - } - - // ── DISTINCT with parameters ──────────────────────────────────────────── - - [Fact] - public async Task Distinct_WithParameterInWhere_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT DISTINCT VALUE c.tags[0] FROM c WHERE c.pk = @pk") - .WithParameter("@pk", "a"); - - var results = await DrainQuery(query); - results.Should().HaveCount(2).And.Contain("admin").And.Contain("user"); - } - - private async Task> DrainQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + .WithParameter("@pk", "a") + .WithParameter("@minCount", 2); + + var results = await DrainQuery(query); + // "user" appears in items 2 and 4 → cnt=2, "admin" in items 1 and 3 → cnt=2 + results.Should().HaveCount(2); + } + + // ── VALUE queries with parameters ─────────────────────────────────────── + + [Fact] + public async Task SelectValue_WithParameterizedExpression() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT VALUE c.score + @bonus FROM c WHERE c.pk = @pk AND c.id = '1'") + .WithParameter("@pk", "a") + .WithParameter("@bonus", 15); + + var results = await DrainQuery(query); + results.Should().ContainSingle().Which.Should().Be(100); + } + + // ── ORDER BY with parameterized expression ────────────────────────────── + + [Fact] + public async Task OrderBy_SimpleFieldWithParamInWhere_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT c.id, c.score FROM c WHERE c.pk = @pk AND c.score > @minScore ORDER BY c.score DESC") + .WithParameter("@pk", "a") + .WithParameter("@minScore", 70); + + var results = await DrainQuery(query); + results.Should().HaveCount(3); // 91, 85, 72 + results[0]["score"]!.Value().Should().Be(91); + results[1]["score"]!.Value().Should().Be(85); + results[2]["score"]!.Value().Should().Be(72); + } + + // ── DISTINCT with parameters ──────────────────────────────────────────── + + [Fact] + public async Task Distinct_WithParameterInWhere_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT DISTINCT VALUE c.tags[0] FROM c WHERE c.pk = @pk") + .WithParameter("@pk", "a"); + + var results = await DrainQuery(query); + results.Should().HaveCount(2).And.Contain("admin").And.Contain("user"); + } + + private async Task> DrainQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PartitionKeyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PartitionKeyTests.cs index 0c015a6..585a9f2 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PartitionKeyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PartitionKeyTests.cs @@ -1,8 +1,8 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -10,261 +10,265 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class PartitionKeyGapTests4 { - [Fact] - public async Task PartitionKey_CompositeKey_ThreePaths() - { - var container = new InMemoryContainer("test", ["/partitionKey", "/name", "/nested/description"]); - - var compositePk = new PartitionKeyBuilder().Add("pk1").Add("Alice").Add("Desc").Build(); - var item = new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "Alice", - Nested = new NestedObject { Description = "Desc", Score = 1.0 } - }; - var response = await container.CreateItemAsync(item, compositePk); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - // Reading back with the same composite key - var read = await container.ReadItemAsync("1", compositePk); - read.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task PartitionKey_BooleanValue() - { - var container = new InMemoryContainer("test", "/active"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","active":true,"name":"BoolPK"}""")), - new PartitionKey(true)); - - var read = await container.ReadItemAsync("1", new PartitionKey(true)); - read.Resource["name"]!.ToString().Should().Be("BoolPK"); - } + [Fact] + public async Task PartitionKey_CompositeKey_ThreePaths() + { + var container = new InMemoryContainer("test", ["/partitionKey", "/name", "/nested/description"]); + + var compositePk = new PartitionKeyBuilder().Add("pk1").Add("Alice").Add("Desc").Build(); + var item = new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Alice", + Nested = new NestedObject { Description = "Desc", Score = 1.0 } + }; + var response = await container.CreateItemAsync(item, compositePk); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + // Reading back with the same composite key + var read = await container.ReadItemAsync("1", compositePk); + read.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task PartitionKey_BooleanValue() + { + var container = new InMemoryContainer("test", "/active"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","active":true,"name":"BoolPK"}""")), + new PartitionKey(true)); + + var read = await container.ReadItemAsync("1", new PartitionKey(true)); + read.Resource["name"]!.ToString().Should().Be("BoolPK"); + } } public class PartitionKeyGapTests { - [Fact] - public async Task PartitionKey_ExtractedFromItem_MatchesExplicitPk() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var item = new TestDocument { Id = "1", PartitionKey = "auto-pk", Name = "Test" }; - - await container.CreateItemAsync(item); - - var read = await container.ReadItemAsync("1", new PartitionKey("auto-pk")); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task PartitionKey_NumericValue() - { - var container = new InMemoryContainer("test", "/value"); - var item = new TestDocument { Id = "1", PartitionKey = "ignored", Name = "Test", Value = 42 }; - - await container.CreateItemAsync(item, new PartitionKey(42)); - - var read = await container.ReadItemAsync("1", new PartitionKey(42)); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task CrossPartition_Query_ReturnsAllPartitions() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "A", Name = "Alice" }, - new PartitionKey("A")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "B", Name = "Bob" }, - new PartitionKey("B")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } + [Fact] + public async Task PartitionKey_ExtractedFromItem_MatchesExplicitPk() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var item = new TestDocument { Id = "1", PartitionKey = "auto-pk", Name = "Test" }; + + await container.CreateItemAsync(item); + + var read = await container.ReadItemAsync("1", new PartitionKey("auto-pk")); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task PartitionKey_NumericValue() + { + var container = new InMemoryContainer("test", "/value"); + var item = new TestDocument { Id = "1", PartitionKey = "ignored", Name = "Test", Value = 42 }; + + await container.CreateItemAsync(item, new PartitionKey(42)); + + var read = await container.ReadItemAsync("1", new PartitionKey(42)); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task CrossPartition_Query_ReturnsAllPartitions() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "A", Name = "Alice" }, + new PartitionKey("A")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "B", Name = "Bob" }, + new PartitionKey("B")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } } public class PartitionKeyGapTests3 { - [Fact] - public async Task PartitionKey_None_ItemsStoredByDocumentPk() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // With PK.None, the PK is extracted from the document body - var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; - await container.CreateItemAsync(item1, PartitionKey.None); - - var item2 = new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }; - await container.CreateItemAsync(item2, PartitionKey.None); - - // Items should be retrievable using their document PK values - var read1 = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("A"); - - var read2 = await container.ReadItemAsync("2", new PartitionKey("pk2")); - read2.Resource.Name.Should().Be("B"); - } - - [Fact(Skip = "InMemoryContainer with PartitionKey.None and missing partition key field throws conflict. " + - "Real Cosmos DB would store the item and allow reading with the fallback PK value. " + - "InMemoryContainer's ExtractPartitionKeyValue path conflicts with stream-based creation. " + - "See divergent behavior test in PartitionKeyFallbackDivergentBehaviorTests.")] - public async Task PartitionKey_MissingInItem_FallsBackToId() - { - // Container with /category PK — but item doesn't have "category" field - var container = new InMemoryContainer("test", "/category"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","name":"NoPK"}""")), - PartitionKey.None); - - // Without a "category" field, ExtractPartitionKeyValue falls back to id - var read = await container.ReadItemAsync("1", new PartitionKey("1")); - read.Resource["name"]!.ToString().Should().Be("NoPK"); - } + [Fact] + public async Task PartitionKey_None_ItemsStoredByDocumentPk() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // With PK.None, the PK is extracted from the document body + var item1 = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; + await container.CreateItemAsync(item1, PartitionKey.None); + + var item2 = new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }; + await container.CreateItemAsync(item2, PartitionKey.None); + + // Items should be retrievable using their document PK values + var read1 = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("A"); + + var read2 = await container.ReadItemAsync("2", new PartitionKey("pk2")); + read2.Resource.Name.Should().Be("B"); + } + + [Fact(Skip = "InMemoryContainer with PartitionKey.None and missing partition key field throws conflict. " + + "Real Cosmos DB would store the item and allow reading with the fallback PK value. " + + "InMemoryContainer's ExtractPartitionKeyValue path conflicts with stream-based creation. " + + "See divergent behavior test in PartitionKeyFallbackDivergentBehaviorTests.")] + public async Task PartitionKey_MissingInItem_FallsBackToId() + { + // Container with /category PK — but item doesn't have "category" field + var container = new InMemoryContainer("test", "/category"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","name":"NoPK"}""")), + PartitionKey.None); + + // Without a "category" field, ExtractPartitionKeyValue falls back to id + var read = await container.ReadItemAsync("1", new PartitionKey("1")); + read.Resource["name"]!.ToString().Should().Be("NoPK"); + } } public class PartitionKeyGapTests2 { - [Fact] - public async Task PartitionKey_CompositeKey_TwoPaths() - { - var container = new InMemoryContainer("test", new[] { "/partitionKey", "/name" }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task PartitionKey_NestedPath_Extraction() - { - var container = new InMemoryContainer("test", "/nested/description"); - await container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "ignored", Name = "Test", - Nested = new NestedObject { Description = "deep-pk", Score = 1.0 } - }, - new PartitionKey("deep-pk")); - - var read = await container.ReadItemAsync("1", new PartitionKey("deep-pk")); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task PartitionKey_NullValue() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - - // Create with PartitionKey.Null - await container.CreateItemAsync(item, PartitionKey.Null); - - // Should be retrievable - var read = await container.ReadItemAsync("1", PartitionKey.Null); - read.Resource.Name.Should().Be("Test"); - } + [Fact] + public async Task PartitionKey_CompositeKey_TwoPaths() + { + var container = new InMemoryContainer("test", new[] { "/partitionKey", "/name" }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task PartitionKey_NestedPath_Extraction() + { + var container = new InMemoryContainer("test", "/nested/description"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "ignored", + Name = "Test", + Nested = new NestedObject { Description = "deep-pk", Score = 1.0 } + }, + new PartitionKey("deep-pk")); + + var read = await container.ReadItemAsync("1", new PartitionKey("deep-pk")); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task PartitionKey_NullValue() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + + // Create with PartitionKey.Null + await container.CreateItemAsync(item, PartitionKey.Null); + + // Should be retrievable + var read = await container.ReadItemAsync("1", PartitionKey.Null); + read.Resource.Name.Should().Be("Test"); + } } public class PartitionKeyFallbackDivergentBehaviorTests { - /// - /// BEHAVIORAL DIFFERENCE: InMemoryContainer with PartitionKey.None on a container - /// whose PK path doesn't exist in the document succeeds but may store with an unexpected PK. - /// Real Cosmos DB would use the partition key extracted from the document body. - /// InMemoryContainer falls back to the id field as the PK value when the PK path is missing. - /// Reading back requires knowing the fallback PK value. - /// - [Fact] - public async Task PartitionKey_None_WithMissingPkField_Succeeds_InMemory() - { - var container = new InMemoryContainer("test", "/category"); - - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","name":"NoPK"}""")), - PartitionKey.None); - - // InMemory accepts the item without throwing - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + /// + /// BEHAVIORAL DIFFERENCE: InMemoryContainer with PartitionKey.None on a container + /// whose PK path doesn't exist in the document succeeds but may store with an unexpected PK. + /// Real Cosmos DB would use the partition key extracted from the document body. + /// InMemoryContainer falls back to the id field as the PK value when the PK path is missing. + /// Reading back requires knowing the fallback PK value. + /// + [Fact] + public async Task PartitionKey_None_WithMissingPkField_Succeeds_InMemory() + { + var container = new InMemoryContainer("test", "/category"); + + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","name":"NoPK"}""")), + PartitionKey.None); + + // InMemory accepts the item without throwing + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ─── PartitionKey.None vs PartitionKey.Null ───────────────────────────── public class PartitionKeyNoneVsNullTests { - /// - /// In real Cosmos DB, PartitionKey.None represents the absence of a partition key (used - /// for containers created without a partition key definition in older API versions). - /// PartitionKey.Null represents an explicit null value. They are semantically distinct. - /// The emulator treats both as the storage key "null", making them interchangeable. - /// - [Fact(Skip = "The emulator's PartitionKeyToString maps both PartitionKey.None and " + - "PartitionKey.Null to the string 'null'. In real Cosmos DB, PartitionKey.None has " + - "special routing behavior for legacy non-partitioned containers, while PartitionKey.Null " + - "is an explicit null value in the partition key field. The emulator does not support " + - "non-partitioned (legacy) containers so the distinction is not meaningful here.")] - public async Task PartitionKeyNone_ShouldNotMatchPartitionKeyNull() - { - var container = new InMemoryContainer("test-container", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = (string)null! }), - PartitionKey.Null); - - // In real Cosmos, reading with PartitionKey.Null would not find an item - // written with PartitionKey.None - var act = () => container.ReadItemAsync("1", PartitionKey.Null); - await act.Should().ThrowAsync(); - } - - /// - /// Sister test: demonstrates the emulator treats None and Null identically. - /// - [Fact] - public async Task PartitionKeyNoneVsNull_EmulatorBehavior_TreatedIdentically() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: PartitionKey.None → system-defined partition for legacy containers. - // PartitionKey.Null → explicit null value in the PK field. An item written with - // PartitionKey.None cannot be read with PartitionKey.Null (different routing). - // In-Memory Emulator: ExtractPartitionKeyValue and PartitionKeyToString both map - // None and Null to the string "null". Items are stored with key (id, "null") in - // both cases, making them interchangeable. This is correct for modern containers - // but differs for legacy non-partitioned scenarios. - var container = new InMemoryContainer("test-container", "/pk"); - - // Create item with explicit null pk value via PartitionKey.Null - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = (string)null! }), - PartitionKey.Null); - - // In the emulator, PartitionKey.None resolves the same way as PartitionKey.Null - var response = await container.ReadItemAsync("1", PartitionKey.None); - response.Resource["id"]!.Value().Should().Be("1", - "the emulator treats PartitionKey.None and PartitionKey.Null identically"); - } + /// + /// In real Cosmos DB, PartitionKey.None represents the absence of a partition key (used + /// for containers created without a partition key definition in older API versions). + /// PartitionKey.Null represents an explicit null value. They are semantically distinct. + /// The emulator treats both as the storage key "null", making them interchangeable. + /// + [Fact(Skip = "The emulator's PartitionKeyToString maps both PartitionKey.None and " + + "PartitionKey.Null to the string 'null'. In real Cosmos DB, PartitionKey.None has " + + "special routing behavior for legacy non-partitioned containers, while PartitionKey.Null " + + "is an explicit null value in the partition key field. The emulator does not support " + + "non-partitioned (legacy) containers so the distinction is not meaningful here.")] + public async Task PartitionKeyNone_ShouldNotMatchPartitionKeyNull() + { + var container = new InMemoryContainer("test-container", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = (string)null! }), + PartitionKey.Null); + + // In real Cosmos, reading with PartitionKey.Null would not find an item + // written with PartitionKey.None + var act = () => container.ReadItemAsync("1", PartitionKey.Null); + await act.Should().ThrowAsync(); + } + + /// + /// Sister test: demonstrates the emulator treats None and Null identically. + /// + [Fact] + public async Task PartitionKeyNoneVsNull_EmulatorBehavior_TreatedIdentically() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: PartitionKey.None → system-defined partition for legacy containers. + // PartitionKey.Null → explicit null value in the PK field. An item written with + // PartitionKey.None cannot be read with PartitionKey.Null (different routing). + // In-Memory Emulator: ExtractPartitionKeyValue and PartitionKeyToString both map + // None and Null to the string "null". Items are stored with key (id, "null") in + // both cases, making them interchangeable. This is correct for modern containers + // but differs for legacy non-partitioned scenarios. + var container = new InMemoryContainer("test-container", "/pk"); + + // Create item with explicit null pk value via PartitionKey.Null + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = (string)null! }), + PartitionKey.Null); + + // In the emulator, PartitionKey.None resolves the same way as PartitionKey.Null + var response = await container.ReadItemAsync("1", PartitionKey.None); + response.Resource["id"]!.Value().Should().Be("1", + "the emulator treats PartitionKey.None and PartitionKey.Null identically"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -273,89 +277,89 @@ await container.CreateItemAsync( public class PartitionKeyDataTypeTests { - [Fact] - public async Task PartitionKey_DoubleValue_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/score"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","score":3.14,"name":"Pi"}""")), - new PartitionKey(3.14)); - - var read = await container.ReadItemAsync("1", new PartitionKey(3.14)); - read.Resource["name"]!.ToString().Should().Be("Pi"); - } - - [Fact] - public async Task PartitionKey_EmptyString_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/category"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","category":"","name":"Empty"}""")), - new PartitionKey("")); - - var read = await container.ReadItemAsync("1", new PartitionKey("")); - read.Resource["name"]!.ToString().Should().Be("Empty"); - } - - [Fact] - public async Task PartitionKey_LongString_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/category"); - var longPk = new string('x', 1000); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"1","category":"{{{longPk}}}","name":"LongPK"}""")), - new PartitionKey(longPk)); - - var read = await container.ReadItemAsync("1", new PartitionKey(longPk)); - read.Resource["name"]!.ToString().Should().Be("LongPK"); - } - - [Fact] - public async Task PartitionKey_UnicodeCharacters_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/category"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", category = "日本語🎉", name = "Unicode" }), - new PartitionKey("日本語🎉")); - - var read = await container.ReadItemAsync("1", new PartitionKey("日本語🎉")); - read.Resource["name"]!.ToString().Should().Be("Unicode"); - } - - [Fact] - public async Task PartitionKey_SpecialJsonCharacters_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/category"); - var specialPk = "he said \"hello\" \\ end"; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", category = specialPk, name = "Special" }), - new PartitionKey(specialPk)); - - var read = await container.ReadItemAsync("1", new PartitionKey(specialPk)); - read.Resource["name"]!.ToString().Should().Be("Special"); - } - - [Fact] - public async Task PartitionKey_ContainingPipeCharacter_StoredAndRetrievable() - { - // Documents delimiter risk: single PK with pipe char works for single-path keys - var container = new InMemoryContainer("test", "/category"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", category = "a|b", name = "Pipe" }), - new PartitionKey("a|b")); - - var read = await container.ReadItemAsync("1", new PartitionKey("a|b")); - read.Resource["name"]!.ToString().Should().Be("Pipe"); - } + [Fact] + public async Task PartitionKey_DoubleValue_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/score"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","score":3.14,"name":"Pi"}""")), + new PartitionKey(3.14)); + + var read = await container.ReadItemAsync("1", new PartitionKey(3.14)); + read.Resource["name"]!.ToString().Should().Be("Pi"); + } + + [Fact] + public async Task PartitionKey_EmptyString_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/category"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","category":"","name":"Empty"}""")), + new PartitionKey("")); + + var read = await container.ReadItemAsync("1", new PartitionKey("")); + read.Resource["name"]!.ToString().Should().Be("Empty"); + } + + [Fact] + public async Task PartitionKey_LongString_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/category"); + var longPk = new string('x', 1000); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"1","category":"{{{longPk}}}","name":"LongPK"}""")), + new PartitionKey(longPk)); + + var read = await container.ReadItemAsync("1", new PartitionKey(longPk)); + read.Resource["name"]!.ToString().Should().Be("LongPK"); + } + + [Fact] + public async Task PartitionKey_UnicodeCharacters_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/category"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "日本語🎉", name = "Unicode" }), + new PartitionKey("日本語🎉")); + + var read = await container.ReadItemAsync("1", new PartitionKey("日本語🎉")); + read.Resource["name"]!.ToString().Should().Be("Unicode"); + } + + [Fact] + public async Task PartitionKey_SpecialJsonCharacters_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/category"); + var specialPk = "he said \"hello\" \\ end"; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = specialPk, name = "Special" }), + new PartitionKey(specialPk)); + + var read = await container.ReadItemAsync("1", new PartitionKey(specialPk)); + read.Resource["name"]!.ToString().Should().Be("Special"); + } + + [Fact] + public async Task PartitionKey_ContainingPipeCharacter_StoredAndRetrievable() + { + // Documents delimiter risk: single PK with pipe char works for single-path keys + var container = new InMemoryContainer("test", "/category"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "a|b", name = "Pipe" }), + new PartitionKey("a|b")); + + var read = await container.ReadItemAsync("1", new PartitionKey("a|b")); + read.Resource["name"]!.ToString().Should().Be("Pipe"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -364,114 +368,114 @@ await container.CreateItemAsync( public class CompositePartitionKeyEdgeCaseTests { - [Fact] - public async Task CompositeKey_MixedTypes_StringAndNumber() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "t1", region = "us-east", name = "Mixed" }), - pk); - - var read = await container.ReadItemAsync("1", pk); - read.Resource["name"]!.ToString().Should().Be("Mixed"); - } - - [Fact] - public async Task CompositeKey_SameIdDifferentComposite_BothExist() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); - var pk2 = new PartitionKeyBuilder().Add("t1").Add("eu").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "doc1", tenant = "t1", region = "us", name = "US" }), - pk1); - await container.CreateItemAsync( - JObject.FromObject(new { id = "doc1", tenant = "t1", region = "eu", name = "EU" }), - pk2); - - container.ItemCount.Should().Be(2); - - var readUS = await container.ReadItemAsync("doc1", pk1); - readUS.Resource["name"]!.ToString().Should().Be("US"); - - var readEU = await container.ReadItemAsync("doc1", pk2); - readEU.Resource["name"]!.ToString().Should().Be("EU"); - } - - [Fact] - public async Task CompositeKey_FourPaths() - { - var container = new InMemoryContainer("test", - ["/tenant", "/region", "/env", "/shard"]); - var pk = new PartitionKeyBuilder() - .Add("acme").Add("us").Add("prod").Add("s1").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "acme", region = "us", env = "prod", shard = "s1" }), - pk); - - var read = await container.ReadItemAsync("1", pk); - read.Resource["tenant"]!.ToString().Should().Be("acme"); - } - - [Fact] - public async Task CompositeKey_ReadMany_RetrievedCorrectly() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); - var pk2 = new PartitionKeyBuilder().Add("t1").Add("eu").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), pk1); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenant = "t1", region = "eu" }), pk2); - - var response = await container.ReadManyItemsAsync([ - ("1", pk1), - ("2", pk2) - ]); - - response.Resource.Should().HaveCount(2); - } - - [Fact] - public async Task CompositeKey_DeleteAllByPartitionKey() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); - var pk2 = new PartitionKeyBuilder().Add("t2").Add("us").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), pk1); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenant = "t1", region = "us" }), pk1); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", tenant = "t2", region = "us" }), pk2); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(pk1); - - container.ItemCount.Should().Be(1); - var remaining = await container.ReadItemAsync("3", pk2); - remaining.Resource["id"]!.ToString().Should().Be("3"); - } - - [Fact] - public async Task CompositeKey_TransactionalBatch() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk = new PartitionKeyBuilder().Add("t1").Add("us").Build(); - - var batch = container.CreateTransactionalBatch(pk); - batch.CreateItem(JObject.FromObject(new { id = "1", tenant = "t1", region = "us", name = "A" })); - batch.CreateItem(JObject.FromObject(new { id = "2", tenant = "t1", region = "us", name = "B" })); - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - container.ItemCount.Should().Be(2); - } + [Fact] + public async Task CompositeKey_MixedTypes_StringAndNumber() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "t1", region = "us-east", name = "Mixed" }), + pk); + + var read = await container.ReadItemAsync("1", pk); + read.Resource["name"]!.ToString().Should().Be("Mixed"); + } + + [Fact] + public async Task CompositeKey_SameIdDifferentComposite_BothExist() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); + var pk2 = new PartitionKeyBuilder().Add("t1").Add("eu").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "doc1", tenant = "t1", region = "us", name = "US" }), + pk1); + await container.CreateItemAsync( + JObject.FromObject(new { id = "doc1", tenant = "t1", region = "eu", name = "EU" }), + pk2); + + container.ItemCount.Should().Be(2); + + var readUS = await container.ReadItemAsync("doc1", pk1); + readUS.Resource["name"]!.ToString().Should().Be("US"); + + var readEU = await container.ReadItemAsync("doc1", pk2); + readEU.Resource["name"]!.ToString().Should().Be("EU"); + } + + [Fact] + public async Task CompositeKey_FourPaths() + { + var container = new InMemoryContainer("test", + ["/tenant", "/region", "/env", "/shard"]); + var pk = new PartitionKeyBuilder() + .Add("acme").Add("us").Add("prod").Add("s1").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "acme", region = "us", env = "prod", shard = "s1" }), + pk); + + var read = await container.ReadItemAsync("1", pk); + read.Resource["tenant"]!.ToString().Should().Be("acme"); + } + + [Fact] + public async Task CompositeKey_ReadMany_RetrievedCorrectly() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); + var pk2 = new PartitionKeyBuilder().Add("t1").Add("eu").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), pk1); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenant = "t1", region = "eu" }), pk2); + + var response = await container.ReadManyItemsAsync([ + ("1", pk1), + ("2", pk2) + ]); + + response.Resource.Should().HaveCount(2); + } + + [Fact] + public async Task CompositeKey_DeleteAllByPartitionKey() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk1 = new PartitionKeyBuilder().Add("t1").Add("us").Build(); + var pk2 = new PartitionKeyBuilder().Add("t2").Add("us").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "t1", region = "us" }), pk1); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenant = "t1", region = "us" }), pk1); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", tenant = "t2", region = "us" }), pk2); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(pk1); + + container.ItemCount.Should().Be(1); + var remaining = await container.ReadItemAsync("3", pk2); + remaining.Resource["id"]!.ToString().Should().Be("3"); + } + + [Fact] + public async Task CompositeKey_TransactionalBatch() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk = new PartitionKeyBuilder().Add("t1").Add("us").Build(); + + var batch = container.CreateTransactionalBatch(pk); + batch.CreateItem(JObject.FromObject(new { id = "1", tenant = "t1", region = "us", name = "A" })); + batch.CreateItem(JObject.FromObject(new { id = "2", tenant = "t1", region = "us", name = "B" })); + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + container.ItemCount.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -480,67 +484,67 @@ public async Task CompositeKey_TransactionalBatch() public class PartitionKeyBugDocumentationTests { - [Fact] - public async Task CompositeKey_WithNullComponent_PreservesPosition() - { - var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); - var pk = new PartitionKeyBuilder().Add("x").Add(null as string).Add("z").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", c = "z" }), pk); - - var read = await container.ReadItemAsync("1", pk); - read.Resource["a"]!.ToString().Should().Be("x"); - } - - [Fact] - public async Task CompositeKey_WithNullComponent_EmulatorBehavior_PreservesPosition() - { - // Null is a valid component in hierarchical partition keys and - // is preserved positionally. ("x", null, "z") != ("x", "z"). - // ExtractPartitionKeyValue now preserves null positions as empty strings - // for composite keys, matching PartitionKeyToString behavior. - var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); - - // PartitionKeyBuilder with null still produces a valid PK for the SDK - var pk = new PartitionKeyBuilder().Add("x").Add(null as string).Add("z").Build(); - - // The emulator preserves null positions in composite keys - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","a":"x","c":"z"}""")), - pk); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - // Verify round-trip: can read back with the same composite PK - var read = await container.ReadItemStreamAsync("1", pk); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task FeedRange_FilterConsistentWithCrudPartitionKey() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Get single feed range - var ranges = await container.GetFeedRangesAsync(); - var iterator = container.GetItemQueryIterator( - ranges[0], new QueryDefinition("SELECT * FROM c")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(d => d.Id == "1", - "item stored via CRUD should be visible in FeedRange-filtered query"); - } + [Fact] + public async Task CompositeKey_WithNullComponent_PreservesPosition() + { + var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); + var pk = new PartitionKeyBuilder().Add("x").Add(null as string).Add("z").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", c = "z" }), pk); + + var read = await container.ReadItemAsync("1", pk); + read.Resource["a"]!.ToString().Should().Be("x"); + } + + [Fact] + public async Task CompositeKey_WithNullComponent_EmulatorBehavior_PreservesPosition() + { + // Null is a valid component in hierarchical partition keys and + // is preserved positionally. ("x", null, "z") != ("x", "z"). + // ExtractPartitionKeyValue now preserves null positions as empty strings + // for composite keys, matching PartitionKeyToString behavior. + var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); + + // PartitionKeyBuilder with null still produces a valid PK for the SDK + var pk = new PartitionKeyBuilder().Add("x").Add(null as string).Add("z").Build(); + + // The emulator preserves null positions in composite keys + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","a":"x","c":"z"}""")), + pk); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + // Verify round-trip: can read back with the same composite PK + var read = await container.ReadItemStreamAsync("1", pk); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task FeedRange_FilterConsistentWithCrudPartitionKey() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Get single feed range + var ranges = await container.GetFeedRangesAsync(); + var iterator = container.GetItemQueryIterator( + ranges[0], new QueryDefinition("SELECT * FROM c")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(d => d.Id == "1", + "item stored via CRUD should be visible in FeedRange-filtered query"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -549,47 +553,47 @@ await container.CreateItemAsync( public class PartitionKeyPathEdgeCaseTests { - [Fact] - public async Task PartitionKey_DeeplyNestedPath_ThreeLevels() - { - var container = new InMemoryContainer("test", "/a/b/c"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","a":{"b":{"c":"deep-value"}},"name":"Deep"}""")), - new PartitionKey("deep-value")); - - var read = await container.ReadItemAsync("1", new PartitionKey("deep-value")); - read.Resource["name"]!.ToString().Should().Be("Deep"); - } - - [Fact] - public async Task PartitionKey_DefaultIdPath() - { - var container = new InMemoryContainer("test", "/id"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "myId", name = "IdAsPK" }), - new PartitionKey("myId")); - - var read = await container.ReadItemAsync("myId", new PartitionKey("myId")); - read.Resource["name"]!.ToString().Should().Be("IdAsPK"); - } - - [Fact] - public async Task PartitionKey_PathCaseSensitivity() - { - // PK path is case-sensitive — /Name != /name - var container = new InMemoryContainer("test", "/Name"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","Name":"CasePK","name":"lowercase"}""")), - new PartitionKey("CasePK")); - - var read = await container.ReadItemAsync("1", new PartitionKey("CasePK")); - read.Resource["Name"]!.ToString().Should().Be("CasePK"); - } + [Fact] + public async Task PartitionKey_DeeplyNestedPath_ThreeLevels() + { + var container = new InMemoryContainer("test", "/a/b/c"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","a":{"b":{"c":"deep-value"}},"name":"Deep"}""")), + new PartitionKey("deep-value")); + + var read = await container.ReadItemAsync("1", new PartitionKey("deep-value")); + read.Resource["name"]!.ToString().Should().Be("Deep"); + } + + [Fact] + public async Task PartitionKey_DefaultIdPath() + { + var container = new InMemoryContainer("test", "/id"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "myId", name = "IdAsPK" }), + new PartitionKey("myId")); + + var read = await container.ReadItemAsync("myId", new PartitionKey("myId")); + read.Resource["name"]!.ToString().Should().Be("IdAsPK"); + } + + [Fact] + public async Task PartitionKey_PathCaseSensitivity() + { + // PK path is case-sensitive — /Name != /name + var container = new InMemoryContainer("test", "/Name"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","Name":"CasePK","name":"lowercase"}""")), + new PartitionKey("CasePK")); + + var read = await container.ReadItemAsync("1", new PartitionKey("CasePK")); + read.Resource["Name"]!.ToString().Should().Be("CasePK"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -598,155 +602,155 @@ await container.CreateItemStreamAsync( public class PartitionKeyOperationsTests { - [Fact] - public async Task Upsert_SameId_DifferentPartitionKey_CreatesTwoItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pkA", Name = "A" }, - new PartitionKey("pkA")); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pkB", Name = "B" }, - new PartitionKey("pkB")); - - container.ItemCount.Should().Be(2); - - var readA = await container.ReadItemAsync("1", new PartitionKey("pkA")); - readA.Resource.Name.Should().Be("A"); - - var readB = await container.ReadItemAsync("1", new PartitionKey("pkB")); - readB.Resource.Name.Should().Be("B"); - } - - [Fact] - public async Task Replace_ExplicitPk_MismatchWithBody_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Replace with explicit PK "pk1" but body says "pk2" — real Cosmos throws BadRequest - var act = () => container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Query_WithPartitionKeyFilter_ReturnsOnlyMatchingPartition() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, - new PartitionKey("pk2")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", "pk1"); - var iterator = container.GetItemQueryIterator(query); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ReadMany_MixedExistingAndNonExisting_ReturnsOnlyExisting() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var response = await container.ReadManyItemsAsync([ - ("1", new PartitionKey("pk1")), - ("999", new PartitionKey("pk1")) - ]); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ReadMany_WithPartitionKeyNull_ReturnsNullPkItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = (string)null!, name = "NullPK" }), - PartitionKey.Null); - - var response = await container.ReadManyItemsAsync([ - ("1", PartitionKey.Null) - ]); - - response.Resource.Should().ContainSingle(); - response.Resource.First()["name"]!.ToString().Should().Be("NullPK"); - } - - [Fact] - public async Task DeleteAllByPartitionKey_NonExistentPk_ReturnsOk() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync( - new PartitionKey("nonexistent")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - container.ItemCount.Should().Be(1, "items in other partitions should be untouched"); - } - - [Fact] - public async Task DeleteAllByPartitionKey_WithNullPk_DeletesNullPkItems() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = (string)null!, name = "NullPK" }), - PartitionKey.Null); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "real", name = "RealPK" }), - new PartitionKey("real")); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.Null); - - container.ItemCount.Should().Be(1); - var remaining = await container.ReadItemAsync("2", new PartitionKey("real")); - remaining.Resource["name"]!.ToString().Should().Be("RealPK"); - } - - [Fact] - public async Task Stream_CreateAndRead_ExplicitPk_WorksCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - - var createResponse = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"myPk","name":"StreamTest"}""")), - new PartitionKey("myPk")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("myPk")); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(readResponse.Content); - var json = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(json); - jObj["name"]!.ToString().Should().Be("StreamTest"); - } + [Fact] + public async Task Upsert_SameId_DifferentPartitionKey_CreatesTwoItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pkA", Name = "A" }, + new PartitionKey("pkA")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pkB", Name = "B" }, + new PartitionKey("pkB")); + + container.ItemCount.Should().Be(2); + + var readA = await container.ReadItemAsync("1", new PartitionKey("pkA")); + readA.Resource.Name.Should().Be("A"); + + var readB = await container.ReadItemAsync("1", new PartitionKey("pkB")); + readB.Resource.Name.Should().Be("B"); + } + + [Fact] + public async Task Replace_ExplicitPk_MismatchWithBody_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Replace with explicit PK "pk1" but body says "pk2" — real Cosmos throws BadRequest + var act = () => container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Query_WithPartitionKeyFilter_ReturnsOnlyMatchingPartition() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, + new PartitionKey("pk2")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", "pk1"); + var iterator = container.GetItemQueryIterator(query); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ReadMany_MixedExistingAndNonExisting_ReturnsOnlyExisting() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var response = await container.ReadManyItemsAsync([ + ("1", new PartitionKey("pk1")), + ("999", new PartitionKey("pk1")) + ]); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ReadMany_WithPartitionKeyNull_ReturnsNullPkItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = (string)null!, name = "NullPK" }), + PartitionKey.Null); + + var response = await container.ReadManyItemsAsync([ + ("1", PartitionKey.Null) + ]); + + response.Resource.Should().ContainSingle(); + response.Resource.First()["name"]!.ToString().Should().Be("NullPK"); + } + + [Fact] + public async Task DeleteAllByPartitionKey_NonExistentPk_ReturnsOk() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync( + new PartitionKey("nonexistent")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + container.ItemCount.Should().Be(1, "items in other partitions should be untouched"); + } + + [Fact] + public async Task DeleteAllByPartitionKey_WithNullPk_DeletesNullPkItems() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = (string)null!, name = "NullPK" }), + PartitionKey.Null); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "real", name = "RealPK" }), + new PartitionKey("real")); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey.Null); + + container.ItemCount.Should().Be(1); + var remaining = await container.ReadItemAsync("2", new PartitionKey("real")); + remaining.Resource["name"]!.ToString().Should().Be("RealPK"); + } + + [Fact] + public async Task Stream_CreateAndRead_ExplicitPk_WorksCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + + var createResponse = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"myPk","name":"StreamTest"}""")), + new PartitionKey("myPk")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("myPk")); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(readResponse.Content); + var json = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(json); + jObj["name"]!.ToString().Should().Be("StreamTest"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -755,89 +759,89 @@ public async Task Stream_CreateAndRead_ExplicitPk_WorksCorrectly() public class PartitionKeyChangeFeedTests { - [Fact] - public async Task SamePartitionKey_AllItemsInSameFeedRange() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create 10 items all with the same PK - for (int i = 0; i < 10; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "samePK", Name = $"Item{i}" }, - new PartitionKey("samePK")); - } - - var ranges = await container.GetFeedRangesAsync(); - ranges.Should().HaveCountGreaterThan(0); - - // Find which range contains our items - int totalFound = 0; - int rangesWithItems = 0; - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - items.AddRange(page); - } - if (items.Count > 0) - { - rangesWithItems++; - totalFound += items.Count; - } - } - - totalFound.Should().Be(10); - rangesWithItems.Should().Be(1, "all items with the same PK should be in the same feed range"); - } - - [Fact] - public async Task ChangeFeed_IncludesPartitionKeyFields() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["partitionKey"]!.ToString().Should().Be("pk1", - "change feed entries should include the partition key field"); - } - - [Fact] - public async Task ChangeFeed_AfterDelete_CheckpointAdvances() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); - checkpointAfterDelete.Should().BeGreaterThan(checkpointAfterCreate, - "delete should advance the change feed checkpoint"); - } + [Fact] + public async Task SamePartitionKey_AllItemsInSameFeedRange() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create 10 items all with the same PK + for (int i = 0; i < 10; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "samePK", Name = $"Item{i}" }, + new PartitionKey("samePK")); + } + + var ranges = await container.GetFeedRangesAsync(); + ranges.Should().HaveCountGreaterThan(0); + + // Find which range contains our items + int totalFound = 0; + int rangesWithItems = 0; + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + items.AddRange(page); + } + if (items.Count > 0) + { + rangesWithItems++; + totalFound += items.Count; + } + } + + totalFound.Should().Be(10); + rangesWithItems.Should().Be(1, "all items with the same PK should be in the same feed range"); + } + + [Fact] + public async Task ChangeFeed_IncludesPartitionKeyFields() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["partitionKey"]!.ToString().Should().Be("pk1", + "change feed entries should include the partition key field"); + } + + [Fact] + public async Task ChangeFeed_AfterDelete_CheckpointAdvances() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var checkpointAfterCreate = container.GetChangeFeedCheckpoint(); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); + checkpointAfterDelete.Should().BeGreaterThan(checkpointAfterCreate, + "delete should advance the change feed checkpoint"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -846,39 +850,39 @@ await container.CreateItemAsync( public class PartitionKeyTypeDiscriminationTests { - [Fact] - public async Task PartitionKey_NumericVsString_ShouldBeDistinct() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 42 }), new PartitionKey(42)); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "42" }), new PartitionKey("42")); - var item1 = (await container.ReadItemAsync("1", new PartitionKey(42))).Resource; - var item2 = (await container.ReadItemAsync("2", new PartitionKey("42"))).Resource; - item1["id"]!.ToString().Should().Be("1"); - item2["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task PartitionKey_BooleanVsString_ShouldBeDistinct() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = true }), new PartitionKey(true)); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "True" }), new PartitionKey("True")); - var item1 = (await container.ReadItemAsync("1", new PartitionKey(true))).Resource; - var item2 = (await container.ReadItemAsync("2", new PartitionKey("True"))).Resource; - item1["id"]!.ToString().Should().Be("1"); - item2["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task PartitionKey_IntegerVsDouble_BothRetrievable() - { - // In Cosmos, all numbers are double (IEEE 754). PK(42) == PK(42.0) - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 42 }), new PartitionKey(42)); - var item = (await container.ReadItemAsync("1", new PartitionKey(42.0))).Resource; - item["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task PartitionKey_NumericVsString_ShouldBeDistinct() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 42 }), new PartitionKey(42)); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "42" }), new PartitionKey("42")); + var item1 = (await container.ReadItemAsync("1", new PartitionKey(42))).Resource; + var item2 = (await container.ReadItemAsync("2", new PartitionKey("42"))).Resource; + item1["id"]!.ToString().Should().Be("1"); + item2["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task PartitionKey_BooleanVsString_ShouldBeDistinct() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = true }), new PartitionKey(true)); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "True" }), new PartitionKey("True")); + var item1 = (await container.ReadItemAsync("1", new PartitionKey(true))).Resource; + var item2 = (await container.ReadItemAsync("2", new PartitionKey("True"))).Resource; + item1["id"]!.ToString().Should().Be("1"); + item2["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task PartitionKey_IntegerVsDouble_BothRetrievable() + { + // In Cosmos, all numbers are double (IEEE 754). PK(42) == PK(42.0) + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 42 }), new PartitionKey(42)); + var item = (await container.ReadItemAsync("1", new PartitionKey(42.0))).Resource; + item["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -887,51 +891,51 @@ public async Task PartitionKey_IntegerVsDouble_BothRetrievable() public class StreamApiPkExtractionTests { - [Fact] - public async Task CreateItemStream_WithPartitionKeyNone_ExtractsPkFromBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1" }); - var json = doc.ToString(); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - var response = await container.CreateItemStreamAsync(stream, PartitionKey.None); - response.IsSuccessStatusCode.Should().BeTrue(); - - // Should be retrievable with the extracted PK - var item = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task UpsertItemStream_WithPartitionKeyNone_ExtractsPkFromBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", val = "original" }); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(doc.ToString())); - - var response = await container.UpsertItemStreamAsync(stream, PartitionKey.None); - response.IsSuccessStatusCode.Should().BeTrue(); - - var item = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - item["val"]!.ToString().Should().Be("original"); - } - - [Fact] - public async Task ReplaceItemStream_ExplicitPkUsed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var updated = JObject.FromObject(new { id = "1", pk = "a", extra = "yes" }).ToString(); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(updated)); - - var response = await container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a")); - response.IsSuccessStatusCode.Should().BeTrue(); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["extra"]!.ToString().Should().Be("yes"); - } + [Fact] + public async Task CreateItemStream_WithPartitionKeyNone_ExtractsPkFromBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1" }); + var json = doc.ToString(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var response = await container.CreateItemStreamAsync(stream, PartitionKey.None); + response.IsSuccessStatusCode.Should().BeTrue(); + + // Should be retrievable with the extracted PK + var item = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task UpsertItemStream_WithPartitionKeyNone_ExtractsPkFromBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var doc = JObject.FromObject(new { id = "1", partitionKey = "pk1", val = "original" }); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(doc.ToString())); + + var response = await container.UpsertItemStreamAsync(stream, PartitionKey.None); + response.IsSuccessStatusCode.Should().BeTrue(); + + var item = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + item["val"]!.ToString().Should().Be("original"); + } + + [Fact] + public async Task ReplaceItemStream_ExplicitPkUsed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var updated = JObject.FromObject(new { id = "1", pk = "a", extra = "yes" }).ToString(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(updated)); + + var response = await container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a")); + response.IsSuccessStatusCode.Should().BeTrue(); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["extra"]!.ToString().Should().Be("yes"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -940,47 +944,47 @@ public async Task ReplaceItemStream_ExplicitPkUsed() public class QueryPkScopingTests { - [Fact] - public async Task Query_WithPartitionKeyNull_ReturnsOnlyNullPkItems() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.Null }).ToList(); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task Query_WithPartitionKeyNone_ReturnsCrossPartition() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.None }).ToList(); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task LinqQuery_WithPartitionKeyNull_ReturnsOnlyNullPkItems() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.Null }) - .Where(x => true) - .ToList(); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } + [Fact] + public async Task Query_WithPartitionKeyNull_ReturnsOnlyNullPkItems() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.Null }).ToList(); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task Query_WithPartitionKeyNone_ReturnsCrossPartition() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b" }), new PartitionKey("b")); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.None }).ToList(); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task LinqQuery_WithPartitionKeyNull_ReturnsOnlyNullPkItems() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = PartitionKey.Null }) + .Where(x => true) + .ToList(); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -989,40 +993,40 @@ public async Task LinqQuery_WithPartitionKeyNull_ReturnsOnlyNullPkItems() public class PkValidationMismatchTests { - [Fact] - public async Task Create_PkMismatchWithBody_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "body-pk" }), - new PartitionKey("explicit-pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Upsert_PkMismatchWithBody_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "body-pk" }), - new PartitionKey("explicit-pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Patch_ModifyingPartitionKeyField_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/pk", "b") }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } + [Fact] + public async Task Create_PkMismatchWithBody_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "body-pk" }), + new PartitionKey("explicit-pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Upsert_PkMismatchWithBody_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "body-pk" }), + new PartitionKey("explicit-pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Patch_ModifyingPartitionKeyField_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/pk", "b") }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1031,61 +1035,61 @@ await act.Should().ThrowAsync() public class HierarchicalPkAdvancedTests { - [Fact] - public async Task HierarchicalPk_PartialKeyQuery_ShouldFilterByPrefix() - { - var container = new InMemoryContainer("test", ["/country", "/city", "/district"]); - - var pk1 = new PartitionKeyBuilder().Add("US").Add("NYC").Add("Manhattan").Build(); - var pk2 = new PartitionKeyBuilder().Add("US").Add("NYC").Add("Brooklyn").Build(); - var pk3 = new PartitionKeyBuilder().Add("UK").Add("London").Add("Westminster").Build(); - - await container.CreateItemAsync(JObject.FromObject(new { id = "1", country = "US", city = "NYC", district = "Manhattan" }), pk1); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", country = "US", city = "NYC", district = "Brooklyn" }), pk2); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", country = "UK", city = "London", district = "Westminster" }), pk3); - - // Query with partial key (first 2 of 3 components) — should match items 1 & 2 - var prefixKey = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = prefixKey }).ToList(); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); - } - - [Fact] - public async Task CompositeKey_AllNullComponents_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().AddNullValue().AddNullValue().Build(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), pk); - - var item = (await container.ReadItemAsync("1", pk)).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task CompositeKey_BooleanAndNumericComponents() - { - var container = new InMemoryContainer("test", ["/active", "/score"]); - var pk = new PartitionKeyBuilder().Add(true).Add(42.5).Build(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", active = true, score = 42.5 }), pk); - - var item = (await container.ReadItemAsync("1", pk)).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task CompositeKey_NestedPathComponents() - { - var container = new InMemoryContainer("test", ["/pk", "/nested/value"]); - var pk = new PartitionKeyBuilder().Add("a").Add("deep").Build(); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"nested\":{\"value\":\"deep\"}}"), pk); - - var item = (await container.ReadItemAsync("1", pk)).Resource; - item["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task HierarchicalPk_PartialKeyQuery_ShouldFilterByPrefix() + { + var container = new InMemoryContainer("test", ["/country", "/city", "/district"]); + + var pk1 = new PartitionKeyBuilder().Add("US").Add("NYC").Add("Manhattan").Build(); + var pk2 = new PartitionKeyBuilder().Add("US").Add("NYC").Add("Brooklyn").Build(); + var pk3 = new PartitionKeyBuilder().Add("UK").Add("London").Add("Westminster").Build(); + + await container.CreateItemAsync(JObject.FromObject(new { id = "1", country = "US", city = "NYC", district = "Manhattan" }), pk1); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", country = "US", city = "NYC", district = "Brooklyn" }), pk2); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", country = "UK", city = "London", district = "Westminster" }), pk3); + + // Query with partial key (first 2 of 3 components) — should match items 1 & 2 + var prefixKey = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = prefixKey }).ToList(); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); + } + + [Fact] + public async Task CompositeKey_AllNullComponents_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().AddNullValue().AddNullValue().Build(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), pk); + + var item = (await container.ReadItemAsync("1", pk)).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task CompositeKey_BooleanAndNumericComponents() + { + var container = new InMemoryContainer("test", ["/active", "/score"]); + var pk = new PartitionKeyBuilder().Add(true).Add(42.5).Build(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", active = true, score = 42.5 }), pk); + + var item = (await container.ReadItemAsync("1", pk)).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task CompositeKey_NestedPathComponents() + { + var container = new InMemoryContainer("test", ["/pk", "/nested/value"]); + var pk = new PartitionKeyBuilder().Add("a").Add("deep").Build(); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"nested\":{\"value\":\"deep\"}}"), pk); + + var item = (await container.ReadItemAsync("1", pk)).Resource; + item["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1094,48 +1098,48 @@ await container.CreateItemAsync( public class DeleteAllChangeFeedTombstoneTests { - [Fact] - public async Task DeleteAllByPartitionKey_RecordsChangeFeedTombstones() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); - - var checkpointBefore = container.GetChangeFeedCheckpoint(); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); - - container.ItemCount.Should().Be(1, "only items with pk='b' should remain"); - var checkpointAfter = container.GetChangeFeedCheckpoint(); - checkpointAfter.Should().BeGreaterThan(checkpointBefore, - "delete-all should advance the change feed checkpoint"); - } - - [Fact] - public async Task DeleteAllByPartitionKey_CompositeKey_TombstonesCorrect() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("x").Add("y").Build(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "x", b = "y" }), pk); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", a = "x", b = "y" }), pk); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(pk); - - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task DeleteTombstone_SinglePkWithPipeChar_ReconstructedCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a|b" }), new PartitionKey("a|b")); - await container.DeleteItemAsync("1", new PartitionKey("a|b")); - - // Verify the item was deleted - var act = () => container.ReadItemAsync("1", new PartitionKey("a|b")); - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task DeleteAllByPartitionKey_RecordsChangeFeedTombstones() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b" }), new PartitionKey("b")); + + var checkpointBefore = container.GetChangeFeedCheckpoint(); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("a")); + + container.ItemCount.Should().Be(1, "only items with pk='b' should remain"); + var checkpointAfter = container.GetChangeFeedCheckpoint(); + checkpointAfter.Should().BeGreaterThan(checkpointBefore, + "delete-all should advance the change feed checkpoint"); + } + + [Fact] + public async Task DeleteAllByPartitionKey_CompositeKey_TombstonesCorrect() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("x").Add("y").Build(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "x", b = "y" }), pk); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", a = "x", b = "y" }), pk); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(pk); + + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task DeleteTombstone_SinglePkWithPipeChar_ReconstructedCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a|b" }), new PartitionKey("a|b")); + await container.DeleteItemAsync("1", new PartitionKey("a|b")); + + // Verify the item was deleted + var act = () => container.ReadItemAsync("1", new PartitionKey("a|b")); + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1144,24 +1148,24 @@ public async Task DeleteTombstone_SinglePkWithPipeChar_ReconstructedCorrectly() public class PkExtractionConsistencyTests { - [Fact] - public async Task PartitionKey_None_PathMissing_StoresWithNullPk() - { - var container = new InMemoryContainer("test", "/nonexistent"); - // PK field doesn't exist in document, should store with null partition key - await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); - var item = (await container.ReadItemAsync("1", PartitionKey.None)).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_None_AllPathsNull_ShouldUseNullNotId() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":null}"), PartitionKey.None); - var item = (await container.ReadItemAsync("1", PartitionKey.Null)).Resource; - item["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task PartitionKey_None_PathMissing_StoresWithNullPk() + { + var container = new InMemoryContainer("test", "/nonexistent"); + // PK field doesn't exist in document, should store with null partition key + await container.CreateItemAsync(JObject.FromObject(new { id = "1" }), PartitionKey.None); + var item = (await container.ReadItemAsync("1", PartitionKey.None)).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_None_AllPathsNull_ShouldUseNullNotId() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":null}"), PartitionKey.None); + var item = (await container.ReadItemAsync("1", PartitionKey.Null)).Resource; + item["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1170,71 +1174,71 @@ public async Task PartitionKey_None_AllPathsNull_ShouldUseNullNotId() public class PkBoundaryValueTests { - [Fact] - public async Task PartitionKey_MaxSize_2KB_ShouldReject() - { - var container = new InMemoryContainer("test", "/pk"); - var largePk = new string('x', 2049); - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = largePk }), new PartitionKey(largePk)); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PartitionKey_Whitespace_OnlySpaces() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = " " }), new PartitionKey(" ")); - - var item = (await container.ReadItemAsync("1", new PartitionKey(" "))).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_NewlinesAndTabs() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "line1\nline2\ttab" }), new PartitionKey("line1\nline2\ttab")); - - var item = (await container.ReadItemAsync("1", new PartitionKey("line1\nline2\ttab"))).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_NullStringLiteral_VsPartitionKeyNull() - { - var container = new InMemoryContainer("test", "/pk"); - // String "null" is distinct from PartitionKey.Null - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "null" }), new PartitionKey("null")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); - - var item1 = (await container.ReadItemAsync("1", new PartitionKey("null"))).Resource; - var item2 = (await container.ReadItemAsync("2", PartitionKey.Null)).Resource; - item1["id"]!.ToString().Should().Be("1"); - item2["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task PartitionKey_NegativeNumber() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = -42 }), new PartitionKey(-42)); - - var item = (await container.ReadItemAsync("1", new PartitionKey(-42))).Resource; - item["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_Zero() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 0 }), new PartitionKey(0)); - - var item = (await container.ReadItemAsync("1", new PartitionKey(0))).Resource; - item["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task PartitionKey_MaxSize_2KB_ShouldReject() + { + var container = new InMemoryContainer("test", "/pk"); + var largePk = new string('x', 2049); + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = largePk }), new PartitionKey(largePk)); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PartitionKey_Whitespace_OnlySpaces() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = " " }), new PartitionKey(" ")); + + var item = (await container.ReadItemAsync("1", new PartitionKey(" "))).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_NewlinesAndTabs() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "line1\nline2\ttab" }), new PartitionKey("line1\nline2\ttab")); + + var item = (await container.ReadItemAsync("1", new PartitionKey("line1\nline2\ttab"))).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_NullStringLiteral_VsPartitionKeyNull() + { + var container = new InMemoryContainer("test", "/pk"); + // String "null" is distinct from PartitionKey.Null + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "null" }), new PartitionKey("null")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2" }), PartitionKey.Null); + + var item1 = (await container.ReadItemAsync("1", new PartitionKey("null"))).Resource; + var item2 = (await container.ReadItemAsync("2", PartitionKey.Null)).Resource; + item1["id"]!.ToString().Should().Be("1"); + item2["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task PartitionKey_NegativeNumber() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = -42 }), new PartitionKey(-42)); + + var item = (await container.ReadItemAsync("1", new PartitionKey(-42))).Resource; + item["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_Zero() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = 0 }), new PartitionKey(0)); + + var item = (await container.ReadItemAsync("1", new PartitionKey(0))).Resource; + item["id"]!.ToString().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1243,24 +1247,24 @@ public async Task PartitionKey_Zero() public class PkContainerLifecycleTests { - [Fact] - public async Task Container_ContainerProperties_ReflectsPartitionKeyDefinition() - { - var container = new InMemoryContainer("test", "/myPk"); - var props = (await container.ReadContainerAsync()).Resource; - props.PartitionKeyPath.Should().Be("/myPk"); - } - - [Fact] - public async Task Container_PartitionKeyPathWithoutLeadingSlash() - { - // The InMemoryContainer constructor accepts paths without leading slash - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task Container_ContainerProperties_ReflectsPartitionKeyDefinition() + { + var container = new InMemoryContainer("test", "/myPk"); + var props = (await container.ReadContainerAsync()).Resource; + props.PartitionKeyPath.Should().Be("/myPk"); + } + + [Fact] + public async Task Container_PartitionKeyPathWithoutLeadingSlash() + { + // The InMemoryContainer constructor accepts paths without leading slash + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.ToString().Should().Be("1"); + } } @@ -1273,69 +1277,69 @@ public async Task Container_PartitionKeyPathWithoutLeadingSlash() public class PkCrudMismatchTests { - [Fact] - public async Task Delete_WithWrongPartitionKey_ReturnsNotFound() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct" }), new PartitionKey("correct")); - - var act = () => container.DeleteItemAsync("1", new PartitionKey("wrong")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task Read_WithWrongPartitionKey_ReturnsNotFound() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct" }), new PartitionKey("correct")); - - var act = () => container.ReadItemAsync("1", new PartitionKey("wrong")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_WithWrongPartitionKey_ReturnsNotFound() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct", name = "orig" }), new PartitionKey("correct")); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("wrong"), - new[] { PatchOperation.Set("/name", "patched") }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task Replace_PkMismatchWithBody_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "body-pk" }), - "1", new PartitionKey("a")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); - } - - [Fact] - public async Task Upsert_SameIdSamePk_UpdatesExisting() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "original" }), new PartitionKey("a")); - - await container.UpsertItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), new PartitionKey("a")); - - var read = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - read["name"]!.ToString().Should().Be("updated"); - } + [Fact] + public async Task Delete_WithWrongPartitionKey_ReturnsNotFound() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct" }), new PartitionKey("correct")); + + var act = () => container.DeleteItemAsync("1", new PartitionKey("wrong")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task Read_WithWrongPartitionKey_ReturnsNotFound() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct" }), new PartitionKey("correct")); + + var act = () => container.ReadItemAsync("1", new PartitionKey("wrong")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_WithWrongPartitionKey_ReturnsNotFound() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "correct", name = "orig" }), new PartitionKey("correct")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("wrong"), + new[] { PatchOperation.Set("/name", "patched") }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task Replace_PkMismatchWithBody_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "body-pk" }), + "1", new PartitionKey("a")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest && e.SubStatusCode == 1001); + } + + [Fact] + public async Task Upsert_SameIdSamePk_UpdatesExisting() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "original" }), new PartitionKey("a")); + + await container.UpsertItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), new PartitionKey("a")); + + var read = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + read["name"]!.ToString().Should().Be("updated"); + } } @@ -1343,150 +1347,150 @@ public async Task Upsert_SameIdSamePk_UpdatesExisting() public class CompositeKeyPipeDelimiterTests { - [Fact] - public async Task CompositeKey_WithPipeInValue_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("val|ue1").Add("val|ue2").Build(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "val|ue1", b = "val|ue2" }), pk); - - var read = (await container.ReadItemAsync("1", pk)).Resource; - read["a"]!.ToString().Should().Be("val|ue1"); - read["b"]!.ToString().Should().Be("val|ue2"); - } - - [Fact] - public async Task CompositeKey_WithPipeInValue_DeleteTombstone_PreservesValues() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("x|y").Add("z").Build(); - - await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "x|y", b = "z" }), pk); - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("1", pk); - - var feed = container.GetChangeFeedIterator(checkpoint); - var tombstones = new List(); - while (feed.HasMoreResults) - { - var page = await feed.ReadNextAsync(); - tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); - } - - tombstones.Should().ContainSingle(); - var t = tombstones[0]; - t["a"]!.ToString().Should().Be("x|y"); - t["b"]!.ToString().Should().Be("z"); - } - - [Fact] - public async Task CompositeKey_WithPipeInValue_DeleteAll_TombstoneCorrect() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("p|q").Add("r").Build(); - - await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "p|q", b = "r" }), pk); - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteAllItemsByPartitionKeyStreamAsync(pk); - - var feed = container.GetChangeFeedIterator(checkpoint); - var tombstones = new List(); - while (feed.HasMoreResults) - { - var page = await feed.ReadNextAsync(); - tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); - } - - tombstones.Should().ContainSingle(); - tombstones[0]["a"]!.ToString().Should().Be("p|q"); - tombstones[0]["b"]!.ToString().Should().Be("r"); - } + [Fact] + public async Task CompositeKey_WithPipeInValue_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("val|ue1").Add("val|ue2").Build(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "val|ue1", b = "val|ue2" }), pk); + + var read = (await container.ReadItemAsync("1", pk)).Resource; + read["a"]!.ToString().Should().Be("val|ue1"); + read["b"]!.ToString().Should().Be("val|ue2"); + } + + [Fact] + public async Task CompositeKey_WithPipeInValue_DeleteTombstone_PreservesValues() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("x|y").Add("z").Build(); + + await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "x|y", b = "z" }), pk); + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("1", pk); + + var feed = container.GetChangeFeedIterator(checkpoint); + var tombstones = new List(); + while (feed.HasMoreResults) + { + var page = await feed.ReadNextAsync(); + tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); + } + + tombstones.Should().ContainSingle(); + var t = tombstones[0]; + t["a"]!.ToString().Should().Be("x|y"); + t["b"]!.ToString().Should().Be("z"); + } + + [Fact] + public async Task CompositeKey_WithPipeInValue_DeleteAll_TombstoneCorrect() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("p|q").Add("r").Build(); + + await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "p|q", b = "r" }), pk); + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteAllItemsByPartitionKeyStreamAsync(pk); + + var feed = container.GetChangeFeedIterator(checkpoint); + var tombstones = new List(); + while (feed.HasMoreResults) + { + var page = await feed.ReadNextAsync(); + tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); + } + + tombstones.Should().ContainSingle(); + tombstones[0]["a"]!.ToString().Should().Be("p|q"); + tombstones[0]["b"]!.ToString().Should().Be("r"); + } } public class CompositeKeyValidationGapTests { - [Fact] - public async Task CompositeKey_DifferentOrder_CreatesDifferentPartitions() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - - var pk1 = new PartitionKeyBuilder().Add("X").Add("Y").Build(); - var pk2 = new PartitionKeyBuilder().Add("Y").Add("X").Build(); - - await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "X", b = "Y" }), pk1); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", a = "Y", b = "X" }), pk2); - - // Both should exist — different partitions - (await container.ReadItemAsync("1", pk1)).Resource["id"]!.ToString().Should().Be("1"); - (await container.ReadItemAsync("2", pk2)).Resource["id"]!.ToString().Should().Be("2"); - } - - [Fact(Skip = "Emulator does not validate composite PK consistency — ValidatePartitionKeyConsistency skips composite keys")] - public async Task CompositeKey_MismatchWithBody_Create_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("explicit-a").Add("explicit-b").Build(); - - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "body-a", b = "body-b" }), pk); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } - - [Fact] - public async Task CompositeKey_MismatchWithBody_Create_EmulatorAcceptsSilently() - { - // Known limitation: emulator skips composite PK validation - var container = new InMemoryContainer("test", ["/a", "/b"]); - var pk = new PartitionKeyBuilder().Add("explicit-a").Add("explicit-b").Build(); - - var response = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "body-a", b = "body-b" }), pk); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task CompositeKey_DifferentOrder_CreatesDifferentPartitions() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + + var pk1 = new PartitionKeyBuilder().Add("X").Add("Y").Build(); + var pk2 = new PartitionKeyBuilder().Add("Y").Add("X").Build(); + + await container.CreateItemAsync(JObject.FromObject(new { id = "1", a = "X", b = "Y" }), pk1); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", a = "Y", b = "X" }), pk2); + + // Both should exist — different partitions + (await container.ReadItemAsync("1", pk1)).Resource["id"]!.ToString().Should().Be("1"); + (await container.ReadItemAsync("2", pk2)).Resource["id"]!.ToString().Should().Be("2"); + } + + [Fact(Skip = "Emulator does not validate composite PK consistency — ValidatePartitionKeyConsistency skips composite keys")] + public async Task CompositeKey_MismatchWithBody_Create_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("explicit-a").Add("explicit-b").Build(); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "body-a", b = "body-b" }), pk); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CompositeKey_MismatchWithBody_Create_EmulatorAcceptsSilently() + { + // Known limitation: emulator skips composite PK validation + var container = new InMemoryContainer("test", ["/a", "/b"]); + var pk = new PartitionKeyBuilder().Add("explicit-a").Add("explicit-b").Build(); + + var response = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "body-a", b = "body-b" }), pk); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } public class CompositeKeyComponentCountTests { - [Fact] - public async Task CompositeKey_FewerComponentsThanPaths_EmulatorBehavior() - { - var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); - - // Only 2 components for a 3-path container - var pk = new PartitionKeyBuilder().Add("x").Add("y").Build(); - var response = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z" }), pk); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - // Can read back with the same 2-component key - var read = (await container.ReadItemAsync("1", pk)).Resource; - read["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task CompositeKey_MoreComponentsThanPaths_EmulatorBehavior() - { - var container = new InMemoryContainer("test", ["/a", "/b"]); - - // 3 components for a 2-path container - var pk = new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build(); - var response = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", b = "y" }), pk); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - // Should be readable with the same key - var read = (await container.ReadItemAsync("1", pk)).Resource; - read["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task CompositeKey_FewerComponentsThanPaths_EmulatorBehavior() + { + var container = new InMemoryContainer("test", ["/a", "/b", "/c"]); + + // Only 2 components for a 3-path container + var pk = new PartitionKeyBuilder().Add("x").Add("y").Build(); + var response = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z" }), pk); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + // Can read back with the same 2-component key + var read = (await container.ReadItemAsync("1", pk)).Resource; + read["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task CompositeKey_MoreComponentsThanPaths_EmulatorBehavior() + { + var container = new InMemoryContainer("test", ["/a", "/b"]); + + // 3 components for a 2-path container + var pk = new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build(); + var response = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", b = "y" }), pk); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + // Should be readable with the same key + var read = (await container.ReadItemAsync("1", pk)).Resource; + read["id"]!.ToString().Should().Be("1"); + } } @@ -1494,128 +1498,128 @@ public async Task CompositeKey_MoreComponentsThanPaths_EmulatorBehavior() public class PkExtractionEdgeCaseTests { - [Fact] - public async Task SinglePk_NullFieldValue_StoresAsNullPk() - { - var container = new InMemoryContainer("test", "/pk"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":null,"name":"test"}""")), - PartitionKey.Null); - - var read = (await container.ReadItemAsync("1", PartitionKey.Null)).Resource; - read["name"]!.ToString().Should().Be("test"); - } - - [Fact] - public async Task SinglePk_MissingField_StoresWithNullPartitionKey() - { - var container = new InMemoryContainer("test", "/nonexistent"); - - await container.CreateItemAsync(JObject.FromObject(new { id = "myid", name = "test" })); - - // When PK field is missing, item is stored with null partition key - var read = (await container.ReadItemAsync("myid", PartitionKey.None)).Resource; - read["name"]!.ToString().Should().Be("test"); - } - - [Fact] - public async Task PartitionKey_ArrayTypeInField_HandledGracefully() - { - var container = new InMemoryContainer("test", "/pk"); - - // PK field contains an array — should use default ToString for the token - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":[1,2,3]}""")), - PartitionKey.None); - - // Should be stored somehow (using the body extraction) and readable - var query = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - } - - [Fact] - public async Task PartitionKey_ObjectTypeInField_HandledGracefully() - { - var container = new InMemoryContainer("test", "/pk"); - - // PK field is a nested object - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":{"nested":"val"}}""")), - PartitionKey.None); - - var query = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - } + [Fact] + public async Task SinglePk_NullFieldValue_StoresAsNullPk() + { + var container = new InMemoryContainer("test", "/pk"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":null,"name":"test"}""")), + PartitionKey.Null); + + var read = (await container.ReadItemAsync("1", PartitionKey.Null)).Resource; + read["name"]!.ToString().Should().Be("test"); + } + + [Fact] + public async Task SinglePk_MissingField_StoresWithNullPartitionKey() + { + var container = new InMemoryContainer("test", "/nonexistent"); + + await container.CreateItemAsync(JObject.FromObject(new { id = "myid", name = "test" })); + + // When PK field is missing, item is stored with null partition key + var read = (await container.ReadItemAsync("myid", PartitionKey.None)).Resource; + read["name"]!.ToString().Should().Be("test"); + } + + [Fact] + public async Task PartitionKey_ArrayTypeInField_HandledGracefully() + { + var container = new InMemoryContainer("test", "/pk"); + + // PK field contains an array — should use default ToString for the token + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":[1,2,3]}""")), + PartitionKey.None); + + // Should be stored somehow (using the body extraction) and readable + var query = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + } + + [Fact] + public async Task PartitionKey_ObjectTypeInField_HandledGracefully() + { + var container = new InMemoryContainer("test", "/pk"); + + // PK field is a nested object + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":{"nested":"val"}}""")), + PartitionKey.None); + + var query = container.GetItemQueryIterator(new QueryDefinition("SELECT * FROM c WHERE c.id = '1'")); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + } } public class PkDoubleEdgeCaseTests { - [Fact] - public async Task PartitionKey_VerySmallDouble_RoundTrips() - { - var container = new InMemoryContainer("test", "/value"); - var tiny = double.Epsilon; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""1"",""value"":{tiny.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}}")), - new PartitionKey(tiny)); - - var read = (await container.ReadItemAsync("1", new PartitionKey(tiny))).Resource; - read["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_MaxDoubleValue_RoundTrips() - { - var container = new InMemoryContainer("test", "/value"); - var max = double.MaxValue; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""1"",""value"":{max.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}}")), - new PartitionKey(max)); - - var read = (await container.ReadItemAsync("1", new PartitionKey(max))).Resource; - read["id"]!.ToString().Should().Be("1"); - } - - [Fact(Skip = "Emulator uses ToString(\"R\") for numeric PK keys — -0.0 produces \"-0\" while 0.0 produces \"0\", creating different partition keys")] - public async Task PartitionKey_NegativeZero_EqualsPositiveZero() - { - var container = new InMemoryContainer("test", "/value"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","value":0}""")), - new PartitionKey(0.0)); - - // -0.0 == 0.0 in IEEE 754, so reading with -0.0 should find the same item - var read = (await container.ReadItemAsync("1", new PartitionKey(-0.0))).Resource; - read["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task PartitionKey_NegativeZero_EmulatorBehavior_TreatedAsDifferent() - { - var container = new InMemoryContainer("test", "/value"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","value":0}""")), - new PartitionKey(0.0)); - - // Emulator creates different PK key strings for -0.0 vs 0.0 - var act = () => container.ReadItemAsync("1", new PartitionKey(-0.0)); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + [Fact] + public async Task PartitionKey_VerySmallDouble_RoundTrips() + { + var container = new InMemoryContainer("test", "/value"); + var tiny = double.Epsilon; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""1"",""value"":{tiny.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}}")), + new PartitionKey(tiny)); + + var read = (await container.ReadItemAsync("1", new PartitionKey(tiny))).Resource; + read["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_MaxDoubleValue_RoundTrips() + { + var container = new InMemoryContainer("test", "/value"); + var max = double.MaxValue; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""1"",""value"":{max.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}}}")), + new PartitionKey(max)); + + var read = (await container.ReadItemAsync("1", new PartitionKey(max))).Resource; + read["id"]!.ToString().Should().Be("1"); + } + + [Fact(Skip = "Emulator uses ToString(\"R\") for numeric PK keys — -0.0 produces \"-0\" while 0.0 produces \"0\", creating different partition keys")] + public async Task PartitionKey_NegativeZero_EqualsPositiveZero() + { + var container = new InMemoryContainer("test", "/value"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","value":0}""")), + new PartitionKey(0.0)); + + // -0.0 == 0.0 in IEEE 754, so reading with -0.0 should find the same item + var read = (await container.ReadItemAsync("1", new PartitionKey(-0.0))).Resource; + read["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task PartitionKey_NegativeZero_EmulatorBehavior_TreatedAsDifferent() + { + var container = new InMemoryContainer("test", "/value"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","value":0}""")), + new PartitionKey(0.0)); + + // Emulator creates different PK key strings for -0.0 vs 0.0 + var act = () => container.ReadItemAsync("1", new PartitionKey(-0.0)); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } @@ -1623,103 +1627,103 @@ await act.Should().ThrowAsync() public class PkEtagInteractionTests { - [Fact] - public async Task Create_WithPk_ThenReplace_IfMatchEtag_Works() - { - var container = new InMemoryContainer("test", "/pk"); - var created = await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), new PartitionKey("a")); - var etag = created.ETag; - - var replaced = await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = etag }); - - replaced.StatusCode.Should().Be(HttpStatusCode.OK); - (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource["name"]!.ToString().Should().Be("updated"); - } - - [Fact] - public async Task Create_WithPk_ThenReplace_IfMatchWrongEtag_Fails() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), new PartitionKey("a")); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "stale-etag" }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } + [Fact] + public async Task Create_WithPk_ThenReplace_IfMatchEtag_Works() + { + var container = new InMemoryContainer("test", "/pk"); + var created = await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), new PartitionKey("a")); + var etag = created.ETag; + + var replaced = await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = etag }); + + replaced.StatusCode.Should().Be(HttpStatusCode.OK); + (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource["name"]!.ToString().Should().Be("updated"); + } + + [Fact] + public async Task Create_WithPk_ThenReplace_IfMatchWrongEtag_Fails() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), new PartitionKey("a")); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "stale-etag" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } } public class PkQueryIntegrationTests { - [Fact] - public async Task SqlQuery_WithPartitionKeyInRequestOptions_FiltersCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b", name = "Bob" }), new PartitionKey("b")); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task SqlQuery_WithPartitionKey_AndWhereClause_CombinesCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b", name = "Alice" }), new PartitionKey("b")); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + [Fact] + public async Task SqlQuery_WithPartitionKeyInRequestOptions_FiltersCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b", name = "Bob" }), new PartitionKey("b")); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task SqlQuery_WithPartitionKey_AndWhereClause_CombinesCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "b", name = "Alice" }), new PartitionKey("b")); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } public class PkTtlInteractionTests { - [Fact] - public async Task Read_WithCorrectPk_ButExpiredTtl_ReturnsNotFound() - { - var container = new InMemoryContainer("test", "/pk") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Read_WithCorrectPk_ButExpiredTtl_ReturnsNotFound() + { + var container = new InMemoryContainer("test", "/pk") { DefaultTimeToLive = 1 }; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await Task.Delay(1500); + await Task.Delay(1500); - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } @@ -1727,77 +1731,77 @@ await act.Should().ThrowAsync() public class PkTombstoneTypePreservationTests { - [Fact] - public async Task DeleteTombstone_BooleanPk_PreservesType() - { - var container = new InMemoryContainer("test", "/active"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","active":true}""")), - new PartitionKey(true)); - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("1", new PartitionKey(true)); - - var feed = container.GetChangeFeedIterator(checkpoint); - var tombstones = new List(); - while (feed.HasMoreResults) - { - var page = await feed.ReadNextAsync(); - tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); - } - - tombstones.Should().ContainSingle(); - var t = tombstones[0]; - t["active"]!.Type.Should().Be(JTokenType.Boolean); - t["active"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task DeleteTombstone_NumericPk_PreservesType() - { - var container = new InMemoryContainer("test", "/score"); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","score":42.5}""")), - new PartitionKey(42.5)); - var checkpoint = container.GetChangeFeedCheckpoint(); - await container.DeleteItemAsync("1", new PartitionKey(42.5)); - - var feed = container.GetChangeFeedIterator(checkpoint); - var tombstones = new List(); - while (feed.HasMoreResults) - { - var page = await feed.ReadNextAsync(); - tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); - } - - tombstones.Should().ContainSingle(); - var t = tombstones[0]; - t["score"]!.Value().Should().Be(42.5); - } - - [Fact] - public async Task HierarchicalPk_DeleteAllWithPartialKey_EmulatorBehavior_DoesNotMatch() - { - // DeleteAllByPartitionKeyStreamAsync uses exact PK string match. - // Partial keys produce a shorter PK string that won't match full composite keys. - var container = new InMemoryContainer("test", ["/country", "/city"]); - - var pk1 = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); - var pk2 = new PartitionKeyBuilder().Add("US").Add("LA").Build(); - var pk3 = new PartitionKeyBuilder().Add("UK").Add("London").Build(); - - await container.CreateItemAsync(JObject.FromObject(new { id = "1", country = "US", city = "NYC" }), pk1); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", country = "US", city = "LA" }), pk2); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", country = "UK", city = "London" }), pk3); - - // Partial key (just country) — emulator does exact match so nothing is deleted - var partialPk = new PartitionKeyBuilder().Add("US").Build(); - await container.DeleteAllItemsByPartitionKeyStreamAsync(partialPk); - - // All items still exist because partial key didn't match any full composite keys - container.ItemCount.Should().Be(3); - } + [Fact] + public async Task DeleteTombstone_BooleanPk_PreservesType() + { + var container = new InMemoryContainer("test", "/active"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","active":true}""")), + new PartitionKey(true)); + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("1", new PartitionKey(true)); + + var feed = container.GetChangeFeedIterator(checkpoint); + var tombstones = new List(); + while (feed.HasMoreResults) + { + var page = await feed.ReadNextAsync(); + tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); + } + + tombstones.Should().ContainSingle(); + var t = tombstones[0]; + t["active"]!.Type.Should().Be(JTokenType.Boolean); + t["active"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task DeleteTombstone_NumericPk_PreservesType() + { + var container = new InMemoryContainer("test", "/score"); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","score":42.5}""")), + new PartitionKey(42.5)); + var checkpoint = container.GetChangeFeedCheckpoint(); + await container.DeleteItemAsync("1", new PartitionKey(42.5)); + + var feed = container.GetChangeFeedIterator(checkpoint); + var tombstones = new List(); + while (feed.HasMoreResults) + { + var page = await feed.ReadNextAsync(); + tombstones.AddRange(page.Resource.Where(j => j["_deleted"]?.Value() == true)); + } + + tombstones.Should().ContainSingle(); + var t = tombstones[0]; + t["score"]!.Value().Should().Be(42.5); + } + + [Fact] + public async Task HierarchicalPk_DeleteAllWithPartialKey_EmulatorBehavior_DoesNotMatch() + { + // DeleteAllByPartitionKeyStreamAsync uses exact PK string match. + // Partial keys produce a shorter PK string that won't match full composite keys. + var container = new InMemoryContainer("test", ["/country", "/city"]); + + var pk1 = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); + var pk2 = new PartitionKeyBuilder().Add("US").Add("LA").Build(); + var pk3 = new PartitionKeyBuilder().Add("UK").Add("London").Build(); + + await container.CreateItemAsync(JObject.FromObject(new { id = "1", country = "US", city = "NYC" }), pk1); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", country = "US", city = "LA" }), pk2); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", country = "UK", city = "London" }), pk3); + + // Partial key (just country) — emulator does exact match so nothing is deleted + var partialPk = new PartitionKeyBuilder().Add("US").Build(); + await container.DeleteAllItemsByPartitionKeyStreamAsync(partialPk); + + // All items still exist because partial key didn't match any full composite keys + container.ItemCount.Should().Be(3); + } } @@ -1805,46 +1809,46 @@ public async Task HierarchicalPk_DeleteAllWithPartialKey_EmulatorBehavior_DoesNo public class StreamApiPkMismatchTests { - [Fact] - public async Task CreateItemStream_PkMismatchWithBody_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), - new PartitionKey("explicit-pk")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task UpsertItemStream_PkMismatchWithBody_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - - var response = await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), - new PartitionKey("explicit-pk")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReplaceItemStream_PkMismatchWithBody_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - // First create a valid item - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - // Replace with mismatched PK: header says "a", body says "body-pk" - var response = await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), - "1", new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task CreateItemStream_PkMismatchWithBody_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), + new PartitionKey("explicit-pk")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UpsertItemStream_PkMismatchWithBody_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + + var response = await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), + new PartitionKey("explicit-pk")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReplaceItemStream_PkMismatchWithBody_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + // First create a valid item + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + // Replace with mismatched PK: header says "a", body says "body-pk" + var response = await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"body-pk"}""")), + "1", new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -1852,27 +1856,27 @@ await container.CreateItemStreamAsync( public class PkSpecialCharacterTests { - [Fact] - public async Task PartitionKey_ControlCharacters_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/pk"); - var pk = "tab\there\nnewline"; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk, name = "test" }), new PartitionKey(pk)); - - var read = (await container.ReadItemAsync("1", new PartitionKey(pk))).Resource; - read["pk"]!.ToString().Should().Be(pk); - } - - [Fact] - public async Task PartitionKey_NullByte_StoredAndRetrievable() - { - var container = new InMemoryContainer("test", "/pk"); - var pk = "before\0after"; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk, name = "test" }), new PartitionKey(pk)); - - var read = (await container.ReadItemAsync("1", new PartitionKey(pk))).Resource; - read["pk"]!.ToString().Should().Be(pk); - } + [Fact] + public async Task PartitionKey_ControlCharacters_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/pk"); + var pk = "tab\there\nnewline"; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk, name = "test" }), new PartitionKey(pk)); + + var read = (await container.ReadItemAsync("1", new PartitionKey(pk))).Resource; + read["pk"]!.ToString().Should().Be(pk); + } + + [Fact] + public async Task PartitionKey_NullByte_StoredAndRetrievable() + { + var container = new InMemoryContainer("test", "/pk"); + var pk = "before\0after"; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk, name = "test" }), new PartitionKey(pk)); + + var read = (await container.ReadItemAsync("1", new PartitionKey(pk))).Resource; + read["pk"]!.ToString().Should().Be(pk); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchArrayPathResolutionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchArrayPathResolutionTests.cs index cfb82c1..a852c14 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchArrayPathResolutionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchArrayPathResolutionTests.cs @@ -1,824 +1,853 @@ +using AwesomeAssertions; using CosmosDB.InMemoryEmulator; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; -using AwesomeAssertions; namespace CosmosDB.InMemoryEmulator.Tests; public class PatchArrayPathResolutionTests { - #region Test Models - - public class DocWithItems - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; - [JsonProperty("items")] public List Items { get; set; } = new(); - [JsonProperty("topVal")] public string TopVal { get; set; } = ""; - } - - public class ItemEntry - { - [JsonProperty("name")] public string Name { get; set; } = ""; - [JsonProperty("count")] public int Count { get; set; } - [JsonProperty("val")] public string Val { get; set; } = ""; - [JsonProperty("existingProp")] public string ExistingProp { get; set; } = ""; - [JsonProperty("propToRemove")] public string PropToRemove { get; set; } = ""; - [JsonProperty("children")] public List Children { get; set; } = new(); - } - - public class DocWithDeepNesting - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; - [JsonProperty("a")] public List A { get; set; } = new(); - [JsonProperty("result")] public string Result { get; set; } = ""; - } - - public class LevelB - { - [JsonProperty("b")] public List B { get; set; } = new(); - } - - public class LevelC - { - [JsonProperty("c")] public string C { get; set; } = ""; - [JsonProperty("value")] public int Value { get; set; } - [JsonProperty("newField")] public string NewField { get; set; } = ""; - } - - public class DocWithMatrix - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; - [JsonProperty("matrix")] public List> Matrix { get; set; } = new(); - } - - public class DocWithRootAndNestedTransactions - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; - [JsonProperty("transactions")] public Dictionary Transactions { get; set; } = new(); - [JsonProperty("runs")] public List Runs { get; set; } = new(); - [JsonProperty("count")] public int Count { get; set; } - } - - public class RunEntry - { - [JsonProperty("status")] public string Status { get; set; } = ""; - [JsonProperty("transactions")] public List Transactions { get; set; } = new(); - [JsonProperty("count")] public int Count { get; set; } - } - - #endregion - - private static InMemoryContainer CreateContainer() => new("test", "/partitionKey"); - private static PartitionKey PK(string val) => new(val); - - #region 1.1 Set Operations on Array-Nested Paths - - [Fact] - public async Task Set_PropertyInsideArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "original" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/items/0/name", "updated") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("updated"); - } - - [Fact] - public async Task Set_DeeplyNestedArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { C = "original" }, new() { C = "other" } } } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/a/0/b/1/c", "updated") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.A[0].B[0].C.Should().Be("original"); - result.Resource.A[0].B[1].C.Should().Be("updated"); - } - - [Fact] - public async Task Set_ArrayElementAtVariousIndices_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = Enumerable.Range(0, 6).Select(i => new ItemEntry { Name = $"item{i}" }).ToList() - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/items/0/name", "zero"), - PatchOperation.Set("/items/1/name", "one"), - PatchOperation.Set("/items/5/name", "five"), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("zero"); - result.Resource.Items[1].Name.Should().Be("one"); - result.Resource.Items[2].Name.Should().Be("item2"); - result.Resource.Items[5].Name.Should().Be("five"); - } - - [Fact] - public async Task Set_RootAndNestedShareTerminalName_DoesNotCorrupt() - { - var container = CreateContainer(); - var doc = new DocWithRootAndNestedTransactions - { - Id = "1", PartitionKey = "pk", - Transactions = new Dictionary { ["a"] = "1" }, - Runs = new List { new() { Status = "In", Transactions = new List { "old" } } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/transactions", new Dictionary { ["b"] = "2" }), - PatchOperation.Set("/runs/0/transactions", new List { "new1", "new2" }), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Transactions.Should().HaveCount(1); - result.Resource.Transactions["b"].Should().Be("2"); - result.Resource.Runs[0].Transactions.Should().BeEquivalentTo(new[] { "new1", "new2" }); - } - - [Fact] - public async Task Set_MultipleNestedPathsInSamePatch_AllResolveCorrectly() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List - { - new() { Name = "a", Count = 1, Val = "x" }, - new() { Name = "b", Count = 2, Val = "y" }, - new() { Name = "c", Count = 3, Val = "z" }, - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/items/0/name", "A"), - PatchOperation.Set("/items/1/val", "Y"), - PatchOperation.Set("/items/2/count", 30), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("A"); - result.Resource.Items[1].Val.Should().Be("Y"); - result.Resource.Items[2].Count.Should().Be(30); - } - - [Fact] - public async Task Set_PropertyOnArrayRootVsInsideElement_BothWork() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "orig" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - // First set inside an element - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/items/0/name", "updated") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("updated"); - - // Then replace the whole array - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/items", new List { new() { Name = "replaced" } }) }); - - result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items.Should().HaveCount(1); - result.Resource.Items[0].Name.Should().Be("replaced"); - } - - #endregion - - #region 1.2 Add Operations on Array-Nested Paths - - [Fact] - public async Task Add_PropertyToExistingArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "item0" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Add("/items/0/val", "added-value") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Val.Should().Be("added-value"); - } - - [Fact] - public async Task Add_NewElementToNestedArray_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "item0", Children = new List { "child0" } } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Add("/items/0/children/-", "child1") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Children.Should().BeEquivalentTo(new[] { "child0", "child1" }); - } - - [Fact] - public async Task Add_DeeplyNestedNewProperty_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { C = "c0" }, new() { C = "c1" } } } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Add("/a/0/b/1/newField", "hello") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.A[0].B[1].NewField.Should().Be("hello"); - } - - #endregion - - #region 1.3 Replace Operations on Array-Nested Paths - - [Fact] - public async Task Replace_PropertyInsideArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { ExistingProp = "original" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Replace("/items/0/existingProp", "replaced") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].ExistingProp.Should().Be("replaced"); - } - - [Fact] - public async Task Replace_EntireArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "old", Count = 1 } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Replace("/items/0", new ItemEntry { Name = "new", Count = 99 }) }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("new"); - result.Resource.Items[0].Count.Should().Be(99); - } - - #endregion - - #region 1.4 Remove Operations on Array-Nested Paths - - [Fact] - public async Task Remove_PropertyFromArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { PropToRemove = "removeMe" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Remove("/items/0/propToRemove") }); - - // Read raw to verify removal - var stream = await container.ReadItemStreamAsync("1", PK("pk")); - using var reader = new System.IO.StreamReader(stream.Content); - var jObj = JObject.Parse(await reader.ReadToEndAsync()); - jObj.SelectToken("items[0].propToRemove").Should().BeNull(); - } - - [Fact] - public async Task Remove_DeeplyNestedProperty_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { C = "original" }, new() { C = "toRemove" } } } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Remove("/a/0/b/1/c") }); - - var stream = await container.ReadItemStreamAsync("1", PK("pk")); - using var reader = new System.IO.StreamReader(stream.Content); - var jObj = JObject.Parse(await reader.ReadToEndAsync()); - jObj.SelectToken("a[0].b[1].c").Should().BeNull(); - jObj.SelectToken("a[0].b[0].c")?.ToString().Should().Be("original"); - } - - #endregion - - #region 1.5 Increment Operations on Array-Nested Paths - - [Fact] - public async Task Increment_NumberInsideArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Count = 10 } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Increment("/items/0/count", 5) }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Count.Should().Be(15); - } - - [Fact] - public async Task Increment_DeeplyNestedNumber_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { Value = 100 }, new() { Value = 200 } } } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Increment("/a/0/b/1/value", 50) }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.A[0].B[0].Value.Should().Be(100); - result.Resource.A[0].B[1].Value.Should().Be(250); - } - - #endregion - - #region 1.6 Move Operations on Array-Nested Paths - - [Fact] - public async Task Move_FromArrayElementToRoot_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Val = "moveMe" } }, - TopVal = "" - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Move(from, path) — from=/items/0/val (source), path=/topVal (destination) - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Move("/items/0/val", "/topVal") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.TopVal.Should().Be("moveMe"); - } - - [Fact] - public async Task Move_FromRootToArrayElement_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Val = "" } }, - TopVal = "rootValue" - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Move(from, path) — from=/topVal (source), path=/items/0/val (destination) - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Move("/topVal", "/items/0/val") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Val.Should().Be("rootValue"); - } - - [Fact] - public async Task Move_BetweenArrayElements_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List - { - new() { Val = "source" }, - new() { Val = "target" } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Move(from, path) — from=/items/0/val (source), path=/items/1/val (destination) - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Move("/items/0/val", "/items/1/val") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[1].Val.Should().Be("source"); - } - - [Fact] - public async Task Move_DeeplyNestedSource_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { C = "c0" }, new() { C = "deepValue" } } } - }, - Result = "" - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Move(from, path) — from=/a/0/b/1/c (source), path=/result (destination) - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Move("/a/0/b/1/c", "/result") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Result.Should().Be("deepValue"); - } - - [Fact] - public async Task Move_DeeplyNestedTarget_Works() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() { B = new List { new() { C = "c0" }, new() { C = "" } } } - }, - Result = "moveThis" - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Move(from, path) — from=/result (source), path=/a/0/b/1/c (destination) - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Move("/result", "/a/0/b/1/c") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.A[0].B[1].C.Should().Be("moveThis"); - } - - #endregion - - #region 2.1 Path Segment Edge Cases - - [Fact] - public async Task Path_WithConsecutiveNumericSegments_ResolvesCorrectly() - { - var container = CreateContainer(); - var doc = new DocWithMatrix - { - Id = "1", PartitionKey = "pk", - Matrix = new List> { new() { 10, 20, 30 }, new() { 40, 50, 60 } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/matrix/0/1", 99) }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Matrix[0][0].Should().Be(10); - result.Resource.Matrix[0][1].Should().Be(99); - result.Resource.Matrix[0][2].Should().Be(30); - } - - [Fact] - public async Task Path_WithLargeArrayIndex_Works() - { - var container = CreateContainer(); - var items = Enumerable.Range(0, 100).Select(i => new ItemEntry { Name = $"item{i}" }).ToList(); - var doc = new DocWithItems { Id = "1", PartitionKey = "pk", Items = items }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/items/99/name", "last") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[99].Name.Should().Be("last"); - result.Resource.Items[0].Name.Should().Be("item0"); - } - - [Fact] - public async Task Path_WithSingleSegment_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems { Id = "1", PartitionKey = "pk", TopVal = "original" }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/topVal", "updated") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.TopVal.Should().Be("updated"); - } - - [Fact] - public async Task Path_WithTwoSegments_OneNumeric_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() { Name = "original" } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Replace("/items/0", new ItemEntry { Name = "replaced" }) }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("replaced"); - } - - #endregion - - #region 2.2 Document Shape Variations - - [Fact] - public async Task Patch_ObjectContainingArrayOfObjects_Set_Works() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List - { - new() { Name = "a", Count = 1 }, - new() { Name = "b", Count = 2 } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/items/0/name", "A"), - PatchOperation.Set("/items/1/count", 20) - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("A"); - result.Resource.Items[1].Count.Should().Be(20); - } - - [Fact] - public async Task Patch_ArrayOfArrays_Set_Works() - { - var container = CreateContainer(); - var doc = new DocWithMatrix - { - Id = "1", PartitionKey = "pk", - Matrix = new List> { new() { 1, 2 }, new() { 3, 4 } } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/matrix/0/0", 10), - PatchOperation.Set("/matrix/1/1", 40) - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Matrix[0][0].Should().Be(10); - result.Resource.Matrix[0][1].Should().Be(2); - result.Resource.Matrix[1][0].Should().Be(3); - result.Resource.Matrix[1][1].Should().Be(40); - } - - [Fact] - public async Task Patch_DeeplyNestedMixedStructure_AllOpsWork() - { - var container = CreateContainer(); - var doc = new DocWithDeepNesting - { - Id = "1", PartitionKey = "pk", - A = new List - { - new() - { - B = new List - { - new() { C = "level5-val", Value = 1 }, - new() { C = "other", Value = 2 } - } - } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - // Set, increment, remove across deep paths - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/a/0/b/0/c", "updated"), - PatchOperation.Increment("/a/0/b/1/value", 10), - PatchOperation.Remove("/a/0/b/1/c"), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.A[0].B[0].C.Should().Be("updated"); - result.Resource.A[0].B[1].Value.Should().Be(12); - - // Verify c was removed from b[1] - var stream = await container.ReadItemStreamAsync("1", PK("pk")); - using var reader = new System.IO.StreamReader(stream.Content); - var jObj = JObject.Parse(await reader.ReadToEndAsync()); - jObj.SelectToken("a[0].b[1].c").Should().BeNull(); - } - - [Fact] - public async Task Patch_EmptyArrayElement_AddProperty_Works() - { - var container = CreateContainer(); - // Create doc with an empty object in the array - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List { new() } // all defaults (empty strings, 0) - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] { PatchOperation.Set("/items/0/name", "populated") }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].Name.Should().Be("populated"); - } - - #endregion - - #region 1.7 Mixed Operations Across Root and Nested Paths - - [Fact] - public async Task MixedOps_SetRootAndArrayNested_SameTerminalName_NoCorruption() - { - var container = CreateContainer(); - var doc = new DocWithRootAndNestedTransactions - { - Id = "1", PartitionKey = "pk", - Transactions = new Dictionary { ["k1"] = "v1" }, - Runs = new List - { - new() { Status = "Pending", Transactions = new List { "t1" } }, - new() { Status = "Done", Transactions = new List { "t2" } } - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Set("/transactions", new Dictionary { ["k2"] = "v2" }), - PatchOperation.Set("/runs/0/transactions", new List { "t1-new" }), - PatchOperation.Set("/runs/1/status", "Active"), - PatchOperation.Set("/runs/1/transactions", new List { "t2-new", "t3-new" }), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Transactions.Should().HaveCount(1); - result.Resource.Transactions["k2"].Should().Be("v2"); - result.Resource.Runs[0].Transactions.Should().BeEquivalentTo(new[] { "t1-new" }); - result.Resource.Runs[1].Status.Should().Be("Active"); - result.Resource.Runs[1].Transactions.Should().BeEquivalentTo(new[] { "t2-new", "t3-new" }); - } - - [Fact] - public async Task MixedOps_AddRemoveReplaceAcrossDepths_AllCorrect() - { - var container = CreateContainer(); - var doc = new DocWithItems - { - Id = "1", PartitionKey = "pk", - Items = new List - { - new() { Name = "item0", Count = 5, ExistingProp = "old", PropToRemove = "bye" }, - } - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Replace("/items/0/existingProp", "new"), - PatchOperation.Remove("/items/0/propToRemove"), - PatchOperation.Add("/items/0/val", "added"), - PatchOperation.Increment("/items/0/count", 3), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Items[0].ExistingProp.Should().Be("new"); - result.Resource.Items[0].Val.Should().Be("added"); - result.Resource.Items[0].Count.Should().Be(8); - - // Verify removal via raw JSON - var stream = await container.ReadItemStreamAsync("1", PK("pk")); - using var reader = new System.IO.StreamReader(stream.Content); - var jObj = JObject.Parse(await reader.ReadToEndAsync()); - jObj.SelectToken("items[0].propToRemove").Should().BeNull(); - } - - [Fact] - public async Task MixedOps_IncrementAtRootAndNested_BothWork() - { - var container = CreateContainer(); - var doc = new DocWithRootAndNestedTransactions - { - Id = "1", PartitionKey = "pk", - Count = 10, - Runs = new List { new() { Count = 20 } }, - Transactions = new Dictionary() - }; - await container.CreateItemAsync(doc, PK("pk")); - - await container.PatchItemAsync("1", PK("pk"), - new[] - { - PatchOperation.Increment("/count", 5), - PatchOperation.Increment("/runs/0/count", 10), - }); - - var result = await container.ReadItemAsync("1", PK("pk")); - result.Resource.Count.Should().Be(15); - result.Resource.Runs[0].Count.Should().Be(30); - } - - #endregion + #region Test Models + + public class DocWithItems + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; + [JsonProperty("items")] public List Items { get; set; } = new(); + [JsonProperty("topVal")] public string TopVal { get; set; } = ""; + } + + public class ItemEntry + { + [JsonProperty("name")] public string Name { get; set; } = ""; + [JsonProperty("count")] public int Count { get; set; } + [JsonProperty("val")] public string Val { get; set; } = ""; + [JsonProperty("existingProp")] public string ExistingProp { get; set; } = ""; + [JsonProperty("propToRemove")] public string PropToRemove { get; set; } = ""; + [JsonProperty("children")] public List Children { get; set; } = new(); + } + + public class DocWithDeepNesting + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; + [JsonProperty("a")] public List A { get; set; } = new(); + [JsonProperty("result")] public string Result { get; set; } = ""; + } + + public class LevelB + { + [JsonProperty("b")] public List B { get; set; } = new(); + } + + public class LevelC + { + [JsonProperty("c")] public string C { get; set; } = ""; + [JsonProperty("value")] public int Value { get; set; } + [JsonProperty("newField")] public string NewField { get; set; } = ""; + } + + public class DocWithMatrix + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; + [JsonProperty("matrix")] public List> Matrix { get; set; } = new(); + } + + public class DocWithRootAndNestedTransactions + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; + [JsonProperty("transactions")] public Dictionary Transactions { get; set; } = new(); + [JsonProperty("runs")] public List Runs { get; set; } = new(); + [JsonProperty("count")] public int Count { get; set; } + } + + public class RunEntry + { + [JsonProperty("status")] public string Status { get; set; } = ""; + [JsonProperty("transactions")] public List Transactions { get; set; } = new(); + [JsonProperty("count")] public int Count { get; set; } + } + + #endregion + + private static InMemoryContainer CreateContainer() => new("test", "/partitionKey"); + private static PartitionKey PK(string val) => new(val); + + #region 1.1 Set Operations on Array-Nested Paths + + [Fact] + public async Task Set_PropertyInsideArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "original" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/items/0/name", "updated") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("updated"); + } + + [Fact] + public async Task Set_DeeplyNestedArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { C = "original" }, new() { C = "other" } } } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/a/0/b/1/c", "updated") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.A[0].B[0].C.Should().Be("original"); + result.Resource.A[0].B[1].C.Should().Be("updated"); + } + + [Fact] + public async Task Set_ArrayElementAtVariousIndices_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = Enumerable.Range(0, 6).Select(i => new ItemEntry { Name = $"item{i}" }).ToList() + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/items/0/name", "zero"), + PatchOperation.Set("/items/1/name", "one"), + PatchOperation.Set("/items/5/name", "five"), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("zero"); + result.Resource.Items[1].Name.Should().Be("one"); + result.Resource.Items[2].Name.Should().Be("item2"); + result.Resource.Items[5].Name.Should().Be("five"); + } + + [Fact] + public async Task Set_RootAndNestedShareTerminalName_DoesNotCorrupt() + { + var container = CreateContainer(); + var doc = new DocWithRootAndNestedTransactions + { + Id = "1", + PartitionKey = "pk", + Transactions = new Dictionary { ["a"] = "1" }, + Runs = new List { new() { Status = "In", Transactions = new List { "old" } } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/transactions", new Dictionary { ["b"] = "2" }), + PatchOperation.Set("/runs/0/transactions", new List { "new1", "new2" }), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Transactions.Should().HaveCount(1); + result.Resource.Transactions["b"].Should().Be("2"); + result.Resource.Runs[0].Transactions.Should().BeEquivalentTo(new[] { "new1", "new2" }); + } + + [Fact] + public async Task Set_MultipleNestedPathsInSamePatch_AllResolveCorrectly() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List + { + new() { Name = "a", Count = 1, Val = "x" }, + new() { Name = "b", Count = 2, Val = "y" }, + new() { Name = "c", Count = 3, Val = "z" }, + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/items/0/name", "A"), + PatchOperation.Set("/items/1/val", "Y"), + PatchOperation.Set("/items/2/count", 30), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("A"); + result.Resource.Items[1].Val.Should().Be("Y"); + result.Resource.Items[2].Count.Should().Be(30); + } + + [Fact] + public async Task Set_PropertyOnArrayRootVsInsideElement_BothWork() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "orig" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + // First set inside an element + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/items/0/name", "updated") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("updated"); + + // Then replace the whole array + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/items", new List { new() { Name = "replaced" } }) }); + + result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items.Should().HaveCount(1); + result.Resource.Items[0].Name.Should().Be("replaced"); + } + + #endregion + + #region 1.2 Add Operations on Array-Nested Paths + + [Fact] + public async Task Add_PropertyToExistingArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "item0" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Add("/items/0/val", "added-value") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Val.Should().Be("added-value"); + } + + [Fact] + public async Task Add_NewElementToNestedArray_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "item0", Children = new List { "child0" } } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Add("/items/0/children/-", "child1") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Children.Should().BeEquivalentTo(new[] { "child0", "child1" }); + } + + [Fact] + public async Task Add_DeeplyNestedNewProperty_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { C = "c0" }, new() { C = "c1" } } } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Add("/a/0/b/1/newField", "hello") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.A[0].B[1].NewField.Should().Be("hello"); + } + + #endregion + + #region 1.3 Replace Operations on Array-Nested Paths + + [Fact] + public async Task Replace_PropertyInsideArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { ExistingProp = "original" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Replace("/items/0/existingProp", "replaced") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].ExistingProp.Should().Be("replaced"); + } + + [Fact] + public async Task Replace_EntireArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "old", Count = 1 } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Replace("/items/0", new ItemEntry { Name = "new", Count = 99 }) }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("new"); + result.Resource.Items[0].Count.Should().Be(99); + } + + #endregion + + #region 1.4 Remove Operations on Array-Nested Paths + + [Fact] + public async Task Remove_PropertyFromArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { PropToRemove = "removeMe" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Remove("/items/0/propToRemove") }); + + // Read raw to verify removal + var stream = await container.ReadItemStreamAsync("1", PK("pk")); + using var reader = new System.IO.StreamReader(stream.Content); + var jObj = JObject.Parse(await reader.ReadToEndAsync()); + jObj.SelectToken("items[0].propToRemove").Should().BeNull(); + } + + [Fact] + public async Task Remove_DeeplyNestedProperty_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { C = "original" }, new() { C = "toRemove" } } } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Remove("/a/0/b/1/c") }); + + var stream = await container.ReadItemStreamAsync("1", PK("pk")); + using var reader = new System.IO.StreamReader(stream.Content); + var jObj = JObject.Parse(await reader.ReadToEndAsync()); + jObj.SelectToken("a[0].b[1].c").Should().BeNull(); + jObj.SelectToken("a[0].b[0].c")?.ToString().Should().Be("original"); + } + + #endregion + + #region 1.5 Increment Operations on Array-Nested Paths + + [Fact] + public async Task Increment_NumberInsideArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Count = 10 } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Increment("/items/0/count", 5) }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Count.Should().Be(15); + } + + [Fact] + public async Task Increment_DeeplyNestedNumber_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { Value = 100 }, new() { Value = 200 } } } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Increment("/a/0/b/1/value", 50) }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.A[0].B[0].Value.Should().Be(100); + result.Resource.A[0].B[1].Value.Should().Be(250); + } + + #endregion + + #region 1.6 Move Operations on Array-Nested Paths + + [Fact] + public async Task Move_FromArrayElementToRoot_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Val = "moveMe" } }, + TopVal = "" + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Move(from, path) — from=/items/0/val (source), path=/topVal (destination) + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Move("/items/0/val", "/topVal") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.TopVal.Should().Be("moveMe"); + } + + [Fact] + public async Task Move_FromRootToArrayElement_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Val = "" } }, + TopVal = "rootValue" + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Move(from, path) — from=/topVal (source), path=/items/0/val (destination) + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Move("/topVal", "/items/0/val") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Val.Should().Be("rootValue"); + } + + [Fact] + public async Task Move_BetweenArrayElements_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List + { + new() { Val = "source" }, + new() { Val = "target" } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Move(from, path) — from=/items/0/val (source), path=/items/1/val (destination) + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Move("/items/0/val", "/items/1/val") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[1].Val.Should().Be("source"); + } + + [Fact] + public async Task Move_DeeplyNestedSource_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { C = "c0" }, new() { C = "deepValue" } } } + }, + Result = "" + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Move(from, path) — from=/a/0/b/1/c (source), path=/result (destination) + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Move("/a/0/b/1/c", "/result") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Result.Should().Be("deepValue"); + } + + [Fact] + public async Task Move_DeeplyNestedTarget_Works() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() { B = new List { new() { C = "c0" }, new() { C = "" } } } + }, + Result = "moveThis" + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Move(from, path) — from=/result (source), path=/a/0/b/1/c (destination) + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Move("/result", "/a/0/b/1/c") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.A[0].B[1].C.Should().Be("moveThis"); + } + + #endregion + + #region 2.1 Path Segment Edge Cases + + [Fact] + public async Task Path_WithConsecutiveNumericSegments_ResolvesCorrectly() + { + var container = CreateContainer(); + var doc = new DocWithMatrix + { + Id = "1", + PartitionKey = "pk", + Matrix = new List> { new() { 10, 20, 30 }, new() { 40, 50, 60 } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/matrix/0/1", 99) }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Matrix[0][0].Should().Be(10); + result.Resource.Matrix[0][1].Should().Be(99); + result.Resource.Matrix[0][2].Should().Be(30); + } + + [Fact] + public async Task Path_WithLargeArrayIndex_Works() + { + var container = CreateContainer(); + var items = Enumerable.Range(0, 100).Select(i => new ItemEntry { Name = $"item{i}" }).ToList(); + var doc = new DocWithItems { Id = "1", PartitionKey = "pk", Items = items }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/items/99/name", "last") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[99].Name.Should().Be("last"); + result.Resource.Items[0].Name.Should().Be("item0"); + } + + [Fact] + public async Task Path_WithSingleSegment_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems { Id = "1", PartitionKey = "pk", TopVal = "original" }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/topVal", "updated") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.TopVal.Should().Be("updated"); + } + + [Fact] + public async Task Path_WithTwoSegments_OneNumeric_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() { Name = "original" } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Replace("/items/0", new ItemEntry { Name = "replaced" }) }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("replaced"); + } + + #endregion + + #region 2.2 Document Shape Variations + + [Fact] + public async Task Patch_ObjectContainingArrayOfObjects_Set_Works() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List + { + new() { Name = "a", Count = 1 }, + new() { Name = "b", Count = 2 } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/items/0/name", "A"), + PatchOperation.Set("/items/1/count", 20) + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("A"); + result.Resource.Items[1].Count.Should().Be(20); + } + + [Fact] + public async Task Patch_ArrayOfArrays_Set_Works() + { + var container = CreateContainer(); + var doc = new DocWithMatrix + { + Id = "1", + PartitionKey = "pk", + Matrix = new List> { new() { 1, 2 }, new() { 3, 4 } } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/matrix/0/0", 10), + PatchOperation.Set("/matrix/1/1", 40) + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Matrix[0][0].Should().Be(10); + result.Resource.Matrix[0][1].Should().Be(2); + result.Resource.Matrix[1][0].Should().Be(3); + result.Resource.Matrix[1][1].Should().Be(40); + } + + [Fact] + public async Task Patch_DeeplyNestedMixedStructure_AllOpsWork() + { + var container = CreateContainer(); + var doc = new DocWithDeepNesting + { + Id = "1", + PartitionKey = "pk", + A = new List + { + new() + { + B = new List + { + new() { C = "level5-val", Value = 1 }, + new() { C = "other", Value = 2 } + } + } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + // Set, increment, remove across deep paths + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/a/0/b/0/c", "updated"), + PatchOperation.Increment("/a/0/b/1/value", 10), + PatchOperation.Remove("/a/0/b/1/c"), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.A[0].B[0].C.Should().Be("updated"); + result.Resource.A[0].B[1].Value.Should().Be(12); + + // Verify c was removed from b[1] + var stream = await container.ReadItemStreamAsync("1", PK("pk")); + using var reader = new System.IO.StreamReader(stream.Content); + var jObj = JObject.Parse(await reader.ReadToEndAsync()); + jObj.SelectToken("a[0].b[1].c").Should().BeNull(); + } + + [Fact] + public async Task Patch_EmptyArrayElement_AddProperty_Works() + { + var container = CreateContainer(); + // Create doc with an empty object in the array + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List { new() } // all defaults (empty strings, 0) + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] { PatchOperation.Set("/items/0/name", "populated") }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].Name.Should().Be("populated"); + } + + #endregion + + #region 1.7 Mixed Operations Across Root and Nested Paths + + [Fact] + public async Task MixedOps_SetRootAndArrayNested_SameTerminalName_NoCorruption() + { + var container = CreateContainer(); + var doc = new DocWithRootAndNestedTransactions + { + Id = "1", + PartitionKey = "pk", + Transactions = new Dictionary { ["k1"] = "v1" }, + Runs = new List + { + new() { Status = "Pending", Transactions = new List { "t1" } }, + new() { Status = "Done", Transactions = new List { "t2" } } + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Set("/transactions", new Dictionary { ["k2"] = "v2" }), + PatchOperation.Set("/runs/0/transactions", new List { "t1-new" }), + PatchOperation.Set("/runs/1/status", "Active"), + PatchOperation.Set("/runs/1/transactions", new List { "t2-new", "t3-new" }), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Transactions.Should().HaveCount(1); + result.Resource.Transactions["k2"].Should().Be("v2"); + result.Resource.Runs[0].Transactions.Should().BeEquivalentTo(new[] { "t1-new" }); + result.Resource.Runs[1].Status.Should().Be("Active"); + result.Resource.Runs[1].Transactions.Should().BeEquivalentTo(new[] { "t2-new", "t3-new" }); + } + + [Fact] + public async Task MixedOps_AddRemoveReplaceAcrossDepths_AllCorrect() + { + var container = CreateContainer(); + var doc = new DocWithItems + { + Id = "1", + PartitionKey = "pk", + Items = new List + { + new() { Name = "item0", Count = 5, ExistingProp = "old", PropToRemove = "bye" }, + } + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Replace("/items/0/existingProp", "new"), + PatchOperation.Remove("/items/0/propToRemove"), + PatchOperation.Add("/items/0/val", "added"), + PatchOperation.Increment("/items/0/count", 3), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Items[0].ExistingProp.Should().Be("new"); + result.Resource.Items[0].Val.Should().Be("added"); + result.Resource.Items[0].Count.Should().Be(8); + + // Verify removal via raw JSON + var stream = await container.ReadItemStreamAsync("1", PK("pk")); + using var reader = new System.IO.StreamReader(stream.Content); + var jObj = JObject.Parse(await reader.ReadToEndAsync()); + jObj.SelectToken("items[0].propToRemove").Should().BeNull(); + } + + [Fact] + public async Task MixedOps_IncrementAtRootAndNested_BothWork() + { + var container = CreateContainer(); + var doc = new DocWithRootAndNestedTransactions + { + Id = "1", + PartitionKey = "pk", + Count = 10, + Runs = new List { new() { Count = 20 } }, + Transactions = new Dictionary() + }; + await container.CreateItemAsync(doc, PK("pk")); + + await container.PatchItemAsync("1", PK("pk"), + new[] + { + PatchOperation.Increment("/count", 5), + PatchOperation.Increment("/runs/0/count", 10), + }); + + var result = await container.ReadItemAsync("1", PK("pk")); + result.Resource.Count.Should().Be(15); + result.Resource.Runs[0].Count.Should().Be(30); + } + + #endregion } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchDeepDiveTests.cs index 0cee615..3e7b2e8 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchDeepDiveTests.cs @@ -16,54 +16,54 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class PatchEtagEdgeCaseTests { - [Fact] - public async Task Patch_WithIfMatchWildcard_AlwaysSucceeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { IfMatchEtag = "*" }); - - result.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Patch_WithIfNoneMatchCurrentETag_ThrowsPreconditionFailed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { IfNoneMatchEtag = created.ETag }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Patch_WithIfNoneMatchStaleETag_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { IfNoneMatchEtag = "stale-etag" }); - - result.Resource.Name.Should().Be("Updated"); - } + [Fact] + public async Task Patch_WithIfMatchWildcard_AlwaysSucceeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { IfMatchEtag = "*" }); + + result.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Patch_WithIfNoneMatchCurrentETag_ThrowsPreconditionFailed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { IfNoneMatchEtag = created.ETag }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_WithIfNoneMatchStaleETag_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { IfNoneMatchEtag = "stale-etag" }); + + result.Resource.Name.Should().Be("Updated"); + } } @@ -71,35 +71,35 @@ await container.CreateItemAsync( public class PatchStreamPathValidationTests { - [Fact] - public async Task PatchItemStreamAsync_SetIdField_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/id", "new-id")]); - - result.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchItemStreamAsync_SetPartitionKeyField_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/partitionKey", "new-pk")]); - - result.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task PatchItemStreamAsync_SetIdField_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/id", "new-id")]); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchItemStreamAsync_SetPartitionKeyField_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/partitionKey", "new-pk")]); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -107,55 +107,65 @@ await container.CreateItemAsync( public class PatchDeepNestedPathTests { - [Fact] - public async Task Patch_Set_ThreeLevelDeepPath_Creates() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "desc", Score = 1.0 } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/nested/score", 99.9)]); - - result.Resource["nested"]!["score"]!.Value().Should().Be(99.9); - } - - [Fact] - public async Task Patch_Add_DeeplyNestedArrayAppend() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","nested":{"tags":["a","b"]}}""")), - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Add("/nested/tags/-", "c")]); - - var tags = result.Resource["nested"]!["tags"]!.ToObject(); - tags.Should().BeEquivalentTo("a", "b", "c"); - } - - [Fact] - public async Task Patch_Remove_DeeplyNestedProperty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "desc", Score = 5.0 } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Remove("/nested/description")]); - - result.Resource["nested"]!["description"].Should().BeNull(); - result.Resource["nested"]!["score"]!.Value().Should().Be(5.0); - } + [Fact] + public async Task Patch_Set_ThreeLevelDeepPath_Creates() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "desc", Score = 1.0 } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/nested/score", 99.9)]); + + result.Resource["nested"]!["score"]!.Value().Should().Be(99.9); + } + + [Fact] + public async Task Patch_Add_DeeplyNestedArrayAppend() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","nested":{"tags":["a","b"]}}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Add("/nested/tags/-", "c")]); + + var tags = result.Resource["nested"]!["tags"]!.ToObject(); + tags.Should().BeEquivalentTo("a", "b", "c"); + } + + [Fact] + public async Task Patch_Remove_DeeplyNestedProperty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "desc", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Remove("/nested/description")]); + + result.Resource["nested"]!["description"].Should().BeNull(); + result.Resource["nested"]!["score"]!.Value().Should().Be(5.0); + } } @@ -163,43 +173,43 @@ await container.CreateItemAsync( public class PatchTtlInteractionTests { - [Fact] - public async Task Patch_ExpiredTtlItem_ThrowsNotFound() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(1500); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "patched")]); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_Set_TtlToNegativeOne_DisablesPerItemTtl() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Set _ttl to -1 to disable per-item TTL - await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/_ttl", -1)]); - - await Task.Delay(1500); - - // Item should still be readable - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read["name"]!.ToString().Should().Be("Test"); - } + [Fact] + public async Task Patch_ExpiredTtlItem_ThrowsNotFound() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 1 }; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(1500); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "patched")]); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_Set_TtlToNegativeOne_DisablesPerItemTtl() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 1 }; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Set _ttl to -1 to disable per-item TTL + await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/_ttl", -1)]); + + await Task.Delay(1500); + + // Item should still be readable + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read["name"]!.ToString().Should().Be("Test"); + } } @@ -207,67 +217,67 @@ await container.PatchItemAsync( public class PatchIncrementEdgeCaseTests { - [Fact] - public async Task Patch_Increment_ByZero_ValueUnchanged() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 0)]); - - result.Resource["value"]!.Value().Should().Be(42); - } - - [Fact] - public async Task Patch_Increment_LargeValue_HandlesLargeNumbers() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","value":1000000}""")), - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 999999)]); - - result.Resource["value"]!.Value().Should().Be(1999999); - } - - [Fact] - public async Task Patch_Increment_Double_ThenInt_TypePromotesToDouble() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","value":1.5}""")), - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 1)]); - - result.Resource["value"]!.Value().Should().Be(2.5); - } - - [Fact] - public async Task Patch_Increment_Int_ThenDouble_TypePromotesToDouble() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 0.5)]); - - result.Resource["value"]!.Value().Should().Be(10.5); - } + [Fact] + public async Task Patch_Increment_ByZero_ValueUnchanged() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 0)]); + + result.Resource["value"]!.Value().Should().Be(42); + } + + [Fact] + public async Task Patch_Increment_LargeValue_HandlesLargeNumbers() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","value":1000000}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 999999)]); + + result.Resource["value"]!.Value().Should().Be(1999999); + } + + [Fact] + public async Task Patch_Increment_Double_ThenInt_TypePromotesToDouble() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","value":1.5}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 1)]); + + result.Resource["value"]!.Value().Should().Be(2.5); + } + + [Fact] + public async Task Patch_Increment_Int_ThenDouble_TypePromotesToDouble() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 0.5)]); + + result.Resource["value"]!.Value().Should().Be(10.5); + } } @@ -275,50 +285,50 @@ await container.CreateItemAsync( public class PatchArrayOperationTests { - [Fact] - public async Task Patch_Add_ArrayIndex0_InsertsAtBeginning() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["b", "c"] }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/0", "a")]); - - result.Resource["tags"]!.ToObject().Should().BeEquivalentTo("a", "b", "c"); - } - - [Fact] - public async Task Patch_Remove_LastArrayElement_LeavesEmptyArray() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["only"] }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Remove("/tags/0")]); - - result.Resource["tags"]!.ToObject().Should().BeEmpty(); - } - - [Fact] - public async Task Patch_Set_EntireArrayToNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/tags", null)]); - - result.Resource["tags"]!.Type.Should().Be(JTokenType.Null); - } + [Fact] + public async Task Patch_Add_ArrayIndex0_InsertsAtBeginning() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["b", "c"] }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/0", "a")]); + + result.Resource["tags"]!.ToObject().Should().BeEquivalentTo("a", "b", "c"); + } + + [Fact] + public async Task Patch_Remove_LastArrayElement_LeavesEmptyArray() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["only"] }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Remove("/tags/0")]); + + result.Resource["tags"]!.ToObject().Should().BeEmpty(); + } + + [Fact] + public async Task Patch_Set_EntireArrayToNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/tags", null)]); + + result.Resource["tags"]!.Type.Should().Be(JTokenType.Null); + } } @@ -326,71 +336,80 @@ await container.CreateItemAsync( public class PatchMoveEdgeCaseDeepTests { - [Fact] - public async Task Move_BetweenDifferentNestingLevels_NestedToRoot() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", - Nested = new NestedObject { Score = 42.0 } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Move("/nested/score", "/topScore")]); - - result.Resource["topScore"]!.Value().Should().Be(42.0); - result.Resource["nested"]!["score"].Should().BeNull(); - } - - [Fact] - public async Task Move_BetweenDifferentNestingLevels_RootToNested() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Display", - Nested = new NestedObject { Description = "desc" } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/nested/displayName")]); - - result.Resource["nested"]!["displayName"]!.ToString().Should().Be("Display"); - result.Resource["name"].Should().BeNull(); - } - - [Fact] - public async Task Move_IdField_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/id")]); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Move_PartitionKeyField_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/partitionKey")]); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } + [Fact] + public async Task Move_BetweenDifferentNestingLevels_NestedToRoot() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Nested = new NestedObject { Score = 42.0 } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Move("/nested/score", "/topScore")]); + + result.Resource["topScore"]!.Value().Should().Be(42.0); + result.Resource["nested"]!["score"].Should().BeNull(); + } + + [Fact] + public async Task Move_BetweenDifferentNestingLevels_RootToNested() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Display", + Nested = new NestedObject { Description = "desc" } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/nested/displayName")]); + + result.Resource["nested"]!["displayName"]!.ToString().Should().Be("Display"); + result.Resource["name"].Should().BeNull(); + } + + [Fact] + public async Task Move_IdField_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/id")]); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Move_PartitionKeyField_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/partitionKey")]); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } } @@ -398,51 +417,55 @@ await act.Should().ThrowAsync() public class PatchReplaceTypeCoercionTests { - [Fact] - public async Task Patch_Replace_IntWithString_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Replace("/value", "forty-two")]); - - result.Resource["value"]!.ToString().Should().Be("forty-two"); - } - - [Fact] - public async Task Patch_Replace_ObjectWithScalar_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", - Nested = new NestedObject { Description = "desc" } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Replace("/nested", "flat-value")]); - - result.Resource["nested"]!.ToString().Should().Be("flat-value"); - } - - [Fact] - public async Task Patch_Replace_NullValue_SetsToNull() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Replace("/name", null)]); - - result.Resource["name"]!.Type.Should().Be(JTokenType.Null); - } + [Fact] + public async Task Patch_Replace_IntWithString_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Replace("/value", "forty-two")]); + + result.Resource["value"]!.ToString().Should().Be("forty-two"); + } + + [Fact] + public async Task Patch_Replace_ObjectWithScalar_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Nested = new NestedObject { Description = "desc" } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Replace("/nested", "flat-value")]); + + result.Resource["nested"]!.ToString().Should().Be("flat-value"); + } + + [Fact] + public async Task Patch_Replace_NullValue_SetsToNull() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Replace("/name", null)]); + + result.Resource["name"]!.Type.Should().Be(JTokenType.Null); + } } @@ -450,68 +473,75 @@ await container.CreateItemAsync( public class PatchCombinedOperationTests { - [Fact] - public async Task Patch_All5OperationTypes_InSingleCall() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", - Value = 10, Tags = ["a"], Nested = new NestedObject { Score = 5.0 } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "Updated"), - PatchOperation.Replace("/value", 20), - PatchOperation.Add("/tags/-", "b"), - PatchOperation.Remove("/nested/description"), - PatchOperation.Increment("/nested/score", 3) - ]); - - result.Resource["name"]!.ToString().Should().Be("Updated"); - result.Resource["value"]!.Value().Should().Be(20); - result.Resource["tags"]!.ToObject().Should().Contain("b"); - result.Resource["nested"]!["description"].Should().BeNull(); - result.Resource["nested"]!["score"]!.Value().Should().Be(8.0); - } - - [Fact] - public async Task Patch_SequentialPatchCalls_AccumulateChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1", Value = 1 }, - new PartitionKey("pk1")); - - await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "V2")]); - - await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 1)]); - - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read["name"]!.ToString().Should().Be("V2"); - read["value"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Patch_MultipleRemoves_SequentialIndexShift() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - // Remove /tags/0 twice — first removes "a", then "b" (shifted) - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Remove("/tags/0"), PatchOperation.Remove("/tags/0")]); - - result.Resource["tags"]!.ToObject().Should().BeEquivalentTo("c"); - } + [Fact] + public async Task Patch_All5OperationTypes_InSingleCall() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Original", + Value = 10, + Tags = ["a"], + Nested = new NestedObject { Score = 5.0 } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "Updated"), + PatchOperation.Replace("/value", 20), + PatchOperation.Add("/tags/-", "b"), + PatchOperation.Remove("/nested/description"), + PatchOperation.Increment("/nested/score", 3) + ]); + + result.Resource["name"]!.ToString().Should().Be("Updated"); + result.Resource["value"]!.Value().Should().Be(20); + result.Resource["tags"]!.ToObject().Should().Contain("b"); + result.Resource["nested"]!["description"].Should().BeNull(); + result.Resource["nested"]!["score"]!.Value().Should().Be(8.0); + } + + [Fact] + public async Task Patch_SequentialPatchCalls_AccumulateChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1", Value = 1 }, + new PartitionKey("pk1")); + + await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "V2")]); + + await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 1)]); + + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read["name"]!.ToString().Should().Be("V2"); + read["value"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Patch_MultipleRemoves_SequentialIndexShift() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + // Remove /tags/0 twice — first removes "a", then "b" (shifted) + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Remove("/tags/0"), PatchOperation.Remove("/tags/0")]); + + result.Resource["tags"]!.ToObject().Should().BeEquivalentTo("c"); + } } @@ -519,35 +549,35 @@ await container.CreateItemAsync( public class PatchPartitionKeyVariantTests { - [Fact] - public async Task Patch_WithPartitionKeyNone_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - result.Resource["name"]!.ToString().Should().Be("Patched"); - } - - [Fact] - public async Task Patch_HierarchicalPK_SetSubPath_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", ["/country", "/city"]); - var pk = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", country = "US", city = "NYC", name = "Test" }), pk); - - var act = () => container.PatchItemAsync( - "1", pk, - [PatchOperation.Set("/country", "UK")]); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } + [Fact] + public async Task Patch_WithPartitionKeyNone_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + result.Resource["name"]!.ToString().Should().Be("Patched"); + } + + [Fact] + public async Task Patch_HierarchicalPK_SetSubPath_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", ["/country", "/city"]); + var pk = new PartitionKeyBuilder().Add("US").Add("NYC").Build(); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", country = "US", city = "NYC", name = "Test" }), pk); + + var act = () => container.PatchItemAsync( + "1", pk, + [PatchOperation.Set("/country", "UK")]); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } } @@ -555,42 +585,156 @@ await act.Should().ThrowAsync() public class PatchFilterPredicateEdgeTests { - [Fact] - public async Task Patch_FilterPredicate_WithNestedFieldCondition() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", - Nested = new NestedObject { Score = 5.0 } }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "updated")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.nested.score > 3" }); - - result.Resource["name"]!.ToString().Should().Be("updated"); - } - - [Fact] - public async Task Patch_FilterPredicate_CombinedWithETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "updated")], - new PatchItemRequestOptions - { - IfMatchEtag = created.ETag, - FilterPredicate = "FROM c WHERE c.name = 'Test'" - }); - - result.Resource["name"]!.ToString().Should().Be("updated"); - } + [Fact] + public async Task Patch_FilterPredicate_WithNestedFieldCondition() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Nested = new NestedObject { Score = 5.0 } + }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.nested.score > 3" }); + + result.Resource["name"]!.ToString().Should().Be("updated"); + } + + [Fact] + public async Task Patch_FilterPredicate_CombinedWithETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")], + new PatchItemRequestOptions + { + IfMatchEtag = created.ETag, + FilterPredicate = "FROM c WHERE c.name = 'Test'" + }); + + result.Resource["name"]!.ToString().Should().Be("updated"); + } +} + + +// ── Category K2: FilterPredicate Missing Property = null (Issue #70) ───────── + +/// +/// Regression tests for Issue #70: FilterPredicate should treat missing properties as null. +/// Uses InMemoryContainer directly with raw JSON streams to control property presence. +/// +/// Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update#filter-predicate +/// "The filter predicate is evaluated against the existing state of the document." +/// Missing properties are semantically equivalent to null in filter predicate evaluation. +/// +public class PatchFilterPredicateMissingPropertyTests +{ + [Fact] + public async Task Patch_FilterPredicate_MissingPropertyEqualsNull_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Document without linkedId property at all + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/linkedId", "new-value")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.linkedId = null" }); + + result.Resource["linkedId"]!.Value().Should().Be("new-value"); + } + + [Fact] + public async Task Patch_FilterPredicate_MissingPropertyNotEqualNull_ThrowsPreconditionFailed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Document without linkedId property at all + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.linkedId != null" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_FilterPredicate_MissingPropertyEqualsString_ThrowsPreconditionFailed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Document without linkedId property at all + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + // undefined treated as null — null ≠ 'some-value' + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.linkedId = 'some-value'" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_FilterPredicate_ExplicitNullEqualsNull_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Document WITH explicit null for linkedId + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","linkedId":null,"name":"test"}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/linkedId", "new-value")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.linkedId = null" }); + + result.Resource["linkedId"]!.Value().Should().Be("new-value"); + } + + [Fact] + public async Task Patch_FilterPredicate_MissingPropertyWithAndCondition_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + // Document without linkedId but with name + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/linkedId", "new-value")], + new PatchItemRequestOptions + { + FilterPredicate = "FROM c WHERE c.linkedId = null AND c.name = 'test'" + }); + + result.Resource["linkedId"]!.Value().Should().Be("new-value"); + } } @@ -598,82 +742,82 @@ public async Task Patch_FilterPredicate_CombinedWithETag() public class FakeCosmosHandlerPatchBugTests { - [Fact] - public async Task FakeCosmosHandler_PatchReplace_NonExistentPath_Returns400() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(handler) - }); - - var c = client.GetContainer("db", "test"); - await c.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => c.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Replace("/nonExistent", "val")]); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } - - [Fact] - public async Task FakeCosmosHandler_PatchMove_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(handler) - }); - - var c = client.GetContainer("db", "test"); - await c.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "MoveMe" }, - new PartitionKey("pk1")); - - var result = await c.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/movedName")]); - - result.Resource["movedName"]!.ToString().Should().Be("MoveMe"); - result.Resource["name"].Should().BeNull(); - } - - [Fact] - public async Task FakeCosmosHandler_PatchReplace_ExistingPath_Succeeds() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var handler = new FakeCosmosHandler(container); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(handler) - }); - - var c = client.GetContainer("db", "test"); - await c.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await c.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Replace("/name", "Replaced")]); - - result.Resource.Name.Should().Be("Replaced"); - } + [Fact] + public async Task FakeCosmosHandler_PatchReplace_NonExistentPath_Returns400() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(handler) + }); + + var c = client.GetContainer("db", "test"); + await c.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => c.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Replace("/nonExistent", "val")]); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } + + [Fact] + public async Task FakeCosmosHandler_PatchMove_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(handler) + }); + + var c = client.GetContainer("db", "test"); + await c.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "MoveMe" }, + new PartitionKey("pk1")); + + var result = await c.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/movedName")]); + + result.Resource["movedName"]!.ToString().Should().Be("MoveMe"); + result.Resource["name"].Should().BeNull(); + } + + [Fact] + public async Task FakeCosmosHandler_PatchReplace_ExistingPath_Succeeds() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var handler = new FakeCosmosHandler(container); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(handler) + }); + + var c = client.GetContainer("db", "test"); + await c.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await c.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Replace("/name", "Replaced")]); + + result.Resource.Name.Should().Be("Replaced"); + } } @@ -681,29 +825,29 @@ await c.CreateItemAsync( public class PatchAtomicityDeepTests { - [Fact] - public async Task Patch_Atomicity_DocumentSizeExceeded_RollsBack() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, - new PartitionKey("pk1")); - - // First op is fine; second creates a massive string exceeding 2MB - var hugeString = new string('x', 2 * 1024 * 1024); - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "updated"), - PatchOperation.Set("/huge", hugeString) - ]); - - await act.Should().ThrowAsync(); - - // Name should remain "small" — all ops rolled back - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read["name"]!.ToString().Should().Be("small"); - } + [Fact] + public async Task Patch_Atomicity_DocumentSizeExceeded_RollsBack() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "small" }, + new PartitionKey("pk1")); + + // First op is fine; second creates a massive string exceeding 2MB + var hugeString = new string('x', 2 * 1024 * 1024); + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "updated"), + PatchOperation.Set("/huge", hugeString) + ]); + + await act.Should().ThrowAsync(); + + // Name should remain "small" — all ops rolled back + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read["name"]!.ToString().Should().Be("small"); + } } @@ -711,62 +855,62 @@ await container.CreateItemAsync( public class PatchStreamVariantParityTests { - [Fact] - public async Task PatchItemStreamAsync_MoreThan10Ops_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1" }, - new PartitionKey("pk1")); - - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Set($"/field{i}", i)) - .ToList(); - - var result = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), ops); - - result.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchItemStreamAsync_EnableContentResponseOnWrite_False_EmptyBody() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "updated")], - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - // When content response is suppressed, body may be null or empty - if (result.Content is not null) - { - using var reader = new StreamReader(result.Content); - var body = await reader.ReadToEndAsync(); - body.Should().BeNullOrEmpty(); - } - } - - [Fact] - public async Task PatchItemStreamAsync_ResponseHeaders_ContainETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "updated")]); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Headers.ETag.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task PatchItemStreamAsync_MoreThan10Ops_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1" }, + new PartitionKey("pk1")); + + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Set($"/field{i}", i)) + .ToList(); + + var result = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), ops); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchItemStreamAsync_EnableContentResponseOnWrite_False_EmptyBody() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")], + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + // When content response is suppressed, body may be null or empty + if (result.Content is not null) + { + using var reader = new StreamReader(result.Content); + var body = await reader.ReadToEndAsync(); + body.Should().BeNullOrEmpty(); + } + } + + [Fact] + public async Task PatchItemStreamAsync_ResponseHeaders_ContainETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "updated")]); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Headers.ETag.Should().NotBeNullOrEmpty(); + } } @@ -774,51 +918,51 @@ await container.CreateItemAsync( public class PatchConcurrencyDeepTests { - [Fact] - public async Task Patch_Concurrent_SameField_LastWriteWins() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, - new PartitionKey("pk1")); - - var tasks = Enumerable.Range(1, 10).Select(i => - container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/value", i)])); - - await Task.WhenAll(tasks); - - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read["value"]!.Value().Should().BeInRange(1, 10); - } - - [Fact] - public async Task Patch_Concurrent_WithETagCheck_OneSucceedsOneFails() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - var task1 = container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "A")], - new PatchItemRequestOptions { IfMatchEtag = etag }); - - var task2 = container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "B")], - new PatchItemRequestOptions { IfMatchEtag = etag }); - - var results = await Task.WhenAll( - task1.ContinueWith(t => (Success: !t.IsFaulted, t.Exception)), - task2.ContinueWith(t => (Success: !t.IsFaulted, t.Exception))); - - // At least one should succeed, at least one should fail with PreconditionFailed - results.Count(r => r.Success).Should().BeGreaterThan(0); - } + [Fact] + public async Task Patch_Concurrent_SameField_LastWriteWins() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, + new PartitionKey("pk1")); + + var tasks = Enumerable.Range(1, 10).Select(i => + container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/value", i)])); + + await Task.WhenAll(tasks); + + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read["value"]!.Value().Should().BeInRange(1, 10); + } + + [Fact] + public async Task Patch_Concurrent_WithETagCheck_OneSucceedsOneFails() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + var task1 = container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "A")], + new PatchItemRequestOptions { IfMatchEtag = etag }); + + var task2 = container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "B")], + new PatchItemRequestOptions { IfMatchEtag = etag }); + + var results = await Task.WhenAll( + task1.ContinueWith(t => (Success: !t.IsFaulted, t.Exception)), + task2.ContinueWith(t => (Success: !t.IsFaulted, t.Exception))); + + // At least one should succeed, at least one should fail with PreconditionFailed + results.Count(r => r.Success).Should().BeGreaterThan(0); + } } @@ -826,36 +970,36 @@ public async Task Patch_Concurrent_WithETagCheck_OneSucceedsOneFails() public class PatchMiscEdgeCaseTests { - [Fact] - public async Task Patch_Set_PropertyNameWithUnderscore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/my_field", "value")]); - - result.Resource["my_field"]!.ToString().Should().Be("value"); - } - - [Fact] - public async Task Patch_Set_PropertyNameWithDots() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","my.field":"original"}""")), - new PartitionKey("pk1")); - - // Dots in property names are tricky — Cosmos uses / for path separator - var result = await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/my.field", "updated")]); - - // Either updates the dotted property or creates a new one - result.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task Patch_Set_PropertyNameWithUnderscore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/my_field", "value")]); + + result.Resource["my_field"]!.ToString().Should().Be("value"); + } + + [Fact] + public async Task Patch_Set_PropertyNameWithDots() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","my.field":"original"}""")), + new PartitionKey("pk1")); + + // Dots in property names are tricky — Cosmos uses / for path separator + var result = await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/my.field", "updated")]); + + // Either updates the dotted property or creates a new one + result.StatusCode.Should().Be(HttpStatusCode.OK); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchItemTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchItemTests.cs index b256b5e..05a099a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchItemTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchItemTests.cs @@ -1,167 +1,167 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class PatchItemTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task CreateTestItem(string id = "1", string pk = "pk1") - { - var item = new TestDocument - { - Id = id, - PartitionKey = pk, - Name = "Original", - Value = 10, - IsActive = true, - Tags = ["tag1", "tag2"], - Nested = new NestedObject { Description = "Nested", Score = 5.0 } - }; - await _container.CreateItemAsync(item, new PartitionKey(pk)); - return item; - } - - [Fact] - public async Task PatchItemAsync_SetOperation_UpdatesProperty() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task PatchItemAsync_ReplaceOperation_UpdatesProperty() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Replace("/value", 99) }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.Resource.Value.Should().Be(99); - } - - [Fact] - public async Task PatchItemAsync_AddOperation_AddsProperty() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Add("/newField", "newValue") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task PatchItemAsync_RemoveOperation_RemovesProperty() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Remove("/name") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Name.Should().BeNull(); - } - - [Fact] - public async Task PatchItemAsync_IncrementOperation_IncrementsValue() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Increment("/value", 5) }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.Resource.Value.Should().Be(15); - } - - [Fact] - public async Task PatchItemAsync_NonExistentItem_ThrowsNotFound() - { - var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; - - var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), patchOperations); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PatchItemAsync_WithIfMatchCurrentETag_Succeeds() - { - await CreateTestItem(); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations, - new PatchItemRequestOptions { IfMatchEtag = readResponse.ETag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task PatchItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations, - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var exception = await act.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemAsync_UpdatesETag() - { - await CreateTestItem(); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var originalEtag = readResponse.ETag; - - var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.ETag.Should().NotBe(originalEtag); - } - - [Fact] - public async Task PatchItemAsync_MultipleOperations_AllApplied() - { - await CreateTestItem(); - var patchOperations = new List - { - PatchOperation.Set("/name", "MultiPatched"), - PatchOperation.Replace("/value", 42), - PatchOperation.Set("/isActive", false) - }; + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task CreateTestItem(string id = "1", string pk = "pk1") + { + var item = new TestDocument + { + Id = id, + PartitionKey = pk, + Name = "Original", + Value = 10, + IsActive = true, + Tags = ["tag1", "tag2"], + Nested = new NestedObject { Description = "Nested", Score = 5.0 } + }; + await _container.CreateItemAsync(item, new PartitionKey(pk)); + return item; + } + + [Fact] + public async Task PatchItemAsync_SetOperation_UpdatesProperty() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task PatchItemAsync_ReplaceOperation_UpdatesProperty() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Replace("/value", 99) }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.Resource.Value.Should().Be(99); + } + + [Fact] + public async Task PatchItemAsync_AddOperation_AddsProperty() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Add("/newField", "newValue") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchItemAsync_RemoveOperation_RemovesProperty() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Remove("/name") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Name.Should().BeNull(); + } + + [Fact] + public async Task PatchItemAsync_IncrementOperation_IncrementsValue() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Increment("/value", 5) }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.Resource.Value.Should().Be(15); + } + + [Fact] + public async Task PatchItemAsync_NonExistentItem_ThrowsNotFound() + { + var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; + + var act = () => _container.PatchItemAsync("nonexistent", new PartitionKey("pk1"), patchOperations); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PatchItemAsync_WithIfMatchCurrentETag_Succeeds() + { + await CreateTestItem(); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations, + new PatchItemRequestOptions { IfMatchEtag = readResponse.ETag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchItemAsync_WithIfMatchStaleETag_ThrowsPreconditionFailed() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations, + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemAsync_UpdatesETag() + { + await CreateTestItem(); + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var originalEtag = readResponse.ETag; + + var patchOperations = new[] { PatchOperation.Set("/name", "Patched") }; + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.ETag.Should().NotBe(originalEtag); + } + + [Fact] + public async Task PatchItemAsync_MultipleOperations_AllApplied() + { + await CreateTestItem(); + var patchOperations = new List + { + PatchOperation.Set("/name", "MultiPatched"), + PatchOperation.Replace("/value", 42), + PatchOperation.Set("/isActive", false) + }; - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.Resource.Name.Should().Be("MultiPatched"); - response.Resource.Value.Should().Be(42); - response.Resource.IsActive.Should().BeFalse(); - } - - [Fact] - public async Task PatchItemAsync_NestedProperty_UpdatesCorrectly() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Set("/nested/description", "Updated Nested") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.Resource.Nested!.Description.Should().Be("Updated Nested"); - } + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.Resource.Name.Should().Be("MultiPatched"); + response.Resource.Value.Should().Be(42); + response.Resource.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task PatchItemAsync_NestedProperty_UpdatesCorrectly() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Set("/nested/description", "Updated Nested") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.Resource.Nested!.Description.Should().Be("Updated Nested"); + } } @@ -172,23 +172,23 @@ public async Task PatchItemAsync_NestedProperty_UpdatesCorrectly() /// public class PatchPartitionKeyImmutabilityTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_Set_PartitionKeyField_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - // Patch the partition key field — should be rejected - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/partitionKey", "pk2")]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_Set_PartitionKeyField_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + // Patch the partition key field — should be rejected + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/partitionKey", "pk2")]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -200,336 +200,342 @@ await _container.CreateItemAsync( /// public class PatchAtomicityTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_MultipleOps_IfOneFailsAllRollBack() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); - - // Op 1: Set /name to "Changed" (would succeed in isolation) - // Op 2: Increment /name by 1 (will fail because /name is now a string "Changed") - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "Changed"), - PatchOperation.Increment("/name", 1), - ]); - - // Should throw because op 2 fails (can't increment a string) - await act.Should().ThrowAsync(); - - // Item should be unchanged — atomicity means op 1 was rolled back - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Original"); - read.Resource.Value.Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_MultipleOps_IfOneFailsAllRollBack() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); + + // Op 1: Set /name to "Changed" (would succeed in isolation) + // Op 2: Increment /name by 1 (will fail because /name is now a string "Changed") + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "Changed"), + PatchOperation.Increment("/name", 1), + ]); + + // Should throw because op 2 fails (can't increment a string) + await act.Should().ThrowAsync(); + + // Item should be unchanged — atomicity means op 1 was rolled back + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Original"); + read.Resource.Value.Should().Be(10); + } } public class PatchGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_Increment_OnNonNumericField_Throws() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Text", Value = 10 }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/name", 1)]); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Patch_Increment_Long_PreservesLongType() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 100 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", 50L)]); - - response.Resource.Value.Should().Be(150); - } - - [Fact] - public async Task Patch_Add_AppendsToArray() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/-", "c")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().Contain("c"); - } - - [Fact] - public async Task Patch_Add_AtArrayIndex_Inserts() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "c"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/1", "b")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().HaveCount(3); - } - - [Fact] - public async Task Patch_WithFilterPredicate_OnlyAppliesWhenConditionMet() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = false }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - - // Real Cosmos would fail because isActive=false doesn't match the predicate - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Patch_Add_ObjectProperty_CreatesNewProperty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/newProp", "newVal")]); - - response.Resource["newProp"]!.ToString().Should().Be("newVal"); - } - - [Fact] - public async Task Patch_EmptyOperationsList_Throws() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - Array.Empty()); - - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_Increment_OnNonNumericField_Throws() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Text", Value = 10 }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/name", 1)]); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Patch_Increment_Long_PreservesLongType() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 100 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", 50L)]); + + response.Resource.Value.Should().Be(150); + } + + [Fact] + public async Task Patch_Add_AppendsToArray() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/-", "c")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().Contain("c"); + } + + [Fact] + public async Task Patch_Add_AtArrayIndex_Inserts() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "c"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/1", "b")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().HaveCount(3); + } + + [Fact] + public async Task Patch_WithFilterPredicate_OnlyAppliesWhenConditionMet() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = false }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + + // Real Cosmos would fail because isActive=false doesn't match the predicate + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Patch_Add_ObjectProperty_CreatesNewProperty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/newProp", "newVal")]); + + response.Resource["newProp"]!.ToString().Should().Be("newVal"); + } + + [Fact] + public async Task Patch_EmptyOperationsList_Throws() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + Array.Empty()); + + await act.Should().ThrowAsync(); + } } public class PatchGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task CreateTestItem(string id = "1", string pk = "pk1") - { - var item = new TestDocument - { - Id = id, - PartitionKey = pk, - Name = "Original", - Value = 10, - IsActive = true, - Tags = ["tag1", "tag2"], - Nested = new NestedObject { Description = "Nested", Score = 5.0 } - }; - await _container.CreateItemAsync(item, new PartitionKey(pk)); - return item; - } - - [Fact] - public async Task Patch_Remove_OnNonExistentPath_ThrowsBadRequest() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Remove("/nonExistentField") }; - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_MultipleOperations_AppliedSequentially() - { - await CreateTestItem(); - var patchOperations = new List - { - PatchOperation.Set("/value", 100), - PatchOperation.Increment("/value", 5) - }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.Resource.Value.Should().Be(105); - } - - [Fact] - public async Task Patch_RecordsInChangeFeed() - { - await CreateTestItem(); - var beforePatch = _container.GetChangeFeedCheckpoint(); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - var afterPatch = _container.GetChangeFeedCheckpoint(); - afterPatch.Should().BeGreaterThan(beforePatch); - } - - [Fact] - public async Task Patch_ResponseContainsUpdatedDocument() - { - await CreateTestItem(); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); - - response.Resource.Name.Should().Be("Patched"); - response.Resource.Value.Should().Be(15); - } - - [Fact] - public async Task Patch_Set_DeepNestedPath_Updates() - { - await CreateTestItem(); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/nested/score", 99.9)]); - - response.Resource.Nested!.Score.Should().Be(99.9); - } - - [Fact] - public async Task Patch_Move_MovesPropertyToNewPath() - { - await CreateTestItem(); - var patchOperations = new[] { PatchOperation.Move("/name", "/movedName") }; - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task CreateTestItem(string id = "1", string pk = "pk1") + { + var item = new TestDocument + { + Id = id, + PartitionKey = pk, + Name = "Original", + Value = 10, + IsActive = true, + Tags = ["tag1", "tag2"], + Nested = new NestedObject { Description = "Nested", Score = 5.0 } + }; + await _container.CreateItemAsync(item, new PartitionKey(pk)); + return item; + } + + [Fact] + public async Task Patch_Remove_OnNonExistentPath_ThrowsBadRequest() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Remove("/nonExistentField") }; + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_MultipleOperations_AppliedSequentially() + { + await CreateTestItem(); + var patchOperations = new List + { + PatchOperation.Set("/value", 100), + PatchOperation.Increment("/value", 5) + }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.Resource.Value.Should().Be(105); + } + + [Fact] + public async Task Patch_RecordsInChangeFeed() + { + await CreateTestItem(); + var beforePatch = _container.GetChangeFeedCheckpoint(); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + var afterPatch = _container.GetChangeFeedCheckpoint(); + afterPatch.Should().BeGreaterThan(beforePatch); + } + + [Fact] + public async Task Patch_ResponseContainsUpdatedDocument() + { + await CreateTestItem(); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); + + response.Resource.Name.Should().Be("Patched"); + response.Resource.Value.Should().Be(15); + } + + [Fact] + public async Task Patch_Set_DeepNestedPath_Updates() + { + await CreateTestItem(); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/nested/score", 99.9)]); + + response.Resource.Nested!.Score.Should().Be(99.9); + } + + [Fact] + public async Task Patch_Move_MovesPropertyToNewPath() + { + await CreateTestItem(); + var patchOperations = new[] { PatchOperation.Move("/name", "/movedName") }; + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class PatchGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task CreateTestItem(string id = "1", string pk = "pk1") - { - var item = new TestDocument - { - Id = id, PartitionKey = pk, Name = "Original", Value = 10, - IsActive = true, Tags = ["tag1", "tag2"], - Nested = new NestedObject { Description = "Nested", Score = 5.0 } - }; - await _container.CreateItemAsync(item, new PartitionKey(pk)); - return item; - } - - [Fact] - public async Task Patch_Increment_Double_PreservesDoubleType() - { - await _container.CreateItemAsync(new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 1.5 } - }, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/nested/score", 0.3)]); - - response.Resource.Nested!.Score.Should().BeApproximately(1.8, 0.001); - } - - [Fact] - public async Task Patch_Set_CreatesNewProperty_IfMissing() - { - await CreateTestItem(); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/newField", "newValue")]); - - response.Resource["newField"]!.ToString().Should().Be("newValue"); - } - - [Fact] - public async Task Patch_Remove_IdField_ThrowsBadRequest() - { - await CreateTestItem(); - - // Attempting to remove the id field — should be rejected - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Remove("/id")]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_Replace_VsSet_ReplaceRequiresExistingPath() - { - await CreateTestItem(); - - // Set creates new property if it doesn't exist - var setResponse = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/brandNewProp", 42)]); - setResponse.Resource["brandNewProp"]!.ToObject().Should().Be(42); - } - - [Fact] - public async Task Patch_NonExistentItem_Returns404() - { - var act = () => _container.PatchItemAsync("missing", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task CreateTestItem(string id = "1", string pk = "pk1") + { + var item = new TestDocument + { + Id = id, + PartitionKey = pk, + Name = "Original", + Value = 10, + IsActive = true, + Tags = ["tag1", "tag2"], + Nested = new NestedObject { Description = "Nested", Score = 5.0 } + }; + await _container.CreateItemAsync(item, new PartitionKey(pk)); + return item; + } + + [Fact] + public async Task Patch_Increment_Double_PreservesDoubleType() + { + await _container.CreateItemAsync(new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 1.5 } + }, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/nested/score", 0.3)]); + + response.Resource.Nested!.Score.Should().BeApproximately(1.8, 0.001); + } + + [Fact] + public async Task Patch_Set_CreatesNewProperty_IfMissing() + { + await CreateTestItem(); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/newField", "newValue")]); + + response.Resource["newField"]!.ToString().Should().Be("newValue"); + } + + [Fact] + public async Task Patch_Remove_IdField_ThrowsBadRequest() + { + await CreateTestItem(); + + // Attempting to remove the id field — should be rejected + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Remove("/id")]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_Replace_VsSet_ReplaceRequiresExistingPath() + { + await CreateTestItem(); + + // Set creates new property if it doesn't exist + var setResponse = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/brandNewProp", 42)]); + setResponse.Resource["brandNewProp"]!.ToObject().Should().Be(42); + } + + [Fact] + public async Task Patch_NonExistentItem_Returns404() + { + var act = () => _container.PatchItemAsync("missing", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } public class PatchEnableContentResponseDivergentBehaviorTests { - /// - /// BEHAVIORAL DIFFERENCE: InMemoryContainer.PatchItemAsync does not currently respect - /// EnableContentResponseOnWrite from PatchItemRequestOptions (which inherits from - /// ItemRequestOptions). The patch code path reads requestOptions as PatchItemRequestOptions - /// and doesn't check EnableContentResponseOnWrite. If this is not implemented, the test - /// for Patch_WithEnableContentResponseOnWrite_False_ResourceIsNull will be skipped. - /// - [Fact] - public async Task Patch_EnableContentResponseOnWrite_Behavior() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - // Verify the current behavior — may or may not suppress content - var response = await container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - - // Document the actual behavior - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + /// + /// BEHAVIORAL DIFFERENCE: InMemoryContainer.PatchItemAsync does not currently respect + /// EnableContentResponseOnWrite from PatchItemRequestOptions (which inherits from + /// ItemRequestOptions). The patch code path reads requestOptions as PatchItemRequestOptions + /// and doesn't check EnableContentResponseOnWrite. If this is not implemented, the test + /// for Patch_WithEnableContentResponseOnWrite_False_ResourceIsNull will be skipped. + /// + [Fact] + public async Task Patch_EnableContentResponseOnWrite_Behavior() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + // Verify the current behavior — may or may not suppress content + var response = await container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + + // Document the actual behavior + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } @@ -540,47 +546,52 @@ await container.CreateItemAsync( /// public class PatchIncrementAutoCreateTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Increment_NonExistentField_CreatesWithValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/counter", 5)]); - - response.Resource["counter"]!.Value().Should().Be(5); - } - - [Fact] - public async Task Increment_NonExistentField_NegativeValue_CreatesNegative() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/counter", -3)]); - - response.Resource["counter"]!.Value().Should().Be(-3); - } - - [Fact] - public async Task Increment_NonExistentNestedField_CreatesField() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 5.0 } }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/nested/newCounter", 10)]); - - response.Resource["nested"]!["newCounter"]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Increment_NonExistentField_CreatesWithValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/counter", 5)]); + + response.Resource["counter"]!.Value().Should().Be(5); + } + + [Fact] + public async Task Increment_NonExistentField_NegativeValue_CreatesNegative() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/counter", -3)]); + + response.Resource["counter"]!.Value().Should().Be(-3); + } + + [Fact] + public async Task Increment_NonExistentNestedField_CreatesField() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/nested/newCounter", 10)]); + + response.Resource["nested"]!["newCounter"]!.Value().Should().Be(10); + } } @@ -592,49 +603,54 @@ await _container.CreateItemAsync( /// public class PatchReplaceStrictSemanticsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_NonExistentProperty_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Replace("/nonExistentField", "value")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Replace_ExistingProperty_UpdatesValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Replace("/name", "Replaced")]); - - response.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task Replace_NonExistentNestedProperty_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 5.0 } }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Replace("/nested/nonExistent", "value")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_NonExistentProperty_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Replace("/nonExistentField", "value")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Replace_ExistingProperty_UpdatesValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Replace("/name", "Replaced")]); + + response.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task Replace_NonExistentNestedProperty_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Replace("/nested/nonExistent", "value")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -645,64 +661,69 @@ await _container.CreateItemAsync( /// public class PatchRemoveStrictSemanticsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Remove_NonExistentProperty_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Remove("/nonExistentField")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Remove_NonExistentNestedProperty_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 5.0 } }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Remove("/nested/nonExistent")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Remove_ArrayElement_ByIndex_RemovesAndShifts() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Remove("/tags/1")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().BeEquivalentTo(["a", "c"]); - } - - [Fact] - public async Task Remove_ArrayElement_IndexOutOfBounds_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Remove("/tags/5")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Remove_NonExistentProperty_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Remove("/nonExistentField")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Remove_NonExistentNestedProperty_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Remove("/nested/nonExistent")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Remove_ArrayElement_ByIndex_RemovesAndShifts() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Remove("/tags/1")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().BeEquivalentTo(["a", "c"]); + } + + [Fact] + public async Task Remove_ArrayElement_IndexOutOfBounds_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Remove("/tags/5")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -713,36 +734,36 @@ await _container.CreateItemAsync( /// public class PatchSetArrayIndexTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Set_ArrayIndex_UpdatesExistingElement() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/tags/1", "B")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().BeEquivalentTo(["a", "B", "c"]); - tags.Should().HaveCount(3); - } - - [Fact] - public async Task Set_ArrayIndex_OutOfBounds_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/tags/10", "x")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Set_ArrayIndex_UpdatesExistingElement() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/tags/1", "B")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().BeEquivalentTo(["a", "B", "c"]); + tags.Should().HaveCount(3); + } + + [Fact] + public async Task Set_ArrayIndex_OutOfBounds_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/tags/10", "x")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -753,36 +774,36 @@ await _container.CreateItemAsync( /// public class PatchAddArrayBoundsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Add_ArrayIndex_BeyondLength_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/10", "x")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Add_ArrayIndex_AtLength_AppendsElement() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - // Index 2 == array length, should append - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/tags/2", "c")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().BeEquivalentTo(["a", "b", "c"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Add_ArrayIndex_BeyondLength_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/10", "x")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Add_ArrayIndex_AtLength_AppendsElement() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + // Index 2 == array length, should append + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/tags/2", "c")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().BeEquivalentTo(["a", "b", "c"]); + } } @@ -793,81 +814,91 @@ await _container.CreateItemAsync( /// public class PatchMoveValidationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Move_NonExistentSource_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/nonExistent", "/destination")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - /// - /// SKIPPED: Real Cosmos DB rejects Move when path is a JSON child of from - /// (e.g., Move from '/nested' to '/nested/child'). Detecting JSON path ancestry - /// and enforcing this constraint adds complexity with very low practical impact. - /// The emulator does not validate path ancestry — it silently processes the move - /// which may produce unexpected results. - /// - [Fact] - public async Task Move_PathIsChildOfFrom_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 5.0 } }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/nested", "/nested/child")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - /// - /// SISTER TEST documenting divergent behavior for the skipped test above. - /// The emulator does not validate that the destination path is not a child of - /// the source path in Move operations. Real Cosmos DB would reject this with 400. - /// In practice, this is an extremely rare edge case. - /// - [Fact] - public async Task Move_PathIsChildOfFrom_EmulatorBehavior_AlsoRejectsBadRequest() - { - // The emulator now correctly rejects Move when path is a child of from, - // matching real Cosmos DB behavior. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "N", Score = 5.0 } }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/nested", "/nested/child")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Move_ToExistingPath_OverwritesTarget() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Source", Value = 42 }, - new PartitionKey("pk1")); - - // Move /name → /movedName (which doesn't exist yet, fine) - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/movedName")]); - - response.Resource["movedName"]!.ToString().Should().Be("Source"); - response.Resource["name"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Move_NonExistentSource_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/nonExistent", "/destination")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// SKIPPED: Real Cosmos DB rejects Move when path is a JSON child of from + /// (e.g., Move from '/nested' to '/nested/child'). Detecting JSON path ancestry + /// and enforcing this constraint adds complexity with very low practical impact. + /// The emulator does not validate path ancestry — it silently processes the move + /// which may produce unexpected results. + /// + [Fact] + public async Task Move_PathIsChildOfFrom_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/nested", "/nested/child")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// SISTER TEST documenting divergent behavior for the skipped test above. + /// The emulator does not validate that the destination path is not a child of + /// the source path in Move operations. Real Cosmos DB would reject this with 400. + /// In practice, this is an extremely rare edge case. + /// + [Fact] + public async Task Move_PathIsChildOfFrom_EmulatorBehavior_AlsoRejectsBadRequest() + { + // The emulator now correctly rejects Move when path is a child of from, + // matching real Cosmos DB behavior. + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "N", Score = 5.0 } + }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/nested", "/nested/child")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Move_ToExistingPath_OverwritesTarget() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Source", Value = 42 }, + new PartitionKey("pk1")); + + // Move /name → /movedName (which doesn't exist yet, fine) + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/movedName")]); + + response.Resource["movedName"]!.ToString().Should().Be("Source"); + response.Resource["name"].Should().BeNull(); + } } @@ -877,40 +908,40 @@ await _container.CreateItemAsync( /// public class PatchOperationsLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Patch_MoreThan10Operations_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, - new PartitionKey("pk1")); + [Fact] + public async Task Patch_MoreThan10Operations_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, + new PartitionKey("pk1")); - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Increment("/value", 1)) - .ToList(); + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Increment("/value", 1)) + .ToList(); - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), ops); + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), ops); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task Patch_Exactly10Operations_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, - new PartitionKey("pk1")); + [Fact] + public async Task Patch_Exactly10Operations_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 0 }, + new PartitionKey("pk1")); - var ops = Enumerable.Range(0, 10) - .Select(i => PatchOperation.Increment("/value", 1)) - .ToList(); + var ops = Enumerable.Range(0, 10) + .Select(i => PatchOperation.Increment("/value", 1)) + .ToList(); - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), ops); + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), ops); - response.Resource.Value.Should().Be(10); - } + response.Resource.Value.Should().Be(10); + } } @@ -926,43 +957,43 @@ await _container.CreateItemAsync( /// public class PatchSystemPropertyProtectionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_SystemProperty_Ts_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/_ts", 9999999)]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - /// - /// SISTER TEST documenting divergent behavior for system property protection. - /// Real Cosmos DB rejects patches to _ts with 400 Bad Request. - /// The emulator allows it but EnrichWithSystemProperties overwrites _ts afterwards, - /// so the net effect is harmless — the patched _ts value is discarded. - /// - [Fact] - public async Task Patch_SystemProperty_Ts_EmulatorAlsoRejectsBadRequest() - { - // The emulator now correctly rejects patches to system properties, - // matching real Cosmos DB behavior. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/_ts", 9999999)]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_SystemProperty_Ts_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/_ts", 9999999)]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// SISTER TEST documenting divergent behavior for system property protection. + /// Real Cosmos DB rejects patches to _ts with 400 Bad Request. + /// The emulator allows it but EnrichWithSystemProperties overwrites _ts afterwards, + /// so the net effect is harmless — the patched _ts value is discarded. + /// + [Fact] + public async Task Patch_SystemProperty_Ts_EmulatorAlsoRejectsBadRequest() + { + // The emulator now correctly rejects patches to system properties, + // matching real Cosmos DB behavior. + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/_ts", 9999999)]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -972,70 +1003,70 @@ await _container.CreateItemAsync( /// public class PatchStreamVariantTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task PatchItemStreamAsync_SetOperation_ReturnsOK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task PatchItemStreamAsync_NonExistentItem_ReturnsNotFound() - { - var response = await _container.PatchItemStreamAsync("missing", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PatchItemStreamAsync_StaleETag_ReturnsPreconditionFailed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemStreamAsync_FilterPredicate_NonMatching_ReturnsPreconditionFailed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = false }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemStreamAsync_EmptyOperations_ReturnsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task PatchItemStreamAsync_SetOperation_ReturnsOK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchItemStreamAsync_NonExistentItem_ReturnsNotFound() + { + var response = await _container.PatchItemStreamAsync("missing", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PatchItemStreamAsync_StaleETag_ReturnsPreconditionFailed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemStreamAsync_FilterPredicate_NonMatching_ReturnsPreconditionFailed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = false }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemStreamAsync_EmptyOperations_ReturnsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } @@ -1045,123 +1076,123 @@ await _container.CreateItemAsync( /// public class PatchEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_SetNullValue_SetsPropertyToNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", null!)]); - - response.Resource["name"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Patch_SetComplexObject_SetsNestedStructure() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var complexObj = new { street = "123 Main St", city = "Springfield", zip = "62701" }; - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/address", complexObj)]); - - response.Resource["address"]!["city"]!.ToString().Should().Be("Springfield"); - } - - [Fact] - public async Task Patch_IncrementNegative_Decrements() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/value", -3)]); - - response.Resource.Value.Should().Be(7); - } - - [Fact] - public async Task Patch_WrongPartitionKey_ThrowsNotFound() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("wrong-pk"), - [PatchOperation.Set("/name", "Patched")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_AfterDelete_ThrowsNotFound() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_UpdatesTimestamp() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var before = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = before.Resource["_ts"]!.Value(); - - await Task.Delay(10); // Ensure time passes - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - var after = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = after.Resource["_ts"]!.Value(); - tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); - } - - [Fact] - public async Task Patch_Set_BooleanValue_Updates() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = true }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/isActive", false)]); - - response.Resource.IsActive.Should().BeFalse(); - } - - [Fact] - public async Task Patch_Set_ArrayValue_ReplacesEntireArray() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["old1", "old2"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/tags", new[] { "new1", "new2", "new3" })]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().BeEquivalentTo(["new1", "new2", "new3"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_SetNullValue_SetsPropertyToNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", null!)]); + + response.Resource["name"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Patch_SetComplexObject_SetsNestedStructure() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var complexObj = new { street = "123 Main St", city = "Springfield", zip = "62701" }; + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/address", complexObj)]); + + response.Resource["address"]!["city"]!.ToString().Should().Be("Springfield"); + } + + [Fact] + public async Task Patch_IncrementNegative_Decrements() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/value", -3)]); + + response.Resource.Value.Should().Be(7); + } + + [Fact] + public async Task Patch_WrongPartitionKey_ThrowsNotFound() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("wrong-pk"), + [PatchOperation.Set("/name", "Patched")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_AfterDelete_ThrowsNotFound() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_UpdatesTimestamp() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var before = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = before.Resource["_ts"]!.Value(); + + await Task.Delay(10); // Ensure time passes + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + var after = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = after.Resource["_ts"]!.Value(); + tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); + } + + [Fact] + public async Task Patch_Set_BooleanValue_Updates() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", IsActive = true }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/isActive", false)]); + + response.Resource.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Patch_Set_ArrayValue_ReplacesEntireArray() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["old1", "old2"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/tags", new[] { "new1", "new2", "new3" })]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().BeEquivalentTo(["new1", "new2", "new3"]); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1170,77 +1201,77 @@ await _container.CreateItemAsync( public class PatchIncrementErrorHandlingTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Increment_OnNonNumericField_ThrowsCosmosException() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Text" }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/name", 1)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Increment_OnNullValueField_ThrowsCosmosException() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", score = (int?)null }), - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/score", 1)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Increment_OnBooleanField_ThrowsCosmosException() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/isActive", 1)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Increment_OnArrayField_ThrowsCosmosException() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/tags", 1)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Increment_OnObjectField_ThrowsCosmosException() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Nested = new NestedObject { Description = "D", Score = 1.0 } }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Increment("/nested", 1)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Increment_OnNonNumericField_ThrowsCosmosException() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Text" }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/name", 1)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Increment_OnNullValueField_ThrowsCosmosException() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", score = (int?)null }), + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/score", 1)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Increment_OnBooleanField_ThrowsCosmosException() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/isActive", 1)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Increment_OnArrayField_ThrowsCosmosException() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/tags", 1)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Increment_OnObjectField_ThrowsCosmosException() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Nested = new NestedObject { Description = "D", Score = 1.0 } }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Increment("/nested", 1)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1249,45 +1280,45 @@ await _container.CreateItemAsync( public class PatchStreamUniqueKeyTests { - [Fact] - public async Task PatchItemStreamAsync_ViolatesUniqueKey_ReturnsConflict() - { - var properties = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - 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 response = await container.PatchItemStreamAsync("2", new PartitionKey("a"), - [PatchOperation.Set("/email", "alice@test.com")]); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task PatchItemStreamAsync_UniqueKey_NoViolation_Succeeds() - { - var properties = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - 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 response = await container.PatchItemStreamAsync("2", new PartitionKey("a"), - [PatchOperation.Set("/email", "charlie@test.com")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task PatchItemStreamAsync_ViolatesUniqueKey_ReturnsConflict() + { + var properties = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + 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 response = await container.PatchItemStreamAsync("2", new PartitionKey("a"), + [PatchOperation.Set("/email", "alice@test.com")]); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task PatchItemStreamAsync_UniqueKey_NoViolation_Succeeds() + { + var properties = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + 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 response = await container.PatchItemStreamAsync("2", new PartitionKey("a"), + [PatchOperation.Set("/email", "charlie@test.com")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1296,35 +1327,35 @@ public async Task PatchItemStreamAsync_UniqueKey_NoViolation_Succeeds() public class PatchReplaceArrayIndexTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Replace_ValidArrayIndex_UpdatesElement() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Replace("/tags/1", "B")]); - - var tags = response.Resource["tags"]!.ToObject(); - tags.Should().BeEquivalentTo(["a", "B", "c"]); - } - - [Fact] - public async Task Replace_ArrayIndex_OutOfBounds_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Replace("/tags/10", "x")]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Replace_ValidArrayIndex_UpdatesElement() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Replace("/tags/1", "B")]); + + var tags = response.Resource["tags"]!.ToObject(); + tags.Should().BeEquivalentTo(["a", "B", "c"]); + } + + [Fact] + public async Task Replace_ArrayIndex_OutOfBounds_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Replace("/tags/10", "x")]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1333,65 +1364,65 @@ await _container.CreateItemAsync( public class PatchFilterPredicateTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_WithFilterPredicate_MatchingCondition_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true, Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource["name"]!.ToString().Should().Be("Patched"); - } - - [Fact] - public async Task Patch_FilterPredicate_ComplexCondition_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Test' AND c.value > 5" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Patch_FilterPredicate_ComplexCondition_NonMatching_Fails() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 3 }, - new PartitionKey("pk1")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Updated")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Test' AND c.value > 5" }); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemStreamAsync_FilterPredicate_MatchingCondition_ReturnsOK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_WithFilterPredicate_MatchingCondition_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true, Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource["name"]!.ToString().Should().Be("Patched"); + } + + [Fact] + public async Task Patch_FilterPredicate_ComplexCondition_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Test' AND c.value > 5" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Patch_FilterPredicate_ComplexCondition_NonMatching_Fails() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 3 }, + new PartitionKey("pk1")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Updated")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Test' AND c.value > 5" }); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemStreamAsync_FilterPredicate_MatchingCondition_ReturnsOK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", IsActive = true }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1400,37 +1431,37 @@ await _container.CreateItemAsync( public class PatchContentResponseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_EnableContentResponseOnWrite_False_ResourceIsDefault() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task Patch_EnableContentResponseOnWrite_True_ResourceIsPopulated() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - response.Resource.Name.Should().Be("Patched"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_EnableContentResponseOnWrite_False_ResourceIsDefault() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task Patch_EnableContentResponseOnWrite_True_ResourceIsPopulated() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + response.Resource.Name.Should().Be("Patched"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1439,23 +1470,23 @@ await _container.CreateItemAsync( public class PatchPropertyPreservationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_Set_SingleField_OtherFieldsUnchanged() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, IsActive = true, Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Changed")]); - - response.Resource.Name.Should().Be("Changed"); - response.Resource.Value.Should().Be(42); - response.Resource.IsActive.Should().BeTrue(); - response.Resource.Tags.Should().BeEquivalentTo(["a", "b"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_Set_SingleField_OtherFieldsUnchanged() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, IsActive = true, Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Changed")]); + + response.Resource.Name.Should().Be("Changed"); + response.Resource.Value.Should().Be(42); + response.Resource.IsActive.Should().BeTrue(); + response.Resource.Tags.Should().BeEquivalentTo(["a", "b"]); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1464,65 +1495,65 @@ await _container.CreateItemAsync( public class PatchComplexValueTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Patch_Set_IntegerValue_ZeroAndNegative() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 100 }, - new PartitionKey("pk1")); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/value", 0)]); - var item = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - ((int)item["value"]!).Should().Be(0); - - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/value", -999)]); - item = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - ((int)item["value"]!).Should().Be(-999); - } - - [Fact] - public async Task Patch_Set_EmptyStringValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "")]); - - response.Resource["name"]!.ToString().Should().BeEmpty(); - } - - [Fact] - public async Task Patch_Set_EmptyArrayValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a"] }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/tags", Array.Empty())]); - - response.Resource["tags"]!.ToObject().Should().BeEmpty(); - } - - [Fact] - public async Task Patch_Add_NestedObjectValue() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Add("/address", new { street = "123 Main", city = "Test" })]); - - response.Resource["address"]!["street"]!.ToString().Should().Be("123 Main"); - response.Resource["address"]!["city"]!.ToString().Should().Be("Test"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Patch_Set_IntegerValue_ZeroAndNegative() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 100 }, + new PartitionKey("pk1")); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/value", 0)]); + var item = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + ((int)item["value"]!).Should().Be(0); + + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/value", -999)]); + item = (await _container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + ((int)item["value"]!).Should().Be(-999); + } + + [Fact] + public async Task Patch_Set_EmptyStringValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "")]); + + response.Resource["name"]!.ToString().Should().BeEmpty(); + } + + [Fact] + public async Task Patch_Set_EmptyArrayValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Tags = ["a"] }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/tags", Array.Empty())]); + + response.Resource["tags"]!.ToObject().Should().BeEmpty(); + } + + [Fact] + public async Task Patch_Add_NestedObjectValue() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Add("/address", new { street = "123 Main", city = "Test" })]); + + response.Resource["address"]!["street"]!.ToString().Should().Be("123 Main"); + response.Resource["address"]!["city"]!.ToString().Should().Be("Test"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1531,35 +1562,35 @@ await _container.CreateItemAsync( public class PatchMoveEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Move_BetweenNestedPaths() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Nested = new NestedObject { Description = "D", Score = 1.0 } }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/nested/description", "/nested/summary")]); - - response.Resource["nested"]!["summary"]!.ToString().Should().Be("D"); - response.Resource["nested"]!["description"].Should().BeNull(); - } - - [Fact] - public async Task Move_SameFieldRename() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Move("/name", "/displayName")]); - - response.Resource["displayName"]!.ToString().Should().Be("Alice"); - response.Resource["name"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Move_BetweenNestedPaths() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Nested = new NestedObject { Description = "D", Score = 1.0 } }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/nested/description", "/nested/summary")]); + + response.Resource["nested"]!["summary"]!.ToString().Should().Be("D"); + response.Resource["nested"]!["description"].Should().BeNull(); + } + + [Fact] + public async Task Move_SameFieldRename() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Move("/name", "/displayName")]); + + response.Resource["displayName"]!.ToString().Should().Be("Alice"); + response.Resource["name"].Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1568,31 +1599,31 @@ await _container.CreateItemAsync( public class PatchTtlTests { - [Fact] - public async Task Patch_Set_TtlField_UpdatesTtl() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 3600; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var response = await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/ttl", 60)]); - - response.Resource["ttl"]!.Value().Should().Be(60); - } - - [Fact] - public async Task Patch_Remove_TtlField_RemovesPerItemTtl() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 3600; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", ttl = 60 }), new PartitionKey("a")); - - var response = await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Remove("/ttl")]); - - response.Resource["ttl"].Should().BeNull(); - } + [Fact] + public async Task Patch_Set_TtlField_UpdatesTtl() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 3600; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var response = await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/ttl", 60)]); + + response.Resource["ttl"]!.Value().Should().Be(60); + } + + [Fact] + public async Task Patch_Remove_TtlField_RemovesPerItemTtl() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 3600; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", ttl = 60 }), new PartitionKey("a")); + + var response = await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Remove("/ttl")]); + + response.Resource["ttl"].Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1601,33 +1632,33 @@ public async Task Patch_Remove_TtlField_RemovesPerItemTtl() public class PatchHierarchicalPartitionKeyTests { - [Fact] - public async Task Patch_HierarchicalPartitionKey_Succeeds() - { - var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Original" }), pk); - - var response = await container.PatchItemAsync("1", pk, - [PatchOperation.Set("/name", "Patched")]); - - response.Resource["name"]!.ToString().Should().Be("Patched"); - } - - [Fact] - public async Task Patch_HierarchicalPartitionKey_WrongPartition_ThrowsNotFound() - { - var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1" }), pk); - - var wrongPk = new PartitionKeyBuilder().Add("t2").Add("u2").Build(); - var act = () => container.PatchItemAsync("1", wrongPk, - [PatchOperation.Set("/name", "Patched")]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task Patch_HierarchicalPartitionKey_Succeeds() + { + var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Original" }), pk); + + var response = await container.PatchItemAsync("1", pk, + [PatchOperation.Set("/name", "Patched")]); + + response.Resource["name"]!.ToString().Should().Be("Patched"); + } + + [Fact] + public async Task Patch_HierarchicalPartitionKey_WrongPartition_ThrowsNotFound() + { + var container = new InMemoryContainer("test", ["/tenantId", "/userId"]); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1" }), pk); + + var wrongPk = new PartitionKeyBuilder().Add("t2").Add("u2").Build(); + var act = () => container.PatchItemAsync("1", wrongPk, + [PatchOperation.Set("/name", "Patched")]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1636,19 +1667,19 @@ public async Task Patch_HierarchicalPartitionKey_WrongPartition_ThrowsNotFound() public class PatchDocumentSizeTests { - [Fact] - public async Task Patch_ResultExceedsMaxDocSize_ThrowsRequestEntityTooLarge() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var largeValue = new string('x', 2_100_000); // > 2MB - var act = () => container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/bigField", largeValue)]); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + [Fact] + public async Task Patch_ResultExceedsMaxDocSize_ThrowsRequestEntityTooLarge() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var largeValue = new string('x', 2_100_000); // > 2MB + var act = () => container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/bigField", largeValue)]); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1657,34 +1688,34 @@ public async Task Patch_ResultExceedsMaxDocSize_ThrowsRequestEntityTooLarge() public class PatchChangeFeedInteractionTests { - [Fact] - public async Task Patch_RecordsInChangeFeed_WithCorrectContent() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); - - // Drain initial change feed - var cfIterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - while (cfIterator.HasMoreResults) - { - var batch = await cfIterator.ReadNextAsync(); - if (batch.StatusCode == HttpStatusCode.NotModified) break; - } - - await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "Patched")]); - - cfIterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - var results = new List(); - while (cfIterator.HasMoreResults) - { - var batch = await cfIterator.ReadNextAsync(); - if (batch.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(batch); - } - - results.Should().Contain(j => j["name"]!.ToString() == "Patched"); - } + [Fact] + public async Task Patch_RecordsInChangeFeed_WithCorrectContent() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); + + // Drain initial change feed + var cfIterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + while (cfIterator.HasMoreResults) + { + var batch = await cfIterator.ReadNextAsync(); + if (batch.StatusCode == HttpStatusCode.NotModified) break; + } + + await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "Patched")]); + + cfIterator = container.GetChangeFeedIterator(ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + var results = new List(); + while (cfIterator.HasMoreResults) + { + var batch = await cfIterator.ReadNextAsync(); + if (batch.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(batch); + } + + results.Should().Contain(j => j["name"]!.ToString() == "Patched"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1693,27 +1724,27 @@ public async Task Patch_RecordsInChangeFeed_WithCorrectContent() public class PatchConcurrencyTests { - [Fact] - public async Task Patch_ConcurrentPatches_DifferentFields_AllSucceed() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice", value = 0, active = true }), - new PartitionKey("a")); - - var tasks = new[] - { - container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/name", "Bob")]), - container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/value", 42)]), - container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/active", false)]) - }; - - await Task.WhenAll(tasks); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - // All three patches should have applied (though order may vary) - item.Should().NotBeNull(); - } + [Fact] + public async Task Patch_ConcurrentPatches_DifferentFields_AllSucceed() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice", value = 0, active = true }), + new PartitionKey("a")); + + var tasks = new[] + { + container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/name", "Bob")]), + container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/value", 42)]), + container.PatchItemAsync("1", new PartitionKey("a"), [PatchOperation.Set("/active", false)]) + }; + + await Task.WhenAll(tasks); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + // All three patches should have applied (though order may vary) + item.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1722,20 +1753,20 @@ await container.CreateItemAsync( public class PatchStreamResponseBodyTests { - [Fact] - public async Task PatchItemStreamAsync_ResponseBody_ContainsUpdatedDocument() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); - - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "Patched")]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(await reader.ReadToEndAsync()); - body["name"]!.ToString().Should().Be("Patched"); - } + [Fact] + public async Task PatchItemStreamAsync_ResponseBody_ContainsUpdatedDocument() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); + + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "Patched")]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(await reader.ReadToEndAsync()); + body["name"]!.ToString().Should().Be("Patched"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1744,16 +1775,16 @@ public async Task PatchItemStreamAsync_ResponseBody_ContainsUpdatedDocument() public class PatchIdFieldTests { - [Fact] - public async Task Patch_Set_IdField_ShouldThrowBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var act = () => container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/id", "new-id")]); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task Patch_Set_IdField_ShouldThrowBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var act = () => container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/id", "new-id")]); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1762,17 +1793,17 @@ public async Task Patch_Set_IdField_ShouldThrowBadRequest() public class PatchPathEdgeCaseTests2 { - [Fact] - public async Task Patch_Set_PropertyWithSpecialCharacters() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Patch_Set_PropertyWithSpecialCharacters() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Add("/my-field", "value")]); + var response = await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Add("/my-field", "value")]); - response.Resource["my-field"]!.ToString().Should().Be("value"); - } + response.Resource["my-field"]!.ToString().Should().Be("value"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1781,28 +1812,28 @@ public async Task Patch_Set_PropertyWithSpecialCharacters() public class PatchNullArgumentTests { - [Fact] - public async Task Patch_NullOperations_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task Patch_NullOperations_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var act = () => container.PatchItemAsync("1", new PartitionKey("a"), null!); + var act = () => container.PatchItemAsync("1", new PartitionKey("a"), null!); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task PatchItemStreamAsync_NullOperations_ReturnsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task PatchItemStreamAsync_NullOperations_ReturnsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), null!); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), null!); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1811,44 +1842,44 @@ public async Task PatchItemStreamAsync_NullOperations_ReturnsBadRequest() public class PatchAfterMutationTests { - [Fact] - public async Task Patch_AfterUpsert_Succeeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.UpsertItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); - - var response = await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "Patched")]); - - response.Resource["name"]!.ToString().Should().Be("Patched"); - } - - [Fact] - public async Task Patch_AfterReplace_Succeeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "v1" }), new PartitionKey("a")); - await container.ReplaceItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "v2" }), "1", new PartitionKey("a")); - - var response = await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "v3")]); - - response.Resource["name"]!.ToString().Should().Be("v3"); - } - - [Fact] - public async Task Patch_ThenQuery_ReturnsUpdatedItem() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); - - await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "Patched")]); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }) - .Where(x => (string)x["name"]! == "Patched").ToList(); - - results.Should().ContainSingle(); - } + [Fact] + public async Task Patch_AfterUpsert_Succeeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.UpsertItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); + + var response = await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "Patched")]); + + response.Resource["name"]!.ToString().Should().Be("Patched"); + } + + [Fact] + public async Task Patch_AfterReplace_Succeeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "v1" }), new PartitionKey("a")); + await container.ReplaceItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "v2" }), "1", new PartitionKey("a")); + + var response = await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "v3")]); + + response.Resource["name"]!.ToString().Should().Be("v3"); + } + + [Fact] + public async Task Patch_ThenQuery_ReturnsUpdatedItem() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Original" }), new PartitionKey("a")); + + await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "Patched")]); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }) + .Where(x => (string)x["name"]! == "Patched").ToList(); + + results.Should().ContainSingle(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchPathCollisionBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchPathCollisionBugTests.cs index ad81de9..7e2bdeb 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchPathCollisionBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PatchPathCollisionBugTests.cs @@ -1,84 +1,84 @@ using System.Net; +using AwesomeAssertions; using CosmosDB.InMemoryEmulator; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Xunit; -using AwesomeAssertions; namespace CosmosDB.InMemoryEmulator.Tests; public class PatchPathCollisionBugTests { - public class ParentDoc - { - [JsonProperty("id")] public string Id { get; set; } = ""; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; - [JsonProperty("transactions")] public Dictionary Transactions { get; set; } = new(); - [JsonProperty("runs")] public List Runs { get; set; } = new(); - } + public class ParentDoc + { + [JsonProperty("id")] public string Id { get; set; } = ""; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = ""; + [JsonProperty("transactions")] public Dictionary Transactions { get; set; } = new(); + [JsonProperty("runs")] public List Runs { get; set; } = new(); + } - public class RunEntry - { - [JsonProperty("status")] public string Status { get; set; } = ""; - [JsonProperty("transactions")] public List Transactions { get; set; } = new(); - } + public class RunEntry + { + [JsonProperty("status")] public string Status { get; set; } = ""; + [JsonProperty("transactions")] public List Transactions { get; set; } = new(); + } - public class ItemValue - { - [JsonProperty("name")] public string Name { get; set; } = ""; - } + public class ItemValue + { + [JsonProperty("name")] public string Name { get; set; } = ""; + } - [Fact] - public async Task Set_RootAndNestedWithSameTerminalName_DoesNotCorruptRoot() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task Set_RootAndNestedWithSameTerminalName_DoesNotCorruptRoot() + { + var container = new InMemoryContainer("test", "/partitionKey"); - // Create doc with empty transactions dict and empty runs list - var doc = new ParentDoc - { - Id = "doc-1", - PartitionKey = "pk-1", - Transactions = new Dictionary(), - Runs = new List() - }; - await container.CreateItemAsync(doc, new PartitionKey("pk-1")); + // Create doc with empty transactions dict and empty runs list + var doc = new ParentDoc + { + Id = "doc-1", + PartitionKey = "pk-1", + Transactions = new Dictionary(), + Runs = new List() + }; + await container.CreateItemAsync(doc, new PartitionKey("pk-1")); - // Add a run entry - var run = new RunEntry { Status = "InProgress", Transactions = new List() }; - await container.PatchItemAsync( - "doc-1", new PartitionKey("pk-1"), - [PatchOperation.Add("/runs/0", run)]); + // Add a run entry + var run = new RunEntry { Status = "InProgress", Transactions = new List() }; + await container.PatchItemAsync( + "doc-1", new PartitionKey("pk-1"), + [PatchOperation.Add("/runs/0", run)]); - // Patch both root /transactions AND nested /runs/0/transactions - var txId = Guid.NewGuid(); - var allTransactions = new Dictionary - { - [txId] = new ItemValue { Name = "Tx1" } - }; + // Patch both root /transactions AND nested /runs/0/transactions + var txId = Guid.NewGuid(); + var allTransactions = new Dictionary + { + [txId] = new ItemValue { Name = "Tx1" } + }; - await container.PatchItemAsync( - "doc-1", new PartitionKey("pk-1"), - [ - PatchOperation.Set("/transactions", allTransactions), - PatchOperation.Set("/runs/0/status", "Completed"), - PatchOperation.Set("/runs/0/transactions", new List - { - new() { Name = "Tx1" } - }) - ]); + await container.PatchItemAsync( + "doc-1", new PartitionKey("pk-1"), + [ + PatchOperation.Set("/transactions", allTransactions), + PatchOperation.Set("/runs/0/status", "Completed"), + PatchOperation.Set("/runs/0/transactions", new List + { + new() { Name = "Tx1" } + }) + ]); - // Read back — should not throw - var readResponse = await container.ReadItemAsync( - "doc-1", new PartitionKey("pk-1")); + // Read back — should not throw + var readResponse = await container.ReadItemAsync( + "doc-1", new PartitionKey("pk-1")); - // Root transactions should be a dictionary, not a list - readResponse.Resource.Transactions.Should().HaveCount(1); - readResponse.Resource.Transactions.Values.First().Name.Should().Be("Tx1"); + // Root transactions should be a dictionary, not a list + readResponse.Resource.Transactions.Should().HaveCount(1); + readResponse.Resource.Transactions.Values.First().Name.Should().Be("Tx1"); - // Nested transactions should be a list - readResponse.Resource.Runs.Should().HaveCount(1); - readResponse.Resource.Runs[0].Status.Should().Be("Completed"); - readResponse.Resource.Runs[0].Transactions.Should().HaveCount(1); - readResponse.Resource.Runs[0].Transactions[0].Name.Should().Be("Tx1"); - } + // Nested transactions should be a list + readResponse.Resource.Runs.Should().HaveCount(1); + readResponse.Resource.Runs[0].Status.Should().Be("Completed"); + readResponse.Resource.Runs[0].Transactions.Should().HaveCount(1); + readResponse.Resource.Runs[0].Transactions[0].Name.Should().Be("Tx1"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PitrDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PitrDeepDiveTests.cs index aed3cad..88a9d49 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PitrDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PitrDeepDiveTests.cs @@ -17,69 +17,69 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class PitrStreamOperationTests { - [Fact] - public async Task RestoreToPointInTime_WithStreamUpsert_RestoresPreUpsertState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"StreamUpserted"}""")), - new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read.Name.Should().Be("Original"); - } - - [Fact] - public async Task RestoreToPointInTime_WithStreamReplace_RestoresPreReplaceState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"StreamReplaced"}""")), - "1", new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read.Name.Should().Be("Original"); - } - - [Fact] - public async Task RestoreToPointInTime_WithPatchItemStreamAsync_RestoresPrePatchState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - - container.RestoreToPointInTime(restorePoint); - - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read.Name.Should().Be("Original"); - } + [Fact] + public async Task RestoreToPointInTime_WithStreamUpsert_RestoresPreUpsertState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"StreamUpserted"}""")), + new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read.Name.Should().Be("Original"); + } + + [Fact] + public async Task RestoreToPointInTime_WithStreamReplace_RestoresPreReplaceState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"StreamReplaced"}""")), + "1", new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read.Name.Should().Be("Original"); + } + + [Fact] + public async Task RestoreToPointInTime_WithPatchItemStreamAsync_RestoresPrePatchState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + + container.RestoreToPointInTime(restorePoint); + + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read.Name.Should().Be("Original"); + } } @@ -87,95 +87,95 @@ await container.CreateItemAsync( public class PitrSprocTriggerUdfTests { - [Fact] - public async Task RestoreToPointInTime_StoredProcedureExecutionWorksAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterStoredProcedure("sproc1", (pk, args) => - { - return """{"status":"ok"}"""; - }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - // Sproc should still work after restore - var result = await container.Scripts.ExecuteStoredProcedureAsync( - "sproc1", new PartitionKey("pk1"), []); - result.Resource.Should().Contain("ok"); - } - - [Fact] - public async Task RestoreToPointInTime_PreTriggerFiresOnPostRestoreWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var triggerFired = false; - - container.RegisterTrigger("preT1", TriggerType.Pre, TriggerOperation.Create, (JObject doc) => - { - triggerFired = true; - return doc; - }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - container.RestoreToPointInTime(restorePoint); - - triggerFired = false; // Reset - - // Write after restore should fire the trigger - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterRestore" }, - new PartitionKey("pk1"), - new ItemRequestOptions { PreTriggers = ["preT1"] }); - - triggerFired.Should().BeTrue(); - } - - [Fact] - public async Task RestoreToPointInTime_UdfQueryWorksAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RegisterUdf("double", (args) => (int)Convert.ToInt64(args[0]) * 2); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Value = 5 }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/value", 99)]); - - container.RestoreToPointInTime(restorePoint); - - // UDF should work on restored data - var query = container.GetItemQueryIterator( - new QueryDefinition("SELECT udf.double(c.value) AS doubled FROM c")); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["doubled"]!.Value().Should().Be(10); - } + [Fact] + public async Task RestoreToPointInTime_StoredProcedureExecutionWorksAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterStoredProcedure("sproc1", (pk, args) => + { + return """{"status":"ok"}"""; + }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + // Sproc should still work after restore + var result = await container.Scripts.ExecuteStoredProcedureAsync( + "sproc1", new PartitionKey("pk1"), []); + result.Resource.Should().Contain("ok"); + } + + [Fact] + public async Task RestoreToPointInTime_PreTriggerFiresOnPostRestoreWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var triggerFired = false; + + container.RegisterTrigger("preT1", TriggerType.Pre, TriggerOperation.Create, (JObject doc) => + { + triggerFired = true; + return doc; + }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + container.RestoreToPointInTime(restorePoint); + + triggerFired = false; // Reset + + // Write after restore should fire the trigger + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterRestore" }, + new PartitionKey("pk1"), + new ItemRequestOptions { PreTriggers = ["preT1"] }); + + triggerFired.Should().BeTrue(); + } + + [Fact] + public async Task RestoreToPointInTime_UdfQueryWorksAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RegisterUdf("double", (args) => (int)Convert.ToInt64(args[0]) * 2); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Value = 5 }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/value", 99)]); + + container.RestoreToPointInTime(restorePoint); + + // UDF should work on restored data + var query = container.GetItemQueryIterator( + new QueryDefinition("SELECT udf.double(c.value) AS doubled FROM c")); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["doubled"]!.Value().Should().Be(10); + } } @@ -183,37 +183,37 @@ await container.CreateItemAsync( public class PitrFeedRangeTests { - [Fact] - public async Task RestoreToPointInTime_FeedRangeScopedQueryAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, - new PartitionKey("pk2")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - // Query all items — both should be restored - var query = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } + [Fact] + public async Task RestoreToPointInTime_FeedRangeScopedQueryAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 2 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, + new PartitionKey("pk2")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + // Query all items — both should be restored + var query = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } } @@ -221,32 +221,32 @@ await container.CreateItemAsync( public class PitrBulkOperationTests { - [Fact] - public async Task RestoreToPointInTime_BulkCreatedItems_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_BulkCreatedItems_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); - // Create 5 items - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); + // Create 5 items + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - // Create 5 more - for (var i = 5; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); + // Create 5 more + for (var i = 5; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); - container.ItemCount.Should().Be(10); + container.ItemCount.Should().Be(10); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(5); - } + container.ItemCount.Should().Be(5); + } } @@ -254,50 +254,50 @@ await container.CreateItemAsync( public class PitrPerItemTtlTests { - [Fact] - public async Task RestoreToPointInTime_PerItemTtl_ItemRestoredWithOriginalTtlValue() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; + [Fact] + public async Task RestoreToPointInTime_PerItemTtl_ItemRestoredWithOriginalTtlValue() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":300}""")), - new PartitionKey("pk1")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":300}""")), + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("pk1")); + await container.DeleteItemAsync("1", new PartitionKey("pk1")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read["_ttl"]!.Value().Should().Be(300); - } + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read["_ttl"]!.Value().Should().Be(300); + } - [Fact] - public async Task RestoreToPointInTime_PerItemTtl_RestoredItemGetsNewTimestamp() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; + [Fact] + public async Task RestoreToPointInTime_PerItemTtl_RestoredItemGetsNewTimestamp() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":300}""")), - new PartitionKey("pk1")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":300}""")), + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Modified")]); + await container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Modified")]); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - // _ts should be the restore point, not the original creation time - var ts = read["_ts"]!.Value(); - ts.Should().Be(restorePoint.ToUnixTimeSeconds()); - } + var read = (await container.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + // _ts should be the restore point, not the original creation time + var ts = read["_ts"]!.Value(); + ts.Should().Be(restorePoint.ToUnixTimeSeconds()); + } } @@ -305,60 +305,60 @@ await container.CreateItemStreamAsync( public class PitrExportTests { - [Fact] - public async Task RestoreToPointInTime_ExportState_ContainsOnlyRestoredItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Keep" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Keep" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Remove" }, - new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - var exported = container.ExportState(); - var jObj = JObject.Parse(exported); - jObj["items"]!.ToObject()!.Count.Should().Be(2); - } - - [Fact] - public async Task RestoreToPointInTime_ExportThenImport_RoundTrip() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - var exported = container.ExportState(); - - // Import into a new container - var container2 = new InMemoryContainer("test2", "/partitionKey"); - container2.ImportState(exported); - - container2.ItemCount.Should().Be(2); - var read = (await container2.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; - read.Name.Should().Be("A"); - } + [Fact] + public async Task RestoreToPointInTime_ExportState_ContainsOnlyRestoredItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Keep" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Keep" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Remove" }, + new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + var exported = container.ExportState(); + var jObj = JObject.Parse(exported); + jObj["items"]!.ToObject()!.Count.Should().Be(2); + } + + [Fact] + public async Task RestoreToPointInTime_ExportThenImport_RoundTrip() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + var exported = container.ExportState(); + + // Import into a new container + var container2 = new InMemoryContainer("test2", "/partitionKey"); + container2.ImportState(exported); + + container2.ItemCount.Should().Be(2); + var read = (await container2.ReadItemAsync("1", new PartitionKey("pk1"))).Resource; + read.Name.Should().Be("A"); + } } @@ -366,39 +366,39 @@ await container.CreateItemAsync( public class PitrChangeFeedProcessorTests { - [Fact] - public async Task RestoreToPointInTime_ChangeFeedProcessorPicksUpPostRestoreWrites() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - container.RestoreToPointInTime(restorePoint); - - // Get checkpoint after restore - var checkpoint = container.GetChangeFeedCheckpoint(); - - // Write new item - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterRestore" }, - new PartitionKey("pk1")); - - // Read changes since checkpoint - var feed = container.GetChangeFeedIterator(checkpoint); - var changes = new List(); - while (feed.HasMoreResults) - { - var page = await feed.ReadNextAsync(); - changes.AddRange(page); - } - - changes.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } + [Fact] + public async Task RestoreToPointInTime_ChangeFeedProcessorPicksUpPostRestoreWrites() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + container.RestoreToPointInTime(restorePoint); + + // Get checkpoint after restore + var checkpoint = container.GetChangeFeedCheckpoint(); + + // Write new item + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AfterRestore" }, + new PartitionKey("pk1")); + + // Read changes since checkpoint + var feed = container.GetChangeFeedIterator(checkpoint); + var changes = new List(); + while (feed.HasMoreResults) + { + var page = await feed.ReadNextAsync(); + changes.AddRange(page); + } + + changes.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } } @@ -406,56 +406,56 @@ await container.CreateItemAsync( public class PitrItemLockFixTests { - [Fact] - public async Task RestoreToPointInTime_PatchAfterRestore_NoDeadlock() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_PatchAfterRestore_NoDeadlock() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - // Patch to create a lock entry - await container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); + // Patch to create a lock entry + await container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("pk1")); + await container.DeleteItemAsync("1", new PartitionKey("pk1")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Patch same item again — should not deadlock - var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "PatchedAgain")]); + // Patch same item again — should not deadlock + var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "PatchedAgain")]); - result.Resource["name"]!.ToString().Should().Be("PatchedAgain"); - } + result.Resource["name"]!.ToString().Should().Be("PatchedAgain"); + } - [Fact] - public async Task RestoreToPointInTime_DeletedItemPatch_CreatesNewLock() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_DeletedItemPatch_CreatesNewLock() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("pk1")); + await container.DeleteItemAsync("1", new PartitionKey("pk1")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Replace item — should create a new lock and succeed - var result = await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "AfterRestore" }, - "1", new PartitionKey("pk1")); + // Replace item — should create a new lock and succeed + var result = await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "AfterRestore" }, + "1", new PartitionKey("pk1")); - result.Resource.Name.Should().Be("AfterRestore"); - } + result.Resource.Name.Should().Be("AfterRestore"); + } } @@ -463,72 +463,72 @@ await container.CreateItemAsync( public class PitrAdditionalEdgeCaseTests { - [Fact] - public async Task RestoreToPointInTime_DateTimeOffsetMaxValue_KeepsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_DateTimeOffsetMaxValue_KeepsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); - container.RestoreToPointInTime(DateTimeOffset.MaxValue); + container.RestoreToPointInTime(DateTimeOffset.MaxValue); - container.ItemCount.Should().Be(2); - } + container.ItemCount.Should().Be(2); + } - [Fact] - public async Task RestoreToPointInTime_NewWriteAfterRestore_HasCorrectETagAndTs() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_NewWriteAfterRestore_HasCorrectETagAndTs() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Create new item after restore - var created = await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, - new PartitionKey("pk1")); + // Create new item after restore + var created = await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "New" }, + new PartitionKey("pk1")); - created.ETag.Should().NotBeNullOrEmpty(); - var read = (await container.ReadItemAsync("2", new PartitionKey("pk1"))).Resource; - var ts = read["_ts"]!.Value(); - // The new write's _ts should be current time, not the restore point - ts.Should().BeGreaterThanOrEqualTo(restorePoint.ToUnixTimeSeconds()); - } + created.ETag.Should().NotBeNullOrEmpty(); + var read = (await container.ReadItemAsync("2", new PartitionKey("pk1"))).Resource; + var ts = read["_ts"]!.Value(); + // The new write's _ts should be current time, not the restore point + ts.Should().BeGreaterThanOrEqualTo(restorePoint.ToUnixTimeSeconds()); + } - [Fact] - public async Task RestoreToPointInTime_100Items_AllRestoredCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_100Items_AllRestoredCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 100; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); + for (var i = 0; i < 100; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - // Delete 50 - for (var i = 50; i < 100; i++) - await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); + // Delete 50 + for (var i = 50; i < 100; i++) + await container.DeleteItemAsync($"{i}", new PartitionKey("pk1")); - container.ItemCount.Should().Be(50); + container.ItemCount.Should().Be(50); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(100); - } + container.ItemCount.Should().Be(100); + } } @@ -536,80 +536,80 @@ await container.CreateItemAsync( public class PitrDivergentDocTests { - [Fact(Skip = "PITR does not restore container properties — real Cosmos PITR creates a new account")] - public async Task RestoreToPointInTime_DoesNotRestoreContainerProperties_Divergent() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; + [Fact(Skip = "PITR does not restore container properties — real Cosmos PITR creates a new account")] + public async Task RestoreToPointInTime_DoesNotRestoreContainerProperties_Divergent() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - container.DefaultTimeToLive = 60; // Changed after restore point + container.DefaultTimeToLive = 60; // Changed after restore point - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Ideal: TTL should revert to 3600 - container.DefaultTimeToLive.Should().Be(3600); - } + // Ideal: TTL should revert to 3600 + container.DefaultTimeToLive.Should().Be(3600); + } - [Fact] - public async Task RestoreToPointInTime_ContainerPropertiesNotAffectedByRestore_ActualBehavior() - { - var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; + [Fact] + public async Task RestoreToPointInTime_ContainerPropertiesNotAffectedByRestore_ActualBehavior() + { + var container = new InMemoryContainer("test", "/partitionKey") { DefaultTimeToLive = 3600 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1" }, + new PartitionKey("pk1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - container.DefaultTimeToLive = 60; + container.DefaultTimeToLive = 60; - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Actual: TTL stays at the modified value — PITR only restores items - container.DefaultTimeToLive.Should().Be(60); - } + // Actual: TTL stays at the modified value — PITR only restores items + container.DefaultTimeToLive.Should().Be(60); + } - [Fact(Skip = "PITR does not restore sproc registrations — real Cosmos PITR creates a new account")] - public async Task RestoreToPointInTime_DoesNotRestoreSprocRegistration_Divergent() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact(Skip = "PITR does not restore sproc registrations — real Cosmos PITR creates a new account")] + public async Task RestoreToPointInTime_DoesNotRestoreSprocRegistration_Divergent() + { + var container = new InMemoryContainer("test", "/partitionKey"); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - // Register sproc after restore point - container.RegisterStoredProcedure("sp1", (pk, args) => "result"); + // Register sproc after restore point + container.RegisterStoredProcedure("sp1", (pk, args) => "result"); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Ideal: sproc should no longer be registered - var act = () => container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), []); - await act.Should().ThrowAsync(); - } + // Ideal: sproc should no longer be registered + var act = () => container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), []); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task RestoreToPointInTime_SprocRegistrationSurvivesRestore_ActualBehavior() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_SprocRegistrationSurvivesRestore_ActualBehavior() + { + var container = new InMemoryContainer("test", "/partitionKey"); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - container.RegisterStoredProcedure("sp1", (pk, args) => """{"status":"ok"}"""); + container.RegisterStoredProcedure("sp1", (pk, args) => """{"status":"ok"}"""); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Actual: sproc is still registered — PITR only restores items - var result = await container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), []); - result.Resource.Should().Contain("ok"); - } + // Actual: sproc is still registered — PITR only restores items + var result = await container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), []); + result.Resource.Should().Contain("ok"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PointInTimeRestoreTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PointInTimeRestoreTests.cs index dd561d0..4605a9d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PointInTimeRestoreTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PointInTimeRestoreTests.cs @@ -1,234 +1,234 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class PointInTimeRestoreTests { - [Fact] - public async Task RestoreToPointInTime_RestoresItemsAsOfGivenTimestamp() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_RestoresItemsAsOfGivenTimestamp() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(1); - var item = await container.ReadItemAsync("1", new PartitionKey("pk")); - item.Resource.Name.Should().Be("Alice"); - } + container.ItemCount.Should().Be(1); + var item = await container.ReadItemAsync("1", new PartitionKey("pk")); + item.Resource.Name.Should().Be("Alice"); + } - [Fact] - public async Task RestoreToPointInTime_RestoresDeletedItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_RestoresDeletedItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); - container.ItemCount.Should().Be(0); + container.ItemCount.Should().Be(0); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(1); - var item = await container.ReadItemAsync("1", new PartitionKey("pk")); - item.Resource.Name.Should().Be("Alice"); - } + container.ItemCount.Should().Be(1); + var item = await container.ReadItemAsync("1", new PartitionKey("pk")); + item.Resource.Name.Should().Be("Alice"); + } - [Fact] - public async Task RestoreToPointInTime_RestoresOverwrittenValues() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_RestoresOverwrittenValues() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 1 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 1 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 2 }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 2 }, + new PartitionKey("pk")); - var current = await container.ReadItemAsync("1", new PartitionKey("pk")); - current.Resource.Name.Should().Be("Updated"); + var current = await container.ReadItemAsync("1", new PartitionKey("pk")); + current.Resource.Name.Should().Be("Updated"); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - restored.Resource.Name.Should().Be("Original"); - restored.Resource.Value.Should().Be(1); - } + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + restored.Resource.Name.Should().Be("Original"); + restored.Resource.Value.Should().Be(1); + } - [Fact] - public async Task RestoreToPointInTime_BeforeAnyData_ResultsInEmptyContainer() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var beforeAnyData = DateTimeOffset.UtcNow; - await Task.Delay(50); + [Fact] + public async Task RestoreToPointInTime_BeforeAnyData_ResultsInEmptyContainer() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var beforeAnyData = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(beforeAnyData); + container.RestoreToPointInTime(beforeAnyData); - container.ItemCount.Should().Be(0); - } + container.ItemCount.Should().Be(0); + } - [Fact] - public async Task RestoreToPointInTime_MultiplePartitionKeys_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_MultiplePartitionKeys_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, - new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob" }, + new PartitionKey("pk2")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie" }, - new PartitionKey("pk1")); - await container.DeleteItemAsync("2", new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie" }, + new PartitionKey("pk1")); + await container.DeleteItemAsync("2", new PartitionKey("pk2")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(2); - var alice = await container.ReadItemAsync("1", new PartitionKey("pk1")); - alice.Resource.Name.Should().Be("Alice"); - var bob = await container.ReadItemAsync("2", new PartitionKey("pk2")); - bob.Resource.Name.Should().Be("Bob"); - } + container.ItemCount.Should().Be(2); + var alice = await container.ReadItemAsync("1", new PartitionKey("pk1")); + alice.Resource.Name.Should().Be("Alice"); + var bob = await container.ReadItemAsync("2", new PartitionKey("pk2")); + bob.Resource.Name.Should().Be("Bob"); + } - [Fact] - public async Task RestoreToPointInTime_PreservesChangeFeedHistory() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_PreservesChangeFeedHistory() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Change feed should still work after restore — new changes are recorded - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie" }, - new PartitionKey("pk")); + // Change feed should still work after restore — new changes are recorded + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie" }, + new PartitionKey("pk")); - container.ItemCount.Should().Be(2); - } + container.ItemCount.Should().Be(2); + } - [Fact] - public async Task RestoreToPointInTime_MultipleUpdatesToSameItem_RestoresCorrectVersion() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_MultipleUpdatesToSameItem_RestoresCorrectVersion() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1", Value = 1 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1", Value = 1 }, + new PartitionKey("pk")); - await Task.Delay(50); + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2", Value = 2 }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2", Value = 2 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V3", Value = 3 }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V3", Value = 3 }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var item = await container.ReadItemAsync("1", new PartitionKey("pk")); - item.Resource.Name.Should().Be("V2"); - item.Resource.Value.Should().Be(2); - } + var item = await container.ReadItemAsync("1", new PartitionKey("pk")); + item.Resource.Name.Should().Be("V2"); + item.Resource.Value.Should().Be(2); + } - [Fact] - public async Task RestoreToPointInTime_ItemCreatedAndDeletedBeforeRestorePoint_StaysDeleted() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_ItemCreatedAndDeletedBeforeRestorePoint_StaysDeleted() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, + new PartitionKey("pk")); - await container.DeleteItemAsync("1", new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Later" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Later" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(0); - } + container.ItemCount.Should().Be(0); + } - [Fact] - public async Task RestoreToPointInTime_WithPatchOperations_RestoresPrePatchState() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_WithPatchOperations_RestoresPrePatchState() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 10 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.PatchItemAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); + await container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); - var patched = await container.ReadItemAsync("1", new PartitionKey("pk")); - patched.Resource.Name.Should().Be("Patched"); - patched.Resource.Value.Should().Be(15); + var patched = await container.ReadItemAsync("1", new PartitionKey("pk")); + patched.Resource.Name.Should().Be("Patched"); + patched.Resource.Value.Should().Be(15); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - restored.Resource.Name.Should().Be("Original"); - restored.Resource.Value.Should().Be(10); - } + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + restored.Resource.Name.Should().Be("Original"); + restored.Resource.Value.Should().Be(10); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -237,85 +237,85 @@ await container.CreateItemAsync( public class PitrEtagConsistencyTests { - [Fact] - public async Task RestoreToPointInTime_ETagsAreConsistentAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_ETagsAreConsistentAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - var bodyEtag = restored.Resource["_etag"]?.ToString(); - restored.ETag.Should().Be(bodyEtag, - "response.ETag should match the _etag embedded in the JSON body"); - } + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + var bodyEtag = restored.Resource["_etag"]?.ToString(); + restored.ETag.Should().Be(bodyEtag, + "response.ETag should match the _etag embedded in the JSON body"); + } - [Fact] - public async Task RestoreToPointInTime_ETagsAreRegeneratedAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_ETagsAreRegeneratedAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var originalEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; + var originalEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restoredEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; + var restoredEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; - // ETag should be different from the original — it's regenerated on restore - restoredEtag.Should().NotBe(originalEtag); - } + // ETag should be different from the original — it's regenerated on restore + restoredEtag.Should().NotBe(originalEtag); + } - [Fact] - public async Task RestoreToPointInTime_OldETagInvalidForConditionalOps() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_OldETagInvalidForConditionalOps() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); - var oldEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; + var oldEtag = (await container.ReadItemAsync("1", new PartitionKey("pk"))).ETag; - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, - new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - // Old ETag should be invalid for conditional replace - var act = () => container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "WithOldEtag" }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = oldEtag }); + // Old ETag should be invalid for conditional replace + var act = () => container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "WithOldEtag" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = oldEtag }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -324,80 +324,80 @@ await act.Should().ThrowAsync() public class PitrCoreFunctionalityTests { - [Fact] - public async Task RestoreToPointInTime_WithReplaceItem_RestoresPreReplaceState() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_WithReplaceItem_RestoresPreReplaceState() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 1 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original", Value = 1 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Replaced", Value = 99 }, - "1", new PartitionKey("pk")); + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Replaced", Value = 99 }, + "1", new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - restored.Resource.Name.Should().Be("Original"); - restored.Resource.Value.Should().Be(1); - } + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + restored.Resource.Name.Should().Be("Original"); + restored.Resource.Value.Should().Be(1); + } - [Fact] - public async Task RestoreToPointInTime_QueriesWorkAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_QueriesWorkAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("2", new PartitionKey("pk")); + await container.DeleteItemAsync("2", new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } - [Fact] - public async Task RestoreToPointInTime_WithStreamOperations_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task RestoreToPointInTime_WithStreamOperations_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"v1","name":"StreamItem"}""")), - new PartitionKey("v1")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"v1","name":"StreamItem"}""")), + new PartitionKey("v1")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("v1")); + await container.DeleteItemAsync("1", new PartitionKey("v1")); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var read = await container.ReadItemAsync("1", new PartitionKey("v1")); - read.Resource["name"]!.ToString().Should().Be("StreamItem"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("v1")); + read.Resource["name"]!.ToString().Should().Be("StreamItem"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -406,138 +406,138 @@ await container.CreateItemStreamAsync( public class PitrEdgeCaseTests { - [Fact] - public async Task RestoreToPointInTime_ToFutureTimestamp_KeepsAllItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_ToFutureTimestamp_KeepsAllItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(DateTimeOffset.UtcNow.AddHours(1)); + container.RestoreToPointInTime(DateTimeOffset.UtcNow.AddHours(1)); - container.ItemCount.Should().Be(2); - } + container.ItemCount.Should().Be(2); + } - [Fact] - public async Task RestoreToPointInTime_DoubleRestoreToSamePoint_IsIdempotent() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public async Task RestoreToPointInTime_DoubleRestoreToSamePoint_IsIdempotent() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - var firstRestore = await container.ReadItemAsync("1", new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - var secondRestore = await container.ReadItemAsync("1", new PartitionKey("pk")); - - secondRestore.Resource.Name.Should().Be(firstRestore.Resource.Name); - secondRestore.Resource.Value.Should().Be(firstRestore.Resource.Value); - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task RestoreToPointInTime_ConsecutiveRestoresToDifferentPoints() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1", Value = 1 }, - new PartitionKey("pk")); - - var t1 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2", Value = 2 }, - new PartitionKey("pk")); - - var t2 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "New", Value = 99 }, - new PartitionKey("pk")); - - // Restore to t2 — should have 1 item at V2 - container.RestoreToPointInTime(t2); - container.ItemCount.Should().Be(1); - (await container.ReadItemAsync("1", new PartitionKey("pk"))) - .Resource.Name.Should().Be("V2"); - - // Restore to t1 — should have 1 item at V1 - container.RestoreToPointInTime(t1); - container.ItemCount.Should().Be(1); - (await container.ReadItemAsync("1", new PartitionKey("pk"))) - .Resource.Name.Should().Be("V1"); - - // Restore to future — should replay all and have 2 items - container.RestoreToPointInTime(DateTimeOffset.UtcNow.AddHours(1)); - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task RestoreToPointInTime_WithPartitionKeyNone_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var item = new TestDocument { Id = "1", PartitionKey = "fromBody", Name = "AutoPK" }; - await container.CreateItemAsync(item, PartitionKey.None); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("fromBody")); - - container.RestoreToPointInTime(restorePoint); - - container.ItemCount.Should().Be(1); - } + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + var firstRestore = await container.ReadItemAsync("1", new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + var secondRestore = await container.ReadItemAsync("1", new PartitionKey("pk")); + + secondRestore.Resource.Name.Should().Be(firstRestore.Resource.Name); + secondRestore.Resource.Value.Should().Be(firstRestore.Resource.Value); + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task RestoreToPointInTime_ConsecutiveRestoresToDifferentPoints() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1", Value = 1 }, + new PartitionKey("pk")); + + var t1 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2", Value = 2 }, + new PartitionKey("pk")); + + var t2 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "New", Value = 99 }, + new PartitionKey("pk")); + + // Restore to t2 — should have 1 item at V2 + container.RestoreToPointInTime(t2); + container.ItemCount.Should().Be(1); + (await container.ReadItemAsync("1", new PartitionKey("pk"))) + .Resource.Name.Should().Be("V2"); + + // Restore to t1 — should have 1 item at V1 + container.RestoreToPointInTime(t1); + container.ItemCount.Should().Be(1); + (await container.ReadItemAsync("1", new PartitionKey("pk"))) + .Resource.Name.Should().Be("V1"); + + // Restore to future — should replay all and have 2 items + container.RestoreToPointInTime(DateTimeOffset.UtcNow.AddHours(1)); + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task RestoreToPointInTime_WithPartitionKeyNone_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var item = new TestDocument { Id = "1", PartitionKey = "fromBody", Name = "AutoPK" }; + await container.CreateItemAsync(item, PartitionKey.None); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("fromBody")); + + container.RestoreToPointInTime(restorePoint); + + container.ItemCount.Should().Be(1); + } - [Fact] - public async Task RestoreToPointInTime_WithHierarchicalPartitionKeys_RestoresCorrectly() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - var pk = new PartitionKeyBuilder().Add("acme").Add("us").Build(); + [Fact] + public async Task RestoreToPointInTime_WithHierarchicalPartitionKeys_RestoresCorrectly() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + var pk = new PartitionKeyBuilder().Add("acme").Add("us").Build(); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "acme", region = "us", name = "Original" }), - pk); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "acme", region = "us", name = "Original" }), + pk); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", tenant = "acme", region = "us", name = "Updated" }), - pk); + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", tenant = "acme", region = "us", name = "Updated" }), + pk); - container.RestoreToPointInTime(restorePoint); + container.RestoreToPointInTime(restorePoint); - var restored = await container.ReadItemAsync("1", pk); - restored.Resource["name"]!.ToString().Should().Be("Original"); - } + var restored = await container.ReadItemAsync("1", pk); + restored.Resource["name"]!.ToString().Should().Be("Original"); + } - [Fact] - public void RestoreToPointInTime_EmptyContainer_NoOpRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.RestoreToPointInTime(DateTimeOffset.UtcNow); - container.ItemCount.Should().Be(0); - } + [Fact] + public void RestoreToPointInTime_EmptyContainer_NoOpRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.RestoreToPointInTime(DateTimeOffset.UtcNow); + container.ItemCount.Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -546,156 +546,156 @@ public void RestoreToPointInTime_EmptyContainer_NoOpRestore() public class PitrFeatureInteractionTests { - [Fact] - public async Task RestoreToPointInTime_AfterClearItems_RestoresToEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var restorePointWhenItemExisted = DateTimeOffset.UtcNow; - await Task.Delay(50); - - container.ClearItems(); - - // Change feed was wiped — PITR has no history to replay - container.RestoreToPointInTime(restorePointWhenItemExisted); + [Fact] + public async Task RestoreToPointInTime_AfterClearItems_RestoresToEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var restorePointWhenItemExisted = DateTimeOffset.UtcNow; + await Task.Delay(50); + + container.ClearItems(); + + // Change feed was wiped — PITR has no history to replay + container.RestoreToPointInTime(restorePointWhenItemExisted); - container.ItemCount.Should().Be(0, - "ClearItems wipes the change feed, so PITR has no entries to replay"); - } - - [Fact] - public async Task RestoreToPointInTime_AfterImportState_HasNoPreImportHistory() - { - var container = new InMemoryContainer("test", "/partitionKey"); + container.ItemCount.Should().Be(0, + "ClearItems wipes the change feed, so PITR has no entries to replay"); + } + + [Fact] + public async Task RestoreToPointInTime_AfterImportState_HasNoPreImportHistory() + { + var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "BeforeImport" }, - new PartitionKey("pk")); - - var exported = container.ExportState(); - - // ImportState calls ClearItems() internally, wiping the change feed - container.ImportState(exported); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "BeforeImport" }, + new PartitionKey("pk")); + + var exported = container.ExportState(); + + // ImportState calls ClearItems() internally, wiping the change feed + container.ImportState(exported); - // Create a new item after import - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "AfterImport" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + // Create a new item after import + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "AfterImport" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Latest" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Latest" }, + new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); - - // Only item "2" should exist — item "1" has no change feed history (import doesn't record), - // item "3" was created after restore point - container.ItemCount.Should().Be(1); - var item = await container.ReadItemAsync("2", new PartitionKey("pk")); - item.Resource.Name.Should().Be("AfterImport"); - } - - [Fact] - public async Task RestoreToPointInTime_ChangeFeedIteratorWorksAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Before" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - // New operation after restore should be tracked in change feed - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "PostRestore" }, - new PartitionKey("pk")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.LatestVersion); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(page); - } - - results.Should().Contain(r => r.Name == "PostRestore"); - } - - [Fact] - public async Task RestoreToPointInTime_UniqueKeyConstraintsApplyAfterRestore() - { - var properties = new ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Unique" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - // Item "1" with name "Unique" is restored — creating another with same name should conflict - var act = () => container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Unique" }, - new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task RestoreToPointInTime_AfterFailedBatch_GhostEntriesInChangeFeed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk", Name = "Existing" }, - new PartitionKey("pk")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "ghost", PartitionKey = "pk", Name = "Ghost" }); - batch.CreateItem(new TestDocument { Id = "existing", PartitionKey = "pk", Name = "Conflict" }); - - using var response = await batch.ExecuteAsync(); - // Batch should fail due to conflict on "existing" - response.StatusCode.Should().NotBe(HttpStatusCode.OK); - - // Ghost item should not exist - container.ItemCount.Should().Be(1); - - // But PITR might resurrect it from the change feed ghost entries - container.RestoreToPointInTime(DateTimeOffset.UtcNow); - container.ItemCount.Should().Be(1, "ghost items from failed batches should not be resurrected"); - } + container.RestoreToPointInTime(restorePoint); + + // Only item "2" should exist — item "1" has no change feed history (import doesn't record), + // item "3" was created after restore point + container.ItemCount.Should().Be(1); + var item = await container.ReadItemAsync("2", new PartitionKey("pk")); + item.Resource.Name.Should().Be("AfterImport"); + } + + [Fact] + public async Task RestoreToPointInTime_ChangeFeedIteratorWorksAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Before" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + // New operation after restore should be tracked in change feed + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "PostRestore" }, + new PartitionKey("pk")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.LatestVersion); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(page); + } + + results.Should().Contain(r => r.Name == "PostRestore"); + } + + [Fact] + public async Task RestoreToPointInTime_UniqueKeyConstraintsApplyAfterRestore() + { + var properties = new ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Unique" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + // Item "1" with name "Unique" is restored — creating another with same name should conflict + var act = () => container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Unique" }, + new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task RestoreToPointInTime_AfterFailedBatch_GhostEntriesInChangeFeed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk", Name = "Existing" }, + new PartitionKey("pk")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "ghost", PartitionKey = "pk", Name = "Ghost" }); + batch.CreateItem(new TestDocument { Id = "existing", PartitionKey = "pk", Name = "Conflict" }); + + using var response = await batch.ExecuteAsync(); + // Batch should fail due to conflict on "existing" + response.StatusCode.Should().NotBe(HttpStatusCode.OK); + + // Ghost item should not exist + container.ItemCount.Should().Be(1); + + // But PITR might resurrect it from the change feed ghost entries + container.RestoreToPointInTime(DateTimeOffset.UtcNow); + container.ItemCount.Should().Be(1, "ghost items from failed batches should not be resurrected"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -704,56 +704,56 @@ await container.CreateItemAsync( public class PitrConcurrencyTests { - [Fact] - public async Task RestoreToPointInTime_ConcurrentReadsAndRestore_NoException() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - for (int i = 0; i < 20; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - for (int i = 20; i < 30; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - } - - // Concurrent reads + restore should not throw - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var readTask = Task.Run(async () => - { - while (!cts.Token.IsCancellationRequested) - { - try - { - await container.ReadItemAsync("0", new PartitionKey("pk")); - } - catch (CosmosException) { /* item may not exist during restore */ } - catch (OperationCanceledException) { break; } - } - }); - - var restoreTask = Task.Run(() => - { - for (int i = 0; i < 10; i++) - { - container.RestoreToPointInTime(restorePoint); - } - }); - - await restoreTask; - cts.Cancel(); - // If readTask throws an unhandled exception (not CosmosException), this will propagate - await readTask; - } + [Fact] + public async Task RestoreToPointInTime_ConcurrentReadsAndRestore_NoException() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + for (int i = 0; i < 20; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + for (int i = 20; i < 30; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + } + + // Concurrent reads + restore should not throw + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var readTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + try + { + await container.ReadItemAsync("0", new PartitionKey("pk")); + } + catch (CosmosException) { /* item may not exist during restore */ } + catch (OperationCanceledException) { break; } + } + }); + + var restoreTask = Task.Run(() => + { + for (int i = 0; i < 10; i++) + { + container.RestoreToPointInTime(restorePoint); + } + }); + + await restoreTask; + cts.Cancel(); + // If readTask throws an unhandled exception (not CosmosException), this will propagate + await readTask; + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -762,74 +762,74 @@ await container.CreateItemAsync( public class PitrBugFixTests { - [Fact] - public async Task RestoreToPointInTime_TimestampInJsonMatchesDictionaryAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - var tsInJson = restored.Resource["_ts"]?.Value(); - tsInJson.Should().Be(restorePoint.ToUnixTimeSeconds(), - "_ts in the JSON body should match the restore point epoch"); - } - - [Fact] - public async Task RestoreToPointInTime_AfterDeleteAllByPartitionKey_ItemsStayDeleted() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, - new PartitionKey("pk1")); - - await Task.Delay(50); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - var restorePointAfterDelete = DateTimeOffset.UtcNow; - - // Restore to after the delete — items should not reappear - container.RestoreToPointInTime(restorePointAfterDelete); - container.ItemCount.Should().Be(0, - "DeleteAllByPK tombstones prevent items from being resurrected"); - } - - [Fact] - public async Task RestoreToPointInTime_AfterDeleteAllByPartitionKey_OtherPartitionsUnaffected() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, - new PartitionKey("pk2")); - - await Task.Delay(50); - - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - - var restorePoint = DateTimeOffset.UtcNow; - container.RestoreToPointInTime(restorePoint); - - container.ItemCount.Should().Be(1); - var item = (await container.ReadItemAsync("2", new PartitionKey("pk2"))).Resource; - item.Name.Should().Be("B"); - } + [Fact] + public async Task RestoreToPointInTime_TimestampInJsonMatchesDictionaryAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + var tsInJson = restored.Resource["_ts"]?.Value(); + tsInJson.Should().Be(restorePoint.ToUnixTimeSeconds(), + "_ts in the JSON body should match the restore point epoch"); + } + + [Fact] + public async Task RestoreToPointInTime_AfterDeleteAllByPartitionKey_ItemsStayDeleted() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }, + new PartitionKey("pk1")); + + await Task.Delay(50); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + var restorePointAfterDelete = DateTimeOffset.UtcNow; + + // Restore to after the delete — items should not reappear + container.RestoreToPointInTime(restorePointAfterDelete); + container.ItemCount.Should().Be(0, + "DeleteAllByPK tombstones prevent items from being resurrected"); + } + + [Fact] + public async Task RestoreToPointInTime_AfterDeleteAllByPartitionKey_OtherPartitionsUnaffected() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, + new PartitionKey("pk2")); + + await Task.Delay(50); + + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + + var restorePoint = DateTimeOffset.UtcNow; + container.RestoreToPointInTime(restorePoint); + + container.ItemCount.Should().Be(1); + var item = (await container.ReadItemAsync("2", new PartitionKey("pk2"))).Resource; + item.Name.Should().Be("B"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -838,52 +838,52 @@ await container.CreateItemAsync( public class PitrTtlInteractionTests { - [Fact] - public async Task RestoreToPointInTime_TTLExpiredItem_IsResurrectedByRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.DefaultTimeToLive = 1; // 1 second TTL + [Fact] + public async Task RestoreToPointInTime_TTLExpiredItem_IsResurrectedByRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.DefaultTimeToLive = 1; // 1 second TTL - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, + new PartitionKey("pk")); - // Wait for TTL to expire - await Task.Delay(1500); + // Wait for TTL to expire + await Task.Delay(1500); - // Item should be expired - var act = () => container.ReadItemAsync("1", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); + // Item should be expired + var act = () => container.ReadItemAsync("1", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); - // Set a long TTL so restored item won't immediately expire - container.DefaultTimeToLive = 3600; + // Set a long TTL so restored item won't immediately expire + container.DefaultTimeToLive = 3600; - // Restore — timestamps are set to UtcNow (recent), so item is alive again - container.RestoreToPointInTime(DateTimeOffset.UtcNow); + // Restore — timestamps are set to UtcNow (recent), so item is alive again + container.RestoreToPointInTime(DateTimeOffset.UtcNow); - var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); - restored.Resource.Name.Should().Be("Ephemeral"); - } + var restored = await container.ReadItemAsync("1", new PartitionKey("pk")); + restored.Resource.Name.Should().Be("Ephemeral"); + } - [Fact] - public async Task RestoreToPointInTime_TTLConfigurationPreservedAfterRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.DefaultTimeToLive = 3600; + [Fact] + public async Task RestoreToPointInTime_TTLConfigurationPreservedAfterRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.DefaultTimeToLive = 3600; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); - await container.DeleteItemAsync("1", new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); - container.RestoreToPointInTime(restorePoint); - container.DefaultTimeToLive.Should().Be(3600, "TTL configuration should survive restore"); - } + container.RestoreToPointInTime(restorePoint); + container.DefaultTimeToLive.Should().Be(3600, "TTL configuration should survive restore"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -892,101 +892,101 @@ await container.CreateItemAsync( public class PitrOperationCoverageTests { - [Fact] - public async Task RestoreToPointInTime_SuccessfulTransactionalBatch_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - container.ItemCount.Should().Be(2); - } - - [Fact] - public async Task RestoreToPointInTime_WithLinqQuery_ReturnsRestoredData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - var results = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk") }) - .Where(x => x.Name == "Original").ToList(); - - results.Should().ContainSingle().Which.Name.Should().Be("Original"); - } - - [Fact] - public async Task RestoreToPointInTime_ReadManyAfterRestore_ReturnsRestoredItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("2", new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - var readMany = await container.ReadManyItemsAsync([ - ("1", new PartitionKey("pk")), - ("2", new PartitionKey("pk")) - ]); - - readMany.Count.Should().Be(2); - } - - [Fact] - public async Task RestoreToPointInTime_CrossPartitionQueryAfterRestore_ReturnsAllPartitions() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, - new PartitionKey("pk2")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - container.RestoreToPointInTime(restorePoint); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - results.AddRange(await iterator.ReadNextAsync()); - } - results.Should().HaveCount(2); - } + [Fact] + public async Task RestoreToPointInTime_SuccessfulTransactionalBatch_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + container.ItemCount.Should().Be(2); + } + + [Fact] + public async Task RestoreToPointInTime_WithLinqQuery_ReturnsRestoredData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + var results = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk") }) + .Where(x => x.Name == "Original").ToList(); + + results.Should().ContainSingle().Which.Name.Should().Be("Original"); + } + + [Fact] + public async Task RestoreToPointInTime_ReadManyAfterRestore_ReturnsRestoredItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("2", new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + var readMany = await container.ReadManyItemsAsync([ + ("1", new PartitionKey("pk")), + ("2", new PartitionKey("pk")) + ]); + + readMany.Count.Should().Be(2); + } + + [Fact] + public async Task RestoreToPointInTime_CrossPartitionQueryAfterRestore_ReturnsAllPartitions() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, + new PartitionKey("pk2")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + container.RestoreToPointInTime(restorePoint); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + results.AddRange(await iterator.ReadNextAsync()); + } + results.Should().HaveCount(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -995,86 +995,87 @@ await container.CreateItemAsync( public class PitrBoundaryTests { - [Fact] - public async Task RestoreToPointInTime_DateTimeOffsetMinValue_ResultsInEmptyContainer() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(DateTimeOffset.MinValue); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task RestoreToPointInTime_WithNestedComplexJson_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - var original = JObject.FromObject(new - { - id = "1", pk = "a", - nested = new { level1 = new { level2 = "deep" } }, - tags = new[] { "x", "y", "z" } - }); - await container.CreateItemAsync(original, new PartitionKey("a")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/nested", new { level1 = new { level2 = "changed" } })]); - - container.RestoreToPointInTime(restorePoint); - - var restored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - restored["nested"]!["level1"]!["level2"]!.ToString().Should().Be("deep"); - restored["tags"]!.ToObject().Should().BeEquivalentTo(["x", "y", "z"]); - } - - [Fact] - public async Task RestoreToPointInTime_RapidCreateDeleteCycles_RestoresCorrectState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - DateTimeOffset midpoint = default; - - for (int i = 0; i < 5; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = "cycle", PartitionKey = "pk", Name = $"v{i}" }, - new PartitionKey("pk")); - - if (i == 2) midpoint = DateTimeOffset.UtcNow; - await Task.Delay(20); - - await container.DeleteItemAsync("cycle", new PartitionKey("pk")); - await Task.Delay(20); - } - - container.RestoreToPointInTime(midpoint); - - var restored = (await container.ReadItemAsync("cycle", new PartitionKey("pk"))).Resource; - restored.Name.Should().Be("v2"); - } - - [Fact] - public async Task RestoreToPointInTime_LargeItemPayload_RestoresCorrectly() - { - var container = new InMemoryContainer("test", "/pk"); - var largeValue = new string('x', 500_000); // 500KB - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", big = largeValue }), new PartitionKey("a")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.PatchItemAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/big", "small")]); - - container.RestoreToPointInTime(restorePoint); - - var restored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - restored["big"]!.ToString().Should().HaveLength(500_000); - } + [Fact] + public async Task RestoreToPointInTime_DateTimeOffsetMinValue_ResultsInEmptyContainer() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(DateTimeOffset.MinValue); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task RestoreToPointInTime_WithNestedComplexJson_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + var original = JObject.FromObject(new + { + id = "1", + pk = "a", + nested = new { level1 = new { level2 = "deep" } }, + tags = new[] { "x", "y", "z" } + }); + await container.CreateItemAsync(original, new PartitionKey("a")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/nested", new { level1 = new { level2 = "changed" } })]); + + container.RestoreToPointInTime(restorePoint); + + var restored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + restored["nested"]!["level1"]!["level2"]!.ToString().Should().Be("deep"); + restored["tags"]!.ToObject().Should().BeEquivalentTo(["x", "y", "z"]); + } + + [Fact] + public async Task RestoreToPointInTime_RapidCreateDeleteCycles_RestoresCorrectState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + DateTimeOffset midpoint = default; + + for (int i = 0; i < 5; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = "cycle", PartitionKey = "pk", Name = $"v{i}" }, + new PartitionKey("pk")); + + if (i == 2) midpoint = DateTimeOffset.UtcNow; + await Task.Delay(20); + + await container.DeleteItemAsync("cycle", new PartitionKey("pk")); + await Task.Delay(20); + } + + container.RestoreToPointInTime(midpoint); + + var restored = (await container.ReadItemAsync("cycle", new PartitionKey("pk"))).Resource; + restored.Name.Should().Be("v2"); + } + + [Fact] + public async Task RestoreToPointInTime_LargeItemPayload_RestoresCorrectly() + { + var container = new InMemoryContainer("test", "/pk"); + var largeValue = new string('x', 500_000); // 500KB + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", big = largeValue }), new PartitionKey("a")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.PatchItemAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/big", "small")]); + + container.RestoreToPointInTime(restorePoint); + + var restored = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + restored["big"]!.ToString().Should().HaveLength(500_000); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1083,71 +1084,71 @@ public async Task RestoreToPointInTime_LargeItemPayload_RestoresCorrectly() public class PitrDivergentBehaviorTests { - [Fact(Skip = "DIVERGENT: Real Cosmos DB PITR creates a new account from continuous backup. " + - "The emulator replays an immutable change feed. After restoring to T1, original entries " + - "after T1 remain in the feed. A second restore to T2 > T1 replays them, resurrecting " + - "items the first restore removed. This is by-design for the append-only model.")] - public async Task RestoreToPointInTime_PostRestoreWritesShouldNotAffectEarlierRestore() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - var t1 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - var t2 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - // Restore to T1 — only item "1" should exist - container.RestoreToPointInTime(t1); - container.ItemCount.Should().Be(1); - - // Add new item - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, - new PartitionKey("pk")); - - // Restore to T2 — in real Cosmos, "B" was undone. Emulator replays original feed. - container.RestoreToPointInTime(t2); - container.ItemCount.Should().Be(2, "Only items 1 and 2 should exist (B should NOT be resurrected)"); - } - - [Fact] - public async Task RestoreToPointInTime_PostRestoreWritesThenSecondRestore_ActualBehavior() - { - // DIVERGENT BEHAVIOR: The emulator's append-only change feed means - // a second restore to T2 replays original entries, resurrecting item "2" - var container = new InMemoryContainer("test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - var t1 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - var t2 = DateTimeOffset.UtcNow; - await Task.Delay(50); - - container.RestoreToPointInTime(t1); - container.ItemCount.Should().Be(1); - - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, - new PartitionKey("pk")); - - // Second restore to T2 — replays original feed, "2" comes back - container.RestoreToPointInTime(t2); - // Item "2" IS resurrected because original change feed entry persists - (await container.ReadItemAsync("2", new PartitionKey("pk"))) - .Resource.Name.Should().Be("B"); - } + [Fact(Skip = "DIVERGENT: Real Cosmos DB PITR creates a new account from continuous backup. " + + "The emulator replays an immutable change feed. After restoring to T1, original entries " + + "after T1 remain in the feed. A second restore to T2 > T1 replays them, resurrecting " + + "items the first restore removed. This is by-design for the append-only model.")] + public async Task RestoreToPointInTime_PostRestoreWritesShouldNotAffectEarlierRestore() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + var t1 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + var t2 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + // Restore to T1 — only item "1" should exist + container.RestoreToPointInTime(t1); + container.ItemCount.Should().Be(1); + + // Add new item + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, + new PartitionKey("pk")); + + // Restore to T2 — in real Cosmos, "B" was undone. Emulator replays original feed. + container.RestoreToPointInTime(t2); + container.ItemCount.Should().Be(2, "Only items 1 and 2 should exist (B should NOT be resurrected)"); + } + + [Fact] + public async Task RestoreToPointInTime_PostRestoreWritesThenSecondRestore_ActualBehavior() + { + // DIVERGENT BEHAVIOR: The emulator's append-only change feed means + // a second restore to T2 replays original entries, resurrecting item "2" + var container = new InMemoryContainer("test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + var t1 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + var t2 = DateTimeOffset.UtcNow; + await Task.Delay(50); + + container.RestoreToPointInTime(t1); + container.ItemCount.Should().Be(1); + + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "C" }, + new PartitionKey("pk")); + + // Second restore to T2 — replays original feed, "2" comes back + container.RestoreToPointInTime(t2); + // Item "2" IS resurrected because original change feed entry persists + (await container.ReadItemAsync("2", new PartitionKey("pk"))) + .Resource.Name.Should().Be("B"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPipelineBehaviorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPipelineBehaviorTests.cs index da484a9..81b3ba5 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPipelineBehaviorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPipelineBehaviorTests.cs @@ -15,491 +15,491 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class QueryPipelineBehaviorTests { - private static async Task CreateSeededContainer(int count = 10) - { - var container = new InMemoryContainer("qpb-test", "/partitionKey"); - for (var i = 1; i <= count; i++) - { - await container.CreateItemAsync( - new TestDocument - { - Id = i.ToString(), - PartitionKey = $"pk-{i % 3}", // 3 partitions: pk-0, pk-1, pk-2 - Name = $"Item{i}", - Value = i * 10, - IsActive = i % 2 == 0, // even items are active - Tags = new[] { $"tag-{i % 4}", $"tag-{i % 5}" } - }, - new PartitionKey($"pk-{i % 3}")); - } - return container; - } + private static async Task CreateSeededContainer(int count = 10) + { + var container = new InMemoryContainer("qpb-test", "/partitionKey"); + for (var i = 1; i <= count; i++) + { + await container.CreateItemAsync( + new TestDocument + { + Id = i.ToString(), + PartitionKey = $"pk-{i % 3}", // 3 partitions: pk-0, pk-1, pk-2 + Name = $"Item{i}", + Value = i * 10, + IsActive = i % 2 == 0, // even items are active + Tags = new[] { $"tag-{i % 4}", $"tag-{i % 5}" } + }, + new PartitionKey($"pk-{i % 3}")); + } + return container; + } - // ── Basic WHERE ────────────────────────────────────────────────────── + // ── Basic WHERE ────────────────────────────────────────────────────── - [Fact] - public async Task WhereFilter_ReturnsOnlyMatchingItems() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task WhereFilter_ReturnsOnlyMatchingItems() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.isActive = true")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.isActive = true")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().OnlyContain(d => d.IsActive); - results.Should().HaveCount(5); // items 2,4,6,8,10 - } + results.Should().OnlyContain(d => d.IsActive); + results.Should().HaveCount(5); // items 2,4,6,8,10 + } - // ── WHERE + TOP (no ORDER BY) — key lazy optimization path ────── + // ── WHERE + TOP (no ORDER BY) — key lazy optimization path ────── - [Fact] - public async Task WhereWithTop_ReturnsCorrectCount() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task WhereWithTop_ReturnsCorrectCount() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 3 * FROM c WHERE c.value > 30")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 3 * FROM c WHERE c.value > 30")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results.Should().OnlyContain(d => d.Value > 30); - } + results.Should().HaveCount(3); + results.Should().OnlyContain(d => d.Value > 30); + } - // ── WHERE + ORDER BY ──────────────────────────────────────────────── + // ── WHERE + ORDER BY ──────────────────────────────────────────────── - [Fact] - public async Task WhereWithOrderBy_ReturnsSortedFilteredResults() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task WhereWithOrderBy_ReturnsSortedFilteredResults() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.value >= 50 ORDER BY c.value DESC")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.value >= 50 ORDER BY c.value DESC")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().OnlyContain(d => d.Value >= 50); - results.Should().BeInDescendingOrder(d => d.Value); - } + results.Should().OnlyContain(d => d.Value >= 50); + results.Should().BeInDescendingOrder(d => d.Value); + } - // ── WHERE + TOP + ORDER BY ────────────────────────────────────────── + // ── WHERE + TOP + ORDER BY ────────────────────────────────────────── - [Fact] - public async Task WhereTopOrderBy_ReturnsCorrectSortedSubset() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task WhereTopOrderBy_ReturnsCorrectSortedSubset() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT TOP 2 * FROM c WHERE c.isActive = true ORDER BY c.value ASC")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT TOP 2 * FROM c WHERE c.isActive = true ORDER BY c.value ASC")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Should().OnlyContain(d => d.IsActive); - results.Should().BeInAscendingOrder(d => d.Value); - results[0].Value.Should().Be(20); // item 2 - results[1].Value.Should().Be(40); // item 4 - } + results.Should().HaveCount(2); + results.Should().OnlyContain(d => d.IsActive); + results.Should().BeInAscendingOrder(d => d.Value); + results[0].Value.Should().Be(20); // item 2 + results[1].Value.Should().Be(40); // item 4 + } - // ── OFFSET / LIMIT ────────────────────────────────────────────────── + // ── OFFSET / LIMIT ────────────────────────────────────────────────── - [Fact] - public async Task OffsetLimit_ReturnsCorrectPage() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task OffsetLimit_ReturnsCorrectPage() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC OFFSET 3 LIMIT 4")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC OFFSET 3 LIMIT 4")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(4); - results[0].Value.Should().Be(40); // 4th item (0-indexed: skip 10,20,30) - results[3].Value.Should().Be(70); // 7th item - } + results.Should().HaveCount(4); + results[0].Value.Should().Be(40); // 4th item (0-indexed: skip 10,20,30) + results[3].Value.Should().Be(70); // 7th item + } - // ── DISTINCT ──────────────────────────────────────────────────────── + // ── DISTINCT ──────────────────────────────────────────────────────── - [Fact] - public async Task Distinct_ReturnsUniqueValues() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task Distinct_ReturnsUniqueValues() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT VALUE c.partitionKey FROM c")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - // Should be exactly 3 distinct partition keys: pk-0, pk-1, pk-2 - results.Should().HaveCount(3); - results.Should().BeEquivalentTo("pk-0", "pk-1", "pk-2"); - } + // Should be exactly 3 distinct partition keys: pk-0, pk-1, pk-2 + results.Should().HaveCount(3); + results.Should().BeEquivalentTo("pk-0", "pk-1", "pk-2"); + } - // ── DISTINCT + TOP ────────────────────────────────────────────────── + // ── DISTINCT + TOP ────────────────────────────────────────────────── - [Fact] - public async Task DistinctTop_ReturnsLimitedUniqueValues() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task DistinctTop_ReturnsLimitedUniqueValues() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT DISTINCT TOP 2 VALUE c.partitionKey FROM c")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT DISTINCT TOP 2 VALUE c.partitionKey FROM c")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - } + results.Should().HaveCount(2); + } - // ── VALUE SELECT with WHERE ───────────────────────────────────────── + // ── VALUE SELECT with WHERE ───────────────────────────────────────── - [Fact] - public async Task ValueSelect_WithWhere_ReturnsFilteredScalarValues() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task ValueSelect_WithWhere_ReturnsFilteredScalarValues() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.value <= 30 ORDER BY c.value ASC")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.value <= 30 ORDER BY c.value ASC")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(3); - results[0].Should().Be("Item1"); - results[1].Should().Be("Item2"); - results[2].Should().Be("Item3"); - } + results.Should().HaveCount(3); + results[0].Should().Be("Item1"); + results[1].Should().Be("Item2"); + results[2].Should().Be("Item3"); + } - // ── Cross-partition query ─────────────────────────────────────────── + // ── Cross-partition query ─────────────────────────────────────────── - [Fact] - public async Task CrossPartitionQuery_ReturnsAllPartitions() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task CrossPartitionQuery_ReturnsAllPartitions() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(10); - results.Select(d => d.PartitionKey).Distinct().Should().HaveCount(3); - } + results.Should().HaveCount(10); + results.Select(d => d.PartitionKey).Distinct().Should().HaveCount(3); + } - // ── Partition-scoped query ────────────────────────────────────────── + // ── Partition-scoped query ────────────────────────────────────────── - [Fact] - public async Task PartitionScopedQuery_ReturnsOnlyMatchingPartition() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task PartitionScopedQuery_ReturnsOnlyMatchingPartition() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.value > 0"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-0") }); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.value > 0"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-0") }); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().OnlyContain(d => d.PartitionKey == "pk-0"); - // Items with pk-0: 3,6,9 → values 30,60,90 - results.Should().HaveCount(3); - } + results.Should().OnlyContain(d => d.PartitionKey == "pk-0"); + // Items with pk-0: 3,6,9 → values 30,60,90 + results.Should().HaveCount(3); + } - // ── GetItemQueryIterator with null queryText ──────────────────────── + // ── GetItemQueryIterator with null queryText ──────────────────────── - [Fact] - public async Task NullQueryText_ReturnsAllItems() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task NullQueryText_ReturnsAllItems() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - queryText: null); + var iterator = container.GetItemQueryIterator( + queryText: null); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(10); - } + results.Should().HaveCount(10); + } - // ── GetItemQueryIterator with null queryText + partition key ──────── + // ── GetItemQueryIterator with null queryText + partition key ──────── - [Fact] - public async Task NullQueryText_WithPartitionKey_ReturnsPartitionItems() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task NullQueryText_WithPartitionKey_ReturnsPartitionItems() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - queryText: null, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-1") }); + var iterator = container.GetItemQueryIterator( + queryText: null, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-1") }); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().OnlyContain(d => d.PartitionKey == "pk-1"); - } + results.Should().OnlyContain(d => d.PartitionKey == "pk-1"); + } - // ── LINQ queryable ────────────────────────────────────────────────── + // ── LINQ queryable ────────────────────────────────────────────────── - [Fact] - public async Task LinqQueryable_ReturnsCorrectResults() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task LinqQueryable_ReturnsCorrectResults() + { + var container = await CreateSeededContainer(); - var queryable = container.GetItemLinqQueryable(); + var queryable = container.GetItemLinqQueryable(); - var results = queryable.Where(d => d.Value > 50).ToList(); + var results = queryable.Where(d => d.Value > 50).ToList(); - results.Should().HaveCount(5); // items 6,7,8,9,10 - results.Should().OnlyContain(d => d.Value > 50); - } + results.Should().HaveCount(5); // items 6,7,8,9,10 + results.Should().OnlyContain(d => d.Value > 50); + } - // ── LINQ queryable with partition key ──────────────────────────────── + // ── LINQ queryable with partition key ──────────────────────────────── - [Fact] - public async Task LinqQueryable_WithPartitionKey_ReturnsOnlyMatchingPartition() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task LinqQueryable_WithPartitionKey_ReturnsOnlyMatchingPartition() + { + var container = await CreateSeededContainer(); - var queryable = container.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-2") }); + var queryable = container.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-2") }); - var results = queryable.ToList(); + var results = queryable.ToList(); - results.Should().OnlyContain(d => d.PartitionKey == "pk-2"); - } + results.Should().OnlyContain(d => d.PartitionKey == "pk-2"); + } - // ── Stream iterator with null queryText ───────────────────────────── + // ── Stream iterator with null queryText ───────────────────────────── - [Fact] - public async Task StreamIterator_NullQueryText_ReturnsAllItems() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task StreamIterator_NullQueryText_ReturnsAllItems() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryStreamIterator(queryText: null); + var iterator = container.GetItemQueryStreamIterator(queryText: null); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - var body = await new StreamReader(response.Content).ReadToEndAsync(); - var arr = JObject.Parse(body)["Documents"] as JArray; - if (arr != null) - foreach (var item in arr) - results.Add((JObject)item); - } + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + var body = await new StreamReader(response.Content).ReadToEndAsync(); + var arr = JObject.Parse(body)["Documents"] as JArray; + if (arr != null) + foreach (var item in arr) + results.Add((JObject)item); + } - results.Should().HaveCount(10); - } + results.Should().HaveCount(10); + } - // ── GROUP BY ──────────────────────────────────────────────────────── + // ── GROUP BY ──────────────────────────────────────────────────────── - [Fact] - public async Task GroupBy_ReturnsCorrectGroupCounts() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task GroupBy_ReturnsCorrectGroupCounts() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(3); + results.Should().HaveCount(3); - var pk0 = results.First(r => r["partitionKey"]?.ToString() == "pk-0"); - pk0["cnt"]!.Value().Should().Be(3); // items 3,6,9 + var pk0 = results.First(r => r["partitionKey"]?.ToString() == "pk-0"); + pk0["cnt"]!.Value().Should().Be(3); // items 3,6,9 - var pk1 = results.First(r => r["partitionKey"]?.ToString() == "pk-1"); - pk1["cnt"]!.Value().Should().Be(4); // items 1,4,7,10 + var pk1 = results.First(r => r["partitionKey"]?.ToString() == "pk-1"); + pk1["cnt"]!.Value().Should().Be(4); // items 1,4,7,10 - var pk2 = results.First(r => r["partitionKey"]?.ToString() == "pk-2"); - pk2["cnt"]!.Value().Should().Be(3); // items 2,5,8 - } + var pk2 = results.First(r => r["partitionKey"]?.ToString() == "pk-2"); + pk2["cnt"]!.Value().Should().Be(3); // items 2,5,8 + } - // ── TTL expired items filtered from queries ───────────────────────── + // ── TTL expired items filtered from queries ───────────────────────── - [Fact] - public async Task ExpiredItems_FilteredFromQueries() - { - var container = new InMemoryContainer("qpb-ttl", "/partitionKey") - { - DefaultTimeToLive = 1 - }; + [Fact] + public async Task ExpiredItems_FilteredFromQueries() + { + var container = new InMemoryContainer("qpb-ttl", "/partitionKey") + { + DefaultTimeToLive = 1 + }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, + new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "WillExpire", Value = 20 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "WillExpire", Value = 20 }, + new PartitionKey("pk")); - // Wait for items to expire - await Task.Delay(1500); + // Wait for items to expire + await Task.Delay(1500); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - // ── TTL expired items filtered from LINQ ──────────────────────────── + // ── TTL expired items filtered from LINQ ──────────────────────────── - [Fact] - public async Task ExpiredItems_FilteredFromLinq() - { - var container = new InMemoryContainer("qpb-ttl-linq", "/partitionKey") - { - DefaultTimeToLive = 1 - }; + [Fact] + public async Task ExpiredItems_FilteredFromLinq() + { + var container = new InMemoryContainer("qpb-ttl-linq", "/partitionKey") + { + DefaultTimeToLive = 1 + }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, + new PartitionKey("pk")); - await Task.Delay(1500); + await Task.Delay(1500); - var results = container.GetItemLinqQueryable().ToList(); + var results = container.GetItemLinqQueryable().ToList(); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - // ── TTL expired items filtered from null-queryText iterator ────────── + // ── TTL expired items filtered from null-queryText iterator ────────── - [Fact] - public async Task ExpiredItems_FilteredFromNullQueryTextIterator() - { - var container = new InMemoryContainer("qpb-ttl-null", "/partitionKey") - { - DefaultTimeToLive = 1 - }; + [Fact] + public async Task ExpiredItems_FilteredFromNullQueryTextIterator() + { + var container = new InMemoryContainer("qpb-ttl-null", "/partitionKey") + { + DefaultTimeToLive = 1 + }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, - new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Live", Value = 10 }, + new PartitionKey("pk")); - await Task.Delay(1500); + await Task.Delay(1500); - var iterator = container.GetItemQueryIterator(queryText: null); - var results = await DrainIterator(iterator); + var iterator = container.GetItemQueryIterator(queryText: null); + var results = await DrainIterator(iterator); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - // ── Hierarchical partition key prefix query ───────────────────────── + // ── Hierarchical partition key prefix query ───────────────────────── - [Fact] - public async Task HierarchicalPartitionKey_PrefixQuery_ReturnsMatchingItems() - { - var container = new InMemoryContainer("qpb-hpk", new[] { "/region", "/tenant" }); + [Fact] + public async Task HierarchicalPartitionKey_PrefixQuery_ReturnsMatchingItems() + { + var container = new InMemoryContainer("qpb-hpk", new[] { "/region", "/tenant" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", region = "US", tenant = "A", data = "one" }), - new PartitionKeyBuilder().Add("US").Add("A").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", region = "US", tenant = "B", data = "two" }), - new PartitionKeyBuilder().Add("US").Add("B").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", region = "EU", tenant = "A", data = "three" }), - new PartitionKeyBuilder().Add("EU").Add("A").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", region = "US", tenant = "A", data = "one" }), + new PartitionKeyBuilder().Add("US").Add("A").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", region = "US", tenant = "B", data = "two" }), + new PartitionKeyBuilder().Add("US").Add("B").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", region = "EU", tenant = "A", data = "three" }), + new PartitionKeyBuilder().Add("EU").Add("A").Build()); - // Query with prefix PK = "US" should return items 1 and 2 - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions - { - PartitionKey = new PartitionKeyBuilder().Add("US").Build() - }); + // Query with prefix PK = "US" should return items 1 and 2 + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions + { + PartitionKey = new PartitionKeyBuilder().Add("US").Build() + }); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); - } - - // ── JOIN query ────────────────────────────────────────────────────── + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); + } + + // ── JOIN query ────────────────────────────────────────────────────── - [Fact] - public async Task JoinQuery_ExpandsArrayCorrectly() - { - var container = await CreateSeededContainer(3); + [Fact] + public async Task JoinQuery_ExpandsArrayCorrectly() + { + var container = await CreateSeededContainer(3); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, t AS tag FROM c JOIN t IN c.tags")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, t AS tag FROM c JOIN t IN c.tags")); - var results = await DrainIterator(iterator); + var results = await DrainIterator(iterator); - // Each of 3 items has 2 tags → 6 expanded rows - results.Should().HaveCount(6); - } + // Each of 3 items has 2 tags → 6 expanded rows + results.Should().HaveCount(6); + } - // ── Aggregate query ──────────────────────────────────────────────── + // ── Aggregate query ──────────────────────────────────────────────── - [Fact] - public async Task AggregateQuery_ReturnsCorrectResults() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task AggregateQuery_ReturnsCorrectResults() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT COUNT(1) AS total, SUM(c.value) AS valueSum FROM c")); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT COUNT(1) AS total, SUM(c.value) AS valueSum FROM c")); - var results = await DrainIterator(iterator); - - results.Should().HaveCount(1); - results[0]["total"]!.Value().Should().Be(10); - results[0]["valueSum"]!.Value().Should().Be(550); // sum of 10+20+...+100 - } - - // ── Stream iterator with query ───────────────────────────────────── + var results = await DrainIterator(iterator); + + results.Should().HaveCount(1); + results[0]["total"]!.Value().Should().Be(10); + results[0]["valueSum"]!.Value().Should().Be(550); // sum of 10+20+...+100 + } + + // ── Stream iterator with query ───────────────────────────────────── - [Fact] - public async Task StreamIterator_WithQuery_ReturnsFilteredResults() - { - var container = await CreateSeededContainer(); + [Fact] + public async Task StreamIterator_WithQuery_ReturnsFilteredResults() + { + var container = await CreateSeededContainer(); - var iterator = container.GetItemQueryStreamIterator( - new QueryDefinition("SELECT * FROM c WHERE c.value > 80")); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - var body = await new StreamReader(response.Content).ReadToEndAsync(); - var arr = JObject.Parse(body)["Documents"] as JArray; - if (arr != null) - foreach (var item in arr) - results.Add((JObject)item); - } - - results.Should().HaveCount(2); // items with value 90, 100 - } - - // ── Mixed: WHERE + OFFSET + LIMIT ────────────────────────────────── - - [Fact] - public async Task WhereWithOffsetLimit_ReturnsCorrectPage() - { - var container = await CreateSeededContainer(); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.isActive = true ORDER BY c.value ASC OFFSET 1 LIMIT 2")); - - var results = await DrainIterator(iterator); - - results.Should().HaveCount(2); - results[0].Value.Should().Be(40); // skip item 2 (value=20), get items 4,6 - results[1].Value.Should().Be(60); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Helper - // ═══════════════════════════════════════════════════════════════════════════ - - private static async Task> DrainIterator(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } + var iterator = container.GetItemQueryStreamIterator( + new QueryDefinition("SELECT * FROM c WHERE c.value > 80")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + var body = await new StreamReader(response.Content).ReadToEndAsync(); + var arr = JObject.Parse(body)["Documents"] as JArray; + if (arr != null) + foreach (var item in arr) + results.Add((JObject)item); + } + + results.Should().HaveCount(2); // items with value 90, 100 + } + + // ── Mixed: WHERE + OFFSET + LIMIT ────────────────────────────────── + + [Fact] + public async Task WhereWithOffsetLimit_ReturnsCorrectPage() + { + var container = await CreateSeededContainer(); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.isActive = true ORDER BY c.value ASC OFFSET 1 LIMIT 2")); + + var results = await DrainIterator(iterator); + + results.Should().HaveCount(2); + results[0].Value.Should().Be(40); // skip item 2 (value=20), get items 4,6 + results[1].Value.Should().Be(60); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Helper + // ═══════════════════════════════════════════════════════════════════════════ + + private static async Task> DrainIterator(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanDeepDiveTests.cs index 185c7e7..3609ad6 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanDeepDiveTests.cs @@ -13,344 +13,349 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class QueryPlanDeepDiveTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly HttpClient _httpClient; - - public QueryPlanDeepDiveTests() - { - _handler = new FakeCosmosHandler(_container); - _httpClient = new HttpClient(_handler) - { - BaseAddress = new Uri("https://localhost:9999/") - }; - } - - public void Dispose() - { - _httpClient.Dispose(); - _handler.Dispose(); - } - - private async Task GetQueryPlanAsync(string sql) - { - var body = new JObject { ["query"] = sql }.ToString(); - var request = new HttpRequestMessage(HttpMethod.Post, - "dbs/fakeDb/colls/test-container/docs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); - request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); - - var response = await _httpClient.SendAsync(request); - var json = await response.Content.ReadAsStringAsync(); - return JObject.Parse(json); - } - - // ═══════════════════════════════════════════════════════════════ - // Phase 1: Bug-Exposing Tests (BUG-1 through BUG-4) - // ═══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_AggregateInsideNonAggregateFunction_DetectedByBypass() - { - // BUG-1: CONCAT wraps COUNT → COUNT should be detected - var plan = await GetQueryPlanAsync( - "SELECT c.cat, CONCAT(COUNT(1), ' items') AS label FROM c GROUP BY c.cat"); - var info = plan["queryInfo"]!; - - // GROUP BY bypass should activate — aggregates/groupBy cleared - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_AggregateInsideCoalesce_DetectedByBypass() - { - // BUG-2: COALESCE wraps COUNT → COUNT should be detected - var plan = await GetQueryPlanAsync( - "SELECT VALUE COALESCE(COUNT(1), 0) FROM c"); - var info = plan["queryInfo"]!; - - // VALUE aggregate bypass should activate — aggregates cleared - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_AggregateInsideIIF_DetectedByBypass() - { - // BUG-2: IIF wraps SUM → SUM should be detected - var plan = await GetQueryPlanAsync( - "SELECT VALUE IIF(true, SUM(c.val), 0) FROM c"); - var info = plan["queryInfo"]!; - - // VALUE aggregate bypass should activate - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_TwoAggregatesInSingleExpression_BypassActivates() - { - // BUG-3/BUG-5: COUNT + SUM in single SELECT field via binary expression - var plan = await GetQueryPlanAsync( - "SELECT COUNT(1) + SUM(c.val) AS combined FROM c"); - var info = plan["queryInfo"]!; - - // Multi-aggregate bypass should activate (aggregates.Count > 1) - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════ - // Phase 2: Missing Coverage Tests (N5-N23) - // ═══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_DistinctValueOrderByDescSameField_IsOrdered() - { - var plan = await GetQueryPlanAsync( - "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name DESC"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Ordered"); - } - - [Fact] - public async Task QueryPlan_DistinctValueOrderByMultipleFields_FirstFieldMatches() - { - var plan = await GetQueryPlanAsync( - "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name, c.age"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Ordered"); - } - - [Fact] - public async Task QueryPlan_OrderByCustomAlias_RewrittenQueryUsesAlias() - { - var plan = await GetQueryPlanAsync( - "SELECT * FROM root ORDER BY root.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("root"); - } - - [Fact] - public async Task QueryPlan_DistinctOrderBy_RewrittenQueryHasProjectedPayload() - { - var plan = await GetQueryPlanAsync( - "SELECT DISTINCT c.name FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - // Should have orderByItems and payload structure - rewritten.Should().Contain("orderByItems"); - } - - [Fact] - public async Task QueryPlan_OrderByPlusOffsetLimit_OrderByWins() - { - var plan = await GetQueryPlanAsync( - "SELECT * FROM c ORDER BY c.name OFFSET 5 LIMIT 10"); - var info = plan["queryInfo"]!; - - // OFFSET/LIMIT values should be captured - info["offset"]!.Value().Should().Be(5); - info["limit"]!.Value().Should().Be(10); - - // ORDER BY should be set - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - - // Rewritten query should use ORDER BY format - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("orderByItems"); - } - - [Fact] - public async Task QueryPlan_CountDistinct_VerifyFieldValues() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT c.status) FROM c"); - var info = plan["queryInfo"]!; - - var dCountInfo = info["dCountInfo"] as JObject; - dCountInfo.Should().NotBeNull("dCountInfo should be populated for COUNT(DISTINCT c.status)"); - dCountInfo!["dCountAlias"]!.ToString().Should().Be("$1"); - dCountInfo["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); - } - - [Fact] - public async Task QueryPlan_CountDistinct_NestedProperty() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT c.address.city) FROM c"); - var info = plan["queryInfo"]!; - - var dCountInfo = info["dCountInfo"] as JObject; - dCountInfo.Should().NotBeNull("dCountInfo should be populated for nested property"); - dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("address.city"); - } - - [Fact] - public async Task QueryPlan_CountDistinct_CustomAlias() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT root.status) FROM root"); - var info = plan["queryInfo"]!; - - var dCountInfo = info["dCountInfo"] as JObject; - dCountInfo.Should().NotBeNull("dCountInfo should be populated for custom alias"); - dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); - } - - [Fact] - public async Task QueryPlan_SingleAggregateNoGroupByNoValue_PreservedInPlan() - { - var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); - var info = plan["queryInfo"]!; - - var aggs = info["aggregates"]!.ToObject()!; - aggs.Should().Contain("Count"); - } - - [Fact] - public async Task QueryPlan_SingleAggregateWithAlias_MappedCorrectly() - { - var plan = await GetQueryPlanAsync("SELECT COUNT(1) AS cnt FROM c"); - var info = plan["queryInfo"]!; - - var aggs = info["aggregates"]!.ToObject()!; - aggs.Should().Contain("Count"); - - var aliasMap = info["groupByAliasToAggregateType"]!; - aliasMap["cnt"]!.ToString().Should().Be("Count"); - } - - [Fact] - public async Task QueryPlan_OrderByTopCombo_TopInQueryInfoAndNotInRewritten() - { - var plan = await GetQueryPlanAsync( - "SELECT TOP 5 * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - info["top"]!.Value().Should().Be(5); - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("orderByItems"); - } - - [Fact] - public async Task QueryPlan_GroupByWithOrderBy_BypassClearsOrderBy() - { - var plan = await GetQueryPlanAsync( - "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status ORDER BY c.status"); - var info = plan["queryInfo"]!; - - // GROUP BY bypass clears both aggregates and ORDER BY - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeFalse(); - info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_EmptySqlString_HandledGracefully() - { - var plan = await GetQueryPlanAsync(""); - var info = plan["queryInfo"]!; - - info.Should().NotBeNull(); - info["distinctType"]!.ToString().Should().Be("None"); - } - - [Fact] - public async Task QueryPlan_MultipleCountDistinct_OnlyFirstCaptured() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT c.name), COUNT(DISTINCT c.status) FROM c"); - var info = plan["queryInfo"]!; - - // Regex only captures first COUNT(DISTINCT) — verify at least that works - var dCountInfo = info["dCountInfo"] as JObject; - dCountInfo.Should().NotBeNull("dCountInfo should be populated for first COUNT(DISTINCT)"); - dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("name"); - } - - [Fact] - public async Task QueryPlan_SelectValueDistinctOrderBySameField_AllFlags() - { - var plan = await GetQueryPlanAsync( - "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Ordered"); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_OrderByExpressionToString_NestedFunction() - { - var plan = await GetQueryPlanAsync( - "SELECT * FROM c ORDER BY UPPER(LOWER(c.name)) ASC"); - var info = plan["queryInfo"]!; - - var orderByExprs = info["orderByExpressions"]!.ToObject()!; - orderByExprs.Should().HaveCount(1); - // Should contain the stringified nested function expression - orderByExprs[0].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task QueryPlan_ValueAggregateBypass_SingleAggregate() - { - var plan = await GetQueryPlanAsync("SELECT VALUE SUM(c.val) FROM c"); - var info = plan["queryInfo"]!; - - // VALUE aggregate bypass clears aggregates - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_ValueNonAggregate_NotBypassed() - { - var plan = await GetQueryPlanAsync("SELECT VALUE c.name FROM c"); - var info = plan["queryInfo"]!; - - // No aggregates → no bypass needed - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - // ═══════════════════════════════════════════════════════════════ - // Phase 3: Divergent Behavior Tests (skipped + sister pairs) - // ═══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_CountDistinct_ComplexExpression_ShouldPopulateDCountInfo() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT UPPER(c.name)) FROM c"); - var info = plan["queryInfo"]!; - - // dCountInfo should be populated for complex expressions - info["dCountInfo"].Should().NotBeNull(); - } - - [Fact] - public async Task QueryPlan_MultipleCountDistinct_AllShouldBeDetected() - { - var plan = await GetQueryPlanAsync( - "SELECT COUNT(DISTINCT c.name), COUNT(DISTINCT c.status) FROM c"); - var info = plan["queryInfo"]!; - - // Ideal: All COUNT(DISTINCT) expressions should be captured - // This would need an array of dCountInfo objects - info["dCountInfo"].Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly HttpClient _httpClient; + + public QueryPlanDeepDiveTests() + { + _handler = new FakeCosmosHandler(_container); + _httpClient = new HttpClient(_handler) + { + BaseAddress = new Uri("https://localhost:9999/") + }; + } + + public void Dispose() + { + _httpClient.Dispose(); + _handler.Dispose(); + } + + private async Task GetQueryPlanAsync(string sql) + { + var body = new JObject { ["query"] = sql }.ToString(); + var request = new HttpRequestMessage(HttpMethod.Post, + "dbs/fakeDb/colls/test-container/docs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); + request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); + + var response = await _httpClient.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + return JObject.Parse(json); + } + + // ═══════════════════════════════════════════════════════════════ + // Phase 1: Bug-Exposing Tests (BUG-1 through BUG-4) + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_AggregateInsideNonAggregateFunction_DetectedByBypass() + { + // BUG-1: CONCAT wraps COUNT → COUNT should be detected + var plan = await GetQueryPlanAsync( + "SELECT c.cat, CONCAT(COUNT(1), ' items') AS label FROM c GROUP BY c.cat"); + var info = plan["queryInfo"]!; + + // GROUP BY bypass should activate — aggregates/groupBy cleared + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_AggregateInsideCoalesce_DetectedByBypass() + { + // BUG-2: COALESCE wraps COUNT → COUNT should be detected + var plan = await GetQueryPlanAsync( + "SELECT VALUE COALESCE(COUNT(1), 0) FROM c"); + var info = plan["queryInfo"]!; + + // VALUE aggregate bypass should activate — aggregates cleared + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_AggregateInsideIIF_DetectedByBypass() + { + // BUG-2: IIF wraps SUM → SUM should be detected + var plan = await GetQueryPlanAsync( + "SELECT VALUE IIF(true, SUM(c.val), 0) FROM c"); + var info = plan["queryInfo"]!; + + // VALUE aggregate bypass should activate + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_TwoAggregatesInSingleExpression_BypassActivates() + { + // BUG-3/BUG-5: COUNT + SUM in single SELECT field via binary expression + var plan = await GetQueryPlanAsync( + "SELECT COUNT(1) + SUM(c.val) AS combined FROM c"); + var info = plan["queryInfo"]!; + + // Multi-aggregate bypass should activate (aggregates.Count > 1) + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════ + // Phase 2: Missing Coverage Tests (N5-N23) + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_DistinctValueOrderByDescSameField_IsOrdered() + { + var plan = await GetQueryPlanAsync( + "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name DESC"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Ordered"); + } + + [Fact] + public async Task QueryPlan_DistinctValueOrderByMultipleFields_FirstFieldMatches() + { + var plan = await GetQueryPlanAsync( + "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name, c.age"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Ordered"); + } + + [Fact] + public async Task QueryPlan_OrderByCustomAlias_RewrittenQueryUsesAlias() + { + var plan = await GetQueryPlanAsync( + "SELECT * FROM root ORDER BY root.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("root"); + } + + [Fact] + public async Task QueryPlan_DistinctOrderBy_RewrittenQueryHasProjectedPayload() + { + var plan = await GetQueryPlanAsync( + "SELECT DISTINCT c.name FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + // Should have orderByItems and payload structure + rewritten.Should().Contain("orderByItems"); + } + + [Fact] + public async Task QueryPlan_OrderByPlusOffsetLimit_OrderByWins() + { + var plan = await GetQueryPlanAsync( + "SELECT * FROM c ORDER BY c.name OFFSET 5 LIMIT 10"); + var info = plan["queryInfo"]!; + + // OFFSET/LIMIT values should be captured + info["offset"]!.Value().Should().Be(5); + info["limit"]!.Value().Should().Be(10); + + // ORDER BY should be set + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + + // Rewritten query should use ORDER BY format + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("orderByItems"); + } + + [Fact] + public async Task QueryPlan_CountDistinct_VerifyFieldValues() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT c.status) FROM c"); + var info = plan["queryInfo"]!; + + var dCountInfo = info["dCountInfo"] as JObject; + dCountInfo.Should().NotBeNull("dCountInfo should be populated for COUNT(DISTINCT c.status)"); + dCountInfo!["dCountAlias"]!.ToString().Should().Be("$1"); + dCountInfo["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); + } + + [Fact] + public async Task QueryPlan_CountDistinct_NestedProperty() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT c.address.city) FROM c"); + var info = plan["queryInfo"]!; + + var dCountInfo = info["dCountInfo"] as JObject; + dCountInfo.Should().NotBeNull("dCountInfo should be populated for nested property"); + dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("address.city"); + } + + [Fact] + public async Task QueryPlan_CountDistinct_CustomAlias() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT root.status) FROM root"); + var info = plan["queryInfo"]!; + + var dCountInfo = info["dCountInfo"] as JObject; + dCountInfo.Should().NotBeNull("dCountInfo should be populated for custom alias"); + dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("status"); + } + + [Fact] + public async Task QueryPlan_SingleAggregateNoGroupByNoValue_PreservedInPlan() + { + // Single-aggregate projection bypass: aggregates suppressed (Linux compatibility). + // SELECT COUNT(1) FROM c uses isSingleAggregateProjectionBypass so the SDK's + // AggregateQueryPipelineStage doesn't crash with a missing 'payload' field. + var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); + var info = plan["queryInfo"]!; + + var aggs = info["aggregates"]!.ToObject()!; + aggs.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_SingleAggregateWithAlias_MappedCorrectly() + { + // Single-aggregate projection bypass: aggregates and alias map suppressed (Linux compatibility). + // SELECT COUNT(1) AS cnt FROM c uses isSingleAggregateProjectionBypass. + var plan = await GetQueryPlanAsync("SELECT COUNT(1) AS cnt FROM c"); + var info = plan["queryInfo"]!; + + var aggs = info["aggregates"]!.ToObject()!; + aggs.Should().BeEmpty(); + + var aliasMap = info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_OrderByTopCombo_TopInQueryInfoAndNotInRewritten() + { + var plan = await GetQueryPlanAsync( + "SELECT TOP 5 * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + info["top"]!.Value().Should().Be(5); + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("orderByItems"); + } + + [Fact] + public async Task QueryPlan_GroupByWithOrderBy_BypassClearsOrderBy() + { + var plan = await GetQueryPlanAsync( + "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status ORDER BY c.status"); + var info = plan["queryInfo"]!; + + // GROUP BY bypass clears both aggregates and ORDER BY + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeFalse(); + info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_EmptySqlString_HandledGracefully() + { + var plan = await GetQueryPlanAsync(""); + var info = plan["queryInfo"]!; + + info.Should().NotBeNull(); + info["distinctType"]!.ToString().Should().Be("None"); + } + + [Fact] + public async Task QueryPlan_MultipleCountDistinct_OnlyFirstCaptured() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT c.name), COUNT(DISTINCT c.status) FROM c"); + var info = plan["queryInfo"]!; + + // Regex only captures first COUNT(DISTINCT) — verify at least that works + var dCountInfo = info["dCountInfo"] as JObject; + dCountInfo.Should().NotBeNull("dCountInfo should be populated for first COUNT(DISTINCT)"); + dCountInfo!["dCountExpressionBase"]!["propertyPath"]!.ToString().Should().Be("name"); + } + + [Fact] + public async Task QueryPlan_SelectValueDistinctOrderBySameField_AllFlags() + { + var plan = await GetQueryPlanAsync( + "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Ordered"); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_OrderByExpressionToString_NestedFunction() + { + var plan = await GetQueryPlanAsync( + "SELECT * FROM c ORDER BY UPPER(LOWER(c.name)) ASC"); + var info = plan["queryInfo"]!; + + var orderByExprs = info["orderByExpressions"]!.ToObject()!; + orderByExprs.Should().HaveCount(1); + // Should contain the stringified nested function expression + orderByExprs[0].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task QueryPlan_ValueAggregateBypass_SingleAggregate() + { + var plan = await GetQueryPlanAsync("SELECT VALUE SUM(c.val) FROM c"); + var info = plan["queryInfo"]!; + + // VALUE aggregate bypass clears aggregates + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_ValueNonAggregate_NotBypassed() + { + var plan = await GetQueryPlanAsync("SELECT VALUE c.name FROM c"); + var info = plan["queryInfo"]!; + + // No aggregates → no bypass needed + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + // ═══════════════════════════════════════════════════════════════ + // Phase 3: Divergent Behavior Tests (skipped + sister pairs) + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_CountDistinct_ComplexExpression_ShouldPopulateDCountInfo() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT UPPER(c.name)) FROM c"); + var info = plan["queryInfo"]!; + + // dCountInfo should be populated for complex expressions + info["dCountInfo"].Should().NotBeNull(); + } + + [Fact] + public async Task QueryPlan_MultipleCountDistinct_AllShouldBeDetected() + { + var plan = await GetQueryPlanAsync( + "SELECT COUNT(DISTINCT c.name), COUNT(DISTINCT c.status) FROM c"); + var info = plan["queryInfo"]!; + + // Ideal: All COUNT(DISTINCT) expressions should be captured + // This would need an array of dCountInfo objects + info["dCountInfo"].Should().NotBeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanTests.cs index 0f6573b..86b46ca 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryPlanTests.cs @@ -16,1093 +16,1102 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class QueryPlanTests : IDisposable { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly HttpClient _httpClient; - - public QueryPlanTests() - { - _handler = new FakeCosmosHandler(_container); - _httpClient = new HttpClient(_handler) - { - BaseAddress = new Uri("https://localhost:9999/") - }; - } - - public void Dispose() - { - _httpClient.Dispose(); - _handler.Dispose(); - } - - private async Task<(JObject Plan, HttpResponseMessage Response)> GetQueryPlanWithResponseAsync(string sql) - { - var body = new JObject { ["query"] = sql }.ToString(); - var request = new HttpRequestMessage(HttpMethod.Post, - "dbs/fakeDb/colls/test-container/docs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); - request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); - - var response = await _httpClient.SendAsync(request); - var json = await response.Content.ReadAsStringAsync(); - return (JObject.Parse(json), response); - } - - private async Task GetQueryPlanAsync(string sql) - { - var (plan, _) = await GetQueryPlanWithResponseAsync(sql); - return plan; - } - - [Fact] - public async Task QueryPlan_SimpleSelect_HasNoSpecialFlags() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("None"); - info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); - ((bool)info["hasSelectValue"]!).Should().BeFalse(); - info["top"]!.Type.Should().Be(JTokenType.Null); - info["offset"]!.Type.Should().Be(JTokenType.Null); - info["limit"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task QueryPlan_OrderByAscending_SetsOrderByMetadata() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().HaveCount(1); - orderBy[0]!.ToString().Should().Be("Ascending"); - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - expressions[0]!.ToString().Should().Be("c.name"); - - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_OrderByDescending_SetsDescendingFlag() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.value DESC"); - var info = plan["queryInfo"]!; - - var orderBy = (JArray)info["orderBy"]!; - orderBy[0]!.ToString().Should().Be("Descending"); - } - - [Fact] - public async Task QueryPlan_MultipleOrderBy_SetsAllFields() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); - var info = plan["queryInfo"]!; - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().HaveCount(2); - orderBy[0]!.ToString().Should().Be("Ascending"); - orderBy[1]!.ToString().Should().Be("Descending"); - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(2); - } - - [Fact] - public async Task QueryPlan_Top_SetsTopField() - { - var plan = await GetQueryPlanAsync("SELECT TOP 10 * FROM c"); - var info = plan["queryInfo"]!; - - ((int)info["top"]!).Should().Be(10); - } - - [Fact] - public async Task QueryPlan_OffsetLimit_SetsBothFields() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 5 LIMIT 10"); - var info = plan["queryInfo"]!; - - ((int)info["offset"]!).Should().Be(5); - ((int)info["limit"]!).Should().Be(10); - } - - [Fact] - public async Task QueryPlan_Distinct_SetsDistinctTypeUnordered() - { - var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - } - - [Fact] - public async Task QueryPlan_DistinctWithOrderBy_SetsDistinctTypeUnordered() - { - // Non-VALUE DISTINCT + ORDER BY → Unordered (SDK uses hash dedup) - var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - } - - [Fact] - public async Task QueryPlan_CountAggregate_DetectsCount() - { - var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().Contain(t => t.ToString() == "Count"); - } - - [Fact] - public async Task QueryPlan_SumAggregate_DetectsSum() - { - var plan = await GetQueryPlanAsync("SELECT SUM(c.value) FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().Contain(t => t.ToString() == "Sum"); - } - - [Fact] - public async Task QueryPlan_MinMaxAvg_DetectsAll() - { - // Multi-aggregate bypass: aggregates and alias map suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT MIN(c.value) AS minVal, MAX(c.value) AS maxVal, AVG(c.value) AS avgVal FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - - var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; - aliasMap.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_GroupBy_SetsGroupByExpressions() - { - // GROUP BY bypass: pipeline flags are suppressed so the SDK - // doesn't activate GroupByQueryPipelineStage on Linux. - var plan = await GetQueryPlanAsync( - "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status"); - var info = plan["queryInfo"]!; - - var groupBy = (JArray)info["groupByExpressions"]!; - groupBy.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_SelectValue_SetsHasSelectValue() - { - var plan = await GetQueryPlanAsync("SELECT VALUE c.name FROM c"); - var info = plan["queryInfo"]!; - - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_WhereClause_DoesNotAffectFlags() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.value > 10"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("None"); - info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_QueryRanges_CoversFullRange() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c"); - - var ranges = (JArray)plan["queryRanges"]!; - ranges.Should().HaveCount(1); - ranges[0]!["min"]!.ToString().Should().Be(""); - ranges[0]!["max"]!.ToString().Should().Be("FF"); - ((bool)ranges[0]!["isMinInclusive"]!).Should().BeTrue(); - ((bool)ranges[0]!["isMaxInclusive"]!).Should().BeFalse(); - } - - [Fact] - public async Task QueryPlan_ComplexQuery_SetsAllRelevantFlags() - { - // GROUP BY bypass: groupBy, aggregates, orderBy, and alias map are suppressed - // (Linux compatibility). DISTINCT and TOP are preserved. - var plan = await GetQueryPlanAsync( - "SELECT DISTINCT TOP 5 c.category, SUM(c.value) AS total " + - "FROM c WHERE c.isActive = true " + - "GROUP BY c.category " + - "ORDER BY c.category ASC"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - ((int)info["top"]!).Should().Be(5); - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().BeEmpty(); - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - - var groupBy = (JArray)info["groupByExpressions"]!; - groupBy.Should().BeEmpty(); - - var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; - aliasMap.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_UnparsableQuery_StillReturnsValidPlan() - { - // Even if the parser can't handle the query, the plan should be valid - // with sensible defaults so the SDK doesn't crash. - var plan = await GetQueryPlanAsync("SELECT ??? FROM c"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("None"); - info["rewrittenQuery"]!.ToString().Should().NotBeNullOrEmpty(); - plan["queryRanges"]!.Should().BeOfType().Which.Should().NotBeEmpty(); - } - - // ────────────────────────────────────────────────────────────── - // A. ORDER BY edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_OrderByWithoutDirection_DefaultsToAscending() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().HaveCount(1); - orderBy[0]!.ToString().Should().Be("Ascending"); - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - expressions[0]!.ToString().Should().Be("c.name"); - } - - [Fact] - public async Task QueryPlan_OrderByNestedProperty_SetsExpression() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.address.city ASC"); - var info = plan["queryInfo"]!; - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - expressions[0]!.ToString().Should().Be("c.address.city"); - - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_OrderByFunctionExpression_SetsExpression() - { - // When ORDER BY uses a function expression, the parser creates OrderByField - // with Field=null and Expression=FunctionCallExpression. - // The query plan should use ExprToString(field.Expression) as the fallback. - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); - var info = plan["queryInfo"]!; - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - // Should not be null/empty — should contain the stringified expression - expressions[0]!.Type.Should().NotBe(JTokenType.Null); - expressions[0]!.ToString().Should().NotBeNullOrEmpty(); - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().HaveCount(1); - orderBy[0]!.ToString().Should().Be("Ascending"); - } - - [Fact] - public async Task QueryPlan_NoOrderBy_HasNonStreamingOrderByIsFalse() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.value > 10"); - var info = plan["queryInfo"]!; - - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeFalse(); - } - - [Fact] - public async Task QueryPlan_OrderBy_RewrittenQueryHasCorrectStructure() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - // The SDK expects: SELECT c._rid, [{"item": c.name}] AS orderByItems, c AS payload FROM c ORDER BY c.name ASC - rewritten.Should().Contain("_rid"); - rewritten.Should().Contain("orderByItems"); - rewritten.Should().Contain("payload"); - rewritten.Should().Contain("ORDER BY"); - rewritten.Should().Contain("c.name"); - } - - [Fact] - public async Task QueryPlan_MultipleOrderBy_RewrittenQueryHasAllFields() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC, c.age DESC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("c.name"); - rewritten.Should().Contain("c.age"); - rewritten.Should().Contain("orderByItems"); - rewritten.Should().Contain("ASC"); - rewritten.Should().Contain("DESC"); - } - - [Fact] - public async Task QueryPlan_OrderByWithWhere_RewrittenQueryIncludesWhere() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.active = true ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("WHERE"); - rewritten.Should().Contain("ORDER BY"); - rewritten.Should().Contain("c.name"); - } - - // ────────────────────────────────────────────────────────────── - // B. DISTINCT edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_DistinctValue_SetsBothFlags() - { - var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - // ────────────────────────────────────────────────────────────── - // C. Aggregate edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_AggregateInArithmeticExpression_DetectsAggregate() - { - // GROUP BY bypass: aggregates and alias map suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.category, SUM(c.value) * 2 AS total FROM c GROUP BY c.category"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - - var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; - aliasMap.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_DuplicateAggregateType_DeduplicatesInArray() - { - // Multi-aggregate bypass: aggregates and alias map suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT SUM(c.price) AS sumPrice, SUM(c.quantity) AS sumQty FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - - var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; - aliasMap.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_CountWithoutAlias_StillDetectsAggregate() - { - var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().Contain(t => t.ToString() == "Count"); - } - - [Fact] - public async Task QueryPlan_AggregateFunctionCaseInsensitive_Detected() - { - var plan = await GetQueryPlanAsync("SELECT count(1) AS cnt FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().Contain(t => t.ToString() == "Count"); - } - - [Fact] - public async Task QueryPlan_NonAggregateFunction_NotInAggregates() - { - var plan = await GetQueryPlanAsync("SELECT UPPER(c.name) AS upperName FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - // ────────────────────────────────────────────────────────────── - // D. GROUP BY edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_MultipleGroupByFields_SetsAll() - { - // GROUP BY bypass: expressions suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.status, c.region, COUNT(1) AS cnt FROM c GROUP BY c.status, c.region"); - var info = plan["queryInfo"]!; - - var groupBy = (JArray)info["groupByExpressions"]!; - groupBy.Should().BeEmpty(); - } - - // ────────────────────────────────────────────────────────────── - // E. SELECT VALUE + aggregate combination - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_SelectValueWithAggregate_SetsBothFlags() - { - var plan = await GetQueryPlanAsync("SELECT VALUE COUNT(1) FROM c"); - var info = plan["queryInfo"]!; - - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - - // Aggregates are cleared from the query plan for VALUE aggregate queries - // so the SDK doesn't activate AggregateQueryPipelineStage (which fails on Linux). - // The container computes the aggregate directly and returns the raw result. - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - // ────────────────────────────────────────────────────────────── - // F. Rewritten query edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_NonOrderByQuery_RewrittenQueryIsOriginalSql() - { - const string sql = "SELECT * FROM c WHERE c.value > 10"; - var plan = await GetQueryPlanAsync(sql); - var info = plan["queryInfo"]!; - - info["rewrittenQuery"]!.ToString().Should().Be(sql); - } - - // ────────────────────────────────────────────────────────────── - // G. Response structure - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_ResponseVersion_IsTwo() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c"); - - ((int)plan["partitionedQueryExecutionInfoVersion"]!).Should().Be(2); - } - - [Fact] - public async Task QueryPlan_ResponseStatusCode_Is200() - { - var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task QueryPlan_SimpleSelect_AllDefaultFieldsPresent() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c"); - var info = plan["queryInfo"]!; - - // Verify every expected key exists in queryInfo - info["distinctType"].Should().NotBeNull(); - info["top"].Should().NotBeNull(); // JToken exists, value is null - info["offset"].Should().NotBeNull(); - info["limit"].Should().NotBeNull(); - info["orderBy"].Should().NotBeNull(); - info["orderByExpressions"].Should().NotBeNull(); - info["groupByExpressions"].Should().NotBeNull(); - info["groupByAliases"].Should().NotBeNull(); - info["aggregates"].Should().NotBeNull(); - info["groupByAliasToAggregateType"].Should().NotBeNull(); - info["rewrittenQuery"].Should().NotBeNull(); - info["hasSelectValue"].Should().NotBeNull(); - info["hasNonStreamingOrderBy"].Should().NotBeNull(); - - // Top-level structure - plan["partitionedQueryExecutionInfoVersion"].Should().NotBeNull(); - plan["queryRanges"].Should().NotBeNull(); - } - - // ────────────────────────────────────────────────────────────── - // H. General edge cases - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task QueryPlan_QueryWithParameters_ReturnsValidPlan() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name = @name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("None"); - info["rewrittenQuery"]!.ToString().Should().NotBeNullOrEmpty(); - plan["queryRanges"]!.Should().BeOfType().Which.Should().NotBeEmpty(); - } - - [Fact] - public async Task QueryPlan_TopZero_SetsTopToZero() - { - var plan = await GetQueryPlanAsync("SELECT TOP 0 * FROM c"); - var info = plan["queryInfo"]!; - - ((int)info["top"]!).Should().Be(0); - } - - [Fact] - public async Task QueryPlan_SelectSpecificFields_NoAggregates() - { - var plan = await GetQueryPlanAsync("SELECT c.name, c.age FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_JoinQuery_DoesNotAffectOrderByOrAggregates() - { - var plan = await GetQueryPlanAsync( - "SELECT t.name FROM c JOIN t IN c.tags"); - var info = plan["queryInfo"]!; - - info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); - info["distinctType"]!.ToString().Should().Be("None"); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: DISTINCT Edge Cases (A1-A4) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_DistinctValueWithOrderByOnSameField_SetsOrdered() - { - // True "Ordered" case: SELECT DISTINCT VALUE ORDER BY - var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Ordered"); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_DistinctValueWithOrderByOnDifferentField_SetsUnordered() - { - var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c ORDER BY c.age"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - } - - [Fact] - public async Task QueryPlan_DistinctNonValueWithOrderBy_SetsUnordered() - { - // Non-VALUE DISTINCT + ORDER BY = Unordered - var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c ORDER BY c.name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - } - - [Fact] - public async Task QueryPlan_DistinctWithMultipleFields_SetsUnordered() - { - var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name, c.age FROM c"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Unordered"); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: ORDER BY Edge Cases (B1-B5) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_OrderByWithArrayIndex_SetsExpression() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.tags[0] ASC"); - var info = plan["queryInfo"]!; - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - expressions[0]!.ToString().Should().Contain("tags"); - } - - [Fact] - public async Task QueryPlan_OrderByWithThreeFields_SetsAllFields() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.a ASC, c.b DESC, c.c ASC"); - var info = plan["queryInfo"]!; - - var orderBy = (JArray)info["orderBy"]!; - orderBy.Should().HaveCount(3); - orderBy[0]!.ToString().Should().Be("Ascending"); - orderBy[1]!.ToString().Should().Be("Descending"); - orderBy[2]!.ToString().Should().Be("Ascending"); - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(3); - } - - [Fact] - public async Task QueryPlan_OrderByWithSystemProperty_SetsExpression() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c._ts DESC"); - var info = plan["queryInfo"]!; - - var expressions = (JArray)info["orderByExpressions"]!; - expressions.Should().HaveCount(1); - expressions[0]!.ToString().Should().Be("c._ts"); - } - - [Fact] - public async Task QueryPlan_OrderByWithTop_RewrittenQueryDoesNotIncludeTop() - { - var plan = await GetQueryPlanAsync("SELECT TOP 5 * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("ORDER BY"); - rewritten.Should().NotContain("TOP 5"); - } - - [Fact] - public async Task QueryPlan_OrderByWithDistinct_RewrittenQueryStructureCorrect() - { - var plan = await GetQueryPlanAsync("SELECT DISTINCT * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("orderByItems"); - rewritten.Should().Contain("ORDER BY"); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: GROUP BY Edge Cases (C1-C4) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_GroupBy_PopulatesGroupByAliases() - { - // GROUP BY bypass: aliases suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status"); - var info = plan["queryInfo"]!; - - var aliases = (JArray)info["groupByAliases"]!; - aliases.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_GroupByNestedProperty_SetsExpression() - { - // GROUP BY bypass: expressions suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.address.city, COUNT(1) AS cnt FROM c GROUP BY c.address.city"); - var info = plan["queryInfo"]!; - - var groupBy = (JArray)info["groupByExpressions"]!; - groupBy.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_GroupByWithHaving_SetsGroupByExpressions() - { - // GROUP BY bypass: expressions and aggregates suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status HAVING COUNT(1) > 1"); - var info = plan["queryInfo"]!; - - var groupBy = (JArray)info["groupByExpressions"]!; - groupBy.Should().BeEmpty(); - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_GroupByMultipleWithAliases_PopulatesAllAliases() - { - // GROUP BY bypass: aliases suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.status AS s, c.region AS r, COUNT(1) AS cnt FROM c GROUP BY c.status, c.region"); - var info = plan["queryInfo"]!; - - var aliases = (JArray)info["groupByAliases"]!; - aliases.Should().BeEmpty(); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Aggregate Edge Cases (D1-D4) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_AggregateInBinaryExpression_MapsAlias() - { - // GROUP BY bypass: aggregates and alias map suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.category, SUM(c.value) * 2 AS total FROM c GROUP BY c.category"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - - var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; - aliasMap.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_MultipleAggregatesWithGroupBy_AllDetected() - { - // GROUP BY bypass: aggregates suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync( - "SELECT c.cat, COUNT(1) AS cnt, AVG(c.val) AS avg FROM c GROUP BY c.cat"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_AggregateWithoutGroupBy_StillDetected() - { - // Multi-aggregate bypass: aggregates suppressed (Linux compatibility). - var plan = await GetQueryPlanAsync("SELECT COUNT(1) AS cnt, SUM(c.val) AS total FROM c"); - var info = plan["queryInfo"]!; - - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_CountDistinct_SetsDCountInfo() - { - var plan = await GetQueryPlanAsync("SELECT COUNT(DISTINCT c.name) FROM c"); - var info = plan["queryInfo"]!; - info["dCountInfo"].Should().NotBeNull(); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: TOP / OFFSET / LIMIT Edge Cases (E1-E5) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_LargeTopValue_SetsCorrectly() - { - var plan = await GetQueryPlanAsync("SELECT TOP 1000000 * FROM c"); - var info = plan["queryInfo"]!; - - ((int)info["top"]!).Should().Be(1000000); - } - - [Fact] - public async Task QueryPlan_OffsetZero_SetsCorrectly() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 0 LIMIT 10"); - var info = plan["queryInfo"]!; - - ((int)info["offset"]!).Should().Be(0); - ((int)info["limit"]!).Should().Be(10); - } - - [Fact] - public async Task QueryPlan_OffsetLimit_RewrittenQueryStripsClause() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 5 LIMIT 10"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().NotContainEquivalentOf("OFFSET"); - rewritten.Should().NotContainEquivalentOf("LIMIT"); - rewritten.Should().Contain("SELECT"); - } - - [Fact] - public async Task QueryPlan_TopWithOrderBy_TopNotInRewrittenQuery() - { - var plan = await GetQueryPlanAsync("SELECT TOP 10 * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - ((int)info["top"]!).Should().Be(10); - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("ORDER BY"); - rewritten.Should().NotContain("TOP 10"); - } - - [Fact] - public async Task QueryPlan_OffsetLimitWithWhere_RewrittenQueryPreservesWhere() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.active = true OFFSET 5 LIMIT 10"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("WHERE"); - rewritten.Should().NotContainEquivalentOf("OFFSET"); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: SELECT VALUE Edge Cases (F1-F3) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_SelectValueWithExpression_SetsFlag() - { - var plan = await GetQueryPlanAsync("SELECT VALUE CONCAT(c.firstName, ' ', c.lastName) FROM c"); - var info = plan["queryInfo"]!; - - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_SelectValueWithObjectLiteral_SetsFlag() - { - var plan = await GetQueryPlanAsync("SELECT VALUE { name: c.name, age: c.age } FROM c"); - var info = plan["queryInfo"]!; - - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - } - - [Fact] - public async Task QueryPlan_SelectValueCount_SetsBothFlags() - { - var plan = await GetQueryPlanAsync("SELECT VALUE COUNT(1) FROM c"); - var info = plan["queryInfo"]!; - - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - // Aggregates bypassed for VALUE aggregate queries (container computes the result) - var aggregates = (JArray)info["aggregates"]!; - aggregates.Should().BeEmpty(); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Rewritten Query Edge Cases (G1-G3) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_OrderBy_RewrittenQueryUsesCorrectAlias() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("c._rid"); - rewritten.Should().Contain("c AS payload"); - } - - [Fact] - public async Task QueryPlan_OrderBy_RewrittenQueryWithJoin() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c JOIN t IN c.tags ORDER BY c.name ASC"); - var info = plan["queryInfo"]!; - - var rewritten = info["rewrittenQuery"]!.ToString(); - rewritten.Should().Contain("orderByItems"); - rewritten.Should().Contain("ORDER BY"); - } - - [Fact] - public async Task QueryPlan_DistinctQuery_RewrittenQueryIsOriginalSql() - { - const string sql = "SELECT DISTINCT c.name FROM c"; - var plan = await GetQueryPlanAsync(sql); - var info = plan["queryInfo"]!; - - info["rewrittenQuery"]!.ToString().Should().Be(sql); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Response Structure Edge Cases (H1-H4) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_ResponseContentType_IsJson() - { - var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); - - response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); - } - - [Fact] - public async Task QueryPlan_EmptyQueryBody_ReturnsValidPlan() - { - // Send a request with JSON body that has no query field - var body = new JObject().ToString(); - var request = new HttpRequestMessage(HttpMethod.Post, - "dbs/fakeDb/colls/test-container/docs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); - request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); - - var response = await _httpClient.SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var json = JObject.Parse(await response.Content.ReadAsStringAsync()); - json["queryInfo"].Should().NotBeNull(); - } - - [Fact] - public async Task QueryPlan_MissingQueryField_FallsBackToSelectAll() - { - var body = new JObject { ["parameters"] = new JArray() }.ToString(); - var request = new HttpRequestMessage(HttpMethod.Post, - "dbs/fakeDb/colls/test-container/docs") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); - request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); - - var response = await _httpClient.SendAsync(request); - var json = JObject.Parse(await response.Content.ReadAsStringAsync()); - var info = json["queryInfo"]!; - - // Falls back to "SELECT * FROM c" which has no special flags - info["distinctType"]!.ToString().Should().Be("None"); - } - - [Fact] - public async Task QueryPlan_ResponseHasRequestChargeHeader() - { - var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); - - // Query plan requests typically include a charge header - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Complex Patterns (I1-I4) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_EXISTS_Subquery_NoAggregateFlags() - { - var plan = await GetQueryPlanAsync( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'urgent')"); - var info = plan["queryInfo"]!; - - ((JArray)info["aggregates"]!).Should().BeEmpty(); - ((JArray)info["orderBy"]!).Should().BeEmpty(); - } - - [Fact] - public async Task QueryPlan_InClause_NoAggregateFlags() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.status IN ('active', 'pending')"); - var info = plan["queryInfo"]!; - - ((JArray)info["aggregates"]!).Should().BeEmpty(); - info["distinctType"]!.ToString().Should().Be("None"); - } - - [Fact] - public async Task QueryPlan_BetweenClause_NoAggregateFlags() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.age BETWEEN 18 AND 65"); - var info = plan["queryInfo"]!; - - ((JArray)info["aggregates"]!).Should().BeEmpty(); - info["distinctType"]!.ToString().Should().Be("None"); - } - - [Fact] - public async Task QueryPlan_LikeClause_NoAggregateFlags() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name LIKE '%Smith%'"); - var info = plan["queryInfo"]!; - - ((JArray)info["aggregates"]!).Should().BeEmpty(); - info["distinctType"]!.ToString().Should().Be("None"); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Divergent Behavior — dCountInfo / hybridSearch (J) - // ══════════════════════════════════════════════════════════════ - - [Fact(Skip = "Emulator does not support hybridSearchQueryInfo. " + - "See sister test: QueryPlan_HybridSearch_DivergentBehavior_IgnoredGracefully")] - public async Task QueryPlan_HybridSearch_SetsHybridSearchQueryInfo() - { - var plan = await GetQueryPlanAsync( - "SELECT * FROM c ORDER BY RANK RRF(VectorDistance(c.embedding, [1,2,3]), FullTextScore(c.text, 'test'))"); - var info = plan["queryInfo"]!; - info["hybridSearchQueryInfo"].Should().NotBeNull(); - } - - [Fact] - public async Task QueryPlan_HybridSearch_DivergentBehavior_IgnoredGracefully() - { - // DIVERGENT BEHAVIOR: Emulator does not handle hybridSearchQueryInfo. - // Real Cosmos DB returns a hybridSearchQueryInfo field for hybrid search queries. - // Emulator returns a basic plan — no crash. - var plan = await GetQueryPlanAsync("SELECT * FROM c"); - var info = plan["queryInfo"]!; - - info["hybridSearchQueryInfo"].Should().BeNull(); - } - - // ══════════════════════════════════════════════════════════════ - // Deep Dive: Boundary Edge Cases (K1-K3) - // ══════════════════════════════════════════════════════════════ - - [Fact] - public async Task QueryPlan_VeryLongSqlQuery_ReturnsValidPlan() - { - // 500+ char query - var longCondition = string.Join(" OR ", Enumerable.Range(0, 50).Select(i => $"c.field{i} = {i}")); - var sql = $"SELECT * FROM c WHERE {longCondition}"; - sql.Length.Should().BeGreaterThan(500); - - var plan = await GetQueryPlanAsync(sql); - plan["queryInfo"].Should().NotBeNull(); - plan["queryRanges"].Should().NotBeNull(); - } - - [Fact] - public async Task QueryPlan_UnicodeInQuery_ReturnsValidPlan() - { - var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name = '日本語'"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("None"); - plan["queryRanges"].Should().NotBeNull(); - } - - [Fact] - public async Task QueryPlan_CaseInsensitiveKeywords_HandledCorrectly() - { - var plan = await GetQueryPlanAsync("select distinct value c.name from c order by c.name"); - var info = plan["queryInfo"]!; - - info["distinctType"]!.ToString().Should().Be("Ordered"); - ((bool)info["hasSelectValue"]!).Should().BeTrue(); - ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly HttpClient _httpClient; + + public QueryPlanTests() + { + _handler = new FakeCosmosHandler(_container); + _httpClient = new HttpClient(_handler) + { + BaseAddress = new Uri("https://localhost:9999/") + }; + } + + public void Dispose() + { + _httpClient.Dispose(); + _handler.Dispose(); + } + + private async Task<(JObject Plan, HttpResponseMessage Response)> GetQueryPlanWithResponseAsync(string sql) + { + var body = new JObject { ["query"] = sql }.ToString(); + var request = new HttpRequestMessage(HttpMethod.Post, + "dbs/fakeDb/colls/test-container/docs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); + request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); + + var response = await _httpClient.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + return (JObject.Parse(json), response); + } + + private async Task GetQueryPlanAsync(string sql) + { + var (plan, _) = await GetQueryPlanWithResponseAsync(sql); + return plan; + } + + [Fact] + public async Task QueryPlan_SimpleSelect_HasNoSpecialFlags() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("None"); + info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["groupByExpressions"]!.Should().BeOfType().Which.Should().BeEmpty(); + ((bool)info["hasSelectValue"]!).Should().BeFalse(); + info["top"]!.Type.Should().Be(JTokenType.Null); + info["offset"]!.Type.Should().Be(JTokenType.Null); + info["limit"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task QueryPlan_OrderByAscending_SetsOrderByMetadata() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().HaveCount(1); + orderBy[0]!.ToString().Should().Be("Ascending"); + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + expressions[0]!.ToString().Should().Be("c.name"); + + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_OrderByDescending_SetsDescendingFlag() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.value DESC"); + var info = plan["queryInfo"]!; + + var orderBy = (JArray)info["orderBy"]!; + orderBy[0]!.ToString().Should().Be("Descending"); + } + + [Fact] + public async Task QueryPlan_MultipleOrderBy_SetsAllFields() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC, c.value DESC"); + var info = plan["queryInfo"]!; + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().HaveCount(2); + orderBy[0]!.ToString().Should().Be("Ascending"); + orderBy[1]!.ToString().Should().Be("Descending"); + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(2); + } + + [Fact] + public async Task QueryPlan_Top_SetsTopField() + { + var plan = await GetQueryPlanAsync("SELECT TOP 10 * FROM c"); + var info = plan["queryInfo"]!; + + ((int)info["top"]!).Should().Be(10); + } + + [Fact] + public async Task QueryPlan_OffsetLimit_SetsBothFields() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 5 LIMIT 10"); + var info = plan["queryInfo"]!; + + ((int)info["offset"]!).Should().Be(5); + ((int)info["limit"]!).Should().Be(10); + } + + [Fact] + public async Task QueryPlan_Distinct_SetsDistinctTypeUnordered() + { + var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + } + + [Fact] + public async Task QueryPlan_DistinctWithOrderBy_SetsDistinctTypeUnordered() + { + // Non-VALUE DISTINCT + ORDER BY → Unordered (SDK uses hash dedup) + var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + } + + [Fact] + public async Task QueryPlan_CountAggregate_DetectsCount() + { + // Single-aggregate projection bypass: aggregates suppressed (Linux compatibility). + // SELECT COUNT(1) FROM c uses isSingleAggregateProjectionBypass so the SDK's + // AggregateQueryPipelineStage doesn't crash with a missing 'payload' field. + var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_SumAggregate_DetectsSum() + { + // Single-aggregate projection bypass: aggregates suppressed (Linux compatibility). + // SELECT SUM(c.value) FROM c uses isSingleAggregateProjectionBypass. + var plan = await GetQueryPlanAsync("SELECT SUM(c.value) FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_MinMaxAvg_DetectsAll() + { + // Multi-aggregate bypass: aggregates and alias map suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT MIN(c.value) AS minVal, MAX(c.value) AS maxVal, AVG(c.value) AS avgVal FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + + var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_GroupBy_SetsGroupByExpressions() + { + // GROUP BY bypass: pipeline flags are suppressed so the SDK + // doesn't activate GroupByQueryPipelineStage on Linux. + var plan = await GetQueryPlanAsync( + "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status"); + var info = plan["queryInfo"]!; + + var groupBy = (JArray)info["groupByExpressions"]!; + groupBy.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_SelectValue_SetsHasSelectValue() + { + var plan = await GetQueryPlanAsync("SELECT VALUE c.name FROM c"); + var info = plan["queryInfo"]!; + + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_WhereClause_DoesNotAffectFlags() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.value > 10"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("None"); + info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_QueryRanges_CoversFullRange() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c"); + + var ranges = (JArray)plan["queryRanges"]!; + ranges.Should().HaveCount(1); + ranges[0]!["min"]!.ToString().Should().Be(""); + ranges[0]!["max"]!.ToString().Should().Be("FF"); + ((bool)ranges[0]!["isMinInclusive"]!).Should().BeTrue(); + ((bool)ranges[0]!["isMaxInclusive"]!).Should().BeFalse(); + } + + [Fact] + public async Task QueryPlan_ComplexQuery_SetsAllRelevantFlags() + { + // GROUP BY bypass: groupBy, aggregates, orderBy, and alias map are suppressed + // (Linux compatibility). DISTINCT and TOP are preserved. + var plan = await GetQueryPlanAsync( + "SELECT DISTINCT TOP 5 c.category, SUM(c.value) AS total " + + "FROM c WHERE c.isActive = true " + + "GROUP BY c.category " + + "ORDER BY c.category ASC"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + ((int)info["top"]!).Should().Be(5); + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().BeEmpty(); + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + + var groupBy = (JArray)info["groupByExpressions"]!; + groupBy.Should().BeEmpty(); + + var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_UnparsableQuery_StillReturnsValidPlan() + { + // Even if the parser can't handle the query, the plan should be valid + // with sensible defaults so the SDK doesn't crash. + var plan = await GetQueryPlanAsync("SELECT ??? FROM c"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("None"); + info["rewrittenQuery"]!.ToString().Should().NotBeNullOrEmpty(); + plan["queryRanges"]!.Should().BeOfType().Which.Should().NotBeEmpty(); + } + + // ────────────────────────────────────────────────────────────── + // A. ORDER BY edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_OrderByWithoutDirection_DefaultsToAscending() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().HaveCount(1); + orderBy[0]!.ToString().Should().Be("Ascending"); + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + expressions[0]!.ToString().Should().Be("c.name"); + } + + [Fact] + public async Task QueryPlan_OrderByNestedProperty_SetsExpression() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.address.city ASC"); + var info = plan["queryInfo"]!; + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + expressions[0]!.ToString().Should().Be("c.address.city"); + + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_OrderByFunctionExpression_SetsExpression() + { + // When ORDER BY uses a function expression, the parser creates OrderByField + // with Field=null and Expression=FunctionCallExpression. + // The query plan should use ExprToString(field.Expression) as the fallback. + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); + var info = plan["queryInfo"]!; + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + // Should not be null/empty — should contain the stringified expression + expressions[0]!.Type.Should().NotBe(JTokenType.Null); + expressions[0]!.ToString().Should().NotBeNullOrEmpty(); + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().HaveCount(1); + orderBy[0]!.ToString().Should().Be("Ascending"); + } + + [Fact] + public async Task QueryPlan_NoOrderBy_HasNonStreamingOrderByIsFalse() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.value > 10"); + var info = plan["queryInfo"]!; + + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeFalse(); + } + + [Fact] + public async Task QueryPlan_OrderBy_RewrittenQueryHasCorrectStructure() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + // The SDK expects: SELECT c._rid, [{"item": c.name}] AS orderByItems, c AS payload FROM c ORDER BY c.name ASC + rewritten.Should().Contain("_rid"); + rewritten.Should().Contain("orderByItems"); + rewritten.Should().Contain("payload"); + rewritten.Should().Contain("ORDER BY"); + rewritten.Should().Contain("c.name"); + } + + [Fact] + public async Task QueryPlan_MultipleOrderBy_RewrittenQueryHasAllFields() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC, c.age DESC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("c.name"); + rewritten.Should().Contain("c.age"); + rewritten.Should().Contain("orderByItems"); + rewritten.Should().Contain("ASC"); + rewritten.Should().Contain("DESC"); + } + + [Fact] + public async Task QueryPlan_OrderByWithWhere_RewrittenQueryIncludesWhere() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.active = true ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("WHERE"); + rewritten.Should().Contain("ORDER BY"); + rewritten.Should().Contain("c.name"); + } + + // ────────────────────────────────────────────────────────────── + // B. DISTINCT edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_DistinctValue_SetsBothFlags() + { + var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + // ────────────────────────────────────────────────────────────── + // C. Aggregate edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_AggregateInArithmeticExpression_DetectsAggregate() + { + // GROUP BY bypass: aggregates and alias map suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.category, SUM(c.value) * 2 AS total FROM c GROUP BY c.category"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + + var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_DuplicateAggregateType_DeduplicatesInArray() + { + // Multi-aggregate bypass: aggregates and alias map suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT SUM(c.price) AS sumPrice, SUM(c.quantity) AS sumQty FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + + var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_CountWithoutAlias_StillDetectsAggregate() + { + // Single-aggregate projection bypass: aggregates suppressed (Linux compatibility). + // SELECT COUNT(1) FROM c uses isSingleAggregateProjectionBypass. + var plan = await GetQueryPlanAsync("SELECT COUNT(1) FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_AggregateFunctionCaseInsensitive_Detected() + { + // Single-aggregate projection bypass: aggregates suppressed (Linux compatibility). + // SELECT count(1) AS cnt FROM c uses isSingleAggregateProjectionBypass. + var plan = await GetQueryPlanAsync("SELECT count(1) AS cnt FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_NonAggregateFunction_NotInAggregates() + { + var plan = await GetQueryPlanAsync("SELECT UPPER(c.name) AS upperName FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + // ────────────────────────────────────────────────────────────── + // D. GROUP BY edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_MultipleGroupByFields_SetsAll() + { + // GROUP BY bypass: expressions suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.status, c.region, COUNT(1) AS cnt FROM c GROUP BY c.status, c.region"); + var info = plan["queryInfo"]!; + + var groupBy = (JArray)info["groupByExpressions"]!; + groupBy.Should().BeEmpty(); + } + + // ────────────────────────────────────────────────────────────── + // E. SELECT VALUE + aggregate combination + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_SelectValueWithAggregate_SetsBothFlags() + { + var plan = await GetQueryPlanAsync("SELECT VALUE COUNT(1) FROM c"); + var info = plan["queryInfo"]!; + + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + + // Aggregates are cleared from the query plan for VALUE aggregate queries + // so the SDK doesn't activate AggregateQueryPipelineStage (which fails on Linux). + // The container computes the aggregate directly and returns the raw result. + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + // ────────────────────────────────────────────────────────────── + // F. Rewritten query edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_NonOrderByQuery_RewrittenQueryIsOriginalSql() + { + const string sql = "SELECT * FROM c WHERE c.value > 10"; + var plan = await GetQueryPlanAsync(sql); + var info = plan["queryInfo"]!; + + info["rewrittenQuery"]!.ToString().Should().Be(sql); + } + + // ────────────────────────────────────────────────────────────── + // G. Response structure + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_ResponseVersion_IsTwo() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c"); + + ((int)plan["partitionedQueryExecutionInfoVersion"]!).Should().Be(2); + } + + [Fact] + public async Task QueryPlan_ResponseStatusCode_Is200() + { + var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task QueryPlan_SimpleSelect_AllDefaultFieldsPresent() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c"); + var info = plan["queryInfo"]!; + + // Verify every expected key exists in queryInfo + info["distinctType"].Should().NotBeNull(); + info["top"].Should().NotBeNull(); // JToken exists, value is null + info["offset"].Should().NotBeNull(); + info["limit"].Should().NotBeNull(); + info["orderBy"].Should().NotBeNull(); + info["orderByExpressions"].Should().NotBeNull(); + info["groupByExpressions"].Should().NotBeNull(); + info["groupByAliases"].Should().NotBeNull(); + info["aggregates"].Should().NotBeNull(); + info["groupByAliasToAggregateType"].Should().NotBeNull(); + info["rewrittenQuery"].Should().NotBeNull(); + info["hasSelectValue"].Should().NotBeNull(); + info["hasNonStreamingOrderBy"].Should().NotBeNull(); + + // Top-level structure + plan["partitionedQueryExecutionInfoVersion"].Should().NotBeNull(); + plan["queryRanges"].Should().NotBeNull(); + } + + // ────────────────────────────────────────────────────────────── + // H. General edge cases + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryPlan_QueryWithParameters_ReturnsValidPlan() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name = @name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("None"); + info["rewrittenQuery"]!.ToString().Should().NotBeNullOrEmpty(); + plan["queryRanges"]!.Should().BeOfType().Which.Should().NotBeEmpty(); + } + + [Fact] + public async Task QueryPlan_TopZero_SetsTopToZero() + { + var plan = await GetQueryPlanAsync("SELECT TOP 0 * FROM c"); + var info = plan["queryInfo"]!; + + ((int)info["top"]!).Should().Be(0); + } + + [Fact] + public async Task QueryPlan_SelectSpecificFields_NoAggregates() + { + var plan = await GetQueryPlanAsync("SELECT c.name, c.age FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_JoinQuery_DoesNotAffectOrderByOrAggregates() + { + var plan = await GetQueryPlanAsync( + "SELECT t.name FROM c JOIN t IN c.tags"); + var info = plan["queryInfo"]!; + + info["orderBy"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["aggregates"]!.Should().BeOfType().Which.Should().BeEmpty(); + info["distinctType"]!.ToString().Should().Be("None"); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: DISTINCT Edge Cases (A1-A4) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_DistinctValueWithOrderByOnSameField_SetsOrdered() + { + // True "Ordered" case: SELECT DISTINCT VALUE ORDER BY + var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Ordered"); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_DistinctValueWithOrderByOnDifferentField_SetsUnordered() + { + var plan = await GetQueryPlanAsync("SELECT DISTINCT VALUE c.name FROM c ORDER BY c.age"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + } + + [Fact] + public async Task QueryPlan_DistinctNonValueWithOrderBy_SetsUnordered() + { + // Non-VALUE DISTINCT + ORDER BY = Unordered + var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name FROM c ORDER BY c.name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + } + + [Fact] + public async Task QueryPlan_DistinctWithMultipleFields_SetsUnordered() + { + var plan = await GetQueryPlanAsync("SELECT DISTINCT c.name, c.age FROM c"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Unordered"); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: ORDER BY Edge Cases (B1-B5) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_OrderByWithArrayIndex_SetsExpression() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.tags[0] ASC"); + var info = plan["queryInfo"]!; + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + expressions[0]!.ToString().Should().Contain("tags"); + } + + [Fact] + public async Task QueryPlan_OrderByWithThreeFields_SetsAllFields() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.a ASC, c.b DESC, c.c ASC"); + var info = plan["queryInfo"]!; + + var orderBy = (JArray)info["orderBy"]!; + orderBy.Should().HaveCount(3); + orderBy[0]!.ToString().Should().Be("Ascending"); + orderBy[1]!.ToString().Should().Be("Descending"); + orderBy[2]!.ToString().Should().Be("Ascending"); + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(3); + } + + [Fact] + public async Task QueryPlan_OrderByWithSystemProperty_SetsExpression() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c._ts DESC"); + var info = plan["queryInfo"]!; + + var expressions = (JArray)info["orderByExpressions"]!; + expressions.Should().HaveCount(1); + expressions[0]!.ToString().Should().Be("c._ts"); + } + + [Fact] + public async Task QueryPlan_OrderByWithTop_RewrittenQueryDoesNotIncludeTop() + { + var plan = await GetQueryPlanAsync("SELECT TOP 5 * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("ORDER BY"); + rewritten.Should().NotContain("TOP 5"); + } + + [Fact] + public async Task QueryPlan_OrderByWithDistinct_RewrittenQueryStructureCorrect() + { + var plan = await GetQueryPlanAsync("SELECT DISTINCT * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("orderByItems"); + rewritten.Should().Contain("ORDER BY"); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: GROUP BY Edge Cases (C1-C4) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_GroupBy_PopulatesGroupByAliases() + { + // GROUP BY bypass: aliases suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status"); + var info = plan["queryInfo"]!; + + var aliases = (JArray)info["groupByAliases"]!; + aliases.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_GroupByNestedProperty_SetsExpression() + { + // GROUP BY bypass: expressions suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.address.city, COUNT(1) AS cnt FROM c GROUP BY c.address.city"); + var info = plan["queryInfo"]!; + + var groupBy = (JArray)info["groupByExpressions"]!; + groupBy.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_GroupByWithHaving_SetsGroupByExpressions() + { + // GROUP BY bypass: expressions and aggregates suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.status, COUNT(1) AS cnt FROM c GROUP BY c.status HAVING COUNT(1) > 1"); + var info = plan["queryInfo"]!; + + var groupBy = (JArray)info["groupByExpressions"]!; + groupBy.Should().BeEmpty(); + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_GroupByMultipleWithAliases_PopulatesAllAliases() + { + // GROUP BY bypass: aliases suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.status AS s, c.region AS r, COUNT(1) AS cnt FROM c GROUP BY c.status, c.region"); + var info = plan["queryInfo"]!; + + var aliases = (JArray)info["groupByAliases"]!; + aliases.Should().BeEmpty(); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Aggregate Edge Cases (D1-D4) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_AggregateInBinaryExpression_MapsAlias() + { + // GROUP BY bypass: aggregates and alias map suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.category, SUM(c.value) * 2 AS total FROM c GROUP BY c.category"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + + var aliasMap = (JObject)info["groupByAliasToAggregateType"]!; + aliasMap.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_MultipleAggregatesWithGroupBy_AllDetected() + { + // GROUP BY bypass: aggregates suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync( + "SELECT c.cat, COUNT(1) AS cnt, AVG(c.val) AS avg FROM c GROUP BY c.cat"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_AggregateWithoutGroupBy_StillDetected() + { + // Multi-aggregate bypass: aggregates suppressed (Linux compatibility). + var plan = await GetQueryPlanAsync("SELECT COUNT(1) AS cnt, SUM(c.val) AS total FROM c"); + var info = plan["queryInfo"]!; + + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_CountDistinct_SetsDCountInfo() + { + var plan = await GetQueryPlanAsync("SELECT COUNT(DISTINCT c.name) FROM c"); + var info = plan["queryInfo"]!; + info["dCountInfo"].Should().NotBeNull(); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: TOP / OFFSET / LIMIT Edge Cases (E1-E5) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_LargeTopValue_SetsCorrectly() + { + var plan = await GetQueryPlanAsync("SELECT TOP 1000000 * FROM c"); + var info = plan["queryInfo"]!; + + ((int)info["top"]!).Should().Be(1000000); + } + + [Fact] + public async Task QueryPlan_OffsetZero_SetsCorrectly() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 0 LIMIT 10"); + var info = plan["queryInfo"]!; + + ((int)info["offset"]!).Should().Be(0); + ((int)info["limit"]!).Should().Be(10); + } + + [Fact] + public async Task QueryPlan_OffsetLimit_RewrittenQueryStripsClause() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c OFFSET 5 LIMIT 10"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().NotContainEquivalentOf("OFFSET"); + rewritten.Should().NotContainEquivalentOf("LIMIT"); + rewritten.Should().Contain("SELECT"); + } + + [Fact] + public async Task QueryPlan_TopWithOrderBy_TopNotInRewrittenQuery() + { + var plan = await GetQueryPlanAsync("SELECT TOP 10 * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + ((int)info["top"]!).Should().Be(10); + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("ORDER BY"); + rewritten.Should().NotContain("TOP 10"); + } + + [Fact] + public async Task QueryPlan_OffsetLimitWithWhere_RewrittenQueryPreservesWhere() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.active = true OFFSET 5 LIMIT 10"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("WHERE"); + rewritten.Should().NotContainEquivalentOf("OFFSET"); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: SELECT VALUE Edge Cases (F1-F3) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_SelectValueWithExpression_SetsFlag() + { + var plan = await GetQueryPlanAsync("SELECT VALUE CONCAT(c.firstName, ' ', c.lastName) FROM c"); + var info = plan["queryInfo"]!; + + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_SelectValueWithObjectLiteral_SetsFlag() + { + var plan = await GetQueryPlanAsync("SELECT VALUE { name: c.name, age: c.age } FROM c"); + var info = plan["queryInfo"]!; + + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + } + + [Fact] + public async Task QueryPlan_SelectValueCount_SetsBothFlags() + { + var plan = await GetQueryPlanAsync("SELECT VALUE COUNT(1) FROM c"); + var info = plan["queryInfo"]!; + + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + // Aggregates bypassed for VALUE aggregate queries (container computes the result) + var aggregates = (JArray)info["aggregates"]!; + aggregates.Should().BeEmpty(); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Rewritten Query Edge Cases (G1-G3) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_OrderBy_RewrittenQueryUsesCorrectAlias() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("c._rid"); + rewritten.Should().Contain("c AS payload"); + } + + [Fact] + public async Task QueryPlan_OrderBy_RewrittenQueryWithJoin() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c JOIN t IN c.tags ORDER BY c.name ASC"); + var info = plan["queryInfo"]!; + + var rewritten = info["rewrittenQuery"]!.ToString(); + rewritten.Should().Contain("orderByItems"); + rewritten.Should().Contain("ORDER BY"); + } + + [Fact] + public async Task QueryPlan_DistinctQuery_RewrittenQueryIsOriginalSql() + { + const string sql = "SELECT DISTINCT c.name FROM c"; + var plan = await GetQueryPlanAsync(sql); + var info = plan["queryInfo"]!; + + info["rewrittenQuery"]!.ToString().Should().Be(sql); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Response Structure Edge Cases (H1-H4) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_ResponseContentType_IsJson() + { + var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); + + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + } + + [Fact] + public async Task QueryPlan_EmptyQueryBody_ReturnsValidPlan() + { + // Send a request with JSON body that has no query field + var body = new JObject().ToString(); + var request = new HttpRequestMessage(HttpMethod.Post, + "dbs/fakeDb/colls/test-container/docs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); + request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); + + var response = await _httpClient.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); + json["queryInfo"].Should().NotBeNull(); + } + + [Fact] + public async Task QueryPlan_MissingQueryField_FallsBackToSelectAll() + { + var body = new JObject { ["parameters"] = new JArray() }.ToString(); + var request = new HttpRequestMessage(HttpMethod.Post, + "dbs/fakeDb/colls/test-container/docs") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-ms-cosmos-is-query-plan-request", "True"); + request.Headers.Add("x-ms-documentdb-query-enablecrosspartition", "True"); + + var response = await _httpClient.SendAsync(request); + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); + var info = json["queryInfo"]!; + + // Falls back to "SELECT * FROM c" which has no special flags + info["distinctType"]!.ToString().Should().Be("None"); + } + + [Fact] + public async Task QueryPlan_ResponseHasRequestChargeHeader() + { + var (_, response) = await GetQueryPlanWithResponseAsync("SELECT * FROM c"); + + // Query plan requests typically include a charge header + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Complex Patterns (I1-I4) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_EXISTS_Subquery_NoAggregateFlags() + { + var plan = await GetQueryPlanAsync( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'urgent')"); + var info = plan["queryInfo"]!; + + ((JArray)info["aggregates"]!).Should().BeEmpty(); + ((JArray)info["orderBy"]!).Should().BeEmpty(); + } + + [Fact] + public async Task QueryPlan_InClause_NoAggregateFlags() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.status IN ('active', 'pending')"); + var info = plan["queryInfo"]!; + + ((JArray)info["aggregates"]!).Should().BeEmpty(); + info["distinctType"]!.ToString().Should().Be("None"); + } + + [Fact] + public async Task QueryPlan_BetweenClause_NoAggregateFlags() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.age BETWEEN 18 AND 65"); + var info = plan["queryInfo"]!; + + ((JArray)info["aggregates"]!).Should().BeEmpty(); + info["distinctType"]!.ToString().Should().Be("None"); + } + + [Fact] + public async Task QueryPlan_LikeClause_NoAggregateFlags() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name LIKE '%Smith%'"); + var info = plan["queryInfo"]!; + + ((JArray)info["aggregates"]!).Should().BeEmpty(); + info["distinctType"]!.ToString().Should().Be("None"); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Divergent Behavior — dCountInfo / hybridSearch (J) + // ══════════════════════════════════════════════════════════════ + + [Fact(Skip = "Emulator does not support hybridSearchQueryInfo. " + + "See sister test: QueryPlan_HybridSearch_DivergentBehavior_IgnoredGracefully")] + public async Task QueryPlan_HybridSearch_SetsHybridSearchQueryInfo() + { + var plan = await GetQueryPlanAsync( + "SELECT * FROM c ORDER BY RANK RRF(VectorDistance(c.embedding, [1,2,3]), FullTextScore(c.text, 'test'))"); + var info = plan["queryInfo"]!; + info["hybridSearchQueryInfo"].Should().NotBeNull(); + } + + [Fact] + public async Task QueryPlan_HybridSearch_DivergentBehavior_IgnoredGracefully() + { + // DIVERGENT BEHAVIOR: Emulator does not handle hybridSearchQueryInfo. + // Real Cosmos DB returns a hybridSearchQueryInfo field for hybrid search queries. + // Emulator returns a basic plan — no crash. + var plan = await GetQueryPlanAsync("SELECT * FROM c"); + var info = plan["queryInfo"]!; + + info["hybridSearchQueryInfo"].Should().BeNull(); + } + + // ══════════════════════════════════════════════════════════════ + // Deep Dive: Boundary Edge Cases (K1-K3) + // ══════════════════════════════════════════════════════════════ + + [Fact] + public async Task QueryPlan_VeryLongSqlQuery_ReturnsValidPlan() + { + // 500+ char query + var longCondition = string.Join(" OR ", Enumerable.Range(0, 50).Select(i => $"c.field{i} = {i}")); + var sql = $"SELECT * FROM c WHERE {longCondition}"; + sql.Length.Should().BeGreaterThan(500); + + var plan = await GetQueryPlanAsync(sql); + plan["queryInfo"].Should().NotBeNull(); + plan["queryRanges"].Should().NotBeNull(); + } + + [Fact] + public async Task QueryPlan_UnicodeInQuery_ReturnsValidPlan() + { + var plan = await GetQueryPlanAsync("SELECT * FROM c WHERE c.name = '日本語'"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("None"); + plan["queryRanges"].Should().NotBeNull(); + } + + [Fact] + public async Task QueryPlan_CaseInsensitiveKeywords_HandledCorrectly() + { + var plan = await GetQueryPlanAsync("select distinct value c.name from c order by c.name"); + var info = plan["queryInfo"]!; + + info["distinctType"]!.ToString().Should().Be("Ordered"); + ((bool)info["hasSelectValue"]!).Should().BeTrue(); + ((bool)info["hasNonStreamingOrderBy"]!).Should().BeTrue(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTests.cs index f6508e4..946b524 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTests.cs @@ -1,1569 +1,1569 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; using Xunit; -using System.Text; namespace CosmosDB.InMemoryEmulator.Tests; public class QueryTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - [Fact] - public async Task GetItemQueryIterator_SelectAll_ReturnsAllItems() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(5); - } - - [Fact] - public async Task GetItemQueryIterator_WithWhereClause_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.isActive = @isActive") - .WithParameter("@isActive", true); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(3); - results.Should().AllSatisfy(item => item.IsActive.Should().BeTrue()); - } - - [Fact] - public async Task GetItemQueryIterator_WithPartitionKey_FiltersToPartition() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c"); - var requestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey("pk2") }; - - var iterator = _container.GetItemQueryIterator(query, requestOptions: requestOptions); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Diana"); - } - - [Fact] - public async Task GetItemQueryIterator_WithTopClause_LimitsResults() - { - await SeedItems(); - var query = new QueryDefinition("SELECT TOP 2 * FROM c"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task GetItemQueryIterator_WithOffsetLimit_PagesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC OFFSET 1 LIMIT 2"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - results[0].Value.Should().Be(20); - results[1].Value.Should().Be(30); - } - - [Fact] - public async Task GetItemQueryIterator_WithOrderByAsc_SortsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Select(r => r.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task GetItemQueryIterator_WithOrderByDesc_SortsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value DESC"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Select(r => r.Value).Should().BeInDescendingOrder(); - } - - [Fact] - public async Task GetItemQueryIterator_WithSelectProjection_ReturnsProjectedFields() - { - await SeedItems(); - var query = new QueryDefinition("SELECT c.name, c.value FROM c WHERE c.id = '1'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[0]["value"]!.Value().Should().Be(10); - } - - [Fact] - public async Task GetItemQueryIterator_CountAggregate_ReturnsCorrectCount() - { - await SeedItems(); - var query = new QueryDefinition("SELECT COUNT(1) AS itemCount FROM c WHERE c.isActive = true"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["itemCount"]!.Value().Should().Be(3); - } - - [Fact] - public async Task GetItemQueryIterator_LikeOperator_MatchesPattern() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pattern") - .WithParameter("@pattern", "A%"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - [Fact] - public async Task GetItemQueryIterator_StringQueryOverload_Works() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.value > 25"); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task GetItemQueryIterator_NullQuery_ReturnsAllItems() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator(queryText: null); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(5); - } - - [Fact] - public async Task GetItemQueryIterator_WithDistinct_ReturnsUniqueResults() - { - await SeedItems(); - var query = new QueryDefinition("SELECT DISTINCT c.isActive FROM c"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task GetItemQueryIterator_MultipleOrderByFields_SortsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.isActive DESC, c.value ASC"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - var activeItems = results.TakeWhile(r => r.IsActive).ToList(); - var inactiveItems = results.SkipWhile(r => r.IsActive).ToList(); - activeItems.Select(r => r.Value).Should().BeInAscendingOrder(); - inactiveItems.Select(r => r.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task GetItemQueryStreamIterator_ReturnsDocumentsEnvelope() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.id = '1'"); - - var iterator = _container.GetItemQueryStreamIterator(query); - string body; - using (var response = await iterator.ReadNextAsync()) - { - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - body = await reader.ReadToEndAsync(); - } - - var jObj = JObject.Parse(body); - jObj["Documents"].Should().NotBeNull(); - ((JArray)jObj["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task GetItemQueryIterator_AndOrCombination_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.isActive = true AND (c.value > 15 OR c.name = 'Alice')"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task GetItemQueryIterator_NotEqual_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name != 'Alice'"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(4); - } - - [Fact] - public async Task SelectValueRoot_ReturnsEntireDocuments() - { - await SeedItems(); - - var query = new QueryDefinition("SELECT VALUE c FROM c WHERE c.partitionKey = 'pk1'"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(4); - results.Select(document => document.Name).Should().Contain("Alice"); - } - - [Fact] - public void SimplifySdkWhereExpression_RemovesInjectedNodes() - { - var parsed = CosmosSqlParser.Parse( - "SELECT * FROM root WHERE ((true) AND (root.value >= 20) AND IS_DEFINED(root.value))"); - - var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr); - var sql = CosmosSqlParser.ExprToString(simplified!); - - sql.Should().Be("root.value >= 20"); - } - - [Fact] - public void SimplifySdkWhereExpression_RemovesAllTrueAndIsDefined() - { - var parsed = CosmosSqlParser.Parse( - "SELECT * FROM root WHERE ((true) AND IS_DEFINED(root))"); - - var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr); - - simplified.Should().BeNull(); - } - - [Fact] - public void Parse_SdkOrderByQuery_ParsesSuccessfully() - { - var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE (true) ORDER BY root["name"] ASC"""; - - var parsed = CosmosSqlParser.Parse(sdkQuery); - - parsed.SelectFields.Should().HaveCount(3); - parsed.SelectFields[1].Alias.Should().Be("orderByItems"); - parsed.OrderByFields.Should().HaveCount(1); - parsed.OrderByFields![0].Field.Should().Be("root.name"); - } - - [Fact] - public void SimplifySdkWhereExpression_WithFromAlias_PreservesUserIsDefined() - { - var parsed = CosmosSqlParser.Parse( - "SELECT * FROM root WHERE ((true) AND IS_DEFINED(root.name) AND (root.value >= 20) AND IS_DEFINED(root))"); - - var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "root"); - var sql = CosmosSqlParser.ExprToString(simplified!); - - sql.Should().Be("(IS_DEFINED(root.name) AND root.value >= 20)"); - } - - [Fact] - public void SimplifySdkWhereExpression_PreservesFieldPathIsDefined_EvenIfMatchesOrderBy() - { - // The SDK only injects IS_DEFINED(root) (bare alias). User code might write - // IS_DEFINED(root.name) on the same field used in ORDER BY — this must be preserved. - var parsed = CosmosSqlParser.Parse( - "SELECT * FROM root WHERE (IS_DEFINED(root.name) AND root.name = 'Alice' AND IS_DEFINED(root))"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + [Fact] + public async Task GetItemQueryIterator_SelectAll_ReturnsAllItems() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(5); + } + + [Fact] + public async Task GetItemQueryIterator_WithWhereClause_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.isActive = @isActive") + .WithParameter("@isActive", true); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(3); + results.Should().AllSatisfy(item => item.IsActive.Should().BeTrue()); + } + + [Fact] + public async Task GetItemQueryIterator_WithPartitionKey_FiltersToPartition() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c"); + var requestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey("pk2") }; + + var iterator = _container.GetItemQueryIterator(query, requestOptions: requestOptions); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Diana"); + } + + [Fact] + public async Task GetItemQueryIterator_WithTopClause_LimitsResults() + { + await SeedItems(); + var query = new QueryDefinition("SELECT TOP 2 * FROM c"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task GetItemQueryIterator_WithOffsetLimit_PagesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC OFFSET 1 LIMIT 2"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + results[0].Value.Should().Be(20); + results[1].Value.Should().Be(30); + } + + [Fact] + public async Task GetItemQueryIterator_WithOrderByAsc_SortsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Select(r => r.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task GetItemQueryIterator_WithOrderByDesc_SortsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value DESC"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Select(r => r.Value).Should().BeInDescendingOrder(); + } + + [Fact] + public async Task GetItemQueryIterator_WithSelectProjection_ReturnsProjectedFields() + { + await SeedItems(); + var query = new QueryDefinition("SELECT c.name, c.value FROM c WHERE c.id = '1'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[0]["value"]!.Value().Should().Be(10); + } + + [Fact] + public async Task GetItemQueryIterator_CountAggregate_ReturnsCorrectCount() + { + await SeedItems(); + var query = new QueryDefinition("SELECT COUNT(1) AS itemCount FROM c WHERE c.isActive = true"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["itemCount"]!.Value().Should().Be(3); + } + + [Fact] + public async Task GetItemQueryIterator_LikeOperator_MatchesPattern() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pattern") + .WithParameter("@pattern", "A%"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task GetItemQueryIterator_StringQueryOverload_Works() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.value > 25"); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task GetItemQueryIterator_NullQuery_ReturnsAllItems() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator(queryText: null); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(5); + } + + [Fact] + public async Task GetItemQueryIterator_WithDistinct_ReturnsUniqueResults() + { + await SeedItems(); + var query = new QueryDefinition("SELECT DISTINCT c.isActive FROM c"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task GetItemQueryIterator_MultipleOrderByFields_SortsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.isActive DESC, c.value ASC"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + var activeItems = results.TakeWhile(r => r.IsActive).ToList(); + var inactiveItems = results.SkipWhile(r => r.IsActive).ToList(); + activeItems.Select(r => r.Value).Should().BeInAscendingOrder(); + inactiveItems.Select(r => r.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task GetItemQueryStreamIterator_ReturnsDocumentsEnvelope() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.id = '1'"); + + var iterator = _container.GetItemQueryStreamIterator(query); + string body; + using (var response = await iterator.ReadNextAsync()) + { + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + body = await reader.ReadToEndAsync(); + } + + var jObj = JObject.Parse(body); + jObj["Documents"].Should().NotBeNull(); + ((JArray)jObj["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task GetItemQueryIterator_AndOrCombination_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.isActive = true AND (c.value > 15 OR c.name = 'Alice')"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task GetItemQueryIterator_NotEqual_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name != 'Alice'"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(4); + } + + [Fact] + public async Task SelectValueRoot_ReturnsEntireDocuments() + { + await SeedItems(); + + var query = new QueryDefinition("SELECT VALUE c FROM c WHERE c.partitionKey = 'pk1'"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(4); + results.Select(document => document.Name).Should().Contain("Alice"); + } + + [Fact] + public void SimplifySdkWhereExpression_RemovesInjectedNodes() + { + var parsed = CosmosSqlParser.Parse( + "SELECT * FROM root WHERE ((true) AND (root.value >= 20) AND IS_DEFINED(root.value))"); + + var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr); + var sql = CosmosSqlParser.ExprToString(simplified!); + + sql.Should().Be("root.value >= 20"); + } + + [Fact] + public void SimplifySdkWhereExpression_RemovesAllTrueAndIsDefined() + { + var parsed = CosmosSqlParser.Parse( + "SELECT * FROM root WHERE ((true) AND IS_DEFINED(root))"); + + var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr); + + simplified.Should().BeNull(); + } + + [Fact] + public void Parse_SdkOrderByQuery_ParsesSuccessfully() + { + var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE (true) ORDER BY root["name"] ASC"""; + + var parsed = CosmosSqlParser.Parse(sdkQuery); + + parsed.SelectFields.Should().HaveCount(3); + parsed.SelectFields[1].Alias.Should().Be("orderByItems"); + parsed.OrderByFields.Should().HaveCount(1); + parsed.OrderByFields![0].Field.Should().Be("root.name"); + } + + [Fact] + public void SimplifySdkWhereExpression_WithFromAlias_PreservesUserIsDefined() + { + var parsed = CosmosSqlParser.Parse( + "SELECT * FROM root WHERE ((true) AND IS_DEFINED(root.name) AND (root.value >= 20) AND IS_DEFINED(root))"); + + var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "root"); + var sql = CosmosSqlParser.ExprToString(simplified!); + + sql.Should().Be("(IS_DEFINED(root.name) AND root.value >= 20)"); + } + + [Fact] + public void SimplifySdkWhereExpression_PreservesFieldPathIsDefined_EvenIfMatchesOrderBy() + { + // The SDK only injects IS_DEFINED(root) (bare alias). User code might write + // IS_DEFINED(root.name) on the same field used in ORDER BY — this must be preserved. + var parsed = CosmosSqlParser.Parse( + "SELECT * FROM root WHERE (IS_DEFINED(root.name) AND root.name = 'Alice' AND IS_DEFINED(root))"); + + var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "root"); + var sql = CosmosSqlParser.ExprToString(simplified!); + + sql.Should().Be("(IS_DEFINED(root.name) AND root.name = 'Alice')"); + } + + [Fact] + public void SimplifySdkWhereExpression_WithDifferentAlias_StripsCorrectIsDefined() + { + var parsed = CosmosSqlParser.Parse( + "SELECT * FROM c WHERE ((true) AND IS_DEFINED(c) AND c.value > 10)"); + + var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "c"); + var sql = CosmosSqlParser.ExprToString(simplified!); + + sql.Should().Be("c.value > 10"); + } + + [Fact] + public async Task Parse_SdkOrderByQueryEndToEnd_ReturnsResults() + { + await SeedItems(); - var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "root"); - var sql = CosmosSqlParser.ExprToString(simplified!); + var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE (true) ORDER BY root["name"] ASC"""; - sql.Should().Be("(IS_DEFINED(root.name) AND root.name = 'Alice')"); - } + var parsed = CosmosSqlParser.Parse(sdkQuery); + var simplifiedWhere = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, parsed.FromAlias); + + var simplifiedSql = $"SELECT VALUE {parsed.FromAlias} FROM {parsed.FromAlias}"; + if (simplifiedWhere is not null) + { + simplifiedSql += $" WHERE {CosmosSqlParser.ExprToString(simplifiedWhere)}"; + } + simplifiedSql += $" ORDER BY {parsed.OrderByFields![0].Field} ASC"; - [Fact] - public void SimplifySdkWhereExpression_WithDifferentAlias_StripsCorrectIsDefined() - { - var parsed = CosmosSqlParser.Parse( - "SELECT * FROM c WHERE ((true) AND IS_DEFINED(c) AND c.value > 10)"); + var iterator = _container.GetItemQueryIterator(new QueryDefinition(simplifiedSql)); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } - var simplified = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, "c"); - var sql = CosmosSqlParser.ExprToString(simplified!); + results.Should().HaveCount(5); + } - sql.Should().Be("c.value > 10"); - } + [Fact] + public void SimplifySdkQuery_WithOrderByQuery_ProducesCleanSql() + { + var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE ((true) AND IS_DEFINED(root)) ORDER BY root["name"] ASC"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); - [Fact] - public async Task Parse_SdkOrderByQueryEndToEnd_ReturnsResults() - { - await SeedItems(); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE (true) ORDER BY root["name"] ASC"""; + simplified.Should().Be("SELECT VALUE root FROM root ORDER BY root.name ASC"); + } - var parsed = CosmosSqlParser.Parse(sdkQuery); - var simplifiedWhere = CosmosSqlParser.SimplifySdkWhereExpression(parsed.WhereExpr, parsed.FromAlias); + [Fact] + public void SimplifySdkQuery_WithWhereClause_PreservesUserCondition() + { + var sdkQuery = """SELECT root._rid, [{"item": root["value"]}] AS orderByItems, root AS payload FROM root WHERE ((root["value"] > 10) AND IS_DEFINED(root)) ORDER BY root["value"] ASC"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); - var simplifiedSql = $"SELECT VALUE {parsed.FromAlias} FROM {parsed.FromAlias}"; - if (simplifiedWhere is not null) - { - simplifiedSql += $" WHERE {CosmosSqlParser.ExprToString(simplifiedWhere)}"; - } - simplifiedSql += $" ORDER BY {parsed.OrderByFields![0].Field} ASC"; - - var iterator = _container.GetItemQueryIterator(new QueryDefinition(simplifiedSql)); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(5); - } - - [Fact] - public void SimplifySdkQuery_WithOrderByQuery_ProducesCleanSql() - { - var sdkQuery = """SELECT root._rid, [{"item": root["name"]}] AS orderByItems, root AS payload FROM root WHERE ((true) AND IS_DEFINED(root)) ORDER BY root["name"] ASC"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - - simplified.Should().Be("SELECT VALUE root FROM root ORDER BY root.name ASC"); - } - - [Fact] - public void SimplifySdkQuery_WithWhereClause_PreservesUserCondition() - { - var sdkQuery = """SELECT root._rid, [{"item": root["value"]}] AS orderByItems, root AS payload FROM root WHERE ((root["value"] > 10) AND IS_DEFINED(root)) ORDER BY root["value"] ASC"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - - simplified.Should().Be("SELECT VALUE root FROM root WHERE root.value > 10 ORDER BY root.value ASC"); - } - - [Fact] - public void SimplifySdkQuery_WithTopAndWhere_IncludesAll() - { - var sdkQuery = """SELECT TOP 2 root._rid, [{"item": root["value"]}] AS orderByItems, root AS payload FROM root WHERE ((root["value"] >= 20) AND IS_DEFINED(root)) ORDER BY root["value"] DESC"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - simplified.Should().Be("SELECT TOP 2 VALUE root FROM root WHERE root.value >= 20 ORDER BY root.value DESC"); - } + simplified.Should().Be("SELECT VALUE root FROM root WHERE root.value > 10 ORDER BY root.value ASC"); + } - [Fact] - public void SimplifySdkQuery_WithPlainQuery_PassesThroughCleanly() - { - var plainQuery = "SELECT * FROM c WHERE c.value > 10"; - var parsed = CosmosSqlParser.Parse(plainQuery); + [Fact] + public void SimplifySdkQuery_WithTopAndWhere_IncludesAll() + { + var sdkQuery = """SELECT TOP 2 root._rid, [{"item": root["value"]}] AS orderByItems, root AS payload FROM root WHERE ((root["value"] >= 20) AND IS_DEFINED(root)) ORDER BY root["value"] DESC"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - simplified.Should().Be("SELECT * FROM c WHERE c.value > 10"); - } + simplified.Should().Be("SELECT TOP 2 VALUE root FROM root WHERE root.value >= 20 ORDER BY root.value DESC"); + } - [Fact] - public void SimplifySdkQuery_WithDistinctValueSelect_PreservesDistinct() - { - var query = "SELECT DISTINCT VALUE c.value FROM c"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void SimplifySdkQuery_WithPlainQuery_PassesThroughCleanly() + { + var plainQuery = "SELECT * FROM c WHERE c.value > 10"; + var parsed = CosmosSqlParser.Parse(plainQuery); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - simplified.Should().Be("SELECT DISTINCT VALUE c.value FROM c"); - } + simplified.Should().Be("SELECT * FROM c WHERE c.value > 10"); + } - [Fact] - public void SimplifySdkQuery_WithSdkBracketNotation_NormalisesToDotNotation() - { - var sdkQuery = """SELECT VALUE root FROM root WHERE (root["isActive"] = true)"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); + [Fact] + public void SimplifySdkQuery_WithDistinctValueSelect_PreservesDistinct() + { + var query = "SELECT DISTINCT VALUE c.value FROM c"; + var parsed = CosmosSqlParser.Parse(query); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - simplified.Should().Be("SELECT VALUE root FROM root WHERE root.isActive = true"); - } + simplified.Should().Be("SELECT DISTINCT VALUE c.value FROM c"); + } - // ── HAVING emission ── + [Fact] + public void SimplifySdkQuery_WithSdkBracketNotation_NormalisesToDotNotation() + { + var sdkQuery = """SELECT VALUE root FROM root WHERE (root["isActive"] = true)"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); - [Fact] - public void SimplifySdkQuery_WithGroupByAndHaving_EmitsHavingClause() - { - var query = "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category HAVING COUNT(1) > 2"; - var parsed = CosmosSqlParser.Parse(query); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + simplified.Should().Be("SELECT VALUE root FROM root WHERE root.isActive = true"); + } - simplified.Should().Be("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category HAVING COUNT(1) > 2"); - } + // ── HAVING emission ── - [Fact] - public void SimplifySdkQuery_WithGroupByWithoutHaving_OmitsHaving() - { - var query = "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void SimplifySdkQuery_WithGroupByAndHaving_EmitsHavingClause() + { + var query = "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category HAVING COUNT(1) > 2"; + var parsed = CosmosSqlParser.Parse(query); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - simplified.Should().Be("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); - } + simplified.Should().Be("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category HAVING COUNT(1) > 2"); + } - // ── GROUP BY with expressions ── + [Fact] + public void SimplifySdkQuery_WithGroupByWithoutHaving_OmitsHaving() + { + var query = "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"; + var parsed = CosmosSqlParser.Parse(query); - [Fact] - public void Parse_GroupByWithFunctionCall_ParsesCorrectly() - { - var query = "SELECT LOWER(c.category) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.category)"; - var parsed = CosmosSqlParser.Parse(query); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - parsed.GroupByFields.Should().HaveCount(1); - parsed.GroupByFields![0].Should().Be("LOWER(c.category)"); - } + simplified.Should().Be("SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); + } - // ── Subquery expression ── + // ── GROUP BY with expressions ── - [Fact] - public void Parse_SubqueryInSelect_ParsesAsSubqueryExpression() - { - var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void Parse_GroupByWithFunctionCall_ParsesCorrectly() + { + var query = "SELECT LOWER(c.category) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.category)"; + var parsed = CosmosSqlParser.Parse(query); - parsed.SelectFields.Should().HaveCount(2); - parsed.SelectFields[1].Alias.Should().Be("tags"); - parsed.SelectFields[1].SqlExpr.Should().BeOfType(); - } + parsed.GroupByFields.Should().HaveCount(1); + parsed.GroupByFields![0].Should().Be("LOWER(c.category)"); + } - [Fact] - public void ExprToString_SubqueryExpression_ProducesParenthesisedSelect() - { - var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; - var parsed = CosmosSqlParser.Parse(query); + // ── Subquery expression ── - var subExpr = parsed.SelectFields[1].SqlExpr; - var sql = CosmosSqlParser.ExprToString(subExpr); + [Fact] + public void Parse_SubqueryInSelect_ParsesAsSubqueryExpression() + { + var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; + var parsed = CosmosSqlParser.Parse(query); - sql.Should().Be("(SELECT VALUE t FROM t IN c.tags)"); - } + parsed.SelectFields.Should().HaveCount(2); + parsed.SelectFields[1].Alias.Should().Be("tags"); + parsed.SelectFields[1].SqlExpr.Should().BeOfType(); + } - [Fact] - public void SimplifySdkQuery_WithSubquery_PreservesSubquery() - { - var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void ExprToString_SubqueryExpression_ProducesParenthesisedSelect() + { + var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; + var parsed = CosmosSqlParser.Parse(query); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var subExpr = parsed.SelectFields[1].SqlExpr; + var sql = CosmosSqlParser.ExprToString(subExpr); - simplified.Should().Be("SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"); - } + sql.Should().Be("(SELECT VALUE t FROM t IN c.tags)"); + } - // ── ExprToString completeness ── + [Fact] + public void SimplifySdkQuery_WithSubquery_PreservesSubquery() + { + var query = "SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"; + var parsed = CosmosSqlParser.Parse(query); - [Fact] - public void ExprToString_BetweenExpression_ProducesCorrectSql() - { - var query = "SELECT * FROM c WHERE c.value BETWEEN 10 AND 50"; - var parsed = CosmosSqlParser.Parse(query); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); + simplified.Should().Be("SELECT c.id, (SELECT VALUE t FROM t IN c.tags) AS tags FROM c"); + } - sql.Should().Be("c.value BETWEEN 10 AND 50"); - } + // ── ExprToString completeness ── - [Fact] - public void ExprToString_InExpression_ProducesCorrectSql() - { - var query = "SELECT * FROM c WHERE c.status IN ('active', 'pending')"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void ExprToString_BetweenExpression_ProducesCorrectSql() + { + var query = "SELECT * FROM c WHERE c.value BETWEEN 10 AND 50"; + var parsed = CosmosSqlParser.Parse(query); - var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); + var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); - sql.Should().Be("c.status IN ('active', 'pending')"); - } + sql.Should().Be("c.value BETWEEN 10 AND 50"); + } + + [Fact] + public void ExprToString_InExpression_ProducesCorrectSql() + { + var query = "SELECT * FROM c WHERE c.status IN ('active', 'pending')"; + var parsed = CosmosSqlParser.Parse(query); + + var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); + + sql.Should().Be("c.status IN ('active', 'pending')"); + } - [Fact] - public void ExprToString_TernaryExpression_ProducesCorrectSql() - { - var query = "SELECT c.isActive ? 'yes' : 'no' AS label FROM c"; - var parsed = CosmosSqlParser.Parse(query); + [Fact] + public void ExprToString_TernaryExpression_ProducesCorrectSql() + { + var query = "SELECT c.isActive ? 'yes' : 'no' AS label FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + var sql = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr!); + + sql.Should().Be("c.isActive ? 'yes' : 'no'"); + } - var sql = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr!); + [Fact] + public void ExprToString_CoalesceExpression_ProducesCorrectSql() + { + var query = "SELECT c.nickname ?? c.name AS displayName FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + var sql = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr!); + + sql.Should().Be("c.nickname ?? c.name"); + } - sql.Should().Be("c.isActive ? 'yes' : 'no'"); - } - - [Fact] - public void ExprToString_CoalesceExpression_ProducesCorrectSql() - { - var query = "SELECT c.nickname ?? c.name AS displayName FROM c"; - var parsed = CosmosSqlParser.Parse(query); - - var sql = CosmosSqlParser.ExprToString(parsed.SelectFields[0].SqlExpr!); - - sql.Should().Be("c.nickname ?? c.name"); - } - - [Fact] - public void ExprToString_OrNestedInAnd_PreservesGrouping() - { - var query = "SELECT * FROM c WHERE (c.a = 1 OR c.b = 2) AND c.c = 3"; - var parsed = CosmosSqlParser.Parse(query); - - var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); - - sql.Should().Contain("OR"); - sql.Should().Contain("AND"); - // Re-parsing the output should produce equivalent results - var reparsed = CosmosSqlParser.Parse($"SELECT * FROM c WHERE {sql}"); - reparsed.WhereExpr.Should().NotBeNull(); - } - - // ── GROUP BY + HAVING integration ── - - [Fact] - public async Task GetItemQueryIterator_WithGroupByHaving_FiltersGroups() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) > 1"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0]["partitionKey"]!.Value().Should().Be("pk1"); - results[0]["cnt"]!.Value().Should().Be(4); - } - - // ── ARRAY() function with subquery ── - - [Fact] - public void Parse_ArrayFunctionCall_ParsesAsFunction() - { - var query = "SELECT ARRAY(SELECT VALUE t FROM t IN c.tags) AS allTags FROM c"; - var parsed = CosmosSqlParser.Parse(query); - - parsed.SelectFields.Should().HaveCount(1); - parsed.SelectFields[0].Alias.Should().Be("allTags"); - parsed.SelectFields[0].SqlExpr.Should().BeOfType(); - var func = (FunctionCallExpression)parsed.SelectFields[0].SqlExpr!; - func.FunctionName.Should().Be("ARRAY"); - func.Arguments.Should().HaveCount(1); - func.Arguments[0].Should().BeOfType(); - } - - // ── UDF function calls ── - - [Fact] - public void Parse_UdfFunctionCall_ParsesCorrectly() - { - var query = "SELECT udf.myFunc(c.name) AS result FROM c"; - var parsed = CosmosSqlParser.Parse(query); - - parsed.SelectFields.Should().HaveCount(1); - parsed.SelectFields[0].SqlExpr.Should().BeOfType(); - var func = (FunctionCallExpression)parsed.SelectFields[0].SqlExpr!; - func.FunctionName.Should().Be("UDF.myFunc"); - } - - // ── Round-trip: parse then SimplifySdkQuery ── - - [Fact] - public void SimplifySdkQuery_WithJoinAndWhere_PreservesJoin() - { - var query = "SELECT c.id, t AS tag FROM c JOIN t IN c.tags WHERE t = 'a'"; - var parsed = CosmosSqlParser.Parse(query); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - - simplified.Should().Be("SELECT c.id, t AS tag FROM c JOIN t IN c.tags WHERE t = 'a'"); - } - - [Fact] - public void SimplifySdkQuery_WithOffsetLimit_PreservesOffsetLimit() - { - var query = "SELECT * FROM c OFFSET 5 LIMIT 10"; - var parsed = CosmosSqlParser.Parse(query); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - - simplified.Should().Be("SELECT * FROM c OFFSET 5 LIMIT 10"); - } - - [Fact] - public void SimplifySdkQuery_WithSelectManyJoin_NormalisesCorrectly() - { - var sdkQuery = """SELECT VALUE document0 FROM root JOIN document0 IN root["tags"]"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); - - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - - simplified.Should().Be("SELECT VALUE document0 FROM root JOIN document0 IN root.tags"); - } - - [Fact] - public void Parse_AggregateWithNestedObjectArray_ParsesCorrectly() - { - var sdkQuery = """SELECT VALUE [{"item": {"sum": SUM(root["value"]), "count": COUNT(root["value"])}}] FROM root"""; - var parsed = CosmosSqlParser.Parse(sdkQuery); - - parsed.IsValueSelect.Should().BeTrue(); - parsed.SelectFields.Should().HaveCount(1); - } - - [Fact] - public async Task GetItemQueryIterator_WithSelectManyJoin_FlattensArrays() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE t FROM c JOIN t IN c.tags"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().Contain("a"); - results.Should().Contain("b"); - results.Should().Contain("c"); - } - - [Fact] - public async Task SelectValueObjectLiteral_WithComputedField_ReturnsObjectsDirectly() - { - await SeedItems(); - - var sql = "SELECT VALUE {Name: root.name, DoubleValue: (root.value * 2)} FROM root"; - var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(5); - var first = results.FirstOrDefault(r => r["Name"]?.ToString() == "Alice"); - first.Should().NotBeNull(); - first!["DoubleValue"]!.Value().Should().Be(20); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Subquery evaluation - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ArraySubquery_ReturnsFilteredArray() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.name, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'b') AS filtered FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle(); - var tags = results[0]["filtered"] as JArray; - tags.Should().NotBeNull(); - tags.Should().ContainSingle().Which.Value().Should().Be("a"); - } - - [Fact] - public async Task ArraySubquery_WithNoMatches_ReturnsEmptyArray() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.name, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t = 'zzz') AS filtered FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle(); - var tags = results[0]["filtered"] as JArray; - tags.Should().NotBeNull(); - tags.Should().BeEmpty(); - } - - [Fact] - public async Task ScalarSubquery_ReturnsFirstValue() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT c.name, (SELECT VALUE COUNT(1) FROM t IN c.tags) AS tagCount FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Alice"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Subquery ORDER BY / OFFSET / LIMIT - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ArraySubquery_WithOrderByDesc_ReturnsSorted() - { - var container = new InMemoryContainer("subq-order", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s DESC) AS sorted FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - var sorted = results[0]["sorted"]!.ToObject(); - sorted.Should().Equal(50, 40, 30, 20, 10); - } - - [Fact] - public async Task ArraySubquery_WithOrderByAsc_ReturnsSortedAscending() - { - var container = new InMemoryContainer("subq-order-asc", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC) AS sorted FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - var sorted = results[0]["sorted"]!.ToObject(); - sorted.Should().Equal(10, 20, 30, 40, 50); - } - - [Fact] - public async Task ArraySubquery_WithOffsetLimit_ReturnsPage() - { - var container = new InMemoryContainer("subq-offset", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", items = new[] { "a", "b", "c", "d", "e" } }), new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE t FROM t IN c.items OFFSET 1 LIMIT 2) AS page FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - var page = results[0]["page"]!.ToObject(); - page.Should().Equal("b", "c"); - } - - [Fact] - public async Task ArraySubquery_WithOrderByAndOffsetLimit_ReturnsSortedPage() - { - var container = new InMemoryContainer("subq-combo", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); - - // ORDER BY s ASC → [10, 20, 30, 40, 50] → OFFSET 1 LIMIT 2 → [20, 30] - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC OFFSET 1 LIMIT 2) AS page FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - var page = results[0]["page"]!.ToObject(); - page.Should().Equal(20, 30); - } - - [Fact] - public async Task ScalarSubquery_WithOrderByDesc_ReturnsFirstSorted() - { - var container = new InMemoryContainer("subq-scalar-order", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20 } }), new PartitionKey("a")); - - // Scalar subquery returns the first result after sorting — should be 50 (DESC) - var query = new QueryDefinition( - "SELECT (SELECT VALUE s FROM s IN c.scores ORDER BY s DESC) AS top FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - results[0]["top"]!.Value().Should().Be(50); - } - - [Fact] - public async Task ArraySubquery_WithOrderBy_EmptyArray_ReturnsEmpty() - { - var container = new InMemoryContainer("subq-empty", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = Array.Empty() }), new PartitionKey("a")); - - var query = new QueryDefinition( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s DESC OFFSET 0 LIMIT 3) AS sorted FROM c WHERE c.id = '1'"); - - var results = await RunQuery(container, query); - results.Should().ContainSingle(); - var sorted = results[0]["sorted"]!.ToObject(); - sorted.Should().BeEmpty(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Multiple JOINs - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task MultipleJoins_ExpandsBothArrays() - { - var container = new InMemoryContainer("multi-join-test", "/pk"); - await container.CreateItemAsync(new MultiJoinDocument - { - Id = "1", - Pk = "pk1", - Colors = ["red", "blue"], - Sizes = ["S", "M"] - }, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT color, size FROM c JOIN color IN c.colors JOIN size IN c.sizes"); - - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - // 2 colors × 2 sizes = 4 cross-product results - results.Should().HaveCount(4); - results.Select(r => $"{r["color"]}-{r["size"]}").Should() - .BeEquivalentTo(["red-S", "red-M", "blue-S", "blue-M"]); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // BETWEEN and IN expressions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Between_FiltersInRange() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 20 AND 40"); - - var results = await RunQuery(query); - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie", "Diana"]); - } - - [Fact] - public async Task InExpression_FiltersMatchingValues() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice', 'Charlie', 'Eve')"); - - var results = await RunQuery(query); - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie", "Eve"]); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Ternary and coalesce expressions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task TernaryExpression_EvaluatesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE c.isActive ? 'active' : 'inactive' FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle().Which.Should().Be("active"); - } - - [Fact] - public async Task CoalesceExpression_ReturnsFirstNonNull() - { - var container = new InMemoryContainer("coalesce-test", "/partitionKey"); - await container.CreateItemAsync(new TestDocument - { - Id = "1", - PartitionKey = "pk1", - Name = "Test", - Nested = null - }, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT VALUE c.nested.description ?? 'default' FROM c"); - - var results = await RunQuery(container, query); - - results.Should().ContainSingle().Which.Should().Be("default"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Object and array literals in SELECT - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task ObjectLiteral_InSelect_ProjectsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE {'name': c.name, 'doubled': c.value * 2} FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Alice"); - results[0]["doubled"]!.Value().Should().Be(20); - } - - [Fact] - public void Parser_ParsesArrayLiteralValueSelect() - { - var query = "SELECT VALUE [c.name, c.value] FROM c"; - var parsed = CosmosSqlParser.Parse(query); - - parsed.IsValueSelect.Should().BeTrue(); - parsed.SelectFields.Should().ContainSingle(); - parsed.SelectFields[0].SqlExpr.Should().BeOfType(); - } - - [Fact] - public async Task ArrayLiteral_InSelect_ProjectsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition( - "SELECT VALUE [c.name, c.value] FROM c WHERE c.id = '1'"); - - var results = await RunQuery(query); - - results.Should().ContainSingle(); - results[0][0]!.Value().Should().Be("Alice"); - results[0][1]!.Value().Should().Be(10); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Single-quoted object keys in SQL (GeoJSON / SDK patterns) - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public void Parser_HandlesSingleQuotedObjectKeys() - { - var query = "SELECT VALUE {'type': 'Point', 'coordinates': [0, 0]} FROM c"; - var parsed = CosmosSqlParser.Parse(query); - - parsed.SelectFields.Should().ContainSingle(); - parsed.IsValueSelect.Should().BeTrue(); - } - - private async Task> RunQuery(QueryDefinition query) - { - return await RunQuery(_container, query); - } - - private static async Task> RunQuery(InMemoryContainer container, QueryDefinition query) - { - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } + [Fact] + public void ExprToString_OrNestedInAnd_PreservesGrouping() + { + var query = "SELECT * FROM c WHERE (c.a = 1 OR c.b = 2) AND c.c = 3"; + var parsed = CosmosSqlParser.Parse(query); + + var sql = CosmosSqlParser.ExprToString(parsed.WhereExpr!); + + sql.Should().Contain("OR"); + sql.Should().Contain("AND"); + // Re-parsing the output should produce equivalent results + var reparsed = CosmosSqlParser.Parse($"SELECT * FROM c WHERE {sql}"); + reparsed.WhereExpr.Should().NotBeNull(); + } + + // ── GROUP BY + HAVING integration ── + + [Fact] + public async Task GetItemQueryIterator_WithGroupByHaving_FiltersGroups() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) > 1"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0]["partitionKey"]!.Value().Should().Be("pk1"); + results[0]["cnt"]!.Value().Should().Be(4); + } + + // ── ARRAY() function with subquery ── + + [Fact] + public void Parse_ArrayFunctionCall_ParsesAsFunction() + { + var query = "SELECT ARRAY(SELECT VALUE t FROM t IN c.tags) AS allTags FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + parsed.SelectFields.Should().HaveCount(1); + parsed.SelectFields[0].Alias.Should().Be("allTags"); + parsed.SelectFields[0].SqlExpr.Should().BeOfType(); + var func = (FunctionCallExpression)parsed.SelectFields[0].SqlExpr!; + func.FunctionName.Should().Be("ARRAY"); + func.Arguments.Should().HaveCount(1); + func.Arguments[0].Should().BeOfType(); + } + + // ── UDF function calls ── + + [Fact] + public void Parse_UdfFunctionCall_ParsesCorrectly() + { + var query = "SELECT udf.myFunc(c.name) AS result FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + parsed.SelectFields.Should().HaveCount(1); + parsed.SelectFields[0].SqlExpr.Should().BeOfType(); + var func = (FunctionCallExpression)parsed.SelectFields[0].SqlExpr!; + func.FunctionName.Should().Be("UDF.myFunc"); + } + + // ── Round-trip: parse then SimplifySdkQuery ── + + [Fact] + public void SimplifySdkQuery_WithJoinAndWhere_PreservesJoin() + { + var query = "SELECT c.id, t AS tag FROM c JOIN t IN c.tags WHERE t = 'a'"; + var parsed = CosmosSqlParser.Parse(query); + + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + + simplified.Should().Be("SELECT c.id, t AS tag FROM c JOIN t IN c.tags WHERE t = 'a'"); + } + + [Fact] + public void SimplifySdkQuery_WithOffsetLimit_PreservesOffsetLimit() + { + var query = "SELECT * FROM c OFFSET 5 LIMIT 10"; + var parsed = CosmosSqlParser.Parse(query); + + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + + simplified.Should().Be("SELECT * FROM c OFFSET 5 LIMIT 10"); + } + + [Fact] + public void SimplifySdkQuery_WithSelectManyJoin_NormalisesCorrectly() + { + var sdkQuery = """SELECT VALUE document0 FROM root JOIN document0 IN root["tags"]"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); + + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + + simplified.Should().Be("SELECT VALUE document0 FROM root JOIN document0 IN root.tags"); + } + + [Fact] + public void Parse_AggregateWithNestedObjectArray_ParsesCorrectly() + { + var sdkQuery = """SELECT VALUE [{"item": {"sum": SUM(root["value"]), "count": COUNT(root["value"])}}] FROM root"""; + var parsed = CosmosSqlParser.Parse(sdkQuery); + + parsed.IsValueSelect.Should().BeTrue(); + parsed.SelectFields.Should().HaveCount(1); + } + + [Fact] + public async Task GetItemQueryIterator_WithSelectManyJoin_FlattensArrays() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE t FROM c JOIN t IN c.tags"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().Contain("a"); + results.Should().Contain("b"); + results.Should().Contain("c"); + } + + [Fact] + public async Task SelectValueObjectLiteral_WithComputedField_ReturnsObjectsDirectly() + { + await SeedItems(); + + var sql = "SELECT VALUE {Name: root.name, DoubleValue: (root.value * 2)} FROM root"; + var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(5); + var first = results.FirstOrDefault(r => r["Name"]?.ToString() == "Alice"); + first.Should().NotBeNull(); + first!["DoubleValue"]!.Value().Should().Be(20); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Subquery evaluation + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ArraySubquery_ReturnsFilteredArray() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.name, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'b') AS filtered FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle(); + var tags = results[0]["filtered"] as JArray; + tags.Should().NotBeNull(); + tags.Should().ContainSingle().Which.Value().Should().Be("a"); + } + + [Fact] + public async Task ArraySubquery_WithNoMatches_ReturnsEmptyArray() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.name, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t = 'zzz') AS filtered FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle(); + var tags = results[0]["filtered"] as JArray; + tags.Should().NotBeNull(); + tags.Should().BeEmpty(); + } + + [Fact] + public async Task ScalarSubquery_ReturnsFirstValue() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT c.name, (SELECT VALUE COUNT(1) FROM t IN c.tags) AS tagCount FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Alice"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Subquery ORDER BY / OFFSET / LIMIT + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ArraySubquery_WithOrderByDesc_ReturnsSorted() + { + var container = new InMemoryContainer("subq-order", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s DESC) AS sorted FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + var sorted = results[0]["sorted"]!.ToObject(); + sorted.Should().Equal(50, 40, 30, 20, 10); + } + + [Fact] + public async Task ArraySubquery_WithOrderByAsc_ReturnsSortedAscending() + { + var container = new InMemoryContainer("subq-order-asc", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC) AS sorted FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + var sorted = results[0]["sorted"]!.ToObject(); + sorted.Should().Equal(10, 20, 30, 40, 50); + } + + [Fact] + public async Task ArraySubquery_WithOffsetLimit_ReturnsPage() + { + var container = new InMemoryContainer("subq-offset", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", items = new[] { "a", "b", "c", "d", "e" } }), new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE t FROM t IN c.items OFFSET 1 LIMIT 2) AS page FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + var page = results[0]["page"]!.ToObject(); + page.Should().Equal("b", "c"); + } + + [Fact] + public async Task ArraySubquery_WithOrderByAndOffsetLimit_ReturnsSortedPage() + { + var container = new InMemoryContainer("subq-combo", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20, 40 } }), new PartitionKey("a")); + + // ORDER BY s ASC → [10, 20, 30, 40, 50] → OFFSET 1 LIMIT 2 → [20, 30] + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s ASC OFFSET 1 LIMIT 2) AS page FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + var page = results[0]["page"]!.ToObject(); + page.Should().Equal(20, 30); + } + + [Fact] + public async Task ScalarSubquery_WithOrderByDesc_ReturnsFirstSorted() + { + var container = new InMemoryContainer("subq-scalar-order", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = new[] { 30, 10, 50, 20 } }), new PartitionKey("a")); + + // Scalar subquery returns the first result after sorting — should be 50 (DESC) + var query = new QueryDefinition( + "SELECT (SELECT VALUE s FROM s IN c.scores ORDER BY s DESC) AS top FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + results[0]["top"]!.Value().Should().Be(50); + } + + [Fact] + public async Task ArraySubquery_WithOrderBy_EmptyArray_ReturnsEmpty() + { + var container = new InMemoryContainer("subq-empty", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", scores = Array.Empty() }), new PartitionKey("a")); + + var query = new QueryDefinition( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores ORDER BY s DESC OFFSET 0 LIMIT 3) AS sorted FROM c WHERE c.id = '1'"); + + var results = await RunQuery(container, query); + results.Should().ContainSingle(); + var sorted = results[0]["sorted"]!.ToObject(); + sorted.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Multiple JOINs + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task MultipleJoins_ExpandsBothArrays() + { + var container = new InMemoryContainer("multi-join-test", "/pk"); + await container.CreateItemAsync(new MultiJoinDocument + { + Id = "1", + Pk = "pk1", + Colors = ["red", "blue"], + Sizes = ["S", "M"] + }, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT color, size FROM c JOIN color IN c.colors JOIN size IN c.sizes"); + + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + // 2 colors × 2 sizes = 4 cross-product results + results.Should().HaveCount(4); + results.Select(r => $"{r["color"]}-{r["size"]}").Should() + .BeEquivalentTo(["red-S", "red-M", "blue-S", "blue-M"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BETWEEN and IN expressions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Between_FiltersInRange() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 20 AND 40"); + + var results = await RunQuery(query); + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie", "Diana"]); + } + + [Fact] + public async Task InExpression_FiltersMatchingValues() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice', 'Charlie', 'Eve')"); + + var results = await RunQuery(query); + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie", "Eve"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Ternary and coalesce expressions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task TernaryExpression_EvaluatesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE c.isActive ? 'active' : 'inactive' FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle().Which.Should().Be("active"); + } + + [Fact] + public async Task CoalesceExpression_ReturnsFirstNonNull() + { + var container = new InMemoryContainer("coalesce-test", "/partitionKey"); + await container.CreateItemAsync(new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = null + }, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT VALUE c.nested.description ?? 'default' FROM c"); + + var results = await RunQuery(container, query); + + results.Should().ContainSingle().Which.Should().Be("default"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Object and array literals in SELECT + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ObjectLiteral_InSelect_ProjectsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE {'name': c.name, 'doubled': c.value * 2} FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Alice"); + results[0]["doubled"]!.Value().Should().Be(20); + } + + [Fact] + public void Parser_ParsesArrayLiteralValueSelect() + { + var query = "SELECT VALUE [c.name, c.value] FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + parsed.IsValueSelect.Should().BeTrue(); + parsed.SelectFields.Should().ContainSingle(); + parsed.SelectFields[0].SqlExpr.Should().BeOfType(); + } + + [Fact] + public async Task ArrayLiteral_InSelect_ProjectsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition( + "SELECT VALUE [c.name, c.value] FROM c WHERE c.id = '1'"); + + var results = await RunQuery(query); + + results.Should().ContainSingle(); + results[0][0]!.Value().Should().Be("Alice"); + results[0][1]!.Value().Should().Be(10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Single-quoted object keys in SQL (GeoJSON / SDK patterns) + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public void Parser_HandlesSingleQuotedObjectKeys() + { + var query = "SELECT VALUE {'type': 'Point', 'coordinates': [0, 0]} FROM c"; + var parsed = CosmosSqlParser.Parse(query); + + parsed.SelectFields.Should().ContainSingle(); + parsed.IsValueSelect.Should().BeTrue(); + } + + private async Task> RunQuery(QueryDefinition query) + { + return await RunQuery(_container, query); + } + + private static async Task> RunQuery(InMemoryContainer container, QueryDefinition query) + { + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } } public class QueryOrderByGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_OrderBy_NullValues_SortPosition() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"Alice","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","name":"Bob","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","partitionKey":"pk1","name":"Charlie","score":20}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.score"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Query_OrderBy_MissingField_StillReturnsAllItems() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"Alice","rank":1}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.rank"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Query_OrderBy_WithTopAndOffset() - { - for (var i = 1; i <= 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value OFFSET 3 LIMIT 4"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(4); - results[0].Value.Should().Be(4); - results[3].Value.Should().Be(7); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_OrderBy_NullValues_SortPosition() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"Alice","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","name":"Bob","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","partitionKey":"pk1","name":"Charlie","score":20}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.score"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Query_OrderBy_MissingField_StillReturnsAllItems() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"Alice","rank":1}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.rank"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Query_OrderBy_WithTopAndOffset() + { + for (var i = 1; i <= 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value OFFSET 3 LIMIT 4"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(4); + results[0].Value.Should().Be(4); + results[3].Value.Should().Be(7); + } } public class QueryGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["urgent", "review"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["review"] }, - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, IsActive = true, Tags = ["urgent"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = false }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true, Tags = ["urgent", "important"] }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - [Fact] - public async Task Query_NullQueryText_ReturnsAllItems() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator(queryText: null); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(5); - } - - [Fact] - public async Task Query_EmptyString_ReturnsAllItems() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator(""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(5); - } - - [Fact] - public async Task Query_WithPartitionKeyFilter_OnlyScopesToPartition() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results.Should().OnlyContain(t => t.PartitionKey == "pk1"); - } - - [Fact] - public async Task Query_SelectValue_ReturnsRawValues() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE c.name FROM c ORDER BY c.name"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainInOrder("Alice", "Bob", "Charlie", "Diana", "Eve"); - } - - [Fact] - public async Task Query_Where_Between() - { - await SeedItems(); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN @lo AND @hi") - .WithParameter("@lo", 15) - .WithParameter("@hi", 35); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().Contain("Bob").And.Contain("Charlie"); - } - - [Fact] - public async Task Query_Where_In() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE c.name IN ("Alice", "Eve")"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().Contain("Alice").And.Contain("Eve"); - } - - [Fact] - public async Task Query_OrderBy_MultipleFields_MixedDirection() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.partitionKey ASC, c.value DESC"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results[0].PartitionKey.Should().Be("pk1"); - results[0].Value.Should().Be(50); - results[^1].PartitionKey.Should().Be("pk2"); - } - - [Fact] - public async Task Query_GroupBy_WithCount() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); - pk1.Should().NotBeNull(); - pk1!["cnt"]!.ToObject().Should().Be(3); - } - - [Fact] - public async Task Query_Distinct() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT DISTINCT c.partitionKey FROM c"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Query_Top_LimitsResults() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT TOP 2 * FROM c ORDER BY c.value"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Value.Should().Be(10); - results[1].Value.Should().Be(20); - } - - [Fact] - public async Task Query_OffsetLimit_Pagination() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value OFFSET 1 LIMIT 2"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Value.Should().Be(20); - results[1].Value.Should().Be(30); - } - - [Fact] - public async Task Query_Where_Not() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE NOT c.isActive"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Should().OnlyContain(r => !r.IsActive); - } - - [Fact] - public async Task Query_Where_Like() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'A%'"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Query_Where_ArithmeticExpression() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.value * 2 > 50"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Query_ParameterizedQuery_WithMultipleParams() - { - await SeedItems(); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min AND c.isActive = @active") - .WithParameter("@min", 15) - .WithParameter("@active", true); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().Contain("Charlie").And.Contain("Eve"); - } - - [Fact] - public async Task Query_NestedFunctionCalls() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE UPPER(SUBSTRING(c.name, 0, 3)) FROM c WHERE c.id = '1'"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_Join_SingleArrayExpansion() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE t FROM c JOIN t IN c.tags"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Query_Exists_Subquery() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "urgent")"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().Contain("Alice").And.Contain("Charlie").And.Contain("Eve"); - } - - [Fact] - public async Task Query_NullCoalesce_Operator() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "HasName" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT VALUE (c.name ?? "default") FROM c"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["urgent", "review"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["review"] }, + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, IsActive = true, Tags = ["urgent"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = false }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true, Tags = ["urgent", "important"] }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + [Fact] + public async Task Query_NullQueryText_ReturnsAllItems() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator(queryText: null); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(5); + } + + [Fact] + public async Task Query_EmptyString_ReturnsAllItems() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator(""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(5); + } + + [Fact] + public async Task Query_WithPartitionKeyFilter_OnlyScopesToPartition() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results.Should().OnlyContain(t => t.PartitionKey == "pk1"); + } + + [Fact] + public async Task Query_SelectValue_ReturnsRawValues() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE c.name FROM c ORDER BY c.name"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainInOrder("Alice", "Bob", "Charlie", "Diana", "Eve"); + } + + [Fact] + public async Task Query_Where_Between() + { + await SeedItems(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN @lo AND @hi") + .WithParameter("@lo", 15) + .WithParameter("@hi", 35); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().Contain("Bob").And.Contain("Charlie"); + } + + [Fact] + public async Task Query_Where_In() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE c.name IN ("Alice", "Eve")"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().Contain("Alice").And.Contain("Eve"); + } + + [Fact] + public async Task Query_OrderBy_MultipleFields_MixedDirection() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.partitionKey ASC, c.value DESC"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results[0].PartitionKey.Should().Be("pk1"); + results[0].Value.Should().Be(50); + results[^1].PartitionKey.Should().Be("pk2"); + } + + [Fact] + public async Task Query_GroupBy_WithCount() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); + pk1.Should().NotBeNull(); + pk1!["cnt"]!.ToObject().Should().Be(3); + } + + [Fact] + public async Task Query_Distinct() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT DISTINCT c.partitionKey FROM c"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Query_Top_LimitsResults() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT TOP 2 * FROM c ORDER BY c.value"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Value.Should().Be(10); + results[1].Value.Should().Be(20); + } + + [Fact] + public async Task Query_OffsetLimit_Pagination() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value OFFSET 1 LIMIT 2"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Value.Should().Be(20); + results[1].Value.Should().Be(30); + } + + [Fact] + public async Task Query_Where_Not() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE NOT c.isActive"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Should().OnlyContain(r => !r.IsActive); + } + + [Fact] + public async Task Query_Where_Like() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'A%'"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Query_Where_ArithmeticExpression() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.value * 2 > 50"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Query_ParameterizedQuery_WithMultipleParams() + { + await SeedItems(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min AND c.isActive = @active") + .WithParameter("@min", 15) + .WithParameter("@active", true); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().Contain("Charlie").And.Contain("Eve"); + } + + [Fact] + public async Task Query_NestedFunctionCalls() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE UPPER(SUBSTRING(c.name, 0, 3)) FROM c WHERE c.id = '1'"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_Join_SingleArrayExpansion() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE t FROM c JOIN t IN c.tags"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Query_Exists_Subquery() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "urgent")"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().Contain("Alice").And.Contain("Charlie").And.Contain("Eve"); + } + + [Fact] + public async Task Query_NullCoalesce_Operator() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "HasName" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT VALUE (c.name ?? "default") FROM c"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } } @@ -1573,1138 +1573,1144 @@ await _container.CreateItemAsync( /// public class QueryContinuationTokenEdgeCaseTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void QueryIterator_WithInvalidContinuationToken_ThrowsBadRequest() - { - // Invalid continuation token should throw BadRequest (matching real Cosmos DB) - var act = () => _container.GetItemQueryIterator( - "SELECT * FROM c", - continuationToken: "not-a-valid-token"); - - act.Should().Throw() - .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task QueryIterator_WithQueryDefinition_AndContinuationToken_Works() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - - // Read first page with MaxItemCount=2 - var iterator1 = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c ORDER BY c.value"), - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - var page1 = await iterator1.ReadNextAsync(); - var token = page1.ContinuationToken; - - page1.Should().HaveCount(2); - - // Resume from continuation token with QueryDefinition - var iterator2 = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c ORDER BY c.value"), - continuationToken: token, - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - var remaining = new List(); - while (iterator2.HasMoreResults) - { - var page = await iterator2.ReadNextAsync(); - remaining.AddRange(page); - } - - remaining.Should().HaveCount(3); - } -} - - -public class QueryFeedRangeAndQueryDefinitionTests -{ - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task GetItemQueryIterator_WithFeedRange_ReturnsResults() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public void QueryIterator_WithInvalidContinuationToken_ThrowsBadRequest() + { + // Invalid continuation token should throw BadRequest (matching real Cosmos DB) + var act = () => _container.GetItemQueryIterator( + "SELECT * FROM c", + continuationToken: "not-a-valid-token"); - var ranges = await _container.GetFeedRangesAsync(); - var feedRange = ranges[0]; + act.Should().Throw() + .Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - var iterator = _container.GetItemQueryIterator( - feedRange, - new QueryDefinition("SELECT * FROM c")); + [Fact] + public async Task QueryIterator_WithQueryDefinition_AndContinuationToken_Works() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + // Read first page with MaxItemCount=2 + var iterator1 = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c ORDER BY c.value"), + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - results.Should().NotBeEmpty(); - } + var page1 = await iterator1.ReadNextAsync(); + var token = page1.ContinuationToken; - [Fact] - public async Task GetItemQueryStreamIterator_WithFeedRange_ReturnsResults() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + page1.Should().HaveCount(2); - var ranges = await _container.GetFeedRangesAsync(); - var feedRange = ranges[0]; + // Resume from continuation token with QueryDefinition + var iterator2 = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c ORDER BY c.value"), + continuationToken: token, + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var iterator = _container.GetItemQueryStreamIterator( - feedRange, - new QueryDefinition("SELECT * FROM c")); + var remaining = new List(); + while (iterator2.HasMoreResults) + { + var page = await iterator2.ReadNextAsync(); + remaining.AddRange(page); + } - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.Add(page); - } - - results.Should().NotBeEmpty(); - } - - [Fact] - public async Task GetItemQueryIterator_WithQueryDefinition_Parameterized() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Alice"); - - var iterator = _container.GetItemQueryIterator(queryDef); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + remaining.Should().HaveCount(3); + } +} - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task GetItemQueryIterator_WithQueryDefinition_MultipleParams() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 30 }, - new PartitionKey("pk1")); - - var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.value > @min") - .WithParameter("@name", "Alice") - .WithParameter("@min", 5); - - var iterator = _container.GetItemQueryIterator(queryDef); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(2); - results.Should().OnlyContain(r => r.Name == "Alice"); - } +public class QueryFeedRangeAndQueryDefinitionTests +{ + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task GetItemQueryIterator_WithFeedRange_ReturnsResults() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var ranges = await _container.GetFeedRangesAsync(); + var feedRange = ranges[0]; + + var iterator = _container.GetItemQueryIterator( + feedRange, + new QueryDefinition("SELECT * FROM c")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetItemQueryStreamIterator_WithFeedRange_ReturnsResults() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var ranges = await _container.GetFeedRangesAsync(); + var feedRange = ranges[0]; + + var iterator = _container.GetItemQueryStreamIterator( + feedRange, + new QueryDefinition("SELECT * FROM c")); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.Add(page); + } + + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetItemQueryIterator_WithQueryDefinition_Parameterized() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Alice"); + + var iterator = _container.GetItemQueryIterator(queryDef); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task GetItemQueryIterator_WithQueryDefinition_MultipleParams() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 30 }, + new PartitionKey("pk1")); + + var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.value > @min") + .WithParameter("@name", "Alice") + .WithParameter("@min", 5); + + var iterator = _container.GetItemQueryIterator(queryDef); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Should().OnlyContain(r => r.Name == "Alice"); + } } public class QueryGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["urgent", "review"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["review"] }, - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, IsActive = true, Tags = ["urgent"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = false }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true, Tags = ["urgent", "important"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - [Fact] - public async Task Query_Where_NullComparison() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NotNull" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = null"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_Where_IsDefined_TrueForExistingField() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE IS_DEFINED(c.name)"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(5); - } - - [Fact] - public async Task Query_Where_IsDefined_FalseForMissing() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE IS_DEFINED(c.nonExistentField)"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Where_IsNull_TrueForExplicitNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NotNull" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE IS_NULL(c.name)"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_GroupBy_WithSum() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, SUM(c.value) AS total FROM c GROUP BY c.partitionKey"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); - pk1.Should().NotBeNull(); - pk1!["total"]!.ToObject().Should().Be(80); - } - - [Fact] - public async Task Query_GroupBy_WithAvg() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, AVG(c.value) AS avg FROM c GROUP BY c.partitionKey"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public async Task Query_GroupBy_WithMinMax() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, MIN(c.value) AS min, MAX(c.value) AS max FROM c GROUP BY c.partitionKey"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); - pk1!["min"]!.ToObject().Should().Be(10); - pk1!["max"]!.ToObject().Should().Be(50); - } - - [Fact] - public async Task Query_GroupBy_MultipleFields() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey, c.isActive"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCountGreaterThanOrEqualTo(3); - } - - [Fact] - public async Task Query_Join_EmptyArray_ReturnsNoRows() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = [] }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE t FROM c JOIN t IN c.tags"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Join_MultipleJoins_CartesianProduct() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - new MultiJoinDocument { Id = "1", Pk = "pk1", Colors = ["red", "blue"], Sizes = ["S", "M", "L"] }, - new PartitionKey("pk1")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, co AS color, sz AS size FROM c JOIN co IN c.colors JOIN sz IN c.sizes"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(6); // 2 colors * 3 sizes - } - - [Fact] - public async Task Query_Join_WithWhere_FiltersExpandedRows() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["alpha", "beta", "gamma"] }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT VALUE t FROM c JOIN t IN c.tags WHERE t = "gamma" """); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0].ToString().Should().Be("gamma"); - } - - [Fact] - public async Task Query_Contains_CaseSensitive_Default() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE CONTAINS(c.name, "alice")"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Default is case-sensitive, "Alice" != "alice" - results.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Contains_CaseInsensitive() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE CONTAINS(c.name, "alice", true)"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Query_ParameterizedQuery_MultipleParams() - { - await SeedItems(); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min AND c.name != @excluded") - .WithParameter("@min", 15) - .WithParameter("@excluded", "Diana"); - - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().NotContain("Diana"); - } - - [Fact] - public async Task Query_BracketNotation_ForSpecialFieldNames() - { - var json = """{"id":"1","partitionKey":"pk1","field-name":"special-value"}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT c["field-name"] FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Query_AliasedSelect() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.name AS fullName FROM c WHERE c.id = '1'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["fullName"]?.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Query_SelectValue_Count_ReturnsNumber() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE COUNT(1) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - } - - [Fact] - public async Task Query_Select_NestedProperty() - { - await _container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "MyNested", Score = 9.5 } - }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.nested.description FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["urgent", "review"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["review"] }, + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30, IsActive = true, Tags = ["urgent"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = false }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true, Tags = ["urgent", "important"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + [Fact] + public async Task Query_Where_NullComparison() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NotNull" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = null"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_Where_IsDefined_TrueForExistingField() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE IS_DEFINED(c.name)"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(5); + } + + [Fact] + public async Task Query_Where_IsDefined_FalseForMissing() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE IS_DEFINED(c.nonExistentField)"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Where_IsNull_TrueForExplicitNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NotNull" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE IS_NULL(c.name)"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_GroupBy_WithSum() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, SUM(c.value) AS total FROM c GROUP BY c.partitionKey"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); + pk1.Should().NotBeNull(); + pk1!["total"]!.ToObject().Should().Be(80); + } + + [Fact] + public async Task Query_GroupBy_WithAvg() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, AVG(c.value) AS avg FROM c GROUP BY c.partitionKey"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public async Task Query_GroupBy_WithMinMax() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, MIN(c.value) AS min, MAX(c.value) AS max FROM c GROUP BY c.partitionKey"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + var pk1 = results.FirstOrDefault(r => r["partitionKey"]?.ToString() == "pk1"); + pk1!["min"]!.ToObject().Should().Be(10); + pk1!["max"]!.ToObject().Should().Be(50); + } + + [Fact] + public async Task Query_GroupBy_MultipleFields() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey, c.isActive"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCountGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task Query_Join_EmptyArray_ReturnsNoRows() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = [] }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE t FROM c JOIN t IN c.tags"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Join_MultipleJoins_CartesianProduct() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + new MultiJoinDocument { Id = "1", Pk = "pk1", Colors = ["red", "blue"], Sizes = ["S", "M", "L"] }, + new PartitionKey("pk1")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, co AS color, sz AS size FROM c JOIN co IN c.colors JOIN sz IN c.sizes"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(6); // 2 colors * 3 sizes + } + + [Fact] + public async Task Query_Join_WithWhere_FiltersExpandedRows() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["alpha", "beta", "gamma"] }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT VALUE t FROM c JOIN t IN c.tags WHERE t = "gamma" """); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0].ToString().Should().Be("gamma"); + } + + [Fact] + public async Task Query_Contains_CaseSensitive_Default() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE CONTAINS(c.name, "alice")"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Default is case-sensitive, "Alice" != "alice" + results.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Contains_CaseInsensitive() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE CONTAINS(c.name, "alice", true)"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Query_ParameterizedQuery_MultipleParams() + { + await SeedItems(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min AND c.name != @excluded") + .WithParameter("@min", 15) + .WithParameter("@excluded", "Diana"); + + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().NotContain("Diana"); + } + + [Fact] + public async Task Query_BracketNotation_ForSpecialFieldNames() + { + var json = """{"id":"1","partitionKey":"pk1","field-name":"special-value"}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT c["field-name"] FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Query_AliasedSelect() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.name AS fullName FROM c WHERE c.id = '1'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["fullName"]?.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Query_SelectValue_Count_ReturnsNumber() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE COUNT(1) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task Query_Select_NestedProperty() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "MyNested", Score = 9.5 } + }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.nested.description FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } } public class QueryOrderByGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_OrderBy_MixedTypes_NumbersAndStrings() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","sortVal":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","sortVal":"alpha"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","partitionKey":"pk1","sortVal":5}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.sortVal ASC"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // All 3 items should be returned regardless of mixed types - results.Should().HaveCount(3); - } - - [Fact] - public async Task Query_OrderBy_NestedProperty() - { - await _container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "A", - Nested = new NestedObject { Description = "Z", Score = 1.0 } - }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument - { - Id = "2", PartitionKey = "pk1", Name = "B", - Nested = new NestedObject { Description = "A", Score = 2.0 } - }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.nested.description ASC"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results[0].Nested!.Description.Should().Be("A"); - results[1].Nested!.Description.Should().Be("Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_OrderBy_MixedTypes_NumbersAndStrings() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","sortVal":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","sortVal":"alpha"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","partitionKey":"pk1","sortVal":5}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.sortVal ASC"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // All 3 items should be returned regardless of mixed types + results.Should().HaveCount(3); + } + + [Fact] + public async Task Query_OrderBy_NestedProperty() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "A", + Nested = new NestedObject { Description = "Z", Score = 1.0 } + }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument + { + Id = "2", + PartitionKey = "pk1", + Name = "B", + Nested = new NestedObject { Description = "A", Score = 2.0 } + }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.nested.description ASC"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results[0].Nested!.Description.Should().Be("A"); + results[1].Nested!.Description.Should().Be("Z"); + } } public class QueryWhereGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_Where_UndefinedField_NotEqualToNull() - { - // Create item WITHOUT a "status" field - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // undefined != null in Cosmos - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.status = null"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Should NOT match — missing field is undefined, not null - results.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Where_StringConcatOperator() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","first":"John","last":"Doe"}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE c.first || ' ' || c.last = "John Doe" """); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } -} - - -public class QueryFeedRangeDivergentBehaviorTests4 -{ - private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; - - /// - /// FeedRange parameter now scopes query results. When FeedRangeCount > 1, - /// querying through each range and unioning the results yields the full dataset. - /// With the default FeedRangeCount=1, the single range covers the entire hash space. - /// - [Fact] - public async Task QueryIterator_FeedRange_ScopesResultsByRange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all ranges returns all items - allResults.Should().HaveCount(2); - } -} - - -public class QueryIteratorGapTests4 -{ - private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; - - [Fact] - public async Task QueryIterator_WithFeedRange_FiltersByRange() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var ranges = await _container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = _container.GetItemQueryIterator( - range, new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - } - } - - // Union of all feed ranges returns all items - allResults.Should().HaveCount(2); - } - - [Fact] - public async Task QueryIterator_Dispose_IsIdempotent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - // Double dispose should not throw - iterator.Dispose(); - iterator.Dispose(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_Where_UndefinedField_NotEqualToNull() + { + // Create item WITHOUT a "status" field + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // undefined != null in Cosmos + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.status = null"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Should NOT match — missing field is undefined, not null + results.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Where_StringConcatOperator() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","first":"John","last":"Doe"}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE c.first || ' ' || c.last = "John Doe" """); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } } -public class QueryIteratorGapTests -{ - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - for (var i = 1; i <= 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - } - - [Fact] - public async Task Query_WithMaxItemCount_PaginatesCorrectly() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value", - requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); - - var allItems = new List(); - var pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - pageCount++; - } - - allItems.Should().HaveCount(10); - pageCount.Should().BeGreaterThan(1); - } - - [Fact] - public async Task Query_ContinuationToken_ResumesCorrectly() - { - await SeedItems(); - - // First page - var iterator1 = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value", - requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); - var page1 = await iterator1.ReadNextAsync(); - var token = page1.ContinuationToken; - - page1.Should().HaveCount(3); - token.Should().NotBeNullOrEmpty(); - - // Resume from continuation token - var iterator2 = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value", - continuationToken: token, - requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); - - var allRemaining = new List(); - while (iterator2.HasMoreResults) - { - var page = await iterator2.ReadNextAsync(); - allRemaining.AddRange(page); - } +public class QueryFeedRangeDivergentBehaviorTests4 +{ + private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; + + /// + /// FeedRange parameter now scopes query results. When FeedRangeCount > 1, + /// querying through each range and unioning the results yields the full dataset. + /// With the default FeedRangeCount=1, the single range covers the entire hash space. + /// + [Fact] + public async Task QueryIterator_FeedRange_ScopesResultsByRange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all ranges returns all items + allResults.Should().HaveCount(2); + } +} - allRemaining.Should().HaveCount(7); - } - [Fact] - public async Task Query_AfterLastPage_HasMoreResults_IsFalse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); +public class QueryIteratorGapTests4 +{ + private readonly InMemoryContainer _container = new("test-container", "/partitionKey") { FeedRangeCount = 4 }; + + [Fact] + public async Task QueryIterator_WithFeedRange_FiltersByRange() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var ranges = await _container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = _container.GetItemQueryIterator( + range, new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + } + } + + // Union of all feed ranges returns all items + allResults.Should().HaveCount(2); + } + + [Fact] + public async Task QueryIterator_Dispose_IsIdempotent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + // Double dispose should not throw + iterator.Dispose(); + iterator.Dispose(); + } +} - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - iterator.HasMoreResults.Should().BeFalse(); - } +public class QueryIteratorGapTests +{ + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + for (var i = 1; i <= 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + } + + [Fact] + public async Task Query_WithMaxItemCount_PaginatesCorrectly() + { + await SeedItems(); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value", + requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); + + var allItems = new List(); + var pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + pageCount++; + } + + allItems.Should().HaveCount(10); + pageCount.Should().BeGreaterThan(1); + } + + [Fact] + public async Task Query_ContinuationToken_ResumesCorrectly() + { + await SeedItems(); + + // First page + var iterator1 = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value", + requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); + var page1 = await iterator1.ReadNextAsync(); + var token = page1.ContinuationToken; + + page1.Should().HaveCount(3); + token.Should().NotBeNullOrEmpty(); + + // Resume from continuation token + var iterator2 = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value", + continuationToken: token, + requestOptions: new QueryRequestOptions { MaxItemCount = 3 }); + + var allRemaining = new List(); + while (iterator2.HasMoreResults) + { + var page = await iterator2.ReadNextAsync(); + allRemaining.AddRange(page); + } + + allRemaining.Should().HaveCount(7); + } + + [Fact] + public async Task Query_AfterLastPage_HasMoreResults_IsFalse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + iterator.HasMoreResults.Should().BeFalse(); + } } public class QueryBitwiseGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_Where_BitwiseAnd_FiltersCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","flags":7}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","flags":4}""")), - new PartitionKey("pk1")); - - // Use IntBitAnd function instead of & operator in WHERE for reliable filtering - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE IntBitAnd(c.flags, 1) = 1"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_Where_BitwiseAnd_FiltersCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","flags":7}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","flags":4}""")), + new PartitionKey("pk1")); + + // Use IntBitAnd function instead of & operator in WHERE for reliable filtering + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE IntBitAnd(c.flags, 1) = 1"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - // flags=7 (binary 111) has bit 0 set; flags=4 (binary 100) does not - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } + // flags=7 (binary 111) has bit 0 set; flags=4 (binary 100) does not + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } } public class QueryJoinGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Query_Join_NullArray_ReturnsNoRows() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","tags":null}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Query_Join_NullArray_ReturnsNoRows() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","tags":null}""")), + new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE t FROM c JOIN t IN c.tags"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE t FROM c JOIN t IN c.tags"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } } public class QueryGroupByGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = true }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = false }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40, IsActive = false }, new PartitionKey("pk1")); - await _container.CreateItemAsync(new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true }, new PartitionKey("pk1")); - } - - [Fact] - public async Task Query_GroupBy_WithHaving() - { - await SeedItems(); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive HAVING COUNT(1) > 2"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - // isActive=true has 3 items, isActive=false has 2 — only 3 > 2 - results.Should().ContainSingle(); - } + private async Task SeedItems() + { + await _container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = true }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = false }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40, IsActive = false }, new PartitionKey("pk1")); + await _container.CreateItemAsync(new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = true }, new PartitionKey("pk1")); + } - [Fact] - public async Task Query_Count_Star_VsCount_1_SameResult() - { - await SeedItems(); + [Fact] + public async Task Query_GroupBy_WithHaving() + { + await SeedItems(); - var iter1 = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); - var results1 = new List(); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive HAVING COUNT(1) > 2"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - var iter2 = _container.GetItemQueryIterator("SELECT VALUE COUNT(*) FROM c"); - var results2 = new List(); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + // isActive=true has 3 items, isActive=false has 2 — only 3 > 2 + results.Should().ContainSingle(); + } - results1.First().ToObject().Should().Be(results2.First().ToObject()); - } + [Fact] + public async Task Query_Count_Star_VsCount_1_SameResult() + { + await SeedItems(); + + var iter1 = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); + var results1 = new List(); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + + var iter2 = _container.GetItemQueryIterator("SELECT VALUE COUNT(*) FROM c"); + var results2 = new List(); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + + results1.First().ToObject().Should().Be(results2.First().ToObject()); + } } public class QueryParserGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Parser_VeryLongQuery_DoesNotStackOverflow() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 50 }, - new PartitionKey("pk1")); + [Fact] + public async Task Parser_VeryLongQuery_DoesNotStackOverflow() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 50 }, + new PartitionKey("pk1")); - // Build a deeply nested WHERE clause: ((((c.value > 0) AND c.value > 0) AND ...)) - var conditions = string.Join(" AND ", Enumerable.Range(0, 50).Select(_ => "c.value > 0")); - var query = $"SELECT * FROM c WHERE {conditions}"; + // Build a deeply nested WHERE clause: ((((c.value > 0) AND c.value > 0) AND ...)) + var conditions = string.Join(" AND ", Enumerable.Range(0, 50).Select(_ => "c.value > 0")); + var query = $"SELECT * FROM c WHERE {conditions}"; - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } } public class QueryFunctionGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_StringEquals_CaseInsensitive() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "John" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE StringEquals(c.name, "JOHN", true)"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_RegexMatch_PatternMatching() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","email":"test@example.com"}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE RegexMatch(c.email, "^[a-z]+@.*")"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_EscapedQuoteInStringLiteral() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "O'Brien" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'O''Brien'"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Query_NegativeNumberLiteral() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = -5 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.value = -5"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_StringEquals_CaseInsensitive() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "John" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE StringEquals(c.name, "JOHN", true)"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_RegexMatch_PatternMatching() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","email":"test@example.com"}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE RegexMatch(c.email, "^[a-z]+@.*")"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_EscapedQuoteInStringLiteral() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "O'Brien" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'O''Brien'"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Query_NegativeNumberLiteral() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = -5 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.value = -5"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } } public class QuerySelectGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Query_Select_ComputedExpression() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Query_Select_ComputedExpression() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator( - "SELECT c.value * 2 AS doubled FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = _container.GetItemQueryIterator( + "SELECT c.value * 2 AS doubled FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } - [Fact] - public async Task Query_Select_ObjectLiteral() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, - new PartitionKey("pk1")); + [Fact] + public async Task Query_Select_ObjectLiteral() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, + new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator( - """SELECT {"name": c.name, "val": c.value} AS info FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } + var iterator = _container.GetItemQueryIterator( + """SELECT {"name": c.name, "val": c.value} AS info FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } } public class QueryFunctionGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Query_ArraySlice_WithNegativeIndex() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","items":["a","b","c","d","e"]}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT ARRAY_SLICE(c.items, -2) AS sliced FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - var sliced = results[0]["sliced"]!.ToObject(); - sliced.Should().BeEquivalentTo(["d", "e"]); - } - - [Fact] - public async Task Query_TypeChecking_Functions_OnVariousTypes() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","arr":[1,2],"obj":{"a":1},"str":"hello","num":42,"bln":true}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT IS_ARRAY(c.arr) AS isArr, IS_OBJECT(c.obj) AS isObj, IS_STRING(c.num) AS isStrNum, IS_NUMBER(c.str) AS isNumStr, IS_BOOL(c.bln) AS isBln FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["isArr"]!.Value().Should().BeTrue(); - results[0]["isObj"]!.Value().Should().BeTrue(); - results[0]["isStrNum"]!.Value().Should().BeFalse(); - results[0]["isNumStr"]!.Value().Should().BeFalse(); - results[0]["isBln"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task Query_MathFunctions_EdgeCases() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","val":1}""")), - new PartitionKey("pk1")); - - // POWER(0,0) should return 1 per IEEE 754 - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE POWER(0, 0) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0].Value().Should().Be(1.0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Query_ArraySlice_WithNegativeIndex() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","items":["a","b","c","d","e"]}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT ARRAY_SLICE(c.items, -2) AS sliced FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + var sliced = results[0]["sliced"]!.ToObject(); + sliced.Should().BeEquivalentTo(["d", "e"]); + } + + [Fact] + public async Task Query_TypeChecking_Functions_OnVariousTypes() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","arr":[1,2],"obj":{"a":1},"str":"hello","num":42,"bln":true}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT IS_ARRAY(c.arr) AS isArr, IS_OBJECT(c.obj) AS isObj, IS_STRING(c.num) AS isStrNum, IS_NUMBER(c.str) AS isNumStr, IS_BOOL(c.bln) AS isBln FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["isArr"]!.Value().Should().BeTrue(); + results[0]["isObj"]!.Value().Should().BeTrue(); + results[0]["isStrNum"]!.Value().Should().BeFalse(); + results[0]["isNumStr"]!.Value().Should().BeFalse(); + results[0]["isBln"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task Query_MathFunctions_EdgeCases() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","val":1}""")), + new PartitionKey("pk1")); + + // POWER(0,0) should return 1 per IEEE 754 + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE POWER(0, 0) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0].Value().Should().Be(1.0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2713,1052 +2719,1052 @@ await _container.CreateItemStreamAsync( public class FromSourceArrayIterationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task FromSource_WithWhere_IteratesArrayNotTopLevelDocs() - { - var parent = JObject.FromObject(new - { - id = "parent", - partitionKey = "pk1", - children = new[] - { - new { id = "child1", value = 100 }, - new { id = "child2", value = 200 }, - } - }); - var child = JObject.FromObject(new { id = "child1", partitionKey = "pk1", value = 999 }); - await _container.CreateItemAsync(parent, new PartitionKey("pk1")); - await _container.CreateItemAsync(child, new PartitionKey("pk1")); - - // FROM item IN c.children iterates array elements, not top-level docs - var query = new QueryDefinition("SELECT * FROM item IN c.children WHERE item.id = @id") - .WithParameter("@id", "child1"); - - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Array expansion returns the nested element, not the top-level doc with id="child1" - results.Should().ContainSingle(); - results[0]["item"]!["id"]!.Value().Should().Be("child1"); - results[0]["item"]!["value"]!.Value().Should().Be(100); - } - - [Fact] - public async Task FromSource_SelectAll_ReturnsAllArrayElements() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - tags = new[] { "alpha", "beta", "gamma" } - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM t IN c.tags"); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task FromSource_ValueSelect_ReturnsScalarElements() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - tags = new[] { "alpha", "beta", "gamma" } - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE t FROM t IN c.tags"); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEquivalentTo(["alpha", "beta", "gamma"]); - } - - [Fact] - public async Task FromSource_WithWhere_FiltersArrayElements() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - scores = new[] { 10, 25, 30, 5 } - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE s FROM s IN c.scores WHERE s > @min") - .WithParameter("@min", 15); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEquivalentTo([25, 30]); - } - - [Fact] - public async Task FromSource_ObjectElements_WithWhereAndProjection() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - items = new[] - { - new { name = "Widget", price = 10 }, - new { name = "Gadget", price = 50 }, - new { name = "Gizmo", price = 30 }, - } - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT item.name FROM item IN c.items WHERE item.price > @min") - .WithParameter("@min", 20); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["name"]!.Value()).Should().BeEquivalentTo(["Gadget", "Gizmo"]); - } - - [Fact] - public async Task FromSource_EmptyArray_ReturnsNoResults() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - tags = Array.Empty() - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE t FROM t IN c.tags"); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task FromSource_MissingField_ReturnsNoResults() - { - var doc = JObject.FromObject(new - { - id = "doc1", - partitionKey = "pk1", - }); - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE t FROM t IN c.nonexistent"); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task FromSource_WithWhere_IteratesArrayNotTopLevelDocs() + { + var parent = JObject.FromObject(new + { + id = "parent", + partitionKey = "pk1", + children = new[] + { + new { id = "child1", value = 100 }, + new { id = "child2", value = 200 }, + } + }); + var child = JObject.FromObject(new { id = "child1", partitionKey = "pk1", value = 999 }); + await _container.CreateItemAsync(parent, new PartitionKey("pk1")); + await _container.CreateItemAsync(child, new PartitionKey("pk1")); + + // FROM item IN c.children iterates array elements, not top-level docs + var query = new QueryDefinition("SELECT * FROM item IN c.children WHERE item.id = @id") + .WithParameter("@id", "child1"); + + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Array expansion returns the nested element, not the top-level doc with id="child1" + results.Should().ContainSingle(); + results[0]["item"]!["id"]!.Value().Should().Be("child1"); + results[0]["item"]!["value"]!.Value().Should().Be(100); + } + + [Fact] + public async Task FromSource_SelectAll_ReturnsAllArrayElements() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + tags = new[] { "alpha", "beta", "gamma" } + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM t IN c.tags"); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task FromSource_ValueSelect_ReturnsScalarElements() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + tags = new[] { "alpha", "beta", "gamma" } + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE t FROM t IN c.tags"); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEquivalentTo(["alpha", "beta", "gamma"]); + } + + [Fact] + public async Task FromSource_WithWhere_FiltersArrayElements() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + scores = new[] { 10, 25, 30, 5 } + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE s FROM s IN c.scores WHERE s > @min") + .WithParameter("@min", 15); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEquivalentTo([25, 30]); + } + + [Fact] + public async Task FromSource_ObjectElements_WithWhereAndProjection() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + items = new[] + { + new { name = "Widget", price = 10 }, + new { name = "Gadget", price = 50 }, + new { name = "Gizmo", price = 30 }, + } + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT item.name FROM item IN c.items WHERE item.price > @min") + .WithParameter("@min", 20); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["name"]!.Value()).Should().BeEquivalentTo(["Gadget", "Gizmo"]); + } + + [Fact] + public async Task FromSource_EmptyArray_ReturnsNoResults() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + tags = Array.Empty() + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE t FROM t IN c.tags"); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task FromSource_MissingField_ReturnsNoResults() + { + var doc = JObject.FromObject(new + { + id = "doc1", + partitionKey = "pk1", + }); + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE t FROM t IN c.nonexistent"); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } } // ─── Aggregate without GROUP BY ───────────────────────────────────────── public class AggregateWithoutGroupByTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task SelectValueCount_ReturnsExactCount() - { - for (var i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - } - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE COUNT(1) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(5); - } - - [Fact] - public async Task SelectValueSum_ReturnsTotalSum() - { - for (var i = 1; i <= 4; i++) - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a", value = i * 10 }), - new PartitionKey("a")); - } - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE SUM(c.value) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(100); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task SelectValueCount_ReturnsExactCount() + { + for (var i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + } + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE COUNT(1) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(5); + } + + [Fact] + public async Task SelectValueSum_ReturnsTotalSum() + { + for (var i = 1; i <= 4; i++) + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a", value = i * 10 }), + new PartitionKey("a")); + } + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE SUM(c.value) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(100); + } } // ─── LIKE with ESCAPE ─────────────────────────────────────────────────── public class LikeWithEscapeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Like_WithEscapeClause_MatchesLiteralPercent() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", code = "50% off" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", code = "50 items" }), - new PartitionKey("a")); + [Fact] + public async Task Like_WithEscapeClause_MatchesLiteralPercent() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "50% off" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", code = "50 items" }), + new PartitionKey("a")); - // In real Cosmos: LIKE '50!% off' ESCAPE '!' matches only "50% off" - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.code LIKE '50!% off' ESCAPE '!'"); + // In real Cosmos: LIKE '50!% off' ESCAPE '!' matches only "50% off" + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.code LIKE '50!% off' ESCAPE '!'"); - var iterator = _container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } // ─── COT (Cotangent) ──────────────────────────────────────────────────── public class CotFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Cot_ReturnsCorrectValue() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", angle = 1.0 }), - new PartitionKey("a")); + [Fact] + public async Task Cot_ReturnsCorrectValue() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", angle = 1.0 }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE COT(c.angle) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE COT(c.angle) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - // COT(1.0) = 1/TAN(1.0) ≈ 0.6420926 - results.Should().ContainSingle().Which.Should().BeApproximately(1.0 / Math.Tan(1.0), 0.0001); - } + // COT(1.0) = 1/TAN(1.0) ≈ 0.6420926 + results.Should().ContainSingle().Which.Should().BeApproximately(1.0 / Math.Tan(1.0), 0.0001); + } } // ─── CHOOSE ───────────────────────────────────────────────────────────── public class ChooseFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task Choose_ReturnsValueAtIndex() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // CHOOSE is 1-based: CHOOSE(2, 'a', 'b', 'c') → 'b' - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE CHOOSE(2, 'apple', 'banana', 'cherry') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("banana"); - } - - [Fact] - public async Task Choose_OutOfBounds_ReturnsUndefined() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Index 5 is out of bounds for 3 items → undefined - var iterator = _container.GetItemQueryIterator( - "SELECT CHOOSE(5, 'a', 'b', 'c') AS result FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // In real Cosmos DB, undefined values omit the field entirely. - // CHOOSE out-of-bounds returns undefined, so the "result" field is absent. - var subject = results.Should().ContainSingle().Subject; - subject["result"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task Choose_ReturnsValueAtIndex() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // CHOOSE is 1-based: CHOOSE(2, 'a', 'b', 'c') → 'b' + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE CHOOSE(2, 'apple', 'banana', 'cherry') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("banana"); + } + + [Fact] + public async Task Choose_OutOfBounds_ReturnsUndefined() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Index 5 is out of bounds for 3 items → undefined + var iterator = _container.GetItemQueryIterator( + "SELECT CHOOSE(5, 'a', 'b', 'c') AS result FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // In real Cosmos DB, undefined values omit the field entirely. + // CHOOSE out-of-bounds returns undefined, so the "result" field is absent. + var subject = results.Should().ContainSingle().Subject; + subject["result"].Should().BeNull(); + } } // ─── OBJECTTOARRAY ────────────────────────────────────────────────────── public class ObjectToArrayFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task ObjectToArray_ConvertsObjectToNameValuePairs() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", props = new { name = "Alice", age = 30 } }), - new PartitionKey("a")); + [Fact] + public async Task ObjectToArray_ConvertsObjectToNameValuePairs() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", props = new { name = "Alice", age = 30 } }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE ObjectToArray(c.props) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE ObjectToArray(c.props) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - var arr = results.Should().ContainSingle().Subject; - arr.Count.Should().Be(2); - arr[0]["k"]!.Value().Should().Be("name"); - arr[0]["v"]!.Value().Should().Be("Alice"); - arr[1]["k"]!.Value().Should().Be("age"); - arr[1]["v"]!.Value().Should().Be(30); - } + var arr = results.Should().ContainSingle().Subject; + arr.Count.Should().Be(2); + arr[0]["k"]!.Value().Should().Be("name"); + arr[0]["v"]!.Value().Should().Be("Alice"); + arr[1]["k"]!.Value().Should().Be("age"); + arr[1]["v"]!.Value().Should().Be(30); + } } // ─── STRINGJOIN ───────────────────────────────────────────────────────── public class StringJoinFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task StringJoin_JoinsArrayElements() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "red", "green", "blue" } }), - new PartitionKey("a")); + [Fact] + public async Task StringJoin_JoinsArrayElements() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "red", "green", "blue" } }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringJoin(c.tags, ',') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringJoin(c.tags, ',') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be("red,green,blue"); - } + results.Should().ContainSingle().Which.Should().Be("red,green,blue"); + } } // ─── STRINGSPLIT ──────────────────────────────────────────────────────── public class StringSplitFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task StringSplit_SplitsByDelimiter() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", csv = "red,green,blue" }), - new PartitionKey("a")); + [Fact] + public async Task StringSplit_SplitsByDelimiter() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", csv = "red,green,blue" }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringSplit(c.csv, ',') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringSplit(c.csv, ',') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - var arr = results.Should().ContainSingle().Subject; - arr.Select(t => t.Value()).Should().BeEquivalentTo("red", "green", "blue"); - } + var arr = results.Should().ContainSingle().Subject; + arr.Select(t => t.Value()).Should().BeEquivalentTo("red", "green", "blue"); + } } // ─── DOCUMENTID ───────────────────────────────────────────────────────── public class DocumentIdFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task DocumentId_ReturnsRidField() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task DocumentId_ReturnsRidField() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE DOCUMENTID(c) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE DOCUMENTID(c) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - // DOCUMENTID returns the _rid system property - results.Should().ContainSingle().Which.Should().NotBeNullOrEmpty(); - } + // DOCUMENTID returns the _rid system property + results.Should().ContainSingle().Which.Should().NotBeNullOrEmpty(); + } } // ─── ST_AREA ──────────────────────────────────────────────────────────── public class StAreaFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task StArea_ReturnsAreaForPolygon() - { - // A simple square polygon roughly 1° × 1° near the equator - var polygon = new - { - type = "Polygon", - coordinates = new[] - { - new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 0.0, 1.0 }, new[] { 0.0, 0.0 } } - } - }; - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", region = polygon }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE ST_AREA(c.region) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Should return a positive area value (in square meters) - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task StArea_ReturnsAreaForPolygon() + { + // A simple square polygon roughly 1° × 1° near the equator + var polygon = new + { + type = "Polygon", + coordinates = new[] + { + new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 0.0, 1.0 }, new[] { 0.0, 0.0 } } + } + }; + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", region = polygon }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE ST_AREA(c.region) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Should return a positive area value (in square meters) + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } } // ─── NOT LIKE ─────────────────────────────────────────────────────────── public class NotLikeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task NotLike_ExcludesMatchingItems() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), - new PartitionKey("a")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", name = "Alicia" }), - new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name NOT LIKE 'Al%'", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task NotLike_ExcludesMatchingItems() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), + new PartitionKey("a")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", name = "Alicia" }), + new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name NOT LIKE 'Al%'", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } } // ─── Stream Iterator Continuation Token ───────────────────────────────── public class StreamIteratorContinuationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task GetItemQueryStreamIterator_RespectsContinuationToken() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task GetItemQueryStreamIterator_RespectsContinuationToken() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); - var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }; + var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }; - // First page - var iterator1 = _container.GetItemQueryStreamIterator( - "SELECT * FROM c", continuationToken: null, requestOptions: opts); - var page1 = await iterator1.ReadNextAsync(); - var page1Json = await new StreamReader(page1.Content).ReadToEndAsync(); - var page1Docs = JObject.Parse(page1Json)["Documents"] as JArray; - var token = page1.Headers["x-ms-continuation"]; + // First page + var iterator1 = _container.GetItemQueryStreamIterator( + "SELECT * FROM c", continuationToken: null, requestOptions: opts); + var page1 = await iterator1.ReadNextAsync(); + var page1Json = await new StreamReader(page1.Content).ReadToEndAsync(); + var page1Docs = JObject.Parse(page1Json)["Documents"] as JArray; + var token = page1.Headers["x-ms-continuation"]; - token.Should().NotBeNullOrEmpty("there are more items to fetch"); - page1Docs!.Count.Should().Be(2); + token.Should().NotBeNullOrEmpty("there are more items to fetch"); + page1Docs!.Count.Should().Be(2); - // Second page using continuation token - var iterator2 = _container.GetItemQueryStreamIterator( - "SELECT * FROM c", continuationToken: token, requestOptions: opts); - var page2 = await iterator2.ReadNextAsync(); - var page2Json = await new StreamReader(page2.Content).ReadToEndAsync(); - var page2Docs = JObject.Parse(page2Json)["Documents"] as JArray; + // Second page using continuation token + var iterator2 = _container.GetItemQueryStreamIterator( + "SELECT * FROM c", continuationToken: token, requestOptions: opts); + var page2 = await iterator2.ReadNextAsync(); + var page2Json = await new StreamReader(page2.Content).ReadToEndAsync(); + var page2Docs = JObject.Parse(page2Json)["Documents"] as JArray; - page2Docs!.Count.Should().Be(2); + page2Docs!.Count.Should().Be(2); - // Items from page2 should not overlap with page1 - var page1Ids = page1Docs.Select(d => d["id"]!.Value()).ToHashSet(); - var page2Ids = page2Docs.Select(d => d["id"]!.Value()).ToHashSet(); - page1Ids.Overlaps(page2Ids).Should().BeFalse("pages should not have overlapping items"); - } + // Items from page2 should not overlap with page1 + var page1Ids = page1Docs.Select(d => d["id"]!.Value()).ToHashSet(); + var page2Ids = page2Docs.Select(d => d["id"]!.Value()).ToHashSet(); + page1Ids.Overlaps(page2Ids).Should().BeFalse("pages should not have overlapping items"); + } } // ─── Continuation Token Format ────────────────────────────────────────── public class ContinuationTokenFormatTests { - /// - /// In real Cosmos DB, continuation tokens are opaque base64-encoded JSON strings that - /// include routing info, range IDs, and composite tokens. In the emulator they are - /// simple integer offsets like "0", "3", "6". This test documents the ideal behavior. - /// - [Fact(Skip = "The emulator uses simple integer-offset continuation tokens (e.g. '3') " + - "instead of the opaque base64-encoded JSON tokens used by real Cosmos DB. This is " + - "intentional for simplicity and does not affect pagination correctness, but code that " + - "parses or validates continuation token format will behave differently.")] - public async Task ContinuationToken_ShouldBeOpaqueBase64() - { - var container = new InMemoryContainer("test-container", "/pk"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); - var page = await iterator.ReadNextAsync(); - - // Real Cosmos returns something like: eyJfcmlkIjoiLzEi... - var token = page.ContinuationToken; - token.Should().NotBeNullOrEmpty(); - // Base64 string should not parse as a plain integer - int.TryParse(token, out _).Should().BeFalse(); - } - - /// - /// Sister test: demonstrates the emulator uses simple integer offsets as tokens. - /// - [Fact] - public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() - { - // ── Divergent behavior documentation ── - // Real Cosmos DB: tokens are opaque, versioned, JSON-based, then base64-encoded. - // They contain partition ranges, RIDs, and composite continuation state. - // The exact format is undocumented and changes between SDK versions. - // In-Memory Emulator: tokens are simple integer strings representing the offset - // into the result set. E.g., MaxItemCount=2 → first token is "2", next is "4". - // Pagination still works correctly; only the token format differs. - var container = new InMemoryContainer("test-container", "/pk"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); - var page = await iterator.ReadNextAsync(); - - var token = page.ContinuationToken; - token.Should().NotBeNullOrEmpty(); - int.TryParse(token, out var offset).Should().BeTrue( - "the emulator uses plain integer offsets as continuation tokens"); - offset.Should().Be(2, "first page with MaxItemCount=2 yields offset 2"); - } + /// + /// In real Cosmos DB, continuation tokens are opaque base64-encoded JSON strings that + /// include routing info, range IDs, and composite tokens. In the emulator they are + /// simple integer offsets like "0", "3", "6". This test documents the ideal behavior. + /// + [Fact(Skip = "The emulator uses simple integer-offset continuation tokens (e.g. '3') " + + "instead of the opaque base64-encoded JSON tokens used by real Cosmos DB. This is " + + "intentional for simplicity and does not affect pagination correctness, but code that " + + "parses or validates continuation token format will behave differently.")] + public async Task ContinuationToken_ShouldBeOpaqueBase64() + { + var container = new InMemoryContainer("test-container", "/pk"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); + var page = await iterator.ReadNextAsync(); + + // Real Cosmos returns something like: eyJfcmlkIjoiLzEi... + var token = page.ContinuationToken; + token.Should().NotBeNullOrEmpty(); + // Base64 string should not parse as a plain integer + int.TryParse(token, out _).Should().BeFalse(); + } + + /// + /// Sister test: demonstrates the emulator uses simple integer offsets as tokens. + /// + [Fact] + public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() + { + // ── Divergent behavior documentation ── + // Real Cosmos DB: tokens are opaque, versioned, JSON-based, then base64-encoded. + // They contain partition ranges, RIDs, and composite continuation state. + // The exact format is undocumented and changes between SDK versions. + // In-Memory Emulator: tokens are simple integer strings representing the offset + // into the result set. E.g., MaxItemCount=2 → first token is "2", next is "4". + // Pagination still works correctly; only the token format differs. + var container = new InMemoryContainer("test-container", "/pk"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); + var page = await iterator.ReadNextAsync(); + + var token = page.ContinuationToken; + token.Should().NotBeNullOrEmpty(); + int.TryParse(token, out var offset).Should().BeTrue( + "the emulator uses plain integer offsets as continuation tokens"); + offset.Should().Be(2, "first page with MaxItemCount=2 yields offset 2"); + } } // ─── VECTORDISTANCE ───────────────────────────────────────────────────── public class VectorDistanceTests { - /// - /// VECTORDISTANCE computes similarity between vectors for AI/ML workloads. - /// Supports cosine similarity (default), dot product, and Euclidean distance. - /// No vector index policy is required on the in-memory container. - /// - [Fact] - public async Task VectorDistance_ShouldComputeCosineSimilarity() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), - new PartitionKey("a")); - - // VectorDistance defaults to cosine similarity; ORDER BY expression is fully supported - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0])", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - // Ascending order: orthogonal (score=0) first, then identical (score=1) - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); - results[1]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } + /// + /// VECTORDISTANCE computes similarity between vectors for AI/ML workloads. + /// Supports cosine similarity (default), dot product, and Euclidean distance. + /// No vector index policy is required on the in-memory container. + /// + [Fact] + public async Task VectorDistance_ShouldComputeCosineSimilarity() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), + new PartitionKey("a")); + + // VectorDistance defaults to cosine similarity; ORDER BY expression is fully supported + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0])", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + // Ascending order: orthogonal (score=0) first, then identical (score=1) + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); + results[1]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } } // ─── ObjectToArray k/v format ─────────────────────────────────────────── public class ObjectToArray_KV_Tests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task ObjectToArray_ReturnsKAndVKeys() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", props = new { name = "Alice", age = 30 } }), - new PartitionKey("a")); + [Fact] + public async Task ObjectToArray_ReturnsKAndVKeys() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", props = new { name = "Alice", age = 30 } }), + new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE ObjectToArray(c.props) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE ObjectToArray(c.props) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - var arr = results.Should().ContainSingle().Subject; - arr.Count.Should().Be(2); - // Real Cosmos DB uses lowercase "k" and "v" - arr[0]["k"]!.Value().Should().Be("name"); - arr[0]["v"]!.Value().Should().Be("Alice"); - arr[1]["k"]!.Value().Should().Be("age"); - arr[1]["v"]!.Value().Should().Be(30); - } + var arr = results.Should().ContainSingle().Subject; + arr.Count.Should().Be(2); + // Real Cosmos DB uses lowercase "k" and "v" + arr[0]["k"]!.Value().Should().Be("name"); + arr[0]["v"]!.Value().Should().Be("Alice"); + arr[1]["k"]!.Value().Should().Be("age"); + arr[1]["v"]!.Value().Should().Be(30); + } } // ─── COUNT(c.field) excludes undefined ────────────────────────────────── public class CountFieldTests { - [Fact] - public async Task Query_Count_OfField_ExcludesUndefined() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","optional":"also"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT COUNT(c.optional) AS cnt FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - // Only 2 docs have "optional" field defined - results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Query_Count_Star_CountsAllRows() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT COUNT(1) AS cnt FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(2); - } + [Fact] + public async Task Query_Count_OfField_ExcludesUndefined() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","optional":"also"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT COUNT(c.optional) AS cnt FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + // Only 2 docs have "optional" field defined + results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Query_Count_Star_CountsAllRows() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT COUNT(1) AS cnt FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(2); + } } // ─── MIN/MAX on strings ───────────────────────────────────────────────── public class MinMaxStringTests { - [Fact] - public async Task Query_Min_OnStrings_ReturnsLexicographicMin() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Bob"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT MIN(c.name) AS minName FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Subject["minName"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Query_Max_OnStrings_ReturnsLexicographicMax() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Bob"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT MAX(c.name) AS maxName FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Subject["maxName"]!.Value().Should().Be("Charlie"); - } + [Fact] + public async Task Query_Min_OnStrings_ReturnsLexicographicMin() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Bob"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT MIN(c.name) AS minName FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Subject["minName"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Query_Max_OnStrings_ReturnsLexicographicMax() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Bob"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT MAX(c.name) AS maxName FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Subject["maxName"]!.Value().Should().Be("Charlie"); + } } // ─── AVG empty set returns undefined ──────────────────────────────────── public class AvgEmptySetTests { - [Fact] - public async Task Query_Avg_EmptySet_ReturnsNoValue() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); + [Fact] + public async Task Query_Avg_EmptySet_ReturnsNoValue() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); - // None of the docs have "score" field, so AVG gets empty numeric set - var iterator = container.GetItemQueryIterator( - "SELECT AVG(c.score) AS avgScore FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); + // None of the docs have "score" field, so AVG gets empty numeric set + var iterator = container.GetItemQueryIterator( + "SELECT AVG(c.score) AS avgScore FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); - // Real Cosmos returns {} (empty object) when all aggregated values are undefined - var result = results.Should().ContainSingle().Subject; - result.ContainsKey("avgScore").Should().BeFalse("AVG of empty set should omit the field entirely"); - } + // Real Cosmos returns {} (empty object) when all aggregated values are undefined + var result = results.Should().ContainSingle().Subject; + result.ContainsKey("avgScore").Should().BeFalse("AVG of empty set should omit the field entirely"); + } } // ─── REGEXMATCH modifiers ─────────────────────────────────────────────── public class RegexMatchModifierTests { - [Fact] - public async Task RegexMatch_MultilineModifier_MatchesAcrossLines() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"line1\\nline2\"}")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, '^line2', 'm')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task RegexMatch_SinglelineModifier_DotMatchesNewline() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"line1\\nline2\"}")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, 'line1.line2', 's')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task RegexMatch_CombinedModifiers_Work() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"Hello\\nworld\"}")), - new PartitionKey("a")); - - // 'im' = ignore case + multiline - var iterator = container.GetItemQueryIterator( - "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, '^WORLD', 'im')", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - } + [Fact] + public async Task RegexMatch_MultilineModifier_MatchesAcrossLines() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"line1\\nline2\"}")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, '^line2', 'm')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task RegexMatch_SinglelineModifier_DotMatchesNewline() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"line1\\nline2\"}")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, 'line1.line2', 's')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task RegexMatch_CombinedModifiers_Work() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"text\":\"Hello\\nworld\"}")), + new PartitionKey("a")); + + // 'im' = ignore case + multiline + var iterator = container.GetItemQueryIterator( + "SELECT VALUE c.id FROM c WHERE RegexMatch(c.text, '^WORLD', 'im')", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + } } // ─── EXISTS catch-all ─────────────────────────────────────────────────── public class ExistsCatchAllTests { - [Fact] - public async Task Query_Exists_UnparseableSubquery_ThrowsBadRequest() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - - // This subquery is intentionally malformed to trigger a 400 Bad Request - // GetItemQueryIterator eagerly evaluates so the exception is thrown there - var act = () => - { - container.GetItemQueryIterator( - "SELECT * FROM c WHERE EXISTS(SELECT %%% INVALID %%%)", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - return Task.CompletedTask; - }; - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); - } + [Fact] + public async Task Query_Exists_UnparseableSubquery_ThrowsBadRequest() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + + // This subquery is intentionally malformed to trigger a 400 Bad Request + // GetItemQueryIterator eagerly evaluates so the exception is thrown there + var act = () => + { + container.GetItemQueryIterator( + "SELECT * FROM c WHERE EXISTS(SELECT %%% INVALID %%%)", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + return Task.CompletedTask; + }; + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); + } } // ─── ArrayToObject function ───────────────────────────────────────────── public class ArrayToObjectTests { - [Fact] - public async Task ArrayToObject_ConvertsKVArrayToObject() - { - var container = new InMemoryContainer("test-container", "/pk"); - var json = """{"id":"1","pk":"a","arr":[{"k":"name","v":"Alice"},{"k":"age","v":30}]}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE ArrayToObject(c.arr) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - var obj = results.Should().ContainSingle().Subject; - obj["name"]!.Value().Should().Be("Alice"); - obj["age"]!.Value().Should().Be(30); - } - - [Fact] - public async Task ArrayToObject_WithNonKVArray_ReturnsUndefined() - { - var container = new InMemoryContainer("test-container", "/pk"); - var json = """{"id":"1","pk":"a","arr":["hello","world"]}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - - // SELECT VALUE means undefined results in empty result set - var iterator = container.GetItemQueryIterator( - "SELECT VALUE ArrayToObject(c.arr) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } + [Fact] + public async Task ArrayToObject_ConvertsKVArrayToObject() + { + var container = new InMemoryContainer("test-container", "/pk"); + var json = """{"id":"1","pk":"a","arr":[{"k":"name","v":"Alice"},{"k":"age","v":30}]}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE ArrayToObject(c.arr) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + var obj = results.Should().ContainSingle().Subject; + obj["name"]!.Value().Should().Be("Alice"); + obj["age"]!.Value().Should().Be(30); + } + + [Fact] + public async Task ArrayToObject_WithNonKVArray_ReturnsUndefined() + { + var container = new InMemoryContainer("test-container", "/pk"); + var json = """{"id":"1","pk":"a","arr":["hello","world"]}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + + // SELECT VALUE means undefined results in empty result set + var iterator = container.GetItemQueryIterator( + "SELECT VALUE ArrayToObject(c.arr) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } } // ─── StringTo* functions — invalid input returns undefined ────────────── public class StringToUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task SeedAsync() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"not-valid"}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task StringToNumber_InvalidInput_ReturnsUndefined() - { - await SeedAsync(); - - // SELECT VALUE means undefined values are omitted from results - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringToNumber(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("invalid input to StringToNumber should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToBoolean_InvalidInput_ReturnsUndefined() - { - await SeedAsync(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringToBoolean(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("invalid input to StringToBoolean should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToArray_InvalidInput_ReturnsUndefined() - { - await SeedAsync(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringToArray(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("invalid input to StringToArray should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToObject_InvalidInput_ReturnsUndefined() - { - await SeedAsync(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringToObject(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("invalid input to StringToObject should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToNull_InvalidInput_ReturnsUndefined() - { - await SeedAsync(); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE StringToNull(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty("invalid input to StringToNull should return undefined, omitted by VALUE"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task SeedAsync() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"not-valid"}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task StringToNumber_InvalidInput_ReturnsUndefined() + { + await SeedAsync(); + + // SELECT VALUE means undefined values are omitted from results + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringToNumber(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("invalid input to StringToNumber should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToBoolean_InvalidInput_ReturnsUndefined() + { + await SeedAsync(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringToBoolean(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("invalid input to StringToBoolean should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToArray_InvalidInput_ReturnsUndefined() + { + await SeedAsync(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringToArray(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("invalid input to StringToArray should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToObject_InvalidInput_ReturnsUndefined() + { + await SeedAsync(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringToObject(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("invalid input to StringToObject should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToNull_InvalidInput_ReturnsUndefined() + { + await SeedAsync(); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE StringToNull(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty("invalid input to StringToNull should return undefined, omitted by VALUE"); + } } // ─── GROUP BY without aggregates ──────────────────────────────────────── public class GroupByNoAggregateTests { - [Fact] - public async Task Query_GroupBy_WithoutAggregates_ReturnsProjectedFields() - { - var container = new InMemoryContainer("test-container", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","city":"London","name":"Alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","city":"London","name":"Bob"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","city":"Paris","name":"Charlie"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.city FROM c GROUP BY c.city", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - // Each result should ONLY have "city" — not the full document - foreach (var r in results) - { - r.Properties().Select(p => p.Name).Should().BeEquivalentTo(new[] { "city" }); - } - results.Select(r => r["city"]!.Value()).Should().BeEquivalentTo(new[] { "London", "Paris" }); - } + [Fact] + public async Task Query_GroupBy_WithoutAggregates_ReturnsProjectedFields() + { + var container = new InMemoryContainer("test-container", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","city":"London","name":"Alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","city":"London","name":"Bob"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","city":"Paris","name":"Charlie"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.city FROM c GROUP BY c.city", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + // Each result should ONLY have "city" — not the full document + foreach (var r in results) + { + r.Properties().Select(p => p.Name).Should().BeEquivalentTo(new[] { "city" }); + } + results.Select(r => r["city"]!.Value()).Should().BeEquivalentTo(new[] { "London", "Paris" }); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -3767,187 +3773,187 @@ await container.CreateItemStreamAsync( public class QueryWhereDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task Seed() - { - var items = new[] - { - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10, isActive = true, tags = new[] { "a", "b" }, nested = new { score = 100 } }), - JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Bob", value = 20, isActive = false, tags = new[] { "b", "c" }, nested = new { score = 200 } }), - JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Charlie", value = 30, isActive = true, tags = new[] { "a", "c" }, nested = new { score = 300 } }), - JObject.FromObject(new { id = "4", partitionKey = "pk1", name = "Diana", value = 40, isActive = true, tags = new[] { "d" } }), - JObject.FromObject(new { id = "5", partitionKey = "pk1", name = "Eve", value = 50, isActive = false }), - }; - foreach (var item in items) await _container.CreateItemAsync(item, new PartitionKey("pk1")); - } - - private async Task> Query(string sql, QueryDefinition? def = null) - { - var iterator = def != null - ? _container.GetItemQueryIterator(def) - : _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Where_GreaterThanOrEqual_WithParam_FiltersCorrectly() - { - await Seed(); - var def = new QueryDefinition("SELECT * FROM c WHERE c.value >= @v").WithParameter("@v", 30); - var results = await Query(null!, def); - results.Should().HaveCount(3); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["3", "4", "5"]); - } - - [Fact] - public async Task Where_LessThanOrEqual_WithParam_FiltersCorrectly() - { - await Seed(); - var def = new QueryDefinition("SELECT * FROM c WHERE c.value <= @v").WithParameter("@v", 20); - var results = await Query(null!, def); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task Where_LessThan_WithParam_FiltersCorrectly() - { - await Seed(); - var def = new QueryDefinition("SELECT * FROM c WHERE c.value < @v").WithParameter("@v", 30); - var results = await Query(null!, def); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Where_ComplexBooleanNesting_EvaluatesCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE (c.value = 10 AND c.isActive = true) OR (c.value = 20 AND c.isActive = false)"); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "2"]); - } - - [Fact] - public async Task Where_NotCompound_NegatesEntireExpression() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE NOT (c.value >= 20 AND c.value <= 40)"); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "5"]); - } - - [Fact] - public async Task Where_ArrayIndexAccess_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.tags[0] = 'a'"); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "3"]); - } - - [Fact] - public async Task Where_ImplicitBoolean_MatchesTrueValues() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.isActive"); - results.Should().HaveCount(3); - } - - [Fact] - public async Task Where_NotEqualToNull_MatchesNonNullFields() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.name != null"); - results.Should().HaveCount(5); - } - - [Fact] - public async Task Where_NotIsDefined_MatchesMissingFields() - { - await Seed(); - // Items 4 and 5 dont have "nested" property - var results = await Query("SELECT * FROM c WHERE NOT IS_DEFINED(c.nested)"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Where_OpenRange_And_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.value > 10 AND c.value < 50"); - results.Should().HaveCount(3); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["2", "3", "4"]); - } - - [Fact] - public async Task Where_ArrayContains_InWhere_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'a')"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Where_Like_MiddleMatch_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.name LIKE '%li%'"); - results.Select(r => r["name"]!.ToString()).Should().Contain("Alice"); - results.Select(r => r["name"]!.ToString()).Should().Contain("Charlie"); - } - - [Fact] - public async Task Where_Like_SingleCharWildcard_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE c.name LIKE '_lice'"); - results.Should().HaveCount(1); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Where_Tautology_ReturnsAllItems() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE 1 = 1"); - results.Should().HaveCount(5); - } - - [Fact] - public async Task Where_Contradiction_ReturnsEmpty() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE 1 = 0"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_IsPrimitive_FiltersCorrectly() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE IS_PRIMITIVE(c.name)"); - results.Should().HaveCount(5); - } - - [Fact] - public async Task Where_NotBetween_FiltersOutRange() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE NOT (c.value BETWEEN 20 AND 40)"); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "5"]); - } - - [Fact] - public async Task Where_NotLike_FiltersNonMatching() - { - await Seed(); - var results = await Query("SELECT * FROM c WHERE NOT (c.name LIKE 'A%')"); - results.Select(r => r["name"]!.ToString()).Should().NotContain("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task Seed() + { + var items = new[] + { + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10, isActive = true, tags = new[] { "a", "b" }, nested = new { score = 100 } }), + JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Bob", value = 20, isActive = false, tags = new[] { "b", "c" }, nested = new { score = 200 } }), + JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Charlie", value = 30, isActive = true, tags = new[] { "a", "c" }, nested = new { score = 300 } }), + JObject.FromObject(new { id = "4", partitionKey = "pk1", name = "Diana", value = 40, isActive = true, tags = new[] { "d" } }), + JObject.FromObject(new { id = "5", partitionKey = "pk1", name = "Eve", value = 50, isActive = false }), + }; + foreach (var item in items) await _container.CreateItemAsync(item, new PartitionKey("pk1")); + } + + private async Task> Query(string sql, QueryDefinition? def = null) + { + var iterator = def != null + ? _container.GetItemQueryIterator(def) + : _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Where_GreaterThanOrEqual_WithParam_FiltersCorrectly() + { + await Seed(); + var def = new QueryDefinition("SELECT * FROM c WHERE c.value >= @v").WithParameter("@v", 30); + var results = await Query(null!, def); + results.Should().HaveCount(3); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["3", "4", "5"]); + } + + [Fact] + public async Task Where_LessThanOrEqual_WithParam_FiltersCorrectly() + { + await Seed(); + var def = new QueryDefinition("SELECT * FROM c WHERE c.value <= @v").WithParameter("@v", 20); + var results = await Query(null!, def); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task Where_LessThan_WithParam_FiltersCorrectly() + { + await Seed(); + var def = new QueryDefinition("SELECT * FROM c WHERE c.value < @v").WithParameter("@v", 30); + var results = await Query(null!, def); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Where_ComplexBooleanNesting_EvaluatesCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE (c.value = 10 AND c.isActive = true) OR (c.value = 20 AND c.isActive = false)"); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "2"]); + } + + [Fact] + public async Task Where_NotCompound_NegatesEntireExpression() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT (c.value >= 20 AND c.value <= 40)"); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "5"]); + } + + [Fact] + public async Task Where_ArrayIndexAccess_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.tags[0] = 'a'"); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "3"]); + } + + [Fact] + public async Task Where_ImplicitBoolean_MatchesTrueValues() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.isActive"); + results.Should().HaveCount(3); + } + + [Fact] + public async Task Where_NotEqualToNull_MatchesNonNullFields() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.name != null"); + results.Should().HaveCount(5); + } + + [Fact] + public async Task Where_NotIsDefined_MatchesMissingFields() + { + await Seed(); + // Items 4 and 5 dont have "nested" property + var results = await Query("SELECT * FROM c WHERE NOT IS_DEFINED(c.nested)"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Where_OpenRange_And_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.value > 10 AND c.value < 50"); + results.Should().HaveCount(3); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["2", "3", "4"]); + } + + [Fact] + public async Task Where_ArrayContains_InWhere_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'a')"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Where_Like_MiddleMatch_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.name LIKE '%li%'"); + results.Select(r => r["name"]!.ToString()).Should().Contain("Alice"); + results.Select(r => r["name"]!.ToString()).Should().Contain("Charlie"); + } + + [Fact] + public async Task Where_Like_SingleCharWildcard_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE c.name LIKE '_lice'"); + results.Should().HaveCount(1); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Where_Tautology_ReturnsAllItems() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE 1 = 1"); + results.Should().HaveCount(5); + } + + [Fact] + public async Task Where_Contradiction_ReturnsEmpty() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE 1 = 0"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_IsPrimitive_FiltersCorrectly() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE IS_PRIMITIVE(c.name)"); + results.Should().HaveCount(5); + } + + [Fact] + public async Task Where_NotBetween_FiltersOutRange() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT (c.value BETWEEN 20 AND 40)"); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo(["1", "5"]); + } + + [Fact] + public async Task Where_NotLike_FiltersNonMatching() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT (c.name LIKE 'A%')"); + results.Select(r => r["name"]!.ToString()).Should().NotContain("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -3956,80 +3962,80 @@ public async Task Where_NotLike_FiltersNonMatching() public class QueryOrderByDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_StringField_SortsAlphabetically() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Charlie" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Bob" }), new PartitionKey("pk1")); - - var results = await Query("SELECT * FROM c ORDER BY c.name ASC"); - results.Select(r => r["name"]!.ToString()).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task OrderBy_BooleanField_FalseBeforeTrue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); - - var results = await Query("SELECT * FROM c ORDER BY c.active ASC"); - results[0]["active"]!.Value().Should().BeFalse(); - results[1]["active"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task OrderBy_WithDistinct_CombinesCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", category = "B" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", category = "A" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", category = "B" }), new PartitionKey("pk1")); - - var results = await Query("SELECT DISTINCT c.category FROM c ORDER BY c.category ASC"); - results.Should().HaveCount(2); - results[0]["category"]!.ToString().Should().Be("A"); - results[1]["category"]!.ToString().Should().Be("B"); - } - - [Fact] - public async Task OrderBy_DescWithOffsetLimit_PaginatesCorrectly() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1", value = i * 10 }), new PartitionKey("pk1")); - - var results = await Query("SELECT * FROM c ORDER BY c.value DESC OFFSET 1 LIMIT 2"); - results.Should().HaveCount(2); - results[0]["value"]!.Value().Should().Be(40); - results[1]["value"]!.Value().Should().Be(30); - } - - [Fact] - public async Task OrderBy_AllMissingField_ReturnsAllItems() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); - - var results = await Query("SELECT * FROM c ORDER BY c.nonexistent ASC"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task OrderBy_EmptyResultSet_ReturnsEmpty() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.value > 100 ORDER BY c.value ASC"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_StringField_SortsAlphabetically() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Charlie" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Bob" }), new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c ORDER BY c.name ASC"); + results.Select(r => r["name"]!.ToString()).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task OrderBy_BooleanField_FalseBeforeTrue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c ORDER BY c.active ASC"); + results[0]["active"]!.Value().Should().BeFalse(); + results[1]["active"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task OrderBy_WithDistinct_CombinesCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", category = "B" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", category = "A" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", category = "B" }), new PartitionKey("pk1")); + + var results = await Query("SELECT DISTINCT c.category FROM c ORDER BY c.category ASC"); + results.Should().HaveCount(2); + results[0]["category"]!.ToString().Should().Be("A"); + results[1]["category"]!.ToString().Should().Be("B"); + } + + [Fact] + public async Task OrderBy_DescWithOffsetLimit_PaginatesCorrectly() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1", value = i * 10 }), new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c ORDER BY c.value DESC OFFSET 1 LIMIT 2"); + results.Should().HaveCount(2); + results[0]["value"]!.Value().Should().Be(40); + results[1]["value"]!.Value().Should().Be(30); + } + + [Fact] + public async Task OrderBy_AllMissingField_ReturnsAllItems() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c ORDER BY c.nonexistent ASC"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task OrderBy_EmptyResultSet_ReturnsEmpty() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 5 }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.value > 100 ORDER BY c.value ASC"); + results.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4038,110 +4044,110 @@ public async Task OrderBy_EmptyResultSet_ReturnsEmpty() public class QuerySelectDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> QueryValue(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_MultipleFields_ProjectsAll() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", age = 30, city = "London" }), new PartitionKey("pk1")); - var results = await Query("SELECT c.name, c.age, c.city FROM c"); - results.Should().HaveCount(1); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[0]["age"]!.Value().Should().Be(30); - results[0]["city"]!.ToString().Should().Be("London"); - } - - [Fact] - public async Task SelectValue_EmptyResult_ReturnsEmpty() - { - var results = await QueryValue("SELECT VALUE c.name FROM c WHERE c.id = 'nonexistent'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task SelectValue_ConstantInteger_ReturnsValue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT VALUE 1 FROM c"); - results.Should().ContainSingle().Which.Should().Be(1); - } - - [Fact] - public async Task SelectValue_ConstantString_ReturnsValue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT VALUE 'hello' FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - [Fact] - public async Task SelectValue_ConstantBoolean_ReturnsValue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT VALUE true FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task Select_DeeplyNestedProperty_ProjectsCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", a = new { b = new { c = "deep" } } }), new PartitionKey("pk1")); - var results = await Query("SELECT c.a.b.c AS val FROM c"); - results[0]["val"]!.ToString().Should().Be("deep"); - } - - [Fact] - public async Task Select_TopZero_ReturnsEmpty() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await Query("SELECT TOP 0 * FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Select_TopLargerThanResultSet_ReturnsAll() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await Query("SELECT TOP 100 * FROM c"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task SelectDistinctValue_ReturnsUniqueScalars() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "B" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT DISTINCT VALUE c.cat FROM c"); - results.Should().HaveCount(2); - results.Should().BeEquivalentTo(["A", "B"]); - } - - [Fact] - public async Task Select_SameFieldTwiceWithAliases_ProjectsBoth() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - var results = await Query("SELECT c.name AS name1, c.name AS name2 FROM c"); - results[0]["name1"]!.ToString().Should().Be("Alice"); - results[0]["name2"]!.ToString().Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> QueryValue(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_MultipleFields_ProjectsAll() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", age = 30, city = "London" }), new PartitionKey("pk1")); + var results = await Query("SELECT c.name, c.age, c.city FROM c"); + results.Should().HaveCount(1); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[0]["age"]!.Value().Should().Be(30); + results[0]["city"]!.ToString().Should().Be("London"); + } + + [Fact] + public async Task SelectValue_EmptyResult_ReturnsEmpty() + { + var results = await QueryValue("SELECT VALUE c.name FROM c WHERE c.id = 'nonexistent'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task SelectValue_ConstantInteger_ReturnsValue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT VALUE 1 FROM c"); + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task SelectValue_ConstantString_ReturnsValue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT VALUE 'hello' FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + [Fact] + public async Task SelectValue_ConstantBoolean_ReturnsValue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT VALUE true FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task Select_DeeplyNestedProperty_ProjectsCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", a = new { b = new { c = "deep" } } }), new PartitionKey("pk1")); + var results = await Query("SELECT c.a.b.c AS val FROM c"); + results[0]["val"]!.ToString().Should().Be("deep"); + } + + [Fact] + public async Task Select_TopZero_ReturnsEmpty() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await Query("SELECT TOP 0 * FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Select_TopLargerThanResultSet_ReturnsAll() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await Query("SELECT TOP 100 * FROM c"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task SelectDistinctValue_ReturnsUniqueScalars() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "B" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT DISTINCT VALUE c.cat FROM c"); + results.Should().HaveCount(2); + results.Should().BeEquivalentTo(["A", "B"]); + } + + [Fact] + public async Task Select_SameFieldTwiceWithAliases_ProjectsBoth() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + var results = await Query("SELECT c.name AS name1, c.name AS name2 FROM c"); + results[0]["name1"]!.ToString().Should().Be("Alice"); + results[0]["name2"]!.ToString().Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4150,113 +4156,113 @@ public async Task Select_SameFieldTwiceWithAliases_ProjectsBoth() public class QueryAggregationDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task Seed() - { - var items = new[] - { - JObject.FromObject(new { id = "1", partitionKey = "pk1", category = "A", value = 10, active = true }), - JObject.FromObject(new { id = "2", partitionKey = "pk1", category = "A", value = 20, active = false }), - JObject.FromObject(new { id = "3", partitionKey = "pk1", category = "B", value = 30, active = true }), - JObject.FromObject(new { id = "4", partitionKey = "pk1", category = "B", value = 40, active = true }), - }; - foreach (var item in items) await _container.CreateItemAsync(item, new PartitionKey("pk1")); - } - - private async Task> QueryValue(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_Avg_WithoutGroupBy_ReturnsAverage() - { - await Seed(); - var results = await QueryValue("SELECT VALUE AVG(c.value) FROM c"); - results.Should().ContainSingle().Which.Should().Be(25.0); - } - - [Fact] - public async Task SelectValue_Min_WithoutGroupBy_ReturnsMinimum() - { - await Seed(); - var results = await QueryValue("SELECT VALUE MIN(c.value) FROM c"); - results.Should().ContainSingle().Which.Should().Be(10); - } - - [Fact] - public async Task SelectValue_Max_WithoutGroupBy_ReturnsMaximum() - { - await Seed(); - var results = await QueryValue("SELECT VALUE MAX(c.value) FROM c"); - results.Should().ContainSingle().Which.Should().Be(40); - } - - [Fact] - public async Task Avg_MixedIntFloat_ReturnsPreciseResult() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", value = 20.5 }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", value = 30 }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT VALUE AVG(c.value) FROM c"); - results[0].Should().BeApproximately(20.166666, 0.001); - } - - [Fact] - public async Task Count_EmptyResultSet_ReturnsZero() - { - var results = await QueryValue("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task GroupBy_BooleanField_GroupsCorrectly() - { - await Seed(); - var results = await Query("SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task GroupBy_SingleGroup_AllSameValue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); - var results = await Query("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Aggregate_NestedField_ComputesCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", nested = new { score = 100 } }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", nested = new { score = 200 } }), new PartitionKey("pk1")); - var results = await QueryValue("SELECT VALUE SUM(c.nested.score) FROM c"); - results.Should().ContainSingle().Which.Should().Be(300); - } - - [Fact] - public async Task MultipleAggregates_WithoutGroupBy_AllComputed() - { - await Seed(); - var results = await Query("SELECT COUNT(1) AS cnt, SUM(c.value) AS total FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(4); - results[0]["total"]!.Value().Should().Be(100); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task Seed() + { + var items = new[] + { + JObject.FromObject(new { id = "1", partitionKey = "pk1", category = "A", value = 10, active = true }), + JObject.FromObject(new { id = "2", partitionKey = "pk1", category = "A", value = 20, active = false }), + JObject.FromObject(new { id = "3", partitionKey = "pk1", category = "B", value = 30, active = true }), + JObject.FromObject(new { id = "4", partitionKey = "pk1", category = "B", value = 40, active = true }), + }; + foreach (var item in items) await _container.CreateItemAsync(item, new PartitionKey("pk1")); + } + + private async Task> QueryValue(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_Avg_WithoutGroupBy_ReturnsAverage() + { + await Seed(); + var results = await QueryValue("SELECT VALUE AVG(c.value) FROM c"); + results.Should().ContainSingle().Which.Should().Be(25.0); + } + + [Fact] + public async Task SelectValue_Min_WithoutGroupBy_ReturnsMinimum() + { + await Seed(); + var results = await QueryValue("SELECT VALUE MIN(c.value) FROM c"); + results.Should().ContainSingle().Which.Should().Be(10); + } + + [Fact] + public async Task SelectValue_Max_WithoutGroupBy_ReturnsMaximum() + { + await Seed(); + var results = await QueryValue("SELECT VALUE MAX(c.value) FROM c"); + results.Should().ContainSingle().Which.Should().Be(40); + } + + [Fact] + public async Task Avg_MixedIntFloat_ReturnsPreciseResult() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", value = 20.5 }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", value = 30 }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT VALUE AVG(c.value) FROM c"); + results[0].Should().BeApproximately(20.166666, 0.001); + } + + [Fact] + public async Task Count_EmptyResultSet_ReturnsZero() + { + var results = await QueryValue("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task GroupBy_BooleanField_GroupsCorrectly() + { + await Seed(); + var results = await Query("SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task GroupBy_SingleGroup_AllSameValue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A" }), new PartitionKey("pk1")); + var results = await Query("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Aggregate_NestedField_ComputesCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", nested = new { score = 100 } }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", nested = new { score = 200 } }), new PartitionKey("pk1")); + var results = await QueryValue("SELECT VALUE SUM(c.nested.score) FROM c"); + results.Should().ContainSingle().Which.Should().Be(300); + } + + [Fact] + public async Task MultipleAggregates_WithoutGroupBy_AllComputed() + { + await Seed(); + var results = await Query("SELECT COUNT(1) AS cnt, SUM(c.value) AS total FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(4); + results[0]["total"]!.Value().Should().Be(100); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4265,55 +4271,55 @@ public async Task MultipleAggregates_WithoutGroupBy_AllComputed() public class QueryJoinDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_WithOrderBy_SortsCrossProduct() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "b", "a" } }), new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator("SELECT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Join_WithTop_LimitsCrossProduct() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator("SELECT VALUE t FROM c JOIN t IN c.tags"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(3); - } - - [Fact] - public async Task Join_MixedEmptyAndNonEmpty_ExpandsCorrectDocuments() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b" } }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = Array.Empty() }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", tags = new[] { "c" } }), new PartitionKey("pk1")); - - var results = await Query("SELECT c.id, t AS tag FROM c JOIN t IN c.tags"); - results.Should().HaveCount(3); // 2 from doc 1, 0 from doc 2, 1 from doc 3 - } - - [Fact] - public async Task Join_SelectParentAndJoinedFields_ProjectsBoth() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", tags = new[] { "x", "y" } }), new PartitionKey("pk1")); - var results = await Query("SELECT c.name, t AS tag FROM c JOIN t IN c.tags"); - results.Should().HaveCount(2); - results.Should().AllSatisfy(r => r["name"]!.ToString().Should().Be("Alice")); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_WithOrderBy_SortsCrossProduct() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "b", "a" } }), new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator("SELECT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Join_WithTop_LimitsCrossProduct() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator("SELECT VALUE t FROM c JOIN t IN c.tags"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(3); + } + + [Fact] + public async Task Join_MixedEmptyAndNonEmpty_ExpandsCorrectDocuments() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b" } }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = Array.Empty() }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", tags = new[] { "c" } }), new PartitionKey("pk1")); + + var results = await Query("SELECT c.id, t AS tag FROM c JOIN t IN c.tags"); + results.Should().HaveCount(3); // 2 from doc 1, 0 from doc 2, 1 from doc 3 + } + + [Fact] + public async Task Join_SelectParentAndJoinedFields_ProjectsBoth() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", tags = new[] { "x", "y" } }), new PartitionKey("pk1")); + var results = await Query("SELECT c.name, t AS tag FROM c JOIN t IN c.tags"); + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r["name"]!.ToString().Should().Be("Alice")); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4322,33 +4328,33 @@ public async Task Join_SelectParentAndJoinedFields_ProjectsBoth() public class QuerySubqueryDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Exists_EmptySubquery_ReturnsFalse() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b" } }), new PartitionKey("pk1")); - // filter for tag 'z' which doesn't exist - var results = await Query("SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'z')"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Exists_WithAlwaysTrueSubquery_ReturnsAllItems() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a" } }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "b" } }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Exists_EmptySubquery_ReturnsFalse() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b" } }), new PartitionKey("pk1")); + // filter for tag 'z' which doesn't exist + var results = await Query("SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'z')"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Exists_WithAlwaysTrueSubquery_ReturnsAllItems() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a" } }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "b" } }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); + results.Should().HaveCount(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4357,69 +4363,69 @@ public async Task Exists_WithAlwaysTrueSubquery_ReturnsAllItems() public class QueryPaginationDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task MaxItemCount_One_SingleItemPerPage() - { - for (var i = 1; i <= 3; i++) - await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1" }), new PartitionKey("pk1")); - - var options = new QueryRequestOptions { MaxItemCount = 1 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); - var pages = 0; - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - if (response.Count > 0) pages++; - } - pages.Should().Be(3); - } - - [Fact] - public async Task ContinuationToken_NullOnLastPage() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 10 }); - var response = await iterator.ReadNextAsync(); - response.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task ContinuationToken_WithWhere_FiltersAndPaginates() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1", value = i * 10 }), new PartitionKey("pk1")); - - var allResults = new List(); - var options = new QueryRequestOptions { MaxItemCount = 2 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.value >= 20", requestOptions: options); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - allResults.AddRange(response); - } - allResults.Should().HaveCount(4); - } - - [Fact] - public async Task QueryResponse_HasCorrectStatusCode() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task EmptyQueryResult_ReturnsValidResponse() - { - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'nonexistent'"); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task MaxItemCount_One_SingleItemPerPage() + { + for (var i = 1; i <= 3; i++) + await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1" }), new PartitionKey("pk1")); + + var options = new QueryRequestOptions { MaxItemCount = 1 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); + var pages = 0; + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + if (response.Count > 0) pages++; + } + pages.Should().Be(3); + } + + [Fact] + public async Task ContinuationToken_NullOnLastPage() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 10 }); + var response = await iterator.ReadNextAsync(); + response.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task ContinuationToken_WithWhere_FiltersAndPaginates() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemAsync(JObject.FromObject(new { id = i.ToString(), partitionKey = "pk1", value = i * 10 }), new PartitionKey("pk1")); + + var allResults = new List(); + var options = new QueryRequestOptions { MaxItemCount = 2 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.value >= 20", requestOptions: options); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + allResults.AddRange(response); + } + allResults.Should().HaveCount(4); + } + + [Fact] + public async Task QueryResponse_HasCorrectStatusCode() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task EmptyQueryResult_ReturnsValidResponse() + { + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'nonexistent'"); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4428,68 +4434,68 @@ public async Task EmptyQueryResult_ReturnsValidResponse() public class QueryDataTypeDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Where_IntComparedToFloat_MatchesEquivalentValues() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.value = 10.0"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task Where_LargeNumber_HandledCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 9999999999999L }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.value > 9999999999998"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task Where_UnicodeString_MatchesCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "日本語" }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.name = '日本語'"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task Where_EmptyString_MatchesCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.name = ''"); - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_BooleanInClause_FiltersCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.active IN (true, false)"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Where_DateStringComparison_OrdersCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", createdAt = "2024-01-01" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", createdAt = "2024-06-15" }), new PartitionKey("pk1")); - var results = await Query("SELECT * FROM c WHERE c.createdAt > '2024-03-01'"); - results.Should().HaveCount(1); - results[0]["id"]!.ToString().Should().Be("2"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Where_IntComparedToFloat_MatchesEquivalentValues() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.value = 10.0"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task Where_LargeNumber_HandledCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 9999999999999L }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.value > 9999999999998"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task Where_UnicodeString_MatchesCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "日本語" }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.name = '日本語'"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task Where_EmptyString_MatchesCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.name = ''"); + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_BooleanInClause_FiltersCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.active IN (true, false)"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Where_DateStringComparison_OrdersCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", createdAt = "2024-01-01" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", createdAt = "2024-06-15" }), new PartitionKey("pk1")); + var results = await Query("SELECT * FROM c WHERE c.createdAt > '2024-03-01'"); + results.Should().HaveCount(1); + results[0]["id"]!.ToString().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4498,40 +4504,40 @@ public async Task Where_DateStringComparison_OrdersCorrectly() public class QueryErrorHandlingDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task InvalidSql_ThrowsOrReturnsEmpty() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - // The emulator may throw or return empty - either is acceptable - var act = async () => - { - var iterator = _container.GetItemQueryIterator("NOT A VALID QUERY"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - // Should handle gracefully — not crash with an unhandled exception - try { await act(); } catch { /* Acceptable to throw */ } - } - - [Fact] - public async Task SqlInjection_IsHarmless() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); - // Attempt SQL injection — should not cause side effects - try - { - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = ''; DROP TABLE --'"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - } - catch { /* Acceptable to throw on invalid SQL */ } - // Container should still work fine - var verify = _container.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (verify.HasMoreResults) all.AddRange(await verify.ReadNextAsync()); - all.Should().HaveCount(1); // No data loss - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task InvalidSql_ThrowsOrReturnsEmpty() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + // The emulator may throw or return empty - either is acceptable + var act = async () => + { + var iterator = _container.GetItemQueryIterator("NOT A VALID QUERY"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + // Should handle gracefully — not crash with an unhandled exception + try { await act(); } catch { /* Acceptable to throw */ } + } + + [Fact] + public async Task SqlInjection_IsHarmless() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice" }), new PartitionKey("pk1")); + // Attempt SQL injection — should not cause side effects + try + { + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = ''; DROP TABLE --'"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + } + catch { /* Acceptable to throw on invalid SQL */ } + // Container should still work fine + var verify = _container.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (verify.HasMoreResults) all.AddRange(await verify.ReadNextAsync()); + all.Should().HaveCount(1); // No data loss + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -4540,51 +4546,51 @@ public async Task SqlInjection_IsHarmless() public class QueryDivergentBehaviorDeepDiveTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_MixedNullAndValues_CosmosTypeOrdering() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"partitionKey\":\"pk1\",\"value\":null}"), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"3\",\"partitionKey\":\"pk1\"}"), new PartitionKey("pk1")); // undefined - - var results = await Query("SELECT * FROM c ORDER BY c.value ASC"); - // Cosmos: undefined < null < 10 - results[0]["id"]!.ToString().Should().Be("3"); - results[1]["id"]!.ToString().Should().Be("2"); - results[2]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task CountStar_ParsesAndExecutes() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator("SELECT VALUE COUNT(*) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be(1); - } - - [Fact] - public async Task GroupBy_WithOrderByAggregate_SortsByAggregateValue() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A", value = 10 }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A", value = 20 }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "B", value = 5 }), new PartitionKey("pk1")); - - var results = await Query("SELECT c.cat, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY total ASC"); - results[0]["cat"]!.ToString().Should().Be("B"); - results[1]["cat"]!.ToString().Should().Be("A"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_MixedNullAndValues_CosmosTypeOrdering() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", value = 10 }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"partitionKey\":\"pk1\",\"value\":null}"), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"3\",\"partitionKey\":\"pk1\"}"), new PartitionKey("pk1")); // undefined + + var results = await Query("SELECT * FROM c ORDER BY c.value ASC"); + // Cosmos: undefined < null < 10 + results[0]["id"]!.ToString().Should().Be("3"); + results[1]["id"]!.ToString().Should().Be("2"); + results[2]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task CountStar_ParsesAndExecutes() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator("SELECT VALUE COUNT(*) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task GroupBy_WithOrderByAggregate_SortsByAggregateValue() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A", value = 10 }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A", value = 20 }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "B", value = 5 }), new PartitionKey("pk1")); + + var results = await Query("SELECT c.cat, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY total ASC"); + results[0]["cat"]!.ToString().Should().Be("B"); + results[1]["cat"]!.ToString().Should().Be("A"); + } } @@ -4595,266 +4601,266 @@ public async Task GroupBy_WithOrderByAggregate_SortsByAggregateValue() public class QueryBugFixTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── Bug 1: ValuesEqual epsilon is too loose ── - - [Fact] - public async Task ValuesEqual_CloseButDistinctDoubles_NotEqual() - { - // Bug 1: ValuesEqual uses Math.Abs(l - r) < 0.0001, which makes 1.0 and 1.00009 equal. - // Cosmos DB uses exact IEEE 754 comparison. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":1.0}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":1.00009}""")), - new PartitionKey("pk1")); - - // These are distinct values — both should be returned by IN - var results = await RunQuery("SELECT * FROM c WHERE c.score IN (1.0, 1.00009)"); - results.Should().HaveCount(2, "1.0 and 1.00009 are distinct double values"); - } - - [Fact] - public async Task ValuesEqual_ExactDoubles_AreEqual() - { - // Sanity check: exact same values should still be equal - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":1.5}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.score = 1.5"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task ValuesEqual_DistinctDoubles_InClause_BothMatch() - { - // Values 10.0 and 10.00005 should be treated as distinct - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10.0}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":10.00005}""")), - new PartitionKey("pk1")); - - // WHERE val = 10.0 should only match id=1 - var results = await RunQuery("SELECT * FROM c WHERE c.val = 10.0"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ── Bug 2: AVG in HAVING returns 0.0 for empty group values ── - - [Fact] - public async Task Having_Avg_EmptyGroupValues_DoesNotMatch() - { - // Bug 2: EvaluateHavingAggregate returns 0.0 for AVG with no numeric values, - // which could wrongly match HAVING AVG(c.x) > -1. - // Cosmos DB would return undefined, which should fail any comparison. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","score":20}""")), - new PartitionKey("pk1")); - - // Group "A" has no "score" field at all — AVG should be undefined, not 0.0 - // HAVING AVG(c.score) >= 0 should only match group "B" - var results = await RunQuery( - "SELECT c.cat, AVG(c.score) AS avg FROM c GROUP BY c.cat HAVING AVG(c.score) >= 0"); - - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("B"); - } - - // ── Bug 4: ORDER BY expression returns null for non-numeric types ── - - [Fact] - public async Task OrderBy_FunctionExpression_WithStringResult_SortsCorrectly() - { - // Bug 4: Expression-based ORDER BY casts everything to double. - // Non-numeric results (e.g., LOWER(c.name)) become null, losing sort information. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); - - results[0].Name.Should().Be("Alice"); - results[1].Name.Should().Be("Bob"); - results[2].Name.Should().Be("Charlie"); - } - - // ── Bug 5: UndefinedValue vs UndefinedSortSentinel divergence ── - - [Fact] - public async Task OrderBy_UndefinedValues_SortBeforeNull() - { - // Bug 5: UndefinedValue.Instance falls to default rank 7 in GetTypeRank, - // but should have rank 0 (same as UndefinedSortSentinel). - // The path-based ORDER BY uses UndefinedSortSentinel (correct), - // so this test validates the basic path. See expression-based test for bug trigger. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); // no score field = undefined - - var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); - - // Cosmos type ordering: undefined(0) < null(1) < number(3) - results[0]["id"]!.ToString().Should().Be("3"); // undefined - results[1]["id"]!.ToString().Should().Be("2"); // null - results[2]["id"]!.ToString().Should().Be("1"); // 10 - } - - // ── Bug 6: GROUP BY key delimiter collision ── - - [Fact] - public async Task GroupBy_ValueContainingDelimiter_DoesNotCollide() - { - // Bug 6: Group keys joined with "|". Field values containing "|" cause collisions. - // E.g., GROUP BY c.x, c.y: item {x:"a|b", y:"c"} and {x:"a", y:"b|c"} - // would both produce key "a|b|c", incorrectly merging them. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","x":"a|b","y":"c"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","x":"a","y":"b|c"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT c.x, c.y, COUNT(1) AS cnt FROM c GROUP BY c.x, c.y"); - - // These should form 2 separate groups, each with count 1 - results.Should().HaveCount(2); - results.Should().OnlyContain(r => r["cnt"]!.Value() == 1); - } - - // ── Bug 3: MIN/MAX ignores boolean values ── - - [Fact] - public async Task MinMax_Boolean_ReturnsCorrectValues() - { - // Cosmos DB: MIN(c.isActive) returns false, MAX(c.isActive) returns true - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT MIN(c.active) AS mn, MAX(c.active) AS mx FROM c"); - results.Should().ContainSingle(); - results[0]["mn"]!.Value().Should().BeFalse(); - results[0]["mx"]!.Value().Should().BeTrue(); - } - - /// - /// Companion test verifying MIN/MAX on boolean fields works correctly. - /// Both MIN and MAX should handle booleans with false < true ordering. - /// - [Fact] - public async Task MinMax_Boolean_ReturnsCorrectValues_MixedWithNumeric() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true,"val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false,"val":20}""")), - new PartitionKey("pk1")); - - // MIN/MAX on boolean field - var results = await RunQuery("SELECT MIN(c.active) AS mn, MAX(c.active) AS mx FROM c"); - results.Should().ContainSingle(); - results[0]["mn"]!.Value().Should().BeFalse(); - results[0]["mx"]!.Value().Should().BeTrue(); - - // MIN/MAX on numeric field still works - var numResults = await RunQuery("SELECT MIN(c.val) AS mn, MAX(c.val) AS mx FROM c"); - numResults.Should().ContainSingle(); - numResults[0]["mn"]!.Value().Should().Be(10); - numResults[0]["mx"]!.Value().Should().Be(20); - } - - // ── Bug 7: EXISTS silently catches all exceptions ── - - /// - /// EXISTS with malformed SQL now throws CosmosException(400) like real Cosmos DB. - /// Previously the emulator swallowed the parse error and returned false. - /// - [Fact] - public async Task Exists_MalformedSubquery_ThrowsBadRequest() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a"] }, - new PartitionKey("pk1")); - - // Malformed subquery — should throw 400 Bad Request - var act = () => RunQuery( - """SELECT * FROM c WHERE EXISTS(SELECT BAD SYNTAX HERE)"""); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest); - } - - // ── Bug 8: DISTINCT uses raw string equality ── - - /// - /// Documents that DISTINCT works correctly for the common case: same-source items - /// where Newtonsoft preserves insertion order. This is not a bug for typical usage. - /// The theoretical issue is that after projection that reorders properties, - /// two semantically identical objects with different property orders wouldn't be deduplicated. - /// In practice this rarely happens because all items come from the same serialization path. - /// - [Fact] - public async Task Distinct_ProjectedValues_DeduplicatesCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", IsActive = false }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT DISTINCT c.isActive FROM c"); - - // Two distinct values: true and false - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── Bug 1: ValuesEqual epsilon is too loose ── + + [Fact] + public async Task ValuesEqual_CloseButDistinctDoubles_NotEqual() + { + // Bug 1: ValuesEqual uses Math.Abs(l - r) < 0.0001, which makes 1.0 and 1.00009 equal. + // Cosmos DB uses exact IEEE 754 comparison. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":1.0}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":1.00009}""")), + new PartitionKey("pk1")); + + // These are distinct values — both should be returned by IN + var results = await RunQuery("SELECT * FROM c WHERE c.score IN (1.0, 1.00009)"); + results.Should().HaveCount(2, "1.0 and 1.00009 are distinct double values"); + } + + [Fact] + public async Task ValuesEqual_ExactDoubles_AreEqual() + { + // Sanity check: exact same values should still be equal + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":1.5}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.score = 1.5"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task ValuesEqual_DistinctDoubles_InClause_BothMatch() + { + // Values 10.0 and 10.00005 should be treated as distinct + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10.0}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":10.00005}""")), + new PartitionKey("pk1")); + + // WHERE val = 10.0 should only match id=1 + var results = await RunQuery("SELECT * FROM c WHERE c.val = 10.0"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ── Bug 2: AVG in HAVING returns 0.0 for empty group values ── + + [Fact] + public async Task Having_Avg_EmptyGroupValues_DoesNotMatch() + { + // Bug 2: EvaluateHavingAggregate returns 0.0 for AVG with no numeric values, + // which could wrongly match HAVING AVG(c.x) > -1. + // Cosmos DB would return undefined, which should fail any comparison. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","score":20}""")), + new PartitionKey("pk1")); + + // Group "A" has no "score" field at all — AVG should be undefined, not 0.0 + // HAVING AVG(c.score) >= 0 should only match group "B" + var results = await RunQuery( + "SELECT c.cat, AVG(c.score) AS avg FROM c GROUP BY c.cat HAVING AVG(c.score) >= 0"); + + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("B"); + } + + // ── Bug 4: ORDER BY expression returns null for non-numeric types ── + + [Fact] + public async Task OrderBy_FunctionExpression_WithStringResult_SortsCorrectly() + { + // Bug 4: Expression-based ORDER BY casts everything to double. + // Non-numeric results (e.g., LOWER(c.name)) become null, losing sort information. + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob" }, new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); + + results[0].Name.Should().Be("Alice"); + results[1].Name.Should().Be("Bob"); + results[2].Name.Should().Be("Charlie"); + } + + // ── Bug 5: UndefinedValue vs UndefinedSortSentinel divergence ── + + [Fact] + public async Task OrderBy_UndefinedValues_SortBeforeNull() + { + // Bug 5: UndefinedValue.Instance falls to default rank 7 in GetTypeRank, + // but should have rank 0 (same as UndefinedSortSentinel). + // The path-based ORDER BY uses UndefinedSortSentinel (correct), + // so this test validates the basic path. See expression-based test for bug trigger. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); // no score field = undefined + + var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); + + // Cosmos type ordering: undefined(0) < null(1) < number(3) + results[0]["id"]!.ToString().Should().Be("3"); // undefined + results[1]["id"]!.ToString().Should().Be("2"); // null + results[2]["id"]!.ToString().Should().Be("1"); // 10 + } + + // ── Bug 6: GROUP BY key delimiter collision ── + + [Fact] + public async Task GroupBy_ValueContainingDelimiter_DoesNotCollide() + { + // Bug 6: Group keys joined with "|". Field values containing "|" cause collisions. + // E.g., GROUP BY c.x, c.y: item {x:"a|b", y:"c"} and {x:"a", y:"b|c"} + // would both produce key "a|b|c", incorrectly merging them. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","x":"a|b","y":"c"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","x":"a","y":"b|c"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT c.x, c.y, COUNT(1) AS cnt FROM c GROUP BY c.x, c.y"); + + // These should form 2 separate groups, each with count 1 + results.Should().HaveCount(2); + results.Should().OnlyContain(r => r["cnt"]!.Value() == 1); + } + + // ── Bug 3: MIN/MAX ignores boolean values ── + + [Fact] + public async Task MinMax_Boolean_ReturnsCorrectValues() + { + // Cosmos DB: MIN(c.isActive) returns false, MAX(c.isActive) returns true + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT MIN(c.active) AS mn, MAX(c.active) AS mx FROM c"); + results.Should().ContainSingle(); + results[0]["mn"]!.Value().Should().BeFalse(); + results[0]["mx"]!.Value().Should().BeTrue(); + } + + /// + /// Companion test verifying MIN/MAX on boolean fields works correctly. + /// Both MIN and MAX should handle booleans with false < true ordering. + /// + [Fact] + public async Task MinMax_Boolean_ReturnsCorrectValues_MixedWithNumeric() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true,"val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false,"val":20}""")), + new PartitionKey("pk1")); + + // MIN/MAX on boolean field + var results = await RunQuery("SELECT MIN(c.active) AS mn, MAX(c.active) AS mx FROM c"); + results.Should().ContainSingle(); + results[0]["mn"]!.Value().Should().BeFalse(); + results[0]["mx"]!.Value().Should().BeTrue(); + + // MIN/MAX on numeric field still works + var numResults = await RunQuery("SELECT MIN(c.val) AS mn, MAX(c.val) AS mx FROM c"); + numResults.Should().ContainSingle(); + numResults[0]["mn"]!.Value().Should().Be(10); + numResults[0]["mx"]!.Value().Should().Be(20); + } + + // ── Bug 7: EXISTS silently catches all exceptions ── + + /// + /// EXISTS with malformed SQL now throws CosmosException(400) like real Cosmos DB. + /// Previously the emulator swallowed the parse error and returned false. + /// + [Fact] + public async Task Exists_MalformedSubquery_ThrowsBadRequest() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a"] }, + new PartitionKey("pk1")); + + // Malformed subquery — should throw 400 Bad Request + var act = () => RunQuery( + """SELECT * FROM c WHERE EXISTS(SELECT BAD SYNTAX HERE)"""); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest); + } + + // ── Bug 8: DISTINCT uses raw string equality ── + + /// + /// Documents that DISTINCT works correctly for the common case: same-source items + /// where Newtonsoft preserves insertion order. This is not a bug for typical usage. + /// The theoretical issue is that after projection that reorders properties, + /// two semantically identical objects with different property orders wouldn't be deduplicated. + /// In practice this rarely happens because all items come from the same serialization path. + /// + [Fact] + public async Task Distinct_ProjectedValues_DeduplicatesCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", IsActive = false }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT DISTINCT c.isActive FROM c"); + + // Two distinct values: true and false + results.Should().HaveCount(2); + } } @@ -4864,201 +4870,201 @@ await _container.CreateItemAsync( public class QueryCoreEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - // ── A1: Empty container ── - - [Fact] - public async Task SelectAll_EmptyContainer_ReturnsEmpty() - { - var results = await RunQuery("SELECT * FROM c"); - results.Should().BeEmpty(); - } - - // ── A4: ORDER BY all null values ── - - [Fact] - public async Task OrderBy_AllNullValues_ReturnsAllItems() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); - results.Should().HaveCount(2); - } - - // ── A5: ORDER BY all undefined values ── - - [Fact] - public async Task OrderBy_AllUndefinedValues_ReturnsAllItems() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"B"}""")), - new PartitionKey("pk1")); - - // Neither item has a "score" field — ORDER BY on non-existent field - var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); - results.Should().HaveCount(2); - } - - // ── A6: ORDER BY boolean field ── - - [Fact] - public async Task OrderBy_BooleanField_SortsCorrectly() - { - await SeedItems(); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.isActive ASC"); - - // Cosmos: false < true - var firstFew = results.TakeWhile(r => !r.IsActive).ToList(); - var lastFew = results.SkipWhile(r => !r.IsActive).ToList(); - firstFew.Should().HaveCount(2, "false sorts before true"); - lastFew.Should().HaveCount(3, "true values come after false"); - } - - // ── A7: TOP 0 ── - - [Fact] - public async Task Top_Zero_ReturnsEmpty() - { - await SeedItems(); - var results = await RunQuery("SELECT TOP 0 * FROM c"); - results.Should().BeEmpty(); - } - - // ── A8: TOP exceeds item count ── - - [Fact] - public async Task Top_ExceedsItemCount_ReturnsAll() - { - await SeedItems(); - var results = await RunQuery("SELECT TOP 100 * FROM c"); - results.Should().HaveCount(5); - } - - // ── A9: OFFSET exceeds count ── - - [Fact] - public async Task OffsetLimit_OffsetExceedsCount_ReturnsEmpty() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c ORDER BY c.value OFFSET 100 LIMIT 10"); - results.Should().BeEmpty(); - } - - // ── A10: LIMIT 0 ── - - [Fact] - public async Task OffsetLimit_LimitZero_ReturnsEmpty() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c ORDER BY c.value OFFSET 0 LIMIT 0"); - results.Should().BeEmpty(); - } - - // ── A13: SELECT VALUE with non-existent property ── - - [Fact] - public async Task SelectValue_NonExistentProperty_SkipsItems() - { - await SeedItems(); - - // SELECT VALUE c.nonExistent — items where the property is undefined should be skipped - var results = await RunQuery("SELECT VALUE c.nonExistent FROM c"); - - // All items lack "nonExistent" — all should be skipped - results.Should().BeEmpty(); - } - - // ── A14: SELECT VALUE with literal ── - - [Fact] - public async Task SelectValue_Literal_ReturnsLiteralPerDoc() - { - await SeedItems(); - var results = await RunQuery("SELECT VALUE 42 FROM c"); - results.Should().HaveCount(5); - results.Should().OnlyContain(v => v == 42); - } - - // ── A15: String comparison ── - - [Fact] - public async Task Where_LessThan_Strings_ComparesLexicographically() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.name < 'Charlie'"); - - // "Alice" < "Charlie", "Bob" < "Charlie" - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Bob"]); - } - - // ── A2: Multiple nested parentheses ── - - [Fact] - public async Task Where_MultipleNestedParentheses_EvaluatesCorrectly() - { - await SeedItems(); - var results = await RunQuery( - "SELECT * FROM c WHERE ((c.isActive = true AND c.value > 10) OR (c.isActive = false AND c.value < 30)) AND c.partitionKey = 'pk1'"); - - // pk1 items: Alice(10,true), Bob(20,false), Charlie(30,true), Eve(50,false) - // Alice(true,10): (true AND 10>10=false) OR (false AND ...) → false → skip - // Bob(false,20): (false) OR (true AND 20<30=true) → true → match - // Charlie(true,30): (true AND 30>10=true) OR ... → true → match - // Eve(false,50): (false) OR (true AND 50<30=false) → false → skip - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - // ── A3: Double negation ── - - [Fact] - public async Task Where_DoubleNegation_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE NOT NOT c.isActive"); - results.Should().HaveCount(3); - results.Should().OnlyContain(r => r.IsActive); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + // ── A1: Empty container ── + + [Fact] + public async Task SelectAll_EmptyContainer_ReturnsEmpty() + { + var results = await RunQuery("SELECT * FROM c"); + results.Should().BeEmpty(); + } + + // ── A4: ORDER BY all null values ── + + [Fact] + public async Task OrderBy_AllNullValues_ReturnsAllItems() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); + results.Should().HaveCount(2); + } + + // ── A5: ORDER BY all undefined values ── + + [Fact] + public async Task OrderBy_AllUndefinedValues_ReturnsAllItems() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"B"}""")), + new PartitionKey("pk1")); + + // Neither item has a "score" field — ORDER BY on non-existent field + var results = await RunQuery("SELECT * FROM c ORDER BY c.score ASC"); + results.Should().HaveCount(2); + } + + // ── A6: ORDER BY boolean field ── + + [Fact] + public async Task OrderBy_BooleanField_SortsCorrectly() + { + await SeedItems(); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.isActive ASC"); + + // Cosmos: false < true + var firstFew = results.TakeWhile(r => !r.IsActive).ToList(); + var lastFew = results.SkipWhile(r => !r.IsActive).ToList(); + firstFew.Should().HaveCount(2, "false sorts before true"); + lastFew.Should().HaveCount(3, "true values come after false"); + } + + // ── A7: TOP 0 ── + + [Fact] + public async Task Top_Zero_ReturnsEmpty() + { + await SeedItems(); + var results = await RunQuery("SELECT TOP 0 * FROM c"); + results.Should().BeEmpty(); + } + + // ── A8: TOP exceeds item count ── + + [Fact] + public async Task Top_ExceedsItemCount_ReturnsAll() + { + await SeedItems(); + var results = await RunQuery("SELECT TOP 100 * FROM c"); + results.Should().HaveCount(5); + } + + // ── A9: OFFSET exceeds count ── + + [Fact] + public async Task OffsetLimit_OffsetExceedsCount_ReturnsEmpty() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c ORDER BY c.value OFFSET 100 LIMIT 10"); + results.Should().BeEmpty(); + } + + // ── A10: LIMIT 0 ── + + [Fact] + public async Task OffsetLimit_LimitZero_ReturnsEmpty() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c ORDER BY c.value OFFSET 0 LIMIT 0"); + results.Should().BeEmpty(); + } + + // ── A13: SELECT VALUE with non-existent property ── + + [Fact] + public async Task SelectValue_NonExistentProperty_SkipsItems() + { + await SeedItems(); + + // SELECT VALUE c.nonExistent — items where the property is undefined should be skipped + var results = await RunQuery("SELECT VALUE c.nonExistent FROM c"); + + // All items lack "nonExistent" — all should be skipped + results.Should().BeEmpty(); + } + + // ── A14: SELECT VALUE with literal ── + + [Fact] + public async Task SelectValue_Literal_ReturnsLiteralPerDoc() + { + await SeedItems(); + var results = await RunQuery("SELECT VALUE 42 FROM c"); + results.Should().HaveCount(5); + results.Should().OnlyContain(v => v == 42); + } + + // ── A15: String comparison ── + + [Fact] + public async Task Where_LessThan_Strings_ComparesLexicographically() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.name < 'Charlie'"); + + // "Alice" < "Charlie", "Bob" < "Charlie" + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Bob"]); + } + + // ── A2: Multiple nested parentheses ── + + [Fact] + public async Task Where_MultipleNestedParentheses_EvaluatesCorrectly() + { + await SeedItems(); + var results = await RunQuery( + "SELECT * FROM c WHERE ((c.isActive = true AND c.value > 10) OR (c.isActive = false AND c.value < 30)) AND c.partitionKey = 'pk1'"); + + // pk1 items: Alice(10,true), Bob(20,false), Charlie(30,true), Eve(50,false) + // Alice(true,10): (true AND 10>10=false) OR (false AND ...) → false → skip + // Bob(false,20): (false) OR (true AND 20<30=true) → true → match + // Charlie(true,30): (true AND 30>10=true) OR ... → true → match + // Eve(false,50): (false) OR (true AND 50<30=false) → false → skip + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + // ── A3: Double negation ── + + [Fact] + public async Task Where_DoubleNegation_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE NOT NOT c.isActive"); + results.Should().HaveCount(3); + results.Should().OnlyContain(r => r.IsActive); + } } @@ -5068,139 +5074,139 @@ public async Task Where_DoubleNegation_Works() public class QueryAggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - [Fact] - public async Task Count_WithFieldExpression_CountsNonNullOnly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); // no score field - - var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(1, "only 1 item has the 'score' field"); - } - - [Fact] - public async Task Sum_WithNullValues_IgnoresNulls() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT SUM(c.score) AS total FROM c"); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(30); - } - - [Fact] - public async Task Avg_EmptyContainer_ReturnsNoRows() - { - // AVG on empty container — result should still have 1 row but field omitted (undefined) - var results = await RunQuery("SELECT AVG(c.value) AS avg FROM c"); - results.Should().ContainSingle(); - // The avg field should be absent (undefined) - results[0]["avg"].Should().BeNull("AVG of empty set is undefined"); - } - - [Fact] - public async Task Min_WithStrings_ReturnsLexicographicMin() - { - await SeedItems(); - var results = await RunQuery("SELECT MIN(c.name) AS mn FROM c"); - results.Should().ContainSingle(); - results[0]["mn"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Max_WithStrings_ReturnsLexicographicMax() - { - await SeedItems(); - var results = await RunQuery("SELECT MAX(c.name) AS mx FROM c"); - results.Should().ContainSingle(); - results[0]["mx"]!.Value().Should().Be("Eve"); - } - - [Fact] - public async Task MultipleAggregates_NoGroupBy_ReturnsSingleRow() - { - await SeedItems(); - var results = await RunQuery("SELECT COUNT(1) AS cnt, SUM(c.value) AS total, AVG(c.value) AS avg FROM c"); - - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(5); - results[0]["total"]!.Value().Should().Be(150); - results[0]["avg"]!.Value().Should().Be(30); - } - - [Fact] - public async Task Aggregate_WithWhere_FiltersThenAggregates() - { - await SeedItems(); - var results = await RunQuery("SELECT COUNT(1) AS cnt FROM c WHERE c.isActive = true"); - - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(3); - } - - [Fact] - public async Task SelectValue_Sum_ReturnsSingleValue() - { - await SeedItems(); - var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); - results.Should().ContainSingle(); - results[0].Value().Should().Be(150); - } - - [Fact] - public async Task Sum_WithMixedTypes_IgnoresNonNumeric() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"not-a-number"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":20}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT SUM(c.val) AS total FROM c"); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(30); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + [Fact] + public async Task Count_WithFieldExpression_CountsNonNullOnly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); // no score field + + var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(1, "only 1 item has the 'score' field"); + } + + [Fact] + public async Task Sum_WithNullValues_IgnoresNulls() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT SUM(c.score) AS total FROM c"); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(30); + } + + [Fact] + public async Task Avg_EmptyContainer_ReturnsNoRows() + { + // AVG on empty container — result should still have 1 row but field omitted (undefined) + var results = await RunQuery("SELECT AVG(c.value) AS avg FROM c"); + results.Should().ContainSingle(); + // The avg field should be absent (undefined) + results[0]["avg"].Should().BeNull("AVG of empty set is undefined"); + } + + [Fact] + public async Task Min_WithStrings_ReturnsLexicographicMin() + { + await SeedItems(); + var results = await RunQuery("SELECT MIN(c.name) AS mn FROM c"); + results.Should().ContainSingle(); + results[0]["mn"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Max_WithStrings_ReturnsLexicographicMax() + { + await SeedItems(); + var results = await RunQuery("SELECT MAX(c.name) AS mx FROM c"); + results.Should().ContainSingle(); + results[0]["mx"]!.Value().Should().Be("Eve"); + } + + [Fact] + public async Task MultipleAggregates_NoGroupBy_ReturnsSingleRow() + { + await SeedItems(); + var results = await RunQuery("SELECT COUNT(1) AS cnt, SUM(c.value) AS total, AVG(c.value) AS avg FROM c"); + + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(5); + results[0]["total"]!.Value().Should().Be(150); + results[0]["avg"]!.Value().Should().Be(30); + } + + [Fact] + public async Task Aggregate_WithWhere_FiltersThenAggregates() + { + await SeedItems(); + var results = await RunQuery("SELECT COUNT(1) AS cnt FROM c WHERE c.isActive = true"); + + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(3); + } + + [Fact] + public async Task SelectValue_Sum_ReturnsSingleValue() + { + await SeedItems(); + var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); + results.Should().ContainSingle(); + results[0].Value().Should().Be(150); + } + + [Fact] + public async Task Sum_WithMixedTypes_IgnoresNonNumeric() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"not-a-number"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":20}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT SUM(c.val) AS total FROM c"); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(30); + } } @@ -5210,153 +5216,153 @@ await _container.CreateItemStreamAsync( public class QueryWhereEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - [Fact] - public async Task Where_IsNotDefined_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.nonExistent)"); - results.Should().HaveCount(5, "no items have nonExistent field"); - } - - [Fact] - public async Task Where_CompareWithUndefined_ReturnsNoResults() - { - await SeedItems(); - // Comparing an undefined field to a value should not match any items - var results = await RunQuery("SELECT * FROM c WHERE c.missing > 5"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_BooleanFieldDirect_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.isActive"); - results.Should().HaveCount(3); - results.Should().OnlyContain(r => r.IsActive); - } - - [Fact] - public async Task Where_Between_InclusiveBoundaries() - { - await SeedItems(); - // BETWEEN is inclusive on both ends - var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 10 AND 50"); - results.Should().HaveCount(5, "BETWEEN is inclusive on both boundaries"); - } - - [Fact] - public async Task Where_Like_Underscore_MatchesSingleChar() - { - await SeedItems(); - // _ matches exactly one character: _ob should match "Bob" - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '_ob'"); - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task Where_Like_EscapeCharacter_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","pct":"50%"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","pct":"500"}""")), - new PartitionKey("pk1")); - - // Match literal "50%" using escape character - var results = await RunQuery(@"SELECT * FROM c WHERE c.pct LIKE '50\%' ESCAPE '\'"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_NotLike_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); - results.Should().HaveCount(4); - results.Should().NotContain(r => r.Name == "Alice"); - } - - [Fact] - public async Task Where_NotIn_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.name NOT IN ('Alice', 'Bob')"); - results.Should().HaveCount(3); - results.Select(r => r.Name).Should().BeEquivalentTo(["Charlie", "Diana", "Eve"]); - } - - [Fact] - public async Task Where_NotBetween_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.value NOT BETWEEN 20 AND 40"); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Eve"]); - } - - [Fact] - public async Task Where_Modulo_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.value % 20 = 0"); - results.Select(r => r.Value).Should().BeEquivalentTo([20, 40]); - } - - [Fact] - public async Task Where_Division_Works() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE c.value / 10 > 3"); - results.Select(r => r.Name).Should().BeEquivalentTo(["Diana", "Eve"]); - } - - [Fact] - public async Task Where_InWithParameters_Works() - { - await SeedItems(); - // Note: Cosmos SQL IN with parameters requires the parameter to be used individually - var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN (@n1, @n2)") - .WithParameter("@n1", "Alice") - .WithParameter("@n2", "Eve"); - - var results = await RunQuery(query); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Eve"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + [Fact] + public async Task Where_IsNotDefined_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.nonExistent)"); + results.Should().HaveCount(5, "no items have nonExistent field"); + } + + [Fact] + public async Task Where_CompareWithUndefined_ReturnsNoResults() + { + await SeedItems(); + // Comparing an undefined field to a value should not match any items + var results = await RunQuery("SELECT * FROM c WHERE c.missing > 5"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_BooleanFieldDirect_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.isActive"); + results.Should().HaveCount(3); + results.Should().OnlyContain(r => r.IsActive); + } + + [Fact] + public async Task Where_Between_InclusiveBoundaries() + { + await SeedItems(); + // BETWEEN is inclusive on both ends + var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 10 AND 50"); + results.Should().HaveCount(5, "BETWEEN is inclusive on both boundaries"); + } + + [Fact] + public async Task Where_Like_Underscore_MatchesSingleChar() + { + await SeedItems(); + // _ matches exactly one character: _ob should match "Bob" + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '_ob'"); + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task Where_Like_EscapeCharacter_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","pct":"50%"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","pct":"500"}""")), + new PartitionKey("pk1")); + + // Match literal "50%" using escape character + var results = await RunQuery(@"SELECT * FROM c WHERE c.pct LIKE '50\%' ESCAPE '\'"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_NotLike_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); + results.Should().HaveCount(4); + results.Should().NotContain(r => r.Name == "Alice"); + } + + [Fact] + public async Task Where_NotIn_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.name NOT IN ('Alice', 'Bob')"); + results.Should().HaveCount(3); + results.Select(r => r.Name).Should().BeEquivalentTo(["Charlie", "Diana", "Eve"]); + } + + [Fact] + public async Task Where_NotBetween_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.value NOT BETWEEN 20 AND 40"); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Eve"]); + } + + [Fact] + public async Task Where_Modulo_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.value % 20 = 0"); + results.Select(r => r.Value).Should().BeEquivalentTo([20, 40]); + } + + [Fact] + public async Task Where_Division_Works() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE c.value / 10 > 3"); + results.Select(r => r.Name).Should().BeEquivalentTo(["Diana", "Eve"]); + } + + [Fact] + public async Task Where_InWithParameters_Works() + { + await SeedItems(); + // Note: Cosmos SQL IN with parameters requires the parameter to be used individually + var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN (@n1, @n2)") + .WithParameter("@n1", "Alice") + .WithParameter("@n2", "Eve"); + + var results = await RunQuery(query); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Eve"]); + } } @@ -5366,109 +5372,109 @@ public async Task Where_InWithParameters_Works() public class QueryGroupByEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_NullGroupKey_FormsOwnGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null,"val":20}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"A","val":30}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - results.Should().HaveCount(2, "null forms its own group"); - } - - [Fact] - public async Task GroupBy_UndefinedGroupKey_FormsOwnGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":20}""")), - new PartitionKey("pk1")); // no cat field - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"A","val":30}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - results.Should().HaveCount(2, "undefined forms its own group"); - } - - [Fact] - public async Task GroupBy_WithExpression_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"ALICE"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Bob"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT LOWER(c.name) AS lname, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.name)"); - - results.Should().HaveCount(2); - var alice = results.FirstOrDefault(r => r["lname"]?.ToString() == "alice"); - alice.Should().NotBeNull(); - alice!["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_WithoutAggregates_ReturnsFirstPerGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","name":"Bob"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","name":"Charlie"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.cat, c.name FROM c GROUP BY c.cat"); - results.Should().HaveCount(2, "one row per group"); - } - - [Fact] - public async Task GroupBy_Having_WithSum_FiltersGroups() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","val":20}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","val":5}""")), - new PartitionKey("pk1")); - - // SUM(A)=30 > 10, SUM(B)=5 not > 10 - var results = await RunQuery( - "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING SUM(c.val) > 10"); - - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("A"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_NullGroupKey_FormsOwnGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null,"val":20}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"A","val":30}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + results.Should().HaveCount(2, "null forms its own group"); + } + + [Fact] + public async Task GroupBy_UndefinedGroupKey_FormsOwnGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":20}""")), + new PartitionKey("pk1")); // no cat field + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"A","val":30}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + results.Should().HaveCount(2, "undefined forms its own group"); + } + + [Fact] + public async Task GroupBy_WithExpression_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"ALICE"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Bob"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT LOWER(c.name) AS lname, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.name)"); + + results.Should().HaveCount(2); + var alice = results.FirstOrDefault(r => r["lname"]?.ToString() == "alice"); + alice.Should().NotBeNull(); + alice!["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_WithoutAggregates_ReturnsFirstPerGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","name":"Bob"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","name":"Charlie"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.cat, c.name FROM c GROUP BY c.cat"); + results.Should().HaveCount(2, "one row per group"); + } + + [Fact] + public async Task GroupBy_Having_WithSum_FiltersGroups() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","val":20}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","val":5}""")), + new PartitionKey("pk1")); + + // SUM(A)=30 > 10, SUM(B)=5 not > 10 + var results = await RunQuery( + "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING SUM(c.val) > 10"); + + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("A"); + } } @@ -5478,68 +5484,68 @@ await _container.CreateItemStreamAsync( public class QueryJoinEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_MissingArrayField_ExcludesDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "HasTags", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"NoTags"}""")), - new PartitionKey("pk1")); // no tags field at all - - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - results.Should().HaveCount(2, "only the first item has tags"); - } - - [Fact] - public async Task Join_WithAggregateCount_CountsExpandedRows() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); - results.Should().ContainSingle(); - results[0].Value().Should().Be(3, "JOIN expands 3 tags, COUNT counts expanded rows"); - } - - [Fact] - public async Task Join_WithDistinct_DeduplicatesJoinedRows() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test1", Tags = ["a", "b"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Test2", Tags = ["b", "c"] }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); - // a, b, c — "b" appears in both but DISTINCT removes duplicate - results.Should().HaveCount(3); - } - - [Fact] - public async Task Join_WithOrderBy_SortsJoinedRows() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["c", "a", "b"] }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); - var values = results.Select(r => r.Value()).ToList(); - values.Should().ContainInOrder("a", "b", "c"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_MissingArrayField_ExcludesDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "HasTags", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"NoTags"}""")), + new PartitionKey("pk1")); // no tags field at all + + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + results.Should().HaveCount(2, "only the first item has tags"); + } + + [Fact] + public async Task Join_WithAggregateCount_CountsExpandedRows() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); + results.Should().ContainSingle(); + results[0].Value().Should().Be(3, "JOIN expands 3 tags, COUNT counts expanded rows"); + } + + [Fact] + public async Task Join_WithDistinct_DeduplicatesJoinedRows() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test1", Tags = ["a", "b"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Test2", Tags = ["b", "c"] }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); + // a, b, c — "b" appears in both but DISTINCT removes duplicate + results.Should().HaveCount(3); + } + + [Fact] + public async Task Join_WithOrderBy_SortsJoinedRows() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = ["c", "a", "b"] }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); + var values = results.Select(r => r.Value()).ToList(); + values.Should().ContainInOrder("a", "b", "c"); + } } @@ -5549,58 +5555,58 @@ await _container.CreateItemAsync( public class QuerySubqueryEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task Exists_EmptyArray_ReturnsFalse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = [] }, - new PartitionKey("pk1")); + [Fact] + public async Task Exists_EmptyArray_ReturnsFalse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Tags = [] }, + new PartitionKey("pk1")); - var results = await RunQuery( - """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); + var results = await RunQuery( + """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); - results.Should().BeEmpty("empty array means EXISTS has no matches"); - } + results.Should().BeEmpty("empty array means EXISTS has no matches"); + } - [Fact] - public async Task Exists_WithNonExistentField_ReturnsFalse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public async Task Exists_WithNonExistentField_ReturnsFalse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - var results = await RunQuery( - """SELECT * FROM c WHERE EXISTS(SELECT VALUE x FROM x IN c.nonExistent WHERE x = "a")"""); + var results = await RunQuery( + """SELECT * FROM c WHERE EXISTS(SELECT VALUE x FROM x IN c.nonExistent WHERE x = "a")"""); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - [Fact] - public async Task ArraySubquery_WithDistinct_ReturnsUniqueElements() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","items":["a","b","a","c","b"]}""")), - new PartitionKey("pk1")); + [Fact] + public async Task ArraySubquery_WithDistinct_ReturnsUniqueElements() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","items":["a","b","a","c","b"]}""")), + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT ARRAY(SELECT DISTINCT VALUE t FROM t IN c.items) AS unique FROM c WHERE c.id = '1'"); + var results = await RunQuery( + "SELECT ARRAY(SELECT DISTINCT VALUE t FROM t IN c.items) AS unique FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - var unique = results[0]["unique"]!.ToObject(); - unique.Should().HaveCount(3); - unique.Should().Contain(["a", "b", "c"]); - } + results.Should().ContainSingle(); + var unique = results[0]["unique"]!.ToObject(); + unique.Should().HaveCount(3); + unique.Should().Contain(["a", "b", "c"]); + } } @@ -5610,81 +5616,83 @@ await _container.CreateItemStreamAsync( public class QueryProjectionEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_MultipleFields_ReturnsOnlySelectedFields() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.name, c.value FROM c"); - results.Should().ContainSingle(); - results[0].Properties().Select(p => p.Name).Should().BeEquivalentTo(["name", "value"]); - } - - [Fact] - public async Task Select_NonExistentField_PropertyMissing() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.nonExistent FROM c"); - results.Should().ContainSingle(); - // Non-existent field should be absent from the projected result - results[0]["nonExistent"].Should().BeNull(); - } - - [Fact] - public async Task Select_SystemProperties_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.id, c._ts, c._etag FROM c"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - results[0]["_ts"].Should().NotBeNull("_ts is a system property"); - results[0]["_etag"].Should().NotBeNull("_etag is a system property"); - } - - [Fact] - public async Task Select_ComputedExpression_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.value + 10 AS adjusted FROM c"); - results.Should().ContainSingle(); - results[0]["adjusted"]!.Value().Should().Be(20); - } - - [Fact] - public async Task SelectValue_NestedProperty_Works() - { - await _container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "MyNested", Score = 9.5 } - }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE c.nested.description FROM c"); - results.Should().ContainSingle().Which.Should().Be("MyNested"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_MultipleFields_ReturnsOnlySelectedFields() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.name, c.value FROM c"); + results.Should().ContainSingle(); + results[0].Properties().Select(p => p.Name).Should().BeEquivalentTo(["name", "value"]); + } + + [Fact] + public async Task Select_NonExistentField_PropertyMissing() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.nonExistent FROM c"); + results.Should().ContainSingle(); + // Non-existent field should be absent from the projected result + results[0]["nonExistent"].Should().BeNull(); + } + + [Fact] + public async Task Select_SystemProperties_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.id, c._ts, c._etag FROM c"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + results[0]["_ts"].Should().NotBeNull("_ts is a system property"); + results[0]["_etag"].Should().NotBeNull("_etag is a system property"); + } + + [Fact] + public async Task Select_ComputedExpression_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.value + 10 AS adjusted FROM c"); + results.Should().ContainSingle(); + results[0]["adjusted"]!.Value().Should().Be(20); + } + + [Fact] + public async Task SelectValue_NestedProperty_Works() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "MyNested", Score = 9.5 } + }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE c.nested.description FROM c"); + results.Should().ContainSingle().Which.Should().Be("MyNested"); + } } @@ -5694,68 +5702,68 @@ await _container.CreateItemAsync( public class QueryCrossPartitionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20, IsActive = false }, - new PartitionKey("pk2")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30, IsActive = true }, - new PartitionKey("pk3")); - } - - [Fact] - public async Task CrossPartition_SelectAll_ReturnsAllPartitions() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c"); - results.Should().HaveCount(3); - results.Select(r => r.PartitionKey).Should().BeEquivalentTo(["pk1", "pk2", "pk3"]); - } - - [Fact] - public async Task CrossPartition_Count_ReturnsCorrectTotal() - { - await SeedItems(); - var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - results.Should().ContainSingle().Which.Value().Should().Be(3); - } - - [Fact] - public async Task CrossPartition_OrderBy_SortsGlobally() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c ORDER BY c.value DESC"); - results[0].Name.Should().Be("Charlie"); - results[1].Name.Should().Be("Bob"); - results[2].Name.Should().Be("Alice"); - } - - [Fact] - public async Task CrossPartition_GroupBy_GroupsAcrossPartitions() - { - await SeedItems(); - var results = await RunQuery( - "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive"); - - results.Should().HaveCount(2); - var active = results.FirstOrDefault(r => r["isActive"]!.Value() == true); - active.Should().NotBeNull(); - active!["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20, IsActive = false }, + new PartitionKey("pk2")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30, IsActive = true }, + new PartitionKey("pk3")); + } + + [Fact] + public async Task CrossPartition_SelectAll_ReturnsAllPartitions() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c"); + results.Should().HaveCount(3); + results.Select(r => r.PartitionKey).Should().BeEquivalentTo(["pk1", "pk2", "pk3"]); + } + + [Fact] + public async Task CrossPartition_Count_ReturnsCorrectTotal() + { + await SeedItems(); + var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + results.Should().ContainSingle().Which.Value().Should().Be(3); + } + + [Fact] + public async Task CrossPartition_OrderBy_SortsGlobally() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c ORDER BY c.value DESC"); + results[0].Name.Should().Be("Charlie"); + results[1].Name.Should().Be("Bob"); + results[2].Name.Should().Be("Alice"); + } + + [Fact] + public async Task CrossPartition_GroupBy_GroupsAcrossPartitions() + { + await SeedItems(); + var results = await RunQuery( + "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive"); + + results.Should().HaveCount(2); + var active = results.FirstOrDefault(r => r["isActive"]!.Value() == true); + active.Should().NotBeNull(); + active!["cnt"]!.Value().Should().Be(2); + } } @@ -5765,225 +5773,227 @@ public async Task CrossPartition_GroupBy_GroupsAcrossPartitions() public class QueryBuiltInFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - // ── String functions ── - - [Fact] - public async Task StartsWith_CaseSensitive_Default() - { - await SeedItems(); - var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "a")"""); - results.Should().BeEmpty("STARTSWITH is case-sensitive by default"); - } - - [Fact] - public async Task StartsWith_CaseInsensitive() - { - await SeedItems(); - var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "a", true)"""); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task EndsWith_Works() - { - await SeedItems(); - var results = await RunQuery("""SELECT * FROM c WHERE ENDSWITH(c.name, "ce")"""); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Upper_Lower_InProjection() - { - await SeedItems(); - var results = await RunQuery("SELECT UPPER(c.name) AS up, LOWER(c.name) AS lo FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["up"]!.ToString().Should().Be("ALICE"); - results[0]["lo"]!.ToString().Should().Be("alice"); - } - - [Fact] - public async Task Concat_InProjection() - { - await SeedItems(); - var results = await RunQuery( - "SELECT VALUE CONCAT(c.name, '-', c.partitionKey) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Value().Should().Be("Alice-pk1"); - } - - [Fact] - public async Task Length_InWhere() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE LENGTH(c.name) > 3"); - // Alice(5), Charlie(7) > 3. Bob(3) not > 3. - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); - } - - [Fact] - public async Task Substring_InProjection() - { - await SeedItems(); - var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 0, 3) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Value().Should().Be("Ali"); - } - - [Fact] - public async Task Replace_InProjection() - { - await SeedItems(); - var results = await RunQuery("""SELECT VALUE REPLACE(c.name, "Al", "X") FROM c WHERE c.id = '1'"""); - results.Should().ContainSingle().Which.Value().Should().Be("Xice"); - } - - // ── Type checking functions ── - - [Fact] - public async Task IsNumber_InWhere() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"not-num"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE IS_NUMBER(c.val)"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task IsString_InWhere() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"hello"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE IS_STRING(c.val)"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task IsArray_OnArrayField() - { - await SeedItems(); - var results = await RunQuery("SELECT * FROM c WHERE IS_ARRAY(c.tags)"); - results.Should().HaveCount(3); - } - - [Fact] - public async Task IsObject_OnNestedField() - { - await _container.CreateItemAsync( - new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "Test", - Nested = new NestedObject { Description = "x", Score = 1.0 } - }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NoNested" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE IS_OBJECT(c.nested)"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ── Array functions ── - - [Fact] - public async Task ArrayLength_InProjection() - { - await SeedItems(); - var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.tags) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Value().Should().Be(2); - } - - [Fact] - public async Task ArrayContains_SimpleValue() - { - await SeedItems(); - var results = await RunQuery("""SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, "a")"""); - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); - } - - [Fact] - public async Task ArraySlice_Works() - { - await SeedItems(); - var results = await RunQuery( - "SELECT ARRAY_SLICE(c.tags, 0, 1) AS sliced FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - var sliced = results[0]["sliced"]!.ToObject(); - sliced.Should().Equal("a"); - } - - // ── Math functions ── - - [Fact] - public async Task Abs_Floor_Ceiling_Round_InProjection() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":-3.7}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT ABS(c.val) AS a, FLOOR(c.val) AS f, CEILING(c.val) AS ce, ROUND(c.val) AS r FROM c"); - results.Should().ContainSingle(); - results[0]["a"]!.Value().Should().Be(3.7); - results[0]["f"]!.Value().Should().Be(-4); - results[0]["ce"]!.Value().Should().Be(-3); - results[0]["r"]!.Value().Should().Be(-4); - } - - [Fact] - public async Task Power_Sqrt_InProjection() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":16}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT POWER(c.val, 2) AS p, SQRT(c.val) AS s FROM c"); - results.Should().ContainSingle(); - results[0]["p"]!.Value().Should().Be(256); - results[0]["s"]!.Value().Should().Be(4); - } - - [Fact] - public async Task Left_Right_InProjection() - { - await SeedItems(); - var results = await RunQuery( - "SELECT LEFT(c.name, 3) AS l, RIGHT(c.name, 2) AS r FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["l"]!.ToString().Should().Be("Ali"); - results[0]["r"]!.ToString().Should().Be("ce"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + // ── String functions ── + + [Fact] + public async Task StartsWith_CaseSensitive_Default() + { + await SeedItems(); + var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "a")"""); + results.Should().BeEmpty("STARTSWITH is case-sensitive by default"); + } + + [Fact] + public async Task StartsWith_CaseInsensitive() + { + await SeedItems(); + var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "a", true)"""); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task EndsWith_Works() + { + await SeedItems(); + var results = await RunQuery("""SELECT * FROM c WHERE ENDSWITH(c.name, "ce")"""); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Upper_Lower_InProjection() + { + await SeedItems(); + var results = await RunQuery("SELECT UPPER(c.name) AS up, LOWER(c.name) AS lo FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["up"]!.ToString().Should().Be("ALICE"); + results[0]["lo"]!.ToString().Should().Be("alice"); + } + + [Fact] + public async Task Concat_InProjection() + { + await SeedItems(); + var results = await RunQuery( + "SELECT VALUE CONCAT(c.name, '-', c.partitionKey) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Value().Should().Be("Alice-pk1"); + } + + [Fact] + public async Task Length_InWhere() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE LENGTH(c.name) > 3"); + // Alice(5), Charlie(7) > 3. Bob(3) not > 3. + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); + } + + [Fact] + public async Task Substring_InProjection() + { + await SeedItems(); + var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 0, 3) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Value().Should().Be("Ali"); + } + + [Fact] + public async Task Replace_InProjection() + { + await SeedItems(); + var results = await RunQuery("""SELECT VALUE REPLACE(c.name, "Al", "X") FROM c WHERE c.id = '1'"""); + results.Should().ContainSingle().Which.Value().Should().Be("Xice"); + } + + // ── Type checking functions ── + + [Fact] + public async Task IsNumber_InWhere() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"not-num"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE IS_NUMBER(c.val)"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task IsString_InWhere() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"hello"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE IS_STRING(c.val)"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task IsArray_OnArrayField() + { + await SeedItems(); + var results = await RunQuery("SELECT * FROM c WHERE IS_ARRAY(c.tags)"); + results.Should().HaveCount(3); + } + + [Fact] + public async Task IsObject_OnNestedField() + { + await _container.CreateItemAsync( + new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "x", Score = 1.0 } + }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "NoNested" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE IS_OBJECT(c.nested)"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ── Array functions ── + + [Fact] + public async Task ArrayLength_InProjection() + { + await SeedItems(); + var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.tags) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Value().Should().Be(2); + } + + [Fact] + public async Task ArrayContains_SimpleValue() + { + await SeedItems(); + var results = await RunQuery("""SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, "a")"""); + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); + } + + [Fact] + public async Task ArraySlice_Works() + { + await SeedItems(); + var results = await RunQuery( + "SELECT ARRAY_SLICE(c.tags, 0, 1) AS sliced FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + var sliced = results[0]["sliced"]!.ToObject(); + sliced.Should().Equal("a"); + } + + // ── Math functions ── + + [Fact] + public async Task Abs_Floor_Ceiling_Round_InProjection() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":-3.7}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT ABS(c.val) AS a, FLOOR(c.val) AS f, CEILING(c.val) AS ce, ROUND(c.val) AS r FROM c"); + results.Should().ContainSingle(); + results[0]["a"]!.Value().Should().Be(3.7); + results[0]["f"]!.Value().Should().Be(-4); + results[0]["ce"]!.Value().Should().Be(-3); + results[0]["r"]!.Value().Should().Be(-4); + } + + [Fact] + public async Task Power_Sqrt_InProjection() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":16}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT POWER(c.val, 2) AS p, SQRT(c.val) AS s FROM c"); + results.Should().ContainSingle(); + results[0]["p"]!.Value().Should().Be(256); + results[0]["s"]!.Value().Should().Be(4); + } + + [Fact] + public async Task Left_Right_InProjection() + { + await SeedItems(); + var results = await RunQuery( + "SELECT LEFT(c.name, 3) AS l, RIGHT(c.name, 2) AS r FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["l"]!.ToString().Should().Be("Ali"); + results[0]["r"]!.ToString().Should().Be("ce"); + } } @@ -5993,68 +6003,68 @@ public async Task Left_Right_InProjection() public class QueryNumericEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Where_FloatingPointEquality_ExactMatch() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":9.5}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.score = 9.5"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task OrderBy_IntegersAndDoubles_SortsNumerically() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":2.5}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":5}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.val ASC"); - results[0]["id"]!.ToString().Should().Be("2"); // 2.5 - results[1]["id"]!.ToString().Should().Be("3"); // 5 - results[2]["id"]!.ToString().Should().Be("1"); // 10 - } - - [Fact] - public async Task Where_ScientificNotation_Parses() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1500}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val > 1e3"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Arithmetic_DivisionByZero_ReturnsNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - - // Division by zero should either return null/undefined or Infinity — not crash - var act = async () => await RunQuery("SELECT c.val / 0 AS result FROM c"); - await act.Should().NotThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Where_FloatingPointEquality_ExactMatch() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":9.5}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.score = 9.5"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task OrderBy_IntegersAndDoubles_SortsNumerically() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":2.5}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":5}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.val ASC"); + results[0]["id"]!.ToString().Should().Be("2"); // 2.5 + results[1]["id"]!.ToString().Should().Be("3"); // 5 + results[2]["id"]!.ToString().Should().Be("1"); // 10 + } + + [Fact] + public async Task Where_ScientificNotation_Parses() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1500}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val > 1e3"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Arithmetic_DivisionByZero_ReturnsNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + + // Division by zero should either return null/undefined or Infinity — not crash + var act = async () => await RunQuery("SELECT c.val / 0 AS result FROM c"); + await act.Should().NotThrowAsync(); + } } @@ -6064,55 +6074,55 @@ await _container.CreateItemStreamAsync( public class QueryParserEdgeCaseTests { - [Fact] - public void Parse_UnknownFunction_ParsesWithoutCrash() - { - // Unknown functions should still tokenize/parse as FunctionCallExpression - var parsed = CosmosSqlParser.Parse("SELECT UNKNOWNFUNC(c.id) AS result FROM c"); - parsed.SelectFields.Should().HaveCount(1); - parsed.SelectFields[0].SqlExpr.Should().BeOfType(); - } - - [Fact] - public void ExprToString_RoundTrip_PreservesSemantics() - { - var queries = new[] - { - "SELECT * FROM c WHERE c.value > 10", - "SELECT c.name, c.value FROM c ORDER BY c.value ASC", - "SELECT DISTINCT c.isActive FROM c", - "SELECT VALUE c.name FROM c", - "SELECT * FROM c WHERE c.name LIKE 'A%'", - }; - - foreach (var query in queries) - { - var parsed = CosmosSqlParser.Parse(query); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - // Re-parsing the simplified query should not throw - var reparsed = CosmosSqlParser.Parse(simplified); - reparsed.Should().NotBeNull(); - } - } - - [Fact] - public void Parse_CaseInsensitiveKeywords_AllWork() - { - // All keyword variations should parse identically - var queries = new[] - { - "select * from c where c.value > 10", - "SELECT * FROM c WHERE c.value > 10", - "Select * From c Where c.value > 10", - }; - - foreach (var query in queries) - { - var parsed = CosmosSqlParser.Parse(query); - parsed.IsSelectAll.Should().BeTrue(); - parsed.WhereExpr.Should().NotBeNull(); - } - } + [Fact] + public void Parse_UnknownFunction_ParsesWithoutCrash() + { + // Unknown functions should still tokenize/parse as FunctionCallExpression + var parsed = CosmosSqlParser.Parse("SELECT UNKNOWNFUNC(c.id) AS result FROM c"); + parsed.SelectFields.Should().HaveCount(1); + parsed.SelectFields[0].SqlExpr.Should().BeOfType(); + } + + [Fact] + public void ExprToString_RoundTrip_PreservesSemantics() + { + var queries = new[] + { + "SELECT * FROM c WHERE c.value > 10", + "SELECT c.name, c.value FROM c ORDER BY c.value ASC", + "SELECT DISTINCT c.isActive FROM c", + "SELECT VALUE c.name FROM c", + "SELECT * FROM c WHERE c.name LIKE 'A%'", + }; + + foreach (var query in queries) + { + var parsed = CosmosSqlParser.Parse(query); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + // Re-parsing the simplified query should not throw + var reparsed = CosmosSqlParser.Parse(simplified); + reparsed.Should().NotBeNull(); + } + } + + [Fact] + public void Parse_CaseInsensitiveKeywords_AllWork() + { + // All keyword variations should parse identically + var queries = new[] + { + "select * from c where c.value > 10", + "SELECT * FROM c WHERE c.value > 10", + "Select * From c Where c.value > 10", + }; + + foreach (var query in queries) + { + var parsed = CosmosSqlParser.Parse(query); + parsed.IsSelectAll.Should().BeTrue(); + parsed.WhereExpr.Should().NotBeNull(); + } + } } @@ -6122,319 +6132,319 @@ public void Parse_CaseInsensitiveKeywords_AllWork() public class QueryDeepDiveV2_Phase1_BugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── Bug 13: GROUP BY + JOIN interaction ── - - [Fact] - public async Task GroupBy_AfterJoin_GroupsByExpandedAlias() - { - // GROUP BY on a JOIN alias should group expanded rows by their value - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "a" } }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "b", "c" } }), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); - - // Tags expanded: a, b, a, b, c → GROUP BY: a(2), b(2), c(1) - results.Should().HaveCount(3); - var tagA = results.FirstOrDefault(r => r["tag"]?.ToString() == "a"); - tagA.Should().NotBeNull(); - tagA!["cnt"]!.Value().Should().Be(2); - var tagB = results.FirstOrDefault(r => r["tag"]?.ToString() == "b"); - tagB.Should().NotBeNull(); - tagB!["cnt"]!.Value().Should().Be(2); - var tagC = results.FirstOrDefault(r => r["tag"]?.ToString() == "c"); - tagC.Should().NotBeNull(); - tagC!["cnt"]!.Value().Should().Be(1); - } - - // ── Bug 12: ORDER BY arithmetic expression ── - - [Fact] - public async Task OrderBy_ArithmeticExpression_SortsCorrectly() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 30 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 20 }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.value * 2 ASC"); - - results[0].Value.Should().Be(10); // 10*2=20 - results[1].Value.Should().Be(20); // 20*2=40 - results[2].Value.Should().Be(30); // 30*2=60 - } - - // ── Bug 15: Subquery scalar in WHERE comparison ── - - [Fact] - public async Task Where_SubqueryComparison_ScalarInWhere() - { - // WHERE (SELECT VALUE COUNT(1) FROM t IN c.tags) > 1 - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Tags = ["a", "b", "c"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Tags = ["x"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Tags = [] }, - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT * FROM c WHERE (SELECT VALUE COUNT(1) FROM t IN c.tags) > 1"); - - // Only Alice has >1 tag - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── Bug 11: IS NULL for undefined fields ── - - [Fact] - public async Task Where_IsNull_SqlSyntax_MatchesExplicitNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Active" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.status IS NULL"); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_IsNull_SqlSyntax_DoesNotMatchUndefined() - { - // Item without "status" field at all — IS NULL should NOT match undefined - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.status IS NULL"); - - // In Cosmos DB: undefined field IS NULL → false. The emulator treats undefined = null for comparison purposes. - // If this passes empty, the emulator correctly distinguishes undefined from null. - // If it matches, we need to document the divergence. - results.Should().BeEmpty("undefined field should not match IS NULL in Cosmos DB"); - } - - [Fact] - public async Task Where_IsNotNull_SqlSyntax_MatchesNonNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":"active"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.status IS NOT NULL"); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ── Bug 16: NOT EXISTS ── - - [Fact] - public async Task Where_NotExists_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Tags = ["urgent", "review"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Tags = ["review"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Tags = [] }, - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "urgent")"""); - - // Bob and Charlie don't have "urgent" tag - results.Should().HaveCount(2); - results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - // ── Bug 10: Non-boolean WHERE truthiness ── - - [Fact] - public async Task Where_NonBooleanTruthiness_NumberInWhere_ShouldNotMatch() - { - // WHERE c.value (where c.value=10) — Cosmos DB: only boolean true is truthy - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.value"); - - // Cosmos DB strict boolean: should not match (10 is not boolean true) - results.Should().BeEmpty(); - } - - /// - /// Verifies that WHERE with a boolean field works correctly (true matches, false doesn't). - /// - [Fact] - public async Task Where_BooleanTruthiness_BooleanFieldWorks() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Active", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Inactive", Value = 20, IsActive = false }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.isActive"); - - // Only boolean true matches - results.Should().ContainSingle().Which.Name.Should().Be("Active"); - } - - // ── Bug 14: String concat (||) in SELECT ── - - [Fact] - public async Task Select_StringConcat_InProjection() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","first":"John","last":"Doe"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT c.first || ' ' || c.last AS fullName FROM c"""); - - results.Should().ContainSingle(); - results[0]["fullName"]!.Value().Should().Be("John Doe"); - } - - // ── Bug 17: Null parameter value ── - - [Fact] - public async Task Parameter_NullValue_ComparesWithExplicitNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Active" }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.status = @p") - .WithParameter("@p", null); - - var results = await RunQuery(query); - - // Should match the item with explicit null - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Parameter_NullValue_DoesNotMatchUndefined() - { - // Item without "status" field at all - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.status = @p") - .WithParameter("@p", null); - - var results = await RunQuery(query); - - // undefined field compared to null parameter should not match (undefined != null) - results.Should().BeEmpty("undefined field should not match null parameter"); - } - - // ── Bug 18: HAVING COUNT(c.field) should count defined only ── - - [Fact] - public async Task Having_CountField_CountsDefinedOnly() - { - // In Cosmos DB: HAVING COUNT(c.optional) only counts items where c.optional is defined - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); // no "opt" field - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","opt":"yes"}""")), - new PartitionKey("pk1")); - - // Group A: 2 items, only 1 has "opt" → COUNT(c.opt) = 1 - // Group B: 2 items, both have "opt" → COUNT(c.opt) = 2 - // HAVING COUNT(c.opt) >= 2 → only group B matches - var results = await RunQuery( - "SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); - - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("B"); - } - - /// - /// Companion test: verifies HAVING COUNT(c.field) correctly counts only defined fields. - /// Also verifies COUNT(1) still counts all items. - /// - [Fact] - public async Task Having_CountField_CountsDefinedOnly_Companion() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); // no "opt" field - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","opt":"yes"}""")), - new PartitionKey("pk1")); - - // COUNT(1) counts all items — both groups have 2 - var allResults = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); - allResults.Should().HaveCount(2, "COUNT(1) counts all items in each group"); - - // COUNT(c.opt) counts defined only — group A has 1, group B has 2 - var definedResults = await RunQuery( - "SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); - definedResults.Should().ContainSingle(); - definedResults[0]["cat"]!.ToString().Should().Be("B"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── Bug 13: GROUP BY + JOIN interaction ── + + [Fact] + public async Task GroupBy_AfterJoin_GroupsByExpandedAlias() + { + // GROUP BY on a JOIN alias should group expanded rows by their value + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "a" } }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "b", "c" } }), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); + + // Tags expanded: a, b, a, b, c → GROUP BY: a(2), b(2), c(1) + results.Should().HaveCount(3); + var tagA = results.FirstOrDefault(r => r["tag"]?.ToString() == "a"); + tagA.Should().NotBeNull(); + tagA!["cnt"]!.Value().Should().Be(2); + var tagB = results.FirstOrDefault(r => r["tag"]?.ToString() == "b"); + tagB.Should().NotBeNull(); + tagB!["cnt"]!.Value().Should().Be(2); + var tagC = results.FirstOrDefault(r => r["tag"]?.ToString() == "c"); + tagC.Should().NotBeNull(); + tagC!["cnt"]!.Value().Should().Be(1); + } + + // ── Bug 12: ORDER BY arithmetic expression ── + + [Fact] + public async Task OrderBy_ArithmeticExpression_SortsCorrectly() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 30 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C", Value = 20 }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.value * 2 ASC"); + + results[0].Value.Should().Be(10); // 10*2=20 + results[1].Value.Should().Be(20); // 20*2=40 + results[2].Value.Should().Be(30); // 30*2=60 + } + + // ── Bug 15: Subquery scalar in WHERE comparison ── + + [Fact] + public async Task Where_SubqueryComparison_ScalarInWhere() + { + // WHERE (SELECT VALUE COUNT(1) FROM t IN c.tags) > 1 + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Tags = ["a", "b", "c"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Tags = ["x"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Tags = [] }, + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT * FROM c WHERE (SELECT VALUE COUNT(1) FROM t IN c.tags) > 1"); + + // Only Alice has >1 tag + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── Bug 11: IS NULL for undefined fields ── + + [Fact] + public async Task Where_IsNull_SqlSyntax_MatchesExplicitNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Active" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.status IS NULL"); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_IsNull_SqlSyntax_DoesNotMatchUndefined() + { + // Item without "status" field at all — IS NULL should NOT match undefined + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.status IS NULL"); + + // In Cosmos DB: undefined field IS NULL → false. The emulator treats undefined = null for comparison purposes. + // If this passes empty, the emulator correctly distinguishes undefined from null. + // If it matches, we need to document the divergence. + results.Should().BeEmpty("undefined field should not match IS NULL in Cosmos DB"); + } + + [Fact] + public async Task Where_IsNotNull_SqlSyntax_MatchesNonNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":"active"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.status IS NOT NULL"); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ── Bug 16: NOT EXISTS ── + + [Fact] + public async Task Where_NotExists_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Tags = ["urgent", "review"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Tags = ["review"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Tags = [] }, + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "urgent")"""); + + // Bob and Charlie don't have "urgent" tag + results.Should().HaveCount(2); + results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + // ── Bug 10: Non-boolean WHERE truthiness ── + + [Fact] + public async Task Where_NonBooleanTruthiness_NumberInWhere_ShouldNotMatch() + { + // WHERE c.value (where c.value=10) — Cosmos DB: only boolean true is truthy + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.value"); + + // Cosmos DB strict boolean: should not match (10 is not boolean true) + results.Should().BeEmpty(); + } + + /// + /// Verifies that WHERE with a boolean field works correctly (true matches, false doesn't). + /// + [Fact] + public async Task Where_BooleanTruthiness_BooleanFieldWorks() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Active", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Inactive", Value = 20, IsActive = false }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.isActive"); + + // Only boolean true matches + results.Should().ContainSingle().Which.Name.Should().Be("Active"); + } + + // ── Bug 14: String concat (||) in SELECT ── + + [Fact] + public async Task Select_StringConcat_InProjection() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","first":"John","last":"Doe"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT c.first || ' ' || c.last AS fullName FROM c"""); + + results.Should().ContainSingle(); + results[0]["fullName"]!.Value().Should().Be("John Doe"); + } + + // ── Bug 17: Null parameter value ── + + [Fact] + public async Task Parameter_NullValue_ComparesWithExplicitNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Active" }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.status = @p") + .WithParameter("@p", null); + + var results = await RunQuery(query); + + // Should match the item with explicit null + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Parameter_NullValue_DoesNotMatchUndefined() + { + // Item without "status" field at all + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.status = @p") + .WithParameter("@p", null); + + var results = await RunQuery(query); + + // undefined field compared to null parameter should not match (undefined != null) + results.Should().BeEmpty("undefined field should not match null parameter"); + } + + // ── Bug 18: HAVING COUNT(c.field) should count defined only ── + + [Fact] + public async Task Having_CountField_CountsDefinedOnly() + { + // In Cosmos DB: HAVING COUNT(c.optional) only counts items where c.optional is defined + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); // no "opt" field + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","opt":"yes"}""")), + new PartitionKey("pk1")); + + // Group A: 2 items, only 1 has "opt" → COUNT(c.opt) = 1 + // Group B: 2 items, both have "opt" → COUNT(c.opt) = 2 + // HAVING COUNT(c.opt) >= 2 → only group B matches + var results = await RunQuery( + "SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); + + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("B"); + } + + /// + /// Companion test: verifies HAVING COUNT(c.field) correctly counts only defined fields. + /// Also verifies COUNT(1) still counts all items. + /// + [Fact] + public async Task Having_CountField_CountsDefinedOnly_Companion() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); // no "opt" field + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B","opt":"yes"}""")), + new PartitionKey("pk1")); + + // COUNT(1) counts all items — both groups have 2 + var allResults = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); + allResults.Should().HaveCount(2, "COUNT(1) counts all items in each group"); + + // COUNT(c.opt) counts defined only — group A has 1, group B has 2 + var definedResults = await RunQuery( + "SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); + definedResults.Should().ContainSingle(); + definedResults[0]["cat"]!.ToString().Should().Be("B"); + } } @@ -6444,387 +6454,387 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV2_Phase2_FeatureInteractionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - // ── A3: ORDER BY with function + arithmetic combined ── - - [Fact] - public async Task OrderBy_FunctionAndArithmetic_Combined() - { - await SeedItems(); - - // ORDER BY c.value + LENGTH(c.name) - // Alice: 10+5=15, Bob: 20+3=23, Charlie: 30+7=37, Diana: 40+5=45, Eve: 50+3=53 - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.value + LENGTH(c.name) ASC"); - - results[0].Name.Should().Be("Alice"); // 15 - results[1].Name.Should().Be("Bob"); // 23 - results[2].Name.Should().Be("Charlie"); // 37 - results[3].Name.Should().Be("Diana"); // 45 - results[4].Name.Should().Be("Eve"); // 53 - } - - // ── A4: DISTINCT with ORDER BY on the same field ── - - [Fact] - public async Task Distinct_WithOrderBy_OnSameField() - { - await SeedItems(); - - var results = await RunQuery( - "SELECT DISTINCT c.partitionKey FROM c ORDER BY c.partitionKey ASC"); - - results.Should().HaveCount(2); - results[0]["partitionKey"]!.ToString().Should().Be("pk1"); - results[1]["partitionKey"]!.ToString().Should().Be("pk2"); - } - - // ── A5: GROUP BY with JOIN and WHERE ── - - [Fact] - public async Task GroupBy_WithJoin_AndWhere_FiltersBeforeGrouping() - { - await SeedItems(); - - // Only active items, then expand tags, group by tag - var results = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags WHERE c.isActive = true GROUP BY t"); - - // Active items: Alice(a,b), Charlie(a,c), Diana(d) - // Tags: a(2), b(1), c(1), d(1) - var tagA = results.FirstOrDefault(r => r["tag"]?.ToString() == "a"); - tagA.Should().NotBeNull(); - tagA!["cnt"]!.Value().Should().Be(2); - } - - // ── A7: TOP 1 ── - - [Fact] - public async Task Top1_ReturnsExactlyOne() - { - await SeedItems(); - - var results = await RunQuery( - "SELECT TOP 1 * FROM c ORDER BY c.value DESC"); - - results.Should().ContainSingle().Which.Value.Should().Be(50); - } - - // ── A8: JOIN + GROUP BY + HAVING ── - - [Fact] - public async Task Join_WithGroupBy_AndHaving() - { - await SeedItems(); - - // Expand tags, group by tag, filter groups with HAVING - var results = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) > 1"); - - // All tags expanded: a(Alice,Charlie,Eve=3), b(Alice,Bob=2), c(Bob,Charlie=2), d(Diana=1) - // HAVING COUNT(1) > 1 → a(3), b(2), c(2) - results.Should().HaveCount(3); - results.Select(r => r["tag"]!.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - // ── B7: Ternary in WHERE ── - - [Fact] - public async Task Where_TernaryInWhere_EvaluatesCorrectly() - { - await SeedItems(); - - // Use ternary to conditionally select a value for comparison - var results = await RunQuery( - "SELECT * FROM c WHERE (c.isActive ? c.value : 0) > 10"); - - // Active items with value > 10: Charlie(30), Diana(40) - // Inactive items evaluate to 0, so 0 > 10 = false - results.Select(r => r.Name).Should().BeEquivalentTo(["Charlie", "Diana"]); - } - - // ── B8: Coalesce in WHERE ── - - [Fact] - public async Task Where_CoalesceInWhere_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","nickname":"Ali","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), - new PartitionKey("pk1")); // no nickname - - var results = await RunQuery( - "SELECT * FROM c WHERE (c.nickname ?? c.name) = 'Ali'"); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ── B9: Function on both sides of comparison ── - - [Fact] - public async Task Where_FunctionOnBothSides_Works() - { - await SeedItems(); - - var query = new QueryDefinition( - "SELECT * FROM c WHERE LOWER(c.name) = LOWER(@name)") - .WithParameter("@name", "ALICE"); - - var results = await RunQuery(query); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── B11: IN with null ── - - [Fact] - public async Task Where_InWithNullValue_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":3}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, null, 3)"); - - results.Should().HaveCount(3); - } - - // ── B12: IN with single value ── - - [Fact] - public async Task Where_InWithSingleValue_Works() - { - await SeedItems(); - - var results = await RunQuery("SELECT * FROM c WHERE c.name IN ('Alice')"); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── B13: IN with mixed types ── - - [Fact] - public async Task Where_InWithMixedTypes_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"two"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":true}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, 'two', true)"); - - results.Should().HaveCount(3); - } - - // ── C2: Nested object literal in SELECT ── - - [Fact] - public async Task Select_NestedObjectLiteral_InProjection() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT VALUE {"info": {"name": c.name, "doubled": c.value * 2}} FROM c"""); - - results.Should().ContainSingle(); - results[0]["info"]!["name"]!.Value().Should().Be("Alice"); - results[0]["info"]!["doubled"]!.Value().Should().Be(60); - } - - // ── C3: Coalesce in projection ── - - [Fact] - public async Task Select_CoalesceInProjection_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","nickname":"Ali","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE c.nickname ?? c.name FROM c ORDER BY c.name"); - - results[0].Value().Should().Be("Ali"); // nickname exists → Ali - results[1].Value().Should().Be("Bob"); // no nickname → falls back to name - } - - // ── C4: Ternary + computed in projection ── - - [Fact] - public async Task Select_TernaryInProjection_WithComputedValues() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT c.name, c.isActive ? 'Y' : 'N' AS status, c.value * 2 AS doubled FROM c ORDER BY c.name"); - - results[0]["name"]!.ToString().Should().Be("Alice"); - results[0]["status"]!.ToString().Should().Be("Y"); - results[0]["doubled"]!.Value().Should().Be(20); - results[1]["status"]!.ToString().Should().Be("N"); - } - - // ── C5: SELECT VALUE SUM on empty container ── - - [Fact] - public async Task SelectValue_AggregateSum_EmptyContainer_ReturnsUndefined() - { - // Empty container — SUM of nothing should return undefined (empty results) - var results = await RunQuery("SELECT VALUE SUM(c.val) FROM c"); - - // Cosmos DB: SELECT VALUE SUM on empty set → no result rows - results.Should().BeEmpty("SUM of empty set is undefined, SELECT VALUE skips undefined"); - } - - /// - /// Companion test: verifies AVG also returns undefined on empty set. - /// - [Fact] - public async Task SelectValue_AggregateAvg_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE AVG(c.val) FROM c"); - - results.Should().BeEmpty("AVG of empty set is undefined, SELECT VALUE skips undefined"); - } - - // ── C6: SELECT VALUE COUNT on empty container ── - - [Fact] - public async Task SelectValue_AggregateCount_EmptyContainer_ReturnsZero() - { - var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - - // COUNT always returns a number, even on empty set → 0 - results.Should().ContainSingle().Which.Should().Be(0); - } - - // ── C7: SELECT * with JOIN includes joined field ── - - [Fact] - public async Task Select_Star_WithJoin_IncludesJoinedField() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "x", "y" } }), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c JOIN t IN c.tags"); - - results.Should().HaveCount(2); - // The joined alias "t" should appear as a property in SELECT * - results.Should().AllSatisfy(r => r.ContainsKey("t").Should().BeTrue()); - } - - // ── D3-D5: Parameter type edge cases ── - - [Fact] - public async Task Parameter_BooleanValue_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", IsActive = false }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.isActive = @active") - .WithParameter("@active", true); - - var results = await RunQuery(query); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task Parameter_DoubleValue_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min") - .WithParameter("@min", 10.5); - - var results = await RunQuery(query); - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task Parameter_MissingParameter_HandlesGracefully() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Query references @p but it's not provided - var act = async () => - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = @notProvided"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - - // Should not crash — either return empty or throw a clear error - await act.Should().NotThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + // ── A3: ORDER BY with function + arithmetic combined ── + + [Fact] + public async Task OrderBy_FunctionAndArithmetic_Combined() + { + await SeedItems(); + + // ORDER BY c.value + LENGTH(c.name) + // Alice: 10+5=15, Bob: 20+3=23, Charlie: 30+7=37, Diana: 40+5=45, Eve: 50+3=53 + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.value + LENGTH(c.name) ASC"); + + results[0].Name.Should().Be("Alice"); // 15 + results[1].Name.Should().Be("Bob"); // 23 + results[2].Name.Should().Be("Charlie"); // 37 + results[3].Name.Should().Be("Diana"); // 45 + results[4].Name.Should().Be("Eve"); // 53 + } + + // ── A4: DISTINCT with ORDER BY on the same field ── + + [Fact] + public async Task Distinct_WithOrderBy_OnSameField() + { + await SeedItems(); + + var results = await RunQuery( + "SELECT DISTINCT c.partitionKey FROM c ORDER BY c.partitionKey ASC"); + + results.Should().HaveCount(2); + results[0]["partitionKey"]!.ToString().Should().Be("pk1"); + results[1]["partitionKey"]!.ToString().Should().Be("pk2"); + } + + // ── A5: GROUP BY with JOIN and WHERE ── + + [Fact] + public async Task GroupBy_WithJoin_AndWhere_FiltersBeforeGrouping() + { + await SeedItems(); + + // Only active items, then expand tags, group by tag + var results = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags WHERE c.isActive = true GROUP BY t"); + + // Active items: Alice(a,b), Charlie(a,c), Diana(d) + // Tags: a(2), b(1), c(1), d(1) + var tagA = results.FirstOrDefault(r => r["tag"]?.ToString() == "a"); + tagA.Should().NotBeNull(); + tagA!["cnt"]!.Value().Should().Be(2); + } + + // ── A7: TOP 1 ── + + [Fact] + public async Task Top1_ReturnsExactlyOne() + { + await SeedItems(); + + var results = await RunQuery( + "SELECT TOP 1 * FROM c ORDER BY c.value DESC"); + + results.Should().ContainSingle().Which.Value.Should().Be(50); + } + + // ── A8: JOIN + GROUP BY + HAVING ── + + [Fact] + public async Task Join_WithGroupBy_AndHaving() + { + await SeedItems(); + + // Expand tags, group by tag, filter groups with HAVING + var results = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) > 1"); + + // All tags expanded: a(Alice,Charlie,Eve=3), b(Alice,Bob=2), c(Bob,Charlie=2), d(Diana=1) + // HAVING COUNT(1) > 1 → a(3), b(2), c(2) + results.Should().HaveCount(3); + results.Select(r => r["tag"]!.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + // ── B7: Ternary in WHERE ── + + [Fact] + public async Task Where_TernaryInWhere_EvaluatesCorrectly() + { + await SeedItems(); + + // Use ternary to conditionally select a value for comparison + var results = await RunQuery( + "SELECT * FROM c WHERE (c.isActive ? c.value : 0) > 10"); + + // Active items with value > 10: Charlie(30), Diana(40) + // Inactive items evaluate to 0, so 0 > 10 = false + results.Select(r => r.Name).Should().BeEquivalentTo(["Charlie", "Diana"]); + } + + // ── B8: Coalesce in WHERE ── + + [Fact] + public async Task Where_CoalesceInWhere_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","nickname":"Ali","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), + new PartitionKey("pk1")); // no nickname + + var results = await RunQuery( + "SELECT * FROM c WHERE (c.nickname ?? c.name) = 'Ali'"); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ── B9: Function on both sides of comparison ── + + [Fact] + public async Task Where_FunctionOnBothSides_Works() + { + await SeedItems(); + + var query = new QueryDefinition( + "SELECT * FROM c WHERE LOWER(c.name) = LOWER(@name)") + .WithParameter("@name", "ALICE"); + + var results = await RunQuery(query); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── B11: IN with null ── + + [Fact] + public async Task Where_InWithNullValue_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":3}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, null, 3)"); + + results.Should().HaveCount(3); + } + + // ── B12: IN with single value ── + + [Fact] + public async Task Where_InWithSingleValue_Works() + { + await SeedItems(); + + var results = await RunQuery("SELECT * FROM c WHERE c.name IN ('Alice')"); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── B13: IN with mixed types ── + + [Fact] + public async Task Where_InWithMixedTypes_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"two"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":true}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, 'two', true)"); + + results.Should().HaveCount(3); + } + + // ── C2: Nested object literal in SELECT ── + + [Fact] + public async Task Select_NestedObjectLiteral_InProjection() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT VALUE {"info": {"name": c.name, "doubled": c.value * 2}} FROM c"""); + + results.Should().ContainSingle(); + results[0]["info"]!["name"]!.Value().Should().Be("Alice"); + results[0]["info"]!["doubled"]!.Value().Should().Be(60); + } + + // ── C3: Coalesce in projection ── + + [Fact] + public async Task Select_CoalesceInProjection_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","nickname":"Ali","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","name":"Bob"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE c.nickname ?? c.name FROM c ORDER BY c.name"); + + results[0].Value().Should().Be("Ali"); // nickname exists → Ali + results[1].Value().Should().Be("Bob"); // no nickname → falls back to name + } + + // ── C4: Ternary + computed in projection ── + + [Fact] + public async Task Select_TernaryInProjection_WithComputedValues() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT c.name, c.isActive ? 'Y' : 'N' AS status, c.value * 2 AS doubled FROM c ORDER BY c.name"); + + results[0]["name"]!.ToString().Should().Be("Alice"); + results[0]["status"]!.ToString().Should().Be("Y"); + results[0]["doubled"]!.Value().Should().Be(20); + results[1]["status"]!.ToString().Should().Be("N"); + } + + // ── C5: SELECT VALUE SUM on empty container ── + + [Fact] + public async Task SelectValue_AggregateSum_EmptyContainer_ReturnsUndefined() + { + // Empty container — SUM of nothing should return undefined (empty results) + var results = await RunQuery("SELECT VALUE SUM(c.val) FROM c"); + + // Cosmos DB: SELECT VALUE SUM on empty set → no result rows + results.Should().BeEmpty("SUM of empty set is undefined, SELECT VALUE skips undefined"); + } + + /// + /// Companion test: verifies AVG also returns undefined on empty set. + /// + [Fact] + public async Task SelectValue_AggregateAvg_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE AVG(c.val) FROM c"); + + results.Should().BeEmpty("AVG of empty set is undefined, SELECT VALUE skips undefined"); + } + + // ── C6: SELECT VALUE COUNT on empty container ── + + [Fact] + public async Task SelectValue_AggregateCount_EmptyContainer_ReturnsZero() + { + var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + + // COUNT always returns a number, even on empty set → 0 + results.Should().ContainSingle().Which.Should().Be(0); + } + + // ── C7: SELECT * with JOIN includes joined field ── + + [Fact] + public async Task Select_Star_WithJoin_IncludesJoinedField() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "x", "y" } }), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c JOIN t IN c.tags"); + + results.Should().HaveCount(2); + // The joined alias "t" should appear as a property in SELECT * + results.Should().AllSatisfy(r => r.ContainsKey("t").Should().BeTrue()); + } + + // ── D3-D5: Parameter type edge cases ── + + [Fact] + public async Task Parameter_BooleanValue_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", IsActive = false }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.isActive = @active") + .WithParameter("@active", true); + + var results = await RunQuery(query); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Parameter_DoubleValue_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.value > @min") + .WithParameter("@min", 10.5); + + var results = await RunQuery(query); + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task Parameter_MissingParameter_HandlesGracefully() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Query references @p but it's not provided + var act = async () => + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = @notProvided"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + + // Should not crash — either return empty or throw a clear error + await act.Should().NotThrowAsync(); + } } @@ -6834,391 +6844,391 @@ await _container.CreateItemAsync( public class QueryDeepDiveV2_Phase3_LikeEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task Like_EmptyPattern_MatchesEmptyStringsOnly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":""}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Like_EmptyPattern_MatchesEmptyStringsOnly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":""}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } - [Fact] - public async Task Like_PercentOnly_MatchesEverything() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":""}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Like_PercentOnly_MatchesEverything() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":""}""")), + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%'"); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%'"); - results.Should().HaveCount(2); - } + results.Should().HaveCount(2); + } - [Fact] - public async Task Like_SpecialRegexChars_TreatedAsLiterals() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","pattern":"test.+*?"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","pattern":"testXXXX"}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Like_SpecialRegexChars_TreatedAsLiterals() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","pattern":"test.+*?"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","pattern":"testXXXX"}""")), + new PartitionKey("pk1")); - // Regex chars .+*? should be treated as literal characters, not regex operators - var results = await RunQuery("SELECT * FROM c WHERE c.pattern LIKE 'test.+*?'"); + // Regex chars .+*? should be treated as literal characters, not regex operators + var results = await RunQuery("SELECT * FROM c WHERE c.pattern LIKE 'test.+*?'"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } - [Fact] - public async Task Like_CaseSensitive_Default() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Like_CaseSensitive_Default() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'alice'"); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'alice'"); - results.Should().BeEmpty("LIKE is case-sensitive by default"); - } + results.Should().BeEmpty("LIKE is case-sensitive by default"); + } } public class QueryDeepDiveV2_Phase3_BetweenEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50 }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - [Fact] - public async Task Between_ReversedBounds_ReturnsEmpty() - { - await SeedItems(); - - // BETWEEN 50 AND 10 — reversed bounds should match nothing - var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 50 AND 10"); - - results.Should().BeEmpty("reversed BETWEEN bounds should match nothing"); - } - - [Fact] - public async Task Between_EqualBounds_ExactMatch() - { - await SeedItems(); - - var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 20 AND 20"); - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task Between_WithStrings_LexicographicRange() - { - await SeedItems(); - - // Between 'A' and 'D' — Alice, Bob, Charlie (lexicographically) - var results = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'D'"); - - results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie"]); - } - - [Fact] - public async Task Between_WithNegativeNumbers_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":-5}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":-15}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":5}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN -10 AND -1"); - - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50 }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + [Fact] + public async Task Between_ReversedBounds_ReturnsEmpty() + { + await SeedItems(); + + // BETWEEN 50 AND 10 — reversed bounds should match nothing + var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 50 AND 10"); + + results.Should().BeEmpty("reversed BETWEEN bounds should match nothing"); + } + + [Fact] + public async Task Between_EqualBounds_ExactMatch() + { + await SeedItems(); + + var results = await RunQuery("SELECT * FROM c WHERE c.value BETWEEN 20 AND 20"); + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task Between_WithStrings_LexicographicRange() + { + await SeedItems(); + + // Between 'A' and 'D' — Alice, Bob, Charlie (lexicographically) + var results = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'D'"); + + results.Select(r => r.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie"]); + } + + [Fact] + public async Task Between_WithNegativeNumbers_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":-5}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":-15}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":5}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN -10 AND -1"); + + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } public class QueryDeepDiveV2_Phase3_InEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task In_WithNullValue_MatchesExplicitNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); + [Fact] + public async Task In_WithNullValue_MatchesExplicitNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (null)"); + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (null)"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } - [Fact] - public async Task In_WithBooleanValues_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false}""")), - new PartitionKey("pk1")); + [Fact] + public async Task In_WithBooleanValues_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false}""")), + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.active IN (true)"); + var results = await RunQuery("SELECT * FROM c WHERE c.active IN (true)"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } public class QueryDeepDiveV2_Phase3_AggregateEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Sum_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT SUM(c.val) AS s FROM c"); - - results.Should().ContainSingle(); - // SUM of empty set → undefined, field should be absent - results[0]["s"].Should().BeNull("SUM of empty set is undefined"); - } - - /// - /// Companion: AVG of empty set is also undefined. - /// - [Fact] - public async Task Avg_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT AVG(c.val) AS a FROM c"); - - results.Should().ContainSingle(); - results[0]["a"].Should().BeNull("AVG of empty set is undefined"); - } - - [Fact] - public async Task Min_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT MIN(c.val) AS mn FROM c"); - - results.Should().ContainSingle(); - results[0]["mn"].Should().BeNull("MIN of empty set is undefined"); - } - - [Fact] - public async Task Max_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT MAX(c.val) AS mx FROM c"); - - results.Should().ContainSingle(); - results[0]["mx"].Should().BeNull("MAX of empty set is undefined"); - } - - [Fact] - public async Task Aggregate_AfterJoin_CountsExpandedRows() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), - new PartitionKey("pk1")); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "x" } }), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); - - // 3 tags from doc 1 + 1 tag from doc 2 = 4 expanded rows - results.Should().ContainSingle().Which.Should().Be(4); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Sum_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT SUM(c.val) AS s FROM c"); + + results.Should().ContainSingle(); + // SUM of empty set → undefined, field should be absent + results[0]["s"].Should().BeNull("SUM of empty set is undefined"); + } + + /// + /// Companion: AVG of empty set is also undefined. + /// + [Fact] + public async Task Avg_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT AVG(c.val) AS a FROM c"); + + results.Should().ContainSingle(); + results[0]["a"].Should().BeNull("AVG of empty set is undefined"); + } + + [Fact] + public async Task Min_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT MIN(c.val) AS mn FROM c"); + + results.Should().ContainSingle(); + results[0]["mn"].Should().BeNull("MIN of empty set is undefined"); + } + + [Fact] + public async Task Max_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT MAX(c.val) AS mx FROM c"); + + results.Should().ContainSingle(); + results[0]["mx"].Should().BeNull("MAX of empty set is undefined"); + } + + [Fact] + public async Task Aggregate_AfterJoin_CountsExpandedRows() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), + new PartitionKey("pk1")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk1", tags = new[] { "x" } }), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); + + // 3 tags from doc 1 + 1 tag from doc 2 = 4 expanded rows + results.Should().ContainSingle().Which.Should().Be(4); + } } public class QueryDeepDiveV2_Phase3_FromAliasTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task FromAlias_Root_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task FromAlias_Root_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT * FROM root WHERE root.name = 'Alice'"); + var results = await RunQuery( + "SELECT * FROM root WHERE root.name = 'Alice'"); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } - [Fact] - public async Task FromAlias_Doc_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 20 }, - new PartitionKey("pk1")); + [Fact] + public async Task FromAlias_Doc_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 20 }, + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT * FROM doc WHERE doc.value > 10"); + var results = await RunQuery( + "SELECT * FROM doc WHERE doc.value > 10"); - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } - [Fact] - public async Task FromAlias_ShortName_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + [Fact] + public async Task FromAlias_ShortName_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT * FROM x WHERE x.id = '1'"); + var results = await RunQuery( + "SELECT * FROM x WHERE x.id = '1'"); - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } } public class QueryDeepDiveV2_Phase3_ConcurrencyTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task MultipleIterators_SameContainer_IndependentResults() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); + [Fact] + public async Task MultipleIterators_SameContainer_IndependentResults() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); - // Two iterators with different queries on the same container - var iter1 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - var iter2 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); + // Two iterators with different queries on the same container + var iter1 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + var iter2 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); - var results1 = new List(); - while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); + var results1 = new List(); + while (iter1.HasMoreResults) results1.AddRange(await iter1.ReadNextAsync()); - var results2 = new List(); - while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); + var results2 = new List(); + while (iter2.HasMoreResults) results2.AddRange(await iter2.ReadNextAsync()); - results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); - results2.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } + results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); + results2.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } - [Fact] - public async Task QueryAfterDelete_ReflectsCurrentState() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); + [Fact] + public async Task QueryAfterDelete_ReflectsCurrentState() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); - // Delete one item - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + // Delete one item + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c"); + var results = await RunQuery("SELECT * FROM c"); - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } - [Fact] - public async Task QueryAfterUpsert_ReflectsCurrentState() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task QueryAfterUpsert_ReflectsCurrentState() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); - // Upsert to change value - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 99 }, - new PartitionKey("pk1")); + // Upsert to change value + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 99 }, + new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c"); + var results = await RunQuery("SELECT * FROM c"); - results.Should().ContainSingle().Which.Value.Should().Be(99); - } + results.Should().ContainSingle().Which.Value.Should().Be(99); + } } @@ -7228,71 +7238,71 @@ await _container.UpsertItemAsync( public class QueryDeepDiveV2_Phase4_ParserTests { - [Fact] - public void Parse_IsNull_SqlSyntax_ProducesCorrectAst() - { - var parsed = CosmosSqlParser.Parse("SELECT * FROM c WHERE c.status IS NULL"); - - parsed.WhereExpr.Should().NotBeNull(); - // IS NULL should parse as BinaryExpression(c.status, Equal, null) - parsed.WhereExpr.Should().BeOfType(); - var bin = (BinaryExpression)parsed.WhereExpr!; - bin.Operator.Should().Be(BinaryOp.Equal); - bin.Right.Should().BeOfType(); - ((LiteralExpression)bin.Right).Value.Should().BeNull(); - } - - [Fact] - public void Parse_IsNotNull_SqlSyntax_ProducesCorrectAst() - { - var parsed = CosmosSqlParser.Parse("SELECT * FROM c WHERE c.status IS NOT NULL"); - - parsed.WhereExpr.Should().NotBeNull(); - parsed.WhereExpr.Should().BeOfType(); - var bin = (BinaryExpression)parsed.WhereExpr!; - bin.Operator.Should().Be(BinaryOp.NotEqual); - bin.Right.Should().BeOfType(); - ((LiteralExpression)bin.Right).Value.Should().BeNull(); - } - - [Fact] - public void Parse_NotExists_ProducesCorrectAst() - { - var parsed = CosmosSqlParser.Parse( - """SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); - - parsed.WhereExpr.Should().NotBeNull(); - // Should parse as UnaryExpression(Not, ExistsExpression) - parsed.WhereExpr.Should().BeOfType(); - var unary = (UnaryExpression)parsed.WhereExpr!; - unary.Operator.Should().Be(UnaryOp.Not); - unary.Operand.Should().BeOfType(); - } - - [Fact] - public void Parse_OrderByExpression_Arithmetic() - { - var parsed = CosmosSqlParser.Parse("SELECT * FROM c ORDER BY c.value * 2 ASC"); - - parsed.OrderByFields.Should().HaveCount(1); - parsed.OrderByFields![0].Ascending.Should().BeTrue(); - // The ORDER BY field should have an Expression (not just a Field path) - parsed.OrderByFields[0].Expression.Should().NotBeNull(); - parsed.OrderByFields[0].Expression.Should().BeOfType(); - } - - [Fact] - public void Parse_StringConcatOperator_InSelect() - { - var parsed = CosmosSqlParser.Parse( - """SELECT c.first || ' ' || c.last AS fullName FROM c"""); - - parsed.SelectFields.Should().HaveCount(1); - parsed.SelectFields[0].Alias.Should().Be("fullName"); - parsed.SelectFields[0].SqlExpr.Should().BeOfType(); - var bin = (BinaryExpression)parsed.SelectFields[0].SqlExpr!; - bin.Operator.Should().Be(BinaryOp.StringConcat); - } + [Fact] + public void Parse_IsNull_SqlSyntax_ProducesCorrectAst() + { + var parsed = CosmosSqlParser.Parse("SELECT * FROM c WHERE c.status IS NULL"); + + parsed.WhereExpr.Should().NotBeNull(); + // IS NULL should parse as BinaryExpression(c.status, Equal, null) + parsed.WhereExpr.Should().BeOfType(); + var bin = (BinaryExpression)parsed.WhereExpr!; + bin.Operator.Should().Be(BinaryOp.Equal); + bin.Right.Should().BeOfType(); + ((LiteralExpression)bin.Right).Value.Should().BeNull(); + } + + [Fact] + public void Parse_IsNotNull_SqlSyntax_ProducesCorrectAst() + { + var parsed = CosmosSqlParser.Parse("SELECT * FROM c WHERE c.status IS NOT NULL"); + + parsed.WhereExpr.Should().NotBeNull(); + parsed.WhereExpr.Should().BeOfType(); + var bin = (BinaryExpression)parsed.WhereExpr!; + bin.Operator.Should().Be(BinaryOp.NotEqual); + bin.Right.Should().BeOfType(); + ((LiteralExpression)bin.Right).Value.Should().BeNull(); + } + + [Fact] + public void Parse_NotExists_ProducesCorrectAst() + { + var parsed = CosmosSqlParser.Parse( + """SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); + + parsed.WhereExpr.Should().NotBeNull(); + // Should parse as UnaryExpression(Not, ExistsExpression) + parsed.WhereExpr.Should().BeOfType(); + var unary = (UnaryExpression)parsed.WhereExpr!; + unary.Operator.Should().Be(UnaryOp.Not); + unary.Operand.Should().BeOfType(); + } + + [Fact] + public void Parse_OrderByExpression_Arithmetic() + { + var parsed = CosmosSqlParser.Parse("SELECT * FROM c ORDER BY c.value * 2 ASC"); + + parsed.OrderByFields.Should().HaveCount(1); + parsed.OrderByFields![0].Ascending.Should().BeTrue(); + // The ORDER BY field should have an Expression (not just a Field path) + parsed.OrderByFields[0].Expression.Should().NotBeNull(); + parsed.OrderByFields[0].Expression.Should().BeOfType(); + } + + [Fact] + public void Parse_StringConcatOperator_InSelect() + { + var parsed = CosmosSqlParser.Parse( + """SELECT c.first || ' ' || c.last AS fullName FROM c"""); + + parsed.SelectFields.Should().HaveCount(1); + parsed.SelectFields[0].Alias.Should().Be("fullName"); + parsed.SelectFields[0].SqlExpr.Should().BeOfType(); + var bin = (BinaryExpression)parsed.SelectFields[0].SqlExpr!; + bin.Operator.Should().Be(BinaryOp.StringConcat); + } } @@ -7302,548 +7312,548 @@ public void Parse_StringConcatOperator_InSelect() public class QueryDeepDiveV3_UntestedFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10}""")), - new PartitionKey("pk1")); - } - - // ── B4: REPLICATE ── - - [Fact] - public async Task Replicate_RepeatString_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ababab"); - } - - [Fact] - public async Task Replicate_ZeroCount_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLICATE('x', 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - // ── B5: INDEX_OF ── - - [Fact] - public async Task IndexOf_FindsSubstringPosition() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE INDEX_OF('Hello World', 'World') FROM c"); - results.Should().ContainSingle().Which.Should().Be(6); - } - - [Fact] - public async Task IndexOf_NotFound_ReturnsNegativeOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE INDEX_OF('Hello', 'xyz') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-1); - } - - // ── B6: TRIM / LTRIM / RTRIM ── - - [Fact] - public async Task Trim_RemovesWhitespace() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE TRIM(' hello ') FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - [Fact] - public async Task LTrim_RemovesLeadingWhitespace() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LTRIM(' hello ') FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello "); - } - - [Fact] - public async Task RTrim_RemovesTrailingWhitespace() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE RTRIM(' hello ') FROM c"); - results.Should().ContainSingle().Which.Should().Be(" hello"); - } - - // ── B7: REVERSE ── - - [Fact] - public async Task Reverse_ReversesString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REVERSE('abcdef') FROM c"); - results.Should().ContainSingle().Which.Should().Be("fedcba"); - } - - // ── B8: SIGN / TRUNC ── - - [Fact] - public async Task Sign_ReturnsCorrectSign() - { - await SeedOne(); - var results = await RunQuery( - "SELECT SIGN(-5) AS neg, SIGN(0) AS zero, SIGN(10) AS pos FROM c"); - results.Should().ContainSingle(); - results[0]["neg"]!.Value().Should().Be(-1); - results[0]["zero"]!.Value().Should().Be(0); - results[0]["pos"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Trunc_TruncatesToInteger() - { - await SeedOne(); - var results = await RunQuery( - "SELECT TRUNC(3.7) AS pos, TRUNC(-3.7) AS neg FROM c"); - results.Should().ContainSingle(); - results[0]["pos"]!.Value().Should().Be(3); - results[0]["neg"]!.Value().Should().Be(-3); - } - - // ── B9: EXP / LOG / LOG10 ── - - [Fact] - public async Task Exp_ReturnsEPower() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE EXP(1) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(Math.E, 0.0001); - } - - [Fact] - public async Task Log_ReturnsNaturalLog() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG(2.718281828) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(1.0, 0.001); - } - - [Fact] - public async Task Log10_ReturnsBase10Log() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG10(1000) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); - } - - // ── B10: Trigonometric functions ── - - [Fact] - public async Task Trig_SinCosTan_ReturnCorrectValues() - { - await SeedOne(); - var results = await RunQuery( - "SELECT SIN(0) AS s, COS(0) AS co, TAN(0) AS t FROM c"); - results.Should().ContainSingle(); - results[0]["s"]!.Value().Should().BeApproximately(0, 0.0001); - results[0]["co"]!.Value().Should().BeApproximately(1, 0.0001); - results[0]["t"]!.Value().Should().BeApproximately(0, 0.0001); - } - - [Fact] - public async Task Trig_InverseFunctions_Work() - { - await SeedOne(); - var results = await RunQuery( - "SELECT ASIN(0) AS asin, ACOS(1) AS acos, ATAN(0) AS atan FROM c"); - results.Should().ContainSingle(); - results[0]["asin"]!.Value().Should().BeApproximately(0, 0.0001); - results[0]["acos"]!.Value().Should().BeApproximately(0, 0.0001); - results[0]["atan"]!.Value().Should().BeApproximately(0, 0.0001); - } - - [Fact] - public async Task DegreesRadians_ConvertCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT DEGREES(3.14159265358979) AS deg, RADIANS(180) AS rad FROM c"); - results.Should().ContainSingle(); - results[0]["deg"]!.Value().Should().BeApproximately(180, 0.01); - results[0]["rad"]!.Value().Should().BeApproximately(Math.PI, 0.0001); - } - - // ── B11: SQUARE ── - - [Fact] - public async Task Square_ReturnsSquared() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SQUARE(7) FROM c"); - results.Should().ContainSingle().Which.Should().Be(49); - } - - // ── B12: RAND ── - - [Fact] - public async Task Rand_ReturnsValueBetweenZeroAndOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE RAND() FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeGreaterThanOrEqualTo(0).And.BeLessThan(1); - } - - // ── B13: NumberBin ── - - [Fact] - public async Task NumberBin_RoundsToNearestBin() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(4.5, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3); - } - - // ── B14: Integer operations ── - - [Fact] - public async Task IntAdd_AddsIntegers() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntAdd(10, 20) FROM c"); - results.Should().ContainSingle().Which.Should().Be(30); - } - - [Fact] - public async Task IntBitOr_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitOr(5, 3) FROM c"); - // 5=101, 3=011, OR=111=7 - results.Should().ContainSingle().Which.Should().Be(7); - } - - [Fact] - public async Task IntBitLeftShift_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitLeftShift(1, 4) FROM c"); - // 1 << 4 = 16 - results.Should().ContainSingle().Which.Should().Be(16); - } - - // ── B15: Date/Time functions ── - - [Fact] - public async Task GetCurrentDateTime_ReturnsISO8601String() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c"); - results.Should().ContainSingle(); - DateTime.TryParse(results[0], out _).Should().BeTrue("should be a parseable date/time string"); - } - - [Fact] - public async Task GetCurrentTimestamp_ReturnsEpochMs() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeGreaterThan(0); - } - - [Fact] - public async Task DateTimeAdd_AddsInterval() - { - await SeedOne(); - var results = await RunQuery( - """SELECT VALUE DateTimeAdd("dd", 1, "2024-01-01T00:00:00Z") FROM c"""); - results.Should().ContainSingle(); - results[0].Should().Contain("2024-01-02"); - } - - [Fact] - public async Task DateTimePart_ExtractsPart() - { - await SeedOne(); - var results = await RunQuery( - """SELECT VALUE DateTimePart("yyyy", "2024-06-15T10:30:00Z") FROM c"""); - results.Should().ContainSingle(); - results[0].Value().Should().Be(2024); - } - - [Fact] - public async Task DateTimeDiff_CalculatesDifference() - { - await SeedOne(); - var results = await RunQuery( - """SELECT VALUE DateTimeDiff("dd", "2024-01-01T00:00:00Z", "2024-01-10T00:00:00Z") FROM c"""); - results.Should().ContainSingle(); - results[0].Value().Should().Be(9); - } - - // ── B16: Set functions ── - - [Fact] - public async Task SetIntersect_ReturnsCommonElements() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":[1,2,3],"b":[2,3,4]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE SetIntersect(c.a, c.b) FROM c"); - results.Should().ContainSingle(); - var arr = results[0].Select(t => t.Value()).OrderBy(x => x).ToList(); - arr.Should().BeEquivalentTo([2, 3]); - } - - [Fact] - public async Task SetUnion_ReturnsCombinedElements() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":[1,2],"b":[2,3]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE SetUnion(c.a, c.b) FROM c"); - results.Should().ContainSingle(); - var arr = results[0].Select(t => t.Value()).OrderBy(x => x).ToList(); - arr.Should().BeEquivalentTo([1, 2, 3]); - } - - [Fact] - public async Task SetDifference_ReturnsElementsInFirstNotSecond() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":[1,2,3],"b":[2,3,4]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE SetDifference(c.a, c.b) FROM c"); - results.Should().ContainSingle(); - var arr = results[0].Select(t => t.Value()).ToList(); - arr.Should().BeEquivalentTo([1]); - } - - // ── B17: IIF ── - - [Fact] - public async Task IIF_TrueCondition_ReturnsTrueValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c"); - results.Should().ContainSingle().Which.Should().Be("yes"); - } - - [Fact] - public async Task IIF_FalseCondition_ReturnsFalseValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IIF(false, 'yes', 'no') FROM c"); - results.Should().ContainSingle().Which.Should().Be("no"); - } - - // ── B19: Spatial functions ── - - [Fact] - public async Task StDistance_ReturnsDistanceBetweenPoints() - { - var point1 = new { type = "Point", coordinates = new[] { 0.0, 0.0 } }; - var point2 = new { type = "Point", coordinates = new[] { 0.0, 1.0 } }; - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", loc = point1, target = point2 }), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE ST_DISTANCE(c.loc, c.target) FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeGreaterThan(0, "distance between two different points should be positive"); - } - - [Fact] - public async Task StIsValid_ReturnsTrueForValidGeoJSON() - { - var point = new { type = "Point", coordinates = new[] { 0.0, 0.0 } }; - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", loc = point }), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE ST_ISVALID(c.loc) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── B20: ARRAY_CONCAT ── - - [Fact] - public async Task ArrayConcat_CombinesTwoArrays() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":[1,2],"b":[3,4]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE ARRAY_CONCAT(c.a, c.b) FROM c"); - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().Equal(1, 2, 3, 4); - } - - // ── B21: ToString / ToNumber / ToBoolean ── - - [Fact] - public async Task ToString_ConvertsNumberToString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ToString(42) FROM c"); - results.Should().ContainSingle().Which.Should().Be("42"); - } - - [Fact] - public async Task ToNumber_ConvertsStringToNumber() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","val":"123.45"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE ToNumber(c.val) FROM c"); - results.Should().ContainSingle().Which.Should().Be(123.45); - } - - [Fact] - public async Task ToBoolean_ConvertsStringToBoolean() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","val":"true"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE ToBoolean(c.val) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── B1: ARRAY_CONTAINS partial matching edge cases ── - - [Fact] - public async Task ArrayContainsPartial_MatchesSubsetOfProperties() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","items":[{"name":"Alice","age":30,"city":"London"},{"name":"Bob","age":25,"city":"Paris"}]}""")), - new PartitionKey("pk1")); - - // Partial match: only check "name" property, ignore "age" and "city" - var results = await RunQuery( - """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name":"Alice"}, true)"""); - results.Should().ContainSingle(); - } - - [Fact] - public async Task ArrayContainsPartial_NoMatch_WhenValueDiffers() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","items":[{"name":"Alice","age":30}]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name":"Bob"}, true)"""); - results.Should().BeEmpty(); - } - - // ── B3: ARRAY_CONTAINS_ANY / ARRAY_CONTAINS_ALL ── - - [Fact] - public async Task ArrayContainsAny_MatchesAtLeastOneElement() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","tags":["a","b","c"]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","tags":["d","e"]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ["a","z"])"""); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ArrayContainsAll_MatchesAllElements() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","tags":["a","b","c"]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","tags":["a","c"]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ["a","b"])"""); - // Only doc 1 has both "a" and "b" - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ArrayContainsAll_PartialMatch_ReturnsEmpty() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","tags":["a","b"]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ["a","b","z"])"""); - results.Should().BeEmpty("doc doesn't contain 'z'"); - } - - // ── B15 continued: IS_FINITE_NUMBER, IS_INTEGER ── - - [Fact] - public async Task IsFiniteNumber_WorksCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","val":42}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE IS_FINITE_NUMBER(c.val) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsInteger_WorksCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","intVal":42,"floatVal":3.14}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT IS_INTEGER(c.intVal) AS isInt, IS_INTEGER(c.floatVal) AS isFloat FROM c"); - results.Should().ContainSingle(); - results[0]["isInt"]!.Value().Should().BeTrue(); - results[0]["isFloat"]!.Value().Should().BeFalse(); - } - - // ── PI function ── - - [Fact] - public async Task Pi_ReturnsPiValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE PI() FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI, 0.0001); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10}""")), + new PartitionKey("pk1")); + } + + // ── B4: REPLICATE ── + + [Fact] + public async Task Replicate_RepeatString_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ababab"); + } + + [Fact] + public async Task Replicate_ZeroCount_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLICATE('x', 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + // ── B5: INDEX_OF ── + + [Fact] + public async Task IndexOf_FindsSubstringPosition() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE INDEX_OF('Hello World', 'World') FROM c"); + results.Should().ContainSingle().Which.Should().Be(6); + } + + [Fact] + public async Task IndexOf_NotFound_ReturnsNegativeOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE INDEX_OF('Hello', 'xyz') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-1); + } + + // ── B6: TRIM / LTRIM / RTRIM ── + + [Fact] + public async Task Trim_RemovesWhitespace() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE TRIM(' hello ') FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + [Fact] + public async Task LTrim_RemovesLeadingWhitespace() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LTRIM(' hello ') FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello "); + } + + [Fact] + public async Task RTrim_RemovesTrailingWhitespace() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE RTRIM(' hello ') FROM c"); + results.Should().ContainSingle().Which.Should().Be(" hello"); + } + + // ── B7: REVERSE ── + + [Fact] + public async Task Reverse_ReversesString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REVERSE('abcdef') FROM c"); + results.Should().ContainSingle().Which.Should().Be("fedcba"); + } + + // ── B8: SIGN / TRUNC ── + + [Fact] + public async Task Sign_ReturnsCorrectSign() + { + await SeedOne(); + var results = await RunQuery( + "SELECT SIGN(-5) AS neg, SIGN(0) AS zero, SIGN(10) AS pos FROM c"); + results.Should().ContainSingle(); + results[0]["neg"]!.Value().Should().Be(-1); + results[0]["zero"]!.Value().Should().Be(0); + results[0]["pos"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Trunc_TruncatesToInteger() + { + await SeedOne(); + var results = await RunQuery( + "SELECT TRUNC(3.7) AS pos, TRUNC(-3.7) AS neg FROM c"); + results.Should().ContainSingle(); + results[0]["pos"]!.Value().Should().Be(3); + results[0]["neg"]!.Value().Should().Be(-3); + } + + // ── B9: EXP / LOG / LOG10 ── + + [Fact] + public async Task Exp_ReturnsEPower() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE EXP(1) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(Math.E, 0.0001); + } + + [Fact] + public async Task Log_ReturnsNaturalLog() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG(2.718281828) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(1.0, 0.001); + } + + [Fact] + public async Task Log10_ReturnsBase10Log() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG10(1000) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); + } + + // ── B10: Trigonometric functions ── + + [Fact] + public async Task Trig_SinCosTan_ReturnCorrectValues() + { + await SeedOne(); + var results = await RunQuery( + "SELECT SIN(0) AS s, COS(0) AS co, TAN(0) AS t FROM c"); + results.Should().ContainSingle(); + results[0]["s"]!.Value().Should().BeApproximately(0, 0.0001); + results[0]["co"]!.Value().Should().BeApproximately(1, 0.0001); + results[0]["t"]!.Value().Should().BeApproximately(0, 0.0001); + } + + [Fact] + public async Task Trig_InverseFunctions_Work() + { + await SeedOne(); + var results = await RunQuery( + "SELECT ASIN(0) AS asin, ACOS(1) AS acos, ATAN(0) AS atan FROM c"); + results.Should().ContainSingle(); + results[0]["asin"]!.Value().Should().BeApproximately(0, 0.0001); + results[0]["acos"]!.Value().Should().BeApproximately(0, 0.0001); + results[0]["atan"]!.Value().Should().BeApproximately(0, 0.0001); + } + + [Fact] + public async Task DegreesRadians_ConvertCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT DEGREES(3.14159265358979) AS deg, RADIANS(180) AS rad FROM c"); + results.Should().ContainSingle(); + results[0]["deg"]!.Value().Should().BeApproximately(180, 0.01); + results[0]["rad"]!.Value().Should().BeApproximately(Math.PI, 0.0001); + } + + // ── B11: SQUARE ── + + [Fact] + public async Task Square_ReturnsSquared() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SQUARE(7) FROM c"); + results.Should().ContainSingle().Which.Should().Be(49); + } + + // ── B12: RAND ── + + [Fact] + public async Task Rand_ReturnsValueBetweenZeroAndOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE RAND() FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeGreaterThanOrEqualTo(0).And.BeLessThan(1); + } + + // ── B13: NumberBin ── + + [Fact] + public async Task NumberBin_RoundsToNearestBin() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(4.5, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3); + } + + // ── B14: Integer operations ── + + [Fact] + public async Task IntAdd_AddsIntegers() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntAdd(10, 20) FROM c"); + results.Should().ContainSingle().Which.Should().Be(30); + } + + [Fact] + public async Task IntBitOr_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitOr(5, 3) FROM c"); + // 5=101, 3=011, OR=111=7 + results.Should().ContainSingle().Which.Should().Be(7); + } + + [Fact] + public async Task IntBitLeftShift_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitLeftShift(1, 4) FROM c"); + // 1 << 4 = 16 + results.Should().ContainSingle().Which.Should().Be(16); + } + + // ── B15: Date/Time functions ── + + [Fact] + public async Task GetCurrentDateTime_ReturnsISO8601String() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c"); + results.Should().ContainSingle(); + DateTime.TryParse(results[0], out _).Should().BeTrue("should be a parseable date/time string"); + } + + [Fact] + public async Task GetCurrentTimestamp_ReturnsEpochMs() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeGreaterThan(0); + } + + [Fact] + public async Task DateTimeAdd_AddsInterval() + { + await SeedOne(); + var results = await RunQuery( + """SELECT VALUE DateTimeAdd("dd", 1, "2024-01-01T00:00:00Z") FROM c"""); + results.Should().ContainSingle(); + results[0].Should().Contain("2024-01-02"); + } + + [Fact] + public async Task DateTimePart_ExtractsPart() + { + await SeedOne(); + var results = await RunQuery( + """SELECT VALUE DateTimePart("yyyy", "2024-06-15T10:30:00Z") FROM c"""); + results.Should().ContainSingle(); + results[0].Value().Should().Be(2024); + } + + [Fact] + public async Task DateTimeDiff_CalculatesDifference() + { + await SeedOne(); + var results = await RunQuery( + """SELECT VALUE DateTimeDiff("dd", "2024-01-01T00:00:00Z", "2024-01-10T00:00:00Z") FROM c"""); + results.Should().ContainSingle(); + results[0].Value().Should().Be(9); + } + + // ── B16: Set functions ── + + [Fact] + public async Task SetIntersect_ReturnsCommonElements() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":[1,2,3],"b":[2,3,4]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE SetIntersect(c.a, c.b) FROM c"); + results.Should().ContainSingle(); + var arr = results[0].Select(t => t.Value()).OrderBy(x => x).ToList(); + arr.Should().BeEquivalentTo([2, 3]); + } + + [Fact] + public async Task SetUnion_ReturnsCombinedElements() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":[1,2],"b":[2,3]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE SetUnion(c.a, c.b) FROM c"); + results.Should().ContainSingle(); + var arr = results[0].Select(t => t.Value()).OrderBy(x => x).ToList(); + arr.Should().BeEquivalentTo([1, 2, 3]); + } + + [Fact] + public async Task SetDifference_ReturnsElementsInFirstNotSecond() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":[1,2,3],"b":[2,3,4]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE SetDifference(c.a, c.b) FROM c"); + results.Should().ContainSingle(); + var arr = results[0].Select(t => t.Value()).ToList(); + arr.Should().BeEquivalentTo([1]); + } + + // ── B17: IIF ── + + [Fact] + public async Task IIF_TrueCondition_ReturnsTrueValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c"); + results.Should().ContainSingle().Which.Should().Be("yes"); + } + + [Fact] + public async Task IIF_FalseCondition_ReturnsFalseValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IIF(false, 'yes', 'no') FROM c"); + results.Should().ContainSingle().Which.Should().Be("no"); + } + + // ── B19: Spatial functions ── + + [Fact] + public async Task StDistance_ReturnsDistanceBetweenPoints() + { + var point1 = new { type = "Point", coordinates = new[] { 0.0, 0.0 } }; + var point2 = new { type = "Point", coordinates = new[] { 0.0, 1.0 } }; + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", loc = point1, target = point2 }), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE ST_DISTANCE(c.loc, c.target) FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeGreaterThan(0, "distance between two different points should be positive"); + } + + [Fact] + public async Task StIsValid_ReturnsTrueForValidGeoJSON() + { + var point = new { type = "Point", coordinates = new[] { 0.0, 0.0 } }; + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", loc = point }), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE ST_ISVALID(c.loc) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── B20: ARRAY_CONCAT ── + + [Fact] + public async Task ArrayConcat_CombinesTwoArrays() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":[1,2],"b":[3,4]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE ARRAY_CONCAT(c.a, c.b) FROM c"); + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().Equal(1, 2, 3, 4); + } + + // ── B21: ToString / ToNumber / ToBoolean ── + + [Fact] + public async Task ToString_ConvertsNumberToString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ToString(42) FROM c"); + results.Should().ContainSingle().Which.Should().Be("42"); + } + + [Fact] + public async Task ToNumber_ConvertsStringToNumber() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","val":"123.45"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE ToNumber(c.val) FROM c"); + results.Should().ContainSingle().Which.Should().Be(123.45); + } + + [Fact] + public async Task ToBoolean_ConvertsStringToBoolean() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","val":"true"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE ToBoolean(c.val) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── B1: ARRAY_CONTAINS partial matching edge cases ── + + [Fact] + public async Task ArrayContainsPartial_MatchesSubsetOfProperties() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","items":[{"name":"Alice","age":30,"city":"London"},{"name":"Bob","age":25,"city":"Paris"}]}""")), + new PartitionKey("pk1")); + + // Partial match: only check "name" property, ignore "age" and "city" + var results = await RunQuery( + """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name":"Alice"}, true)"""); + results.Should().ContainSingle(); + } + + [Fact] + public async Task ArrayContainsPartial_NoMatch_WhenValueDiffers() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","items":[{"name":"Alice","age":30}]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name":"Bob"}, true)"""); + results.Should().BeEmpty(); + } + + // ── B3: ARRAY_CONTAINS_ANY / ARRAY_CONTAINS_ALL ── + + [Fact] + public async Task ArrayContainsAny_MatchesAtLeastOneElement() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","tags":["a","b","c"]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","tags":["d","e"]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT * FROM c WHERE ARRAY_CONTAINS_ANY(c.tags, ["a","z"])"""); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ArrayContainsAll_MatchesAllElements() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","tags":["a","b","c"]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","tags":["a","c"]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ["a","b"])"""); + // Only doc 1 has both "a" and "b" + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ArrayContainsAll_PartialMatch_ReturnsEmpty() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","tags":["a","b"]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT * FROM c WHERE ARRAY_CONTAINS_ALL(c.tags, ["a","b","z"])"""); + results.Should().BeEmpty("doc doesn't contain 'z'"); + } + + // ── B15 continued: IS_FINITE_NUMBER, IS_INTEGER ── + + [Fact] + public async Task IsFiniteNumber_WorksCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","val":42}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE IS_FINITE_NUMBER(c.val) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsInteger_WorksCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","intVal":42,"floatVal":3.14}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT IS_INTEGER(c.intVal) AS isInt, IS_INTEGER(c.floatVal) AS isFloat FROM c"); + results.Should().ContainSingle(); + results[0]["isInt"]!.Value().Should().BeTrue(); + results[0]["isFloat"]!.Value().Should().BeFalse(); + } + + // ── PI function ── + + [Fact] + public async Task Pi_ReturnsPiValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE PI() FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI, 0.0001); + } } @@ -7853,279 +7863,279 @@ public async Task Pi_ReturnsPiValue() public class QueryDeepDiveV3_EdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, - }; - foreach (var item in items) - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - - // ── E1: WHERE with arithmetic on both sides ── - - [Fact] - public async Task Where_ArithmeticOnBothSides_ComparesCorrectly() - { - await SeedItems(); - // c.value * 2 > c.value + 15 → 2v > v+15 → v > 15 - var results = await RunQuery( - "SELECT * FROM c WHERE c.value * 2 > c.value + 15"); - results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie", "Diana", "Eve"]); - } - - // ── E2: ORDER BY with multiple expressions ── - - [Fact] - public async Task OrderBy_MultipleExpressions_SortsCorrectly() - { - await SeedItems(); - // LENGTH(c.name) DESC: Charlie(7), Alice(5), Diana(5), Bob(3), Eve(3) - // then c.value ASC as tiebreaker - var results = await RunQuery( - "SELECT * FROM c ORDER BY LENGTH(c.name) DESC, c.value ASC"); - results[0].Name.Should().Be("Charlie"); // len 7 - } - - // ── E3: GROUP BY + ORDER BY COUNT ── - - [Fact] - public async Task GroupBy_OrderByCount_SortsByGroupSize() - { - await SeedItems(); - // pk1 has 4 items, pk2 has 1 - var results = await RunQuery( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey ORDER BY cnt DESC"); - results[0]["partitionKey"]!.ToString().Should().Be("pk1"); - results[1]["partitionKey"]!.ToString().Should().Be("pk2"); - } - - // ── E4: DISTINCT with multiple projected fields ── - - [Fact] - public async Task Distinct_MultipleFields_DeduplicatesByAllFields() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A", status = "active" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A", status = "active" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "A", status = "inactive" }), new PartitionKey("pk1")); - - var results = await RunQuery("SELECT DISTINCT c.cat, c.status FROM c"); - // (A, active) and (A, inactive) = 2 distinct combos - results.Should().HaveCount(2); - } - - // ── E5: SELECT with aliased system properties ── - - [Fact] - public async Task Select_SystemProperty_Ts_WithAlias_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c._ts AS timestamp FROM c"); - results.Should().ContainSingle(); - results[0]["timestamp"].Should().NotBeNull(); - } - - // ── E6: WHERE comparing two fields from the same document ── - - [Fact] - public async Task Where_CompareTwoFields_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":10,"b":5}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","a":3,"b":8}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.a > c.b"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - // ── E9: STARTSWITH with parameterized prefix ── - - [Fact] - public async Task StartsWith_WithParameter_Works() - { - await SeedItems(); - var query = new QueryDefinition("""SELECT * FROM c WHERE STARTSWITH(c.name, @prefix)""") - .WithParameter("@prefix", "Al"); - var results = await RunQuery(query); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── E10: LIKE with parameter pattern ── - - [Fact] - public async Task Like_WithParameter_Works() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pattern") - .WithParameter("@pattern", "A%"); - var results = await RunQuery(query); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── E11: Multiple aggregates with GROUP BY ── - - [Fact] - public async Task GroupBy_MultipleAggregates_AllComputed() - { - await SeedItems(); - var results = await RunQuery( - "SELECT c.partitionKey, COUNT(1) AS cnt, SUM(c.value) AS total, AVG(c.value) AS avg FROM c GROUP BY c.partitionKey"); - - results.Should().HaveCount(2); - var pk1 = results.First(r => r["partitionKey"]!.ToString() == "pk1"); - pk1["cnt"]!.Value().Should().Be(4); - pk1["total"]!.Value().Should().Be(110); // 10+20+30+50 (pk1 items minus Diana who's pk2 — wait, pk1 = Alice(10)+Bob(20)+Charlie(30)+Eve(50) = actually let me check) - } - - // ── E14: DISTINCT on null values ── - - [Fact] - public async Task Distinct_NullValues_TreatedAsOneGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":null}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT DISTINCT c.cat FROM c"); - // "A" and null = 2 distinct values (both nulls collapse to one) - results.Should().HaveCount(2); - } - - // ── E15: JOIN with WHERE referencing both parent and child ── - - [Fact] - public async Task Join_WhereReferencingParentAndChild_Works() - { - await SeedItems(); - var results = await RunQuery( - """SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.isActive = true AND t = "a" """); - // Active items with tag "a": Alice(a,b) → has "a", Charlie(a,c) → has "a" - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo(["Alice", "Charlie"]); - } - - // ── E16: TOP with ORDER BY DESC ── - - [Fact] - public async Task Top_WithOrderByDesc_ReturnsHighestValues() - { - await SeedItems(); - var results = await RunQuery("SELECT TOP 3 * FROM c ORDER BY c.value DESC"); - results.Should().HaveCount(3); - results[0].Value.Should().Be(50); - results[1].Value.Should().Be(40); - results[2].Value.Should().Be(30); - } - - // ── E17: SELECT c.* alias-dot-star form ── - - [Fact] - public async Task Select_AliasDotStar_ReturnsAllFields() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.* FROM c"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - // ── E19: GROUP BY on undefined value across documents ── - - [Fact] - public async Task GroupBy_SomeUndefined_SomeNull_DistinctGroups() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); // undefined cat - - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - // A, null, undefined = up to 3 groups (or 2 if null/undefined collapse) - results.Should().HaveCountGreaterThanOrEqualTo(2); - } - - // ── E20: Nested function calls in WHERE ── - - [Fact] - public async Task Where_NestedFunctionCalls_Works() - { - await SeedItems(); - var results = await RunQuery( - "SELECT * FROM c WHERE LOWER(LEFT(c.name, 3)) = 'ali'"); - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - // ── E12: SELECT VALUE with object literal containing aggregate ── - - [Fact] - public async Task SelectValue_ObjectWithAggregate_NoGroupBy() - { - await SeedItems(); - var results = await RunQuery( - """SELECT VALUE {"total": SUM(c.value), "cnt": COUNT(1)} FROM c"""); - // Cosmos DB: returns one row {"total": 150, "cnt": 5} - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(5); - } - - /// - /// Companion: SELECT VALUE with array literal containing aggregates. - /// - [Fact] - public async Task SelectValue_ArrayWithAggregate_NoGroupBy() - { - await SeedItems(); - var results = await RunQuery( - """SELECT VALUE [COUNT(1), SUM(c.value)] FROM c"""); - results.Should().ContainSingle(); - results[0][0]!.Value().Should().Be(5); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false, Tags = ["b", "c"] }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["a", "c"] }, + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Diana", Value = 40, IsActive = true, Tags = ["d"] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 50, IsActive = false, Tags = ["a"] }, + }; + foreach (var item in items) + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + + // ── E1: WHERE with arithmetic on both sides ── + + [Fact] + public async Task Where_ArithmeticOnBothSides_ComparesCorrectly() + { + await SeedItems(); + // c.value * 2 > c.value + 15 → 2v > v+15 → v > 15 + var results = await RunQuery( + "SELECT * FROM c WHERE c.value * 2 > c.value + 15"); + results.Select(r => r.Name).Should().BeEquivalentTo(["Bob", "Charlie", "Diana", "Eve"]); + } + + // ── E2: ORDER BY with multiple expressions ── + + [Fact] + public async Task OrderBy_MultipleExpressions_SortsCorrectly() + { + await SeedItems(); + // LENGTH(c.name) DESC: Charlie(7), Alice(5), Diana(5), Bob(3), Eve(3) + // then c.value ASC as tiebreaker + var results = await RunQuery( + "SELECT * FROM c ORDER BY LENGTH(c.name) DESC, c.value ASC"); + results[0].Name.Should().Be("Charlie"); // len 7 + } + + // ── E3: GROUP BY + ORDER BY COUNT ── + + [Fact] + public async Task GroupBy_OrderByCount_SortsByGroupSize() + { + await SeedItems(); + // pk1 has 4 items, pk2 has 1 + var results = await RunQuery( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey ORDER BY cnt DESC"); + results[0]["partitionKey"]!.ToString().Should().Be("pk1"); + results[1]["partitionKey"]!.ToString().Should().Be("pk2"); + } + + // ── E4: DISTINCT with multiple projected fields ── + + [Fact] + public async Task Distinct_MultipleFields_DeduplicatesByAllFields() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", cat = "A", status = "active" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", cat = "A", status = "active" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", cat = "A", status = "inactive" }), new PartitionKey("pk1")); + + var results = await RunQuery("SELECT DISTINCT c.cat, c.status FROM c"); + // (A, active) and (A, inactive) = 2 distinct combos + results.Should().HaveCount(2); + } + + // ── E5: SELECT with aliased system properties ── + + [Fact] + public async Task Select_SystemProperty_Ts_WithAlias_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c._ts AS timestamp FROM c"); + results.Should().ContainSingle(); + results[0]["timestamp"].Should().NotBeNull(); + } + + // ── E6: WHERE comparing two fields from the same document ── + + [Fact] + public async Task Where_CompareTwoFields_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":10,"b":5}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","a":3,"b":8}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.a > c.b"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + // ── E9: STARTSWITH with parameterized prefix ── + + [Fact] + public async Task StartsWith_WithParameter_Works() + { + await SeedItems(); + var query = new QueryDefinition("""SELECT * FROM c WHERE STARTSWITH(c.name, @prefix)""") + .WithParameter("@prefix", "Al"); + var results = await RunQuery(query); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── E10: LIKE with parameter pattern ── + + [Fact] + public async Task Like_WithParameter_Works() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pattern") + .WithParameter("@pattern", "A%"); + var results = await RunQuery(query); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── E11: Multiple aggregates with GROUP BY ── + + [Fact] + public async Task GroupBy_MultipleAggregates_AllComputed() + { + await SeedItems(); + var results = await RunQuery( + "SELECT c.partitionKey, COUNT(1) AS cnt, SUM(c.value) AS total, AVG(c.value) AS avg FROM c GROUP BY c.partitionKey"); + + results.Should().HaveCount(2); + var pk1 = results.First(r => r["partitionKey"]!.ToString() == "pk1"); + pk1["cnt"]!.Value().Should().Be(4); + pk1["total"]!.Value().Should().Be(110); // 10+20+30+50 (pk1 items minus Diana who's pk2 — wait, pk1 = Alice(10)+Bob(20)+Charlie(30)+Eve(50) = actually let me check) + } + + // ── E14: DISTINCT on null values ── + + [Fact] + public async Task Distinct_NullValues_TreatedAsOneGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":null}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT DISTINCT c.cat FROM c"); + // "A" and null = 2 distinct values (both nulls collapse to one) + results.Should().HaveCount(2); + } + + // ── E15: JOIN with WHERE referencing both parent and child ── + + [Fact] + public async Task Join_WhereReferencingParentAndChild_Works() + { + await SeedItems(); + var results = await RunQuery( + """SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.isActive = true AND t = "a" """); + // Active items with tag "a": Alice(a,b) → has "a", Charlie(a,c) → has "a" + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo(["Alice", "Charlie"]); + } + + // ── E16: TOP with ORDER BY DESC ── + + [Fact] + public async Task Top_WithOrderByDesc_ReturnsHighestValues() + { + await SeedItems(); + var results = await RunQuery("SELECT TOP 3 * FROM c ORDER BY c.value DESC"); + results.Should().HaveCount(3); + results[0].Value.Should().Be(50); + results[1].Value.Should().Be(40); + results[2].Value.Should().Be(30); + } + + // ── E17: SELECT c.* alias-dot-star form ── + + [Fact] + public async Task Select_AliasDotStar_ReturnsAllFields() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.* FROM c"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + // ── E19: GROUP BY on undefined value across documents ── + + [Fact] + public async Task GroupBy_SomeUndefined_SomeNull_DistinctGroups() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); // undefined cat + + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + // A, null, undefined = up to 3 groups (or 2 if null/undefined collapse) + results.Should().HaveCountGreaterThanOrEqualTo(2); + } + + // ── E20: Nested function calls in WHERE ── + + [Fact] + public async Task Where_NestedFunctionCalls_Works() + { + await SeedItems(); + var results = await RunQuery( + "SELECT * FROM c WHERE LOWER(LEFT(c.name, 3)) = 'ali'"); + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + // ── E12: SELECT VALUE with object literal containing aggregate ── + + [Fact] + public async Task SelectValue_ObjectWithAggregate_NoGroupBy() + { + await SeedItems(); + var results = await RunQuery( + """SELECT VALUE {"total": SUM(c.value), "cnt": COUNT(1)} FROM c"""); + // Cosmos DB: returns one row {"total": 150, "cnt": 5} + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(5); + } + + /// + /// Companion: SELECT VALUE with array literal containing aggregates. + /// + [Fact] + public async Task SelectValue_ArrayWithAggregate_NoGroupBy() + { + await SeedItems(); + var results = await RunQuery( + """SELECT VALUE [COUNT(1), SUM(c.value)] FROM c"""); + results.Should().ContainSingle(); + results[0][0]!.Value().Should().Be(5); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8134,163 +8144,163 @@ public async Task SelectValue_ArrayWithAggregate_NoGroupBy() public class QueryCountFieldNullSemanticsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// Bug 19: COUNT(c.field) should NOT count documents where the field is explicitly null. - /// Cosmos DB semantics: COUNT(c.field) counts only defined AND non-null values. - /// - [Fact] - public async Task CountField_ExcludesExplicitNullValues() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(2, "COUNT(c.score) should exclude null values"); - } - - /// - /// Bug 19: COUNT(c.field) should NOT count documents where the field is missing (undefined). - /// This should already work correctly since SelectToken returns null for missing fields. - /// - [Fact] - public async Task CountField_ExcludesUndefinedValues() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"no-score"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(2, "COUNT(c.score) should exclude undefined (missing) fields"); - } - - /// - /// Bug 19: COUNT(1) counts ALL documents but COUNT(c.field) counts only defined non-null. - /// Setup: 3 docs — {score:10}, {score:null}, {no score field} - /// Expected: COUNT(1) = 3, COUNT(c.score) = 1 - /// - [Fact] - public async Task CountField_VsCount1_Divergence() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"no-score"}""")), - new PartitionKey("pk1")); - - var total = await RunQuery("SELECT COUNT(1) AS total FROM c"); - total.Should().ContainSingle(); - total[0]["total"]!.Value().Should().Be(3); - - var defined = await RunQuery("SELECT COUNT(c.score) AS defined FROM c"); - defined.Should().ContainSingle(); - defined[0]["defined"]!.Value().Should().Be(1, "only score:10 is defined AND non-null"); - } - - /// - /// Bug 19: COUNT(c.field) in GROUP BY context should have the same null-exclusion semantics. - /// Setup: 4 docs — cat A: {opt:"yes"} + {opt:null}, cat B: {opt:"yes"} + {no opt field} - /// Expected: A=1 (null excluded), B=1 (undefined excluded) - /// - [Fact] - public async Task CountField_InGroupBy_ExcludesNulls() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","opt":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat"); - results.Should().HaveCount(2); - - var catA = results.First(r => r["cat"]!.Value() == "A"); - catA["cnt"]!.Value().Should().Be(1, "cat A: null opt should be excluded from count"); - - var catB = results.First(r => r["cat"]!.Value() == "B"); - catB["cnt"]!.Value().Should().Be(1, "cat B: undefined opt should be excluded from count"); - } - - /// - /// Verify AVG(c.field) with null values excludes them from calculation. - /// Setup: 3 docs — {val:10}, {val:null}, {val:30} - /// Expected: AVG = 20.0 (average of 10 and 30, null excluded) - /// - [Fact] - public async Task AvgField_WithNullValues_ExcludesFromCalculation() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":30}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT AVG(c.val) AS avg FROM c"); - results.Should().ContainSingle(); - results[0]["avg"]!.Value().Should().Be(20.0, "AVG should exclude null values from calculation"); - } - - /// - /// COUNT(c.field) via EvaluateAggregateExpression path (SELECT VALUE object with COUNT). - /// - [Fact] - public async Task CountField_InSelectValueObject_ExcludesNulls() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - """SELECT VALUE {"total": COUNT(1), "defined": COUNT(c.score)} FROM c"""); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(3); - results[0]["defined"]!.Value().Should().Be(2, "COUNT(c.score) in SELECT VALUE should exclude null values"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// Bug 19: COUNT(c.field) should NOT count documents where the field is explicitly null. + /// Cosmos DB semantics: COUNT(c.field) counts only defined AND non-null values. + /// + [Fact] + public async Task CountField_ExcludesExplicitNullValues() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(2, "COUNT(c.score) should exclude null values"); + } + + /// + /// Bug 19: COUNT(c.field) should NOT count documents where the field is missing (undefined). + /// This should already work correctly since SelectToken returns null for missing fields. + /// + [Fact] + public async Task CountField_ExcludesUndefinedValues() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"no-score"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(2, "COUNT(c.score) should exclude undefined (missing) fields"); + } + + /// + /// Bug 19: COUNT(1) counts ALL documents but COUNT(c.field) counts only defined non-null. + /// Setup: 3 docs — {score:10}, {score:null}, {no score field} + /// Expected: COUNT(1) = 3, COUNT(c.score) = 1 + /// + [Fact] + public async Task CountField_VsCount1_Divergence() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"no-score"}""")), + new PartitionKey("pk1")); + + var total = await RunQuery("SELECT COUNT(1) AS total FROM c"); + total.Should().ContainSingle(); + total[0]["total"]!.Value().Should().Be(3); + + var defined = await RunQuery("SELECT COUNT(c.score) AS defined FROM c"); + defined.Should().ContainSingle(); + defined[0]["defined"]!.Value().Should().Be(1, "only score:10 is defined AND non-null"); + } + + /// + /// Bug 19: COUNT(c.field) in GROUP BY context should have the same null-exclusion semantics. + /// Setup: 4 docs — cat A: {opt:"yes"} + {opt:null}, cat B: {opt:"yes"} + {no opt field} + /// Expected: A=1 (null excluded), B=1 (undefined excluded) + /// + [Fact] + public async Task CountField_InGroupBy_ExcludesNulls() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","opt":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","partitionKey":"pk1","cat":"B"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.cat, COUNT(c.opt) AS cnt FROM c GROUP BY c.cat"); + results.Should().HaveCount(2); + + var catA = results.First(r => r["cat"]!.Value() == "A"); + catA["cnt"]!.Value().Should().Be(1, "cat A: null opt should be excluded from count"); + + var catB = results.First(r => r["cat"]!.Value() == "B"); + catB["cnt"]!.Value().Should().Be(1, "cat B: undefined opt should be excluded from count"); + } + + /// + /// Verify AVG(c.field) with null values excludes them from calculation. + /// Setup: 3 docs — {val:10}, {val:null}, {val:30} + /// Expected: AVG = 20.0 (average of 10 and 30, null excluded) + /// + [Fact] + public async Task AvgField_WithNullValues_ExcludesFromCalculation() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":30}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT AVG(c.val) AS avg FROM c"); + results.Should().ContainSingle(); + results[0]["avg"]!.Value().Should().Be(20.0, "AVG should exclude null values from calculation"); + } + + /// + /// COUNT(c.field) via EvaluateAggregateExpression path (SELECT VALUE object with COUNT). + /// + [Fact] + public async Task CountField_InSelectValueObject_ExcludesNulls() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","score":20}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + """SELECT VALUE {"total": COUNT(1), "defined": COUNT(c.score)} FROM c"""); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(3); + results[0]["defined"]!.Value().Should().Be(2, "COUNT(c.score) in SELECT VALUE should exclude null values"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8299,73 +8309,73 @@ await _container.CreateItemStreamAsync( public class QuerySubqueryTopOrderByTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// Bug 20: Subquery with TOP + ORDER BY DESC should sort first, then take top. - /// If TOP is applied before ORDER BY, we get an arbitrary first element instead of the max. - /// - [Fact] - public async Task SubqueryScalar_TopWithOrderByDesc_ReturnsMaxValue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[30,10,50,20]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT (SELECT TOP 1 VALUE s FROM s IN c.scores ORDER BY s DESC) AS top FROM c"); - results.Should().ContainSingle(); - results[0]["top"]!.Value().Should().Be(50, "ORDER BY DESC should execute before TOP 1"); - } - - /// - /// Bug 20: Subquery with WHERE + ORDER BY ASC + TOP combined. - /// Filter first, sort ascending, then take top 3. - /// - [Fact] - public async Task SubqueryArray_TopWithOrderByAscAndWhere_Combined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[5,30,10,50,20]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT ARRAY(SELECT TOP 3 VALUE s FROM s IN c.scores WHERE s > 10 ORDER BY s ASC) AS top3 FROM c"); - results.Should().ContainSingle(); - var arr = results[0]["top3"] as JArray; - arr.Should().NotBeNull(); - arr!.Select(t => t.Value()).Should().BeEquivalentTo([20, 30, 50], - opts => opts.WithStrictOrdering(), - "should be filtered > 10, sorted ASC, then TOP 3"); - } - - /// - /// Subquery with ORDER BY ASC + TOP 2 — should return the 2 smallest values. - /// - [Fact] - public async Task SubqueryArray_TopWithOrderByAsc_ReturnsTwoSmallest() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[40,10,30,20]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT ARRAY(SELECT TOP 2 VALUE s FROM s IN c.scores ORDER BY s ASC) AS bottom2 FROM c"); - results.Should().ContainSingle(); - var arr = results[0]["bottom2"] as JArray; - arr.Should().NotBeNull(); - arr!.Select(t => t.Value()).Should().BeEquivalentTo([10, 20], - opts => opts.WithStrictOrdering(), - "should be sorted ASC then TOP 2: [10, 20]"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// Bug 20: Subquery with TOP + ORDER BY DESC should sort first, then take top. + /// If TOP is applied before ORDER BY, we get an arbitrary first element instead of the max. + /// + [Fact] + public async Task SubqueryScalar_TopWithOrderByDesc_ReturnsMaxValue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[30,10,50,20]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT (SELECT TOP 1 VALUE s FROM s IN c.scores ORDER BY s DESC) AS top FROM c"); + results.Should().ContainSingle(); + results[0]["top"]!.Value().Should().Be(50, "ORDER BY DESC should execute before TOP 1"); + } + + /// + /// Bug 20: Subquery with WHERE + ORDER BY ASC + TOP combined. + /// Filter first, sort ascending, then take top 3. + /// + [Fact] + public async Task SubqueryArray_TopWithOrderByAscAndWhere_Combined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[5,30,10,50,20]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT ARRAY(SELECT TOP 3 VALUE s FROM s IN c.scores WHERE s > 10 ORDER BY s ASC) AS top3 FROM c"); + results.Should().ContainSingle(); + var arr = results[0]["top3"] as JArray; + arr.Should().NotBeNull(); + arr!.Select(t => t.Value()).Should().BeEquivalentTo([20, 30, 50], + opts => opts.WithStrictOrdering(), + "should be filtered > 10, sorted ASC, then TOP 3"); + } + + /// + /// Subquery with ORDER BY ASC + TOP 2 — should return the 2 smallest values. + /// + [Fact] + public async Task SubqueryArray_TopWithOrderByAsc_ReturnsTwoSmallest() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","scores":[40,10,30,20]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT ARRAY(SELECT TOP 2 VALUE s FROM s IN c.scores ORDER BY s ASC) AS bottom2 FROM c"); + results.Should().ContainSingle(); + var arr = results[0]["bottom2"] as JArray; + arr.Should().NotBeNull(); + arr!.Select(t => t.Value()).Should().BeEquivalentTo([10, 20], + opts => opts.WithStrictOrdering(), + "should be sorted ASC then TOP 2: [10, 20]"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8374,91 +8384,91 @@ await _container.CreateItemStreamAsync( public class QueryCrossTypeEqualityTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// Bug 21: Boolean true should NOT equal string "True". - /// Cosmos DB uses strict type comparison; different types always return false (except numeric cross-compare). - /// - [Fact] - public async Task BooleanTrue_NotEqualToString_True() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":true}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"True"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val = true"); - results.Should().ContainSingle("only the boolean true doc should match, not the string 'True'"); - results[0]["id"]!.Value().Should().Be("1"); - } - - /// - /// Bug 21: Boolean false should NOT equal string "False". - /// - [Fact] - public async Task BooleanFalse_NotEqualToString_False() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":false}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"False"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val = false"); - results.Should().ContainSingle("only the boolean false doc should match, not the string 'False'"); - results[0]["id"]!.Value().Should().Be("1"); - } - - /// - /// Bug 21: Number 42 should NOT equal string "42". - /// - [Fact] - public async Task Number_NotEqualToString_SameDigits() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"42"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val = 42"); - results.Should().ContainSingle("only the number 42 doc should match, not the string '42'"); - results[0]["id"]!.Value().Should().Be("1"); - } - - /// - /// Bug 21: IN list should be type-strict — number 1 should not match boolean true or string "1". - /// - [Fact] - public async Task InList_TypeStrictness() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":true}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":"1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1)"); - results.Should().ContainSingle("only the number 1 doc should match, not boolean true or string '1'"); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// Bug 21: Boolean true should NOT equal string "True". + /// Cosmos DB uses strict type comparison; different types always return false (except numeric cross-compare). + /// + [Fact] + public async Task BooleanTrue_NotEqualToString_True() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":true}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"True"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val = true"); + results.Should().ContainSingle("only the boolean true doc should match, not the string 'True'"); + results[0]["id"]!.Value().Should().Be("1"); + } + + /// + /// Bug 21: Boolean false should NOT equal string "False". + /// + [Fact] + public async Task BooleanFalse_NotEqualToString_False() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":false}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"False"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val = false"); + results.Should().ContainSingle("only the boolean false doc should match, not the string 'False'"); + results[0]["id"]!.Value().Should().Be("1"); + } + + /// + /// Bug 21: Number 42 should NOT equal string "42". + /// + [Fact] + public async Task Number_NotEqualToString_SameDigits() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":42}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"42"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val = 42"); + results.Should().ContainSingle("only the number 42 doc should match, not the string '42'"); + results[0]["id"]!.Value().Should().Be("1"); + } + + /// + /// Bug 21: IN list should be type-strict — number 1 should not match boolean true or string "1". + /// + [Fact] + public async Task InList_TypeStrictness() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":true}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":"1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1)"); + results.Should().ContainSingle("only the number 1 doc should match, not boolean true or string '1'"); + results[0]["id"]!.Value().Should().Be("1"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8467,53 +8477,53 @@ await _container.CreateItemStreamAsync( public class QueryLikeUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// Bug 22: LIKE with undefined (missing) field should return false, not crash. - /// Some docs have the field, some don't — query should only match docs with the field. - /// - [Fact] - public async Task Like_WithUndefinedField_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A%'"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - /// - /// Fixed: NOT LIKE with undefined field — was known behavioral difference. - /// In Cosmos DB, undefined LIKE 'x' = undefined, NOT undefined = undefined → row excluded. - /// Fixed by implementing three-value logic for comparisons and NOT. - /// - [Fact] - public async Task NotLike_WithUndefinedField_ExcludesRow() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE NOT (c.name LIKE 'A%')"); - results.Should().BeEmpty("Alice matches the pattern, and doc 2 has undefined name"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// Bug 22: LIKE with undefined (missing) field should return false, not crash. + /// Some docs have the field, some don't — query should only match docs with the field. + /// + [Fact] + public async Task Like_WithUndefinedField_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A%'"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + /// + /// Fixed: NOT LIKE with undefined field — was known behavioral difference. + /// In Cosmos DB, undefined LIKE 'x' = undefined, NOT undefined = undefined → row excluded. + /// Fixed by implementing three-value logic for comparisons and NOT. + /// + [Fact] + public async Task NotLike_WithUndefinedField_ExcludesRow() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE NOT (c.name LIKE 'A%')"); + results.Should().BeEmpty("Alice matches the pattern, and doc 2 has undefined name"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8522,78 +8532,78 @@ await _container.CreateItemStreamAsync( public class QueryStringConcatUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// Bug 23: String concat (||) with undefined left side should return undefined. - /// When a field is missing, c.missing || "text" should produce undefined, not "text". - /// - [Fact] - public async Task StringConcat_UndefinedLeftSide_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.missing || ' suffix' AS result FROM c"); - results.Should().ContainSingle(); - // undefined || 'suffix' = undefined → field omitted from result - results[0]["result"].Should().BeNull("undefined concat should produce undefined (omitted field)"); - } - - /// - /// Bug 23: String concat (||) with undefined right side should return undefined. - /// - [Fact] - public async Task StringConcat_UndefinedRightSide_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT 'prefix ' || c.missing AS result FROM c"); - results.Should().ContainSingle(); - results[0]["result"].Should().BeNull("undefined concat should produce undefined (omitted field)"); - } - - /// - /// Bug 23: String concat where both sides are defined should still work normally. - /// - [Fact] - public async Task StringConcat_BothDefined_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","first":"Alice","last":"Smith"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.first || ' ' || c.last AS fullName FROM c"); - results.Should().ContainSingle(); - results[0]["fullName"]!.Value().Should().Be("Alice Smith"); - } - - /// - /// Bug 23 (updated in v2.0.94): String concat with null returns undefined (field omitted from result). - /// Real Cosmos DB: null || 'text' → undefined. - /// - [Fact] - public async Task StringConcat_NullLeft_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT c.name || ' suffix' AS result FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("result").Should().BeFalse("null || string = undefined in Cosmos DB — field omitted"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// Bug 23: String concat (||) with undefined left side should return undefined. + /// When a field is missing, c.missing || "text" should produce undefined, not "text". + /// + [Fact] + public async Task StringConcat_UndefinedLeftSide_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.missing || ' suffix' AS result FROM c"); + results.Should().ContainSingle(); + // undefined || 'suffix' = undefined → field omitted from result + results[0]["result"].Should().BeNull("undefined concat should produce undefined (omitted field)"); + } + + /// + /// Bug 23: String concat (||) with undefined right side should return undefined. + /// + [Fact] + public async Task StringConcat_UndefinedRightSide_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT 'prefix ' || c.missing AS result FROM c"); + results.Should().ContainSingle(); + results[0]["result"].Should().BeNull("undefined concat should produce undefined (omitted field)"); + } + + /// + /// Bug 23: String concat where both sides are defined should still work normally. + /// + [Fact] + public async Task StringConcat_BothDefined_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","first":"Alice","last":"Smith"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.first || ' ' || c.last AS fullName FROM c"); + results.Should().ContainSingle(); + results[0]["fullName"]!.Value().Should().Be("Alice Smith"); + } + + /// + /// Bug 23 (updated in v2.0.94): String concat with null returns undefined (field omitted from result). + /// Real Cosmos DB: null || 'text' → undefined. + /// + [Fact] + public async Task StringConcat_NullLeft_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT c.name || ' suffix' AS result FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("result").Should().BeFalse("null || string = undefined in Cosmos DB — field omitted"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8602,96 +8612,96 @@ await _container.CreateItemStreamAsync( public class QueryUndefinedComparisonTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - /// - /// null = null should be true in Cosmos DB. - /// WHERE c.status = null should match only docs with explicit null, not missing field. - /// - [Fact] - public async Task NullEqualsNull_MatchesExplicitNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"test"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.status = null"); - results.Should().ContainSingle("only the doc with explicit null should match"); - results[0]["id"]!.Value().Should().Be("1"); - } - - /// - /// undefined = undefined should be false in Cosmos DB. - /// Both fields missing — comparison should not match. - /// - [Fact] - public async Task UndefinedEqualsUndefined_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.x = c.y"); - results.Should().BeEmpty("undefined = undefined is false in Cosmos DB"); - } - - /// - /// undefined != 'anything' should be false (not true). - /// Any comparison involving undefined produces false. - /// - [Fact] - public async Task UndefinedNotEqual_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.missing != 'anything'"); - results.Should().BeEmpty("undefined != anything should be false in Cosmos DB"); - } - - /// - /// IS_DEFINED check for undefined field should return false. - /// - [Fact] - public async Task IsDefined_UndefinedField_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.missing)"); - results.Should().BeEmpty("IS_DEFINED should return false for missing field"); - } - - /// - /// NOT IS_DEFINED should match docs where field is missing. - /// - [Fact] - public async Task NotIsDefined_UndefinedField_Matches() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","missing":"here"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.missing)"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + /// + /// null = null should be true in Cosmos DB. + /// WHERE c.status = null should match only docs with explicit null, not missing field. + /// + [Fact] + public async Task NullEqualsNull_MatchesExplicitNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.status = null"); + results.Should().ContainSingle("only the doc with explicit null should match"); + results[0]["id"]!.Value().Should().Be("1"); + } + + /// + /// undefined = undefined should be false in Cosmos DB. + /// Both fields missing — comparison should not match. + /// + [Fact] + public async Task UndefinedEqualsUndefined_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.x = c.y"); + results.Should().BeEmpty("undefined = undefined is false in Cosmos DB"); + } + + /// + /// undefined != 'anything' should be false (not true). + /// Any comparison involving undefined produces false. + /// + [Fact] + public async Task UndefinedNotEqual_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.missing != 'anything'"); + results.Should().BeEmpty("undefined != anything should be false in Cosmos DB"); + } + + /// + /// IS_DEFINED check for undefined field should return false. + /// + [Fact] + public async Task IsDefined_UndefinedField_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.missing)"); + results.Should().BeEmpty("IS_DEFINED should return false for missing field"); + } + + /// + /// NOT IS_DEFINED should match docs where field is missing. + /// + [Fact] + public async Task NotIsDefined_UndefinedField_Matches() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"test"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","missing":"here"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.missing)"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -8700,320 +8710,320 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV3_FunctionCoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"flags":5}""")), - new PartitionKey("pk1")); - } - - // ── F1: ATN2 ── - - [Fact] - public async Task ATN2_ReturnsCorrectAngle() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ATN2(1, 1) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI / 4, 0.0001); - } - - // ── F3: IntMul ── - - [Fact] - public async Task IntMul_MultipliesIntegers() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntMul(7, 6) FROM c"); - results.Should().ContainSingle().Which.Should().Be(42); - } - - // ── F4: IntSub ── - - [Fact] - public async Task IntSub_SubtractsIntegers() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntSub(50, 8) FROM c"); - results.Should().ContainSingle().Which.Should().Be(42); - } - - // ── F5: IntDiv ── - - [Fact] - public async Task IntDiv_DividesIntegers() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntDiv(100, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(33); - } - - // ── F6: IntMod ── - - [Fact] - public async Task IntMod_ReturnsRemainder() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(1); - } - - // ── F7: IntBitXor ── - - [Fact] - public async Task IntBitXor_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitXor(5, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(6); - } - - // ── F8: IntBitRightShift ── - - [Fact] - public async Task IntBitRightShift_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitRightShift(16, 2) FROM c"); - results.Should().ContainSingle().Which.Should().Be(4); - } - - // ── F9: DateTimeBin ── - - [Fact] - public async Task DateTimeBin_RoundsToHour() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE DateTimeBin("2024-06-15T10:37:00Z", "hh", 1) FROM c"""); - results.Should().ContainSingle().Which.Should().Contain("2024-06-15T10:00:00"); - } - - // ── F10: DateTimeToTicks ── - - [Fact] - public async Task DateTimeToTicks_ReturnsPositiveValue() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE DateTimeToTicks("2024-01-01T00:00:00Z") FROM c"""); - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } - - // ── F11: TicksToDateTime ── - - [Fact] - public async Task TicksToDateTime_ConvertsBack() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE TicksToDateTime(DateTimeToTicks("2024-01-01T00:00:00Z")) FROM c"""); - results.Should().ContainSingle().Which.Should().Contain("2024-01-01"); - } - - // ── F12: DateTimeToTimestamp ── - - [Fact] - public async Task DateTimeToTimestamp_ReturnsEpochMs() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE DateTimeToTimestamp("2024-01-01T00:00:00Z") FROM c"""); - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } - - // ── F13: TimestampToDateTime ── - - [Fact] - public async Task TimestampToDateTime_ConvertsEpochZero() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE TimestampToDateTime(0) FROM c"); - results.Should().ContainSingle().Which.Should().Contain("1970-01-01"); - } - - // ── F14: GetCurrentTicks ── - - [Fact] - public async Task GetCurrentTicks_ReturnsPositiveValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c"); - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } - - // ── F18: ENDSWITH with case-insensitive 3rd arg ── - - [Fact] - public async Task EndsWith_CaseInsensitive_ThirdArg() - { - await SeedOne(); - var results = await RunQuery("""SELECT * FROM c WHERE ENDSWITH(c.name, "CE", true)"""); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── F19: ToString with boolean ── - - [Fact] - public async Task ToString_Boolean_ReturnsString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ToString(true) FROM c"); - results.Should().ContainSingle().Which.Should().Be("true"); - } - - // ── F21: NumberBin with negative ── - - [Fact] - public async Task NumberBin_NegativeNumber_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(-4.5, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-6.0); - } - - // ── F24: CHOOSE with 0 index ── - - [Fact] - public async Task Choose_ZeroIndex_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT CHOOSE(0, 'a', 'b') AS result FROM c"); - results.Should().ContainSingle(); - // CHOOSE with 0 (out of range) returns undefined — shown as empty JValue or missing - var resultToken = results[0]["result"]; - if (resultToken != null) - { - resultToken.Type.Should().BeOneOf(JTokenType.Null, JTokenType.Undefined, JTokenType.String); - } - } - - // ── F25: CHOOSE with field as index ── - - [Fact] - public async Task Choose_FieldAsIndex_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","idx":2}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE CHOOSE(c.idx, 'a', 'b', 'c') FROM c"); - results.Should().ContainSingle().Which.Should().Be("b"); - } - - // ── F26: REPLICATE with negative count ── - - [Fact] - public async Task Replicate_NegativeCount_ReturnsEmptyOrNull() - { - await SeedOne(); - // REPLICATE with negative count returns undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); - results.Should().BeEmpty(); // undefined excluded by SELECT VALUE - } - - // ── F27: SUBSTRING edge cases ── - - [Fact] - public async Task Substring_LengthExceedsString_ClipsToEnd() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE SUBSTRING("hello", 2, 100) FROM c"""); - results.Should().ContainSingle().Which.Should().Be("llo"); - } - - // ── F28: LEFT/RIGHT with length > string ── - - [Fact] - public async Task Left_CountExceedsLength_ReturnsFullString() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE LEFT("hi", 100) FROM c"""); - results.Should().ContainSingle().Which.Should().Be("hi"); - } - - [Fact] - public async Task Right_CountExceedsLength_ReturnsFullString() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE RIGHT("hi", 100) FROM c"""); - results.Should().ContainSingle().Which.Should().Be("hi"); - } - - // ── F29: INDEX_OF with start position ── - - [Fact] - public async Task IndexOf_WithStartPosition_Works() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE INDEX_OF("hello hello", "hello", 1) FROM c"""); - results.Should().ContainSingle().Which.Should().Be(6); - } - - // ── F31/F32: StringSplit edge cases ── - - [Fact] - public async Task StringSplit_MultiCharDelimiter_Works() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE StringSplit("a::b::c", "::") FROM c"""); - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - // ── F23: VectorDistance modes ── - - [Fact] - public async Task VectorDistance_Euclidean_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","emb":[0.0,0.0]}""")), - new PartitionKey("pk1")); - - // Use SELECT with alias to avoid array literal parsing issues in SQL - var results = await RunQuery("SELECT VectorDistance(c.emb, [3.0, 4.0], false, 'euclidean') AS dist FROM c"); - results.Should().ContainSingle(); - var dist = results[0]["dist"]; - if (dist != null && dist.Type != JTokenType.Null) - { - dist.Value().Should().BeApproximately(5.0, 0.01); - } - } - - [Fact] - public async Task VectorDistance_DotProduct_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","emb":[1.0,0.0,0.0]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE VectorDistance(c.emb, [0.0, 1.0, 0.0], false, 'dotproduct') FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(0.0, 0.01); - } - - // ── F22: IntBitAnd/IntBitOr in SELECT ── - - [Fact] - public async Task IntBitAnd_InSelect_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitAnd(c.flags, 4) FROM c"); - results.Should().ContainSingle().Which.Should().Be(4); // 5 & 4 = 4 - } - - [Fact] - public async Task IntBitOr_InSelect_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitOr(c.flags, 2) FROM c"); - results.Should().ContainSingle().Which.Should().Be(7); // 5 | 2 = 7 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"flags":5}""")), + new PartitionKey("pk1")); + } + + // ── F1: ATN2 ── + + [Fact] + public async Task ATN2_ReturnsCorrectAngle() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ATN2(1, 1) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI / 4, 0.0001); + } + + // ── F3: IntMul ── + + [Fact] + public async Task IntMul_MultipliesIntegers() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntMul(7, 6) FROM c"); + results.Should().ContainSingle().Which.Should().Be(42); + } + + // ── F4: IntSub ── + + [Fact] + public async Task IntSub_SubtractsIntegers() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntSub(50, 8) FROM c"); + results.Should().ContainSingle().Which.Should().Be(42); + } + + // ── F5: IntDiv ── + + [Fact] + public async Task IntDiv_DividesIntegers() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntDiv(100, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(33); + } + + // ── F6: IntMod ── + + [Fact] + public async Task IntMod_ReturnsRemainder() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(1); + } + + // ── F7: IntBitXor ── + + [Fact] + public async Task IntBitXor_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitXor(5, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(6); + } + + // ── F8: IntBitRightShift ── + + [Fact] + public async Task IntBitRightShift_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitRightShift(16, 2) FROM c"); + results.Should().ContainSingle().Which.Should().Be(4); + } + + // ── F9: DateTimeBin ── + + [Fact] + public async Task DateTimeBin_RoundsToHour() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE DateTimeBin("2024-06-15T10:37:00Z", "hh", 1) FROM c"""); + results.Should().ContainSingle().Which.Should().Contain("2024-06-15T10:00:00"); + } + + // ── F10: DateTimeToTicks ── + + [Fact] + public async Task DateTimeToTicks_ReturnsPositiveValue() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE DateTimeToTicks("2024-01-01T00:00:00Z") FROM c"""); + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } + + // ── F11: TicksToDateTime ── + + [Fact] + public async Task TicksToDateTime_ConvertsBack() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE TicksToDateTime(DateTimeToTicks("2024-01-01T00:00:00Z")) FROM c"""); + results.Should().ContainSingle().Which.Should().Contain("2024-01-01"); + } + + // ── F12: DateTimeToTimestamp ── + + [Fact] + public async Task DateTimeToTimestamp_ReturnsEpochMs() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE DateTimeToTimestamp("2024-01-01T00:00:00Z") FROM c"""); + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } + + // ── F13: TimestampToDateTime ── + + [Fact] + public async Task TimestampToDateTime_ConvertsEpochZero() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE TimestampToDateTime(0) FROM c"); + results.Should().ContainSingle().Which.Should().Contain("1970-01-01"); + } + + // ── F14: GetCurrentTicks ── + + [Fact] + public async Task GetCurrentTicks_ReturnsPositiveValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c"); + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } + + // ── F18: ENDSWITH with case-insensitive 3rd arg ── + + [Fact] + public async Task EndsWith_CaseInsensitive_ThirdArg() + { + await SeedOne(); + var results = await RunQuery("""SELECT * FROM c WHERE ENDSWITH(c.name, "CE", true)"""); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── F19: ToString with boolean ── + + [Fact] + public async Task ToString_Boolean_ReturnsString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ToString(true) FROM c"); + results.Should().ContainSingle().Which.Should().Be("true"); + } + + // ── F21: NumberBin with negative ── + + [Fact] + public async Task NumberBin_NegativeNumber_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(-4.5, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-6.0); + } + + // ── F24: CHOOSE with 0 index ── + + [Fact] + public async Task Choose_ZeroIndex_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT CHOOSE(0, 'a', 'b') AS result FROM c"); + results.Should().ContainSingle(); + // CHOOSE with 0 (out of range) returns undefined — shown as empty JValue or missing + var resultToken = results[0]["result"]; + if (resultToken != null) + { + resultToken.Type.Should().BeOneOf(JTokenType.Null, JTokenType.Undefined, JTokenType.String); + } + } + + // ── F25: CHOOSE with field as index ── + + [Fact] + public async Task Choose_FieldAsIndex_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","idx":2}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE CHOOSE(c.idx, 'a', 'b', 'c') FROM c"); + results.Should().ContainSingle().Which.Should().Be("b"); + } + + // ── F26: REPLICATE with negative count ── + + [Fact] + public async Task Replicate_NegativeCount_ReturnsEmptyOrNull() + { + await SeedOne(); + // REPLICATE with negative count returns undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); + results.Should().BeEmpty(); // undefined excluded by SELECT VALUE + } + + // ── F27: SUBSTRING edge cases ── + + [Fact] + public async Task Substring_LengthExceedsString_ClipsToEnd() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE SUBSTRING("hello", 2, 100) FROM c"""); + results.Should().ContainSingle().Which.Should().Be("llo"); + } + + // ── F28: LEFT/RIGHT with length > string ── + + [Fact] + public async Task Left_CountExceedsLength_ReturnsFullString() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE LEFT("hi", 100) FROM c"""); + results.Should().ContainSingle().Which.Should().Be("hi"); + } + + [Fact] + public async Task Right_CountExceedsLength_ReturnsFullString() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE RIGHT("hi", 100) FROM c"""); + results.Should().ContainSingle().Which.Should().Be("hi"); + } + + // ── F29: INDEX_OF with start position ── + + [Fact] + public async Task IndexOf_WithStartPosition_Works() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE INDEX_OF("hello hello", "hello", 1) FROM c"""); + results.Should().ContainSingle().Which.Should().Be(6); + } + + // ── F31/F32: StringSplit edge cases ── + + [Fact] + public async Task StringSplit_MultiCharDelimiter_Works() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE StringSplit("a::b::c", "::") FROM c"""); + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + // ── F23: VectorDistance modes ── + + [Fact] + public async Task VectorDistance_Euclidean_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","emb":[0.0,0.0]}""")), + new PartitionKey("pk1")); + + // Use SELECT with alias to avoid array literal parsing issues in SQL + var results = await RunQuery("SELECT VectorDistance(c.emb, [3.0, 4.0], false, 'euclidean') AS dist FROM c"); + results.Should().ContainSingle(); + var dist = results[0]["dist"]; + if (dist != null && dist.Type != JTokenType.Null) + { + dist.Value().Should().BeApproximately(5.0, 0.01); + } + } + + [Fact] + public async Task VectorDistance_DotProduct_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","emb":[1.0,0.0,0.0]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE VectorDistance(c.emb, [0.0, 1.0, 0.0], false, 'dotproduct') FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(0.0, 0.01); + } + + // ── F22: IntBitAnd/IntBitOr in SELECT ── + + [Fact] + public async Task IntBitAnd_InSelect_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitAnd(c.flags, 4) FROM c"); + results.Should().ContainSingle().Which.Should().Be(4); // 5 & 4 = 4 + } + + [Fact] + public async Task IntBitOr_InSelect_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitOr(c.flags, 2) FROM c"); + results.Should().ContainSingle().Which.Should().Be(7); // 5 | 2 = 7 + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9022,67 +9032,67 @@ public async Task IntBitOr_InSelect_Works() public class QueryDeepDiveV3_ParserTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","flags":5,"tags":["a","b","c"]}""")), - new PartitionKey("pk1")); - } - - // ── E5: Array index access ── - - [Fact] - public async Task ArrayIndex_InWhere_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT * FROM c WHERE c.tags[0] = 'a'"); - results.Should().ContainSingle(); - } - - // ── E6/E7: UNDEFINED keyword ── - - [Fact] - public async Task UndefinedKeyword_InWhere_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - // IS_DEFINED returns false for missing fields - var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.val)"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - // ── E8: Parse cache overflow ── - - [Fact] - public async Task ParseCache_OverflowDoesNotCrash() - { - await SeedOne(); - // Parse 300+ unique queries to exceed cache capacity (currently 256) - for (var i = 0; i < 300; i++) - { - var results = await RunQuery($"SELECT * FROM c WHERE c.id = '{i}'"); - // Just verify no errors — results aren't important - } - // Final query should still work - var final = await RunQuery("SELECT * FROM c WHERE c.id = '1'"); - final.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","flags":5,"tags":["a","b","c"]}""")), + new PartitionKey("pk1")); + } + + // ── E5: Array index access ── + + [Fact] + public async Task ArrayIndex_InWhere_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT * FROM c WHERE c.tags[0] = 'a'"); + results.Should().ContainSingle(); + } + + // ── E6/E7: UNDEFINED keyword ── + + [Fact] + public async Task UndefinedKeyword_InWhere_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + // IS_DEFINED returns false for missing fields + var results = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.val)"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + // ── E8: Parse cache overflow ── + + [Fact] + public async Task ParseCache_OverflowDoesNotCrash() + { + await SeedOne(); + // Parse 300+ unique queries to exceed cache capacity (currently 256) + for (var i = 0; i < 300; i++) + { + var results = await RunQuery($"SELECT * FROM c WHERE c.id = '{i}'"); + // Just verify no errors — results aren't important + } + // Final query should still work + var final = await RunQuery("SELECT * FROM c WHERE c.id = '1'"); + final.Should().ContainSingle(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9091,137 +9101,137 @@ public async Task ParseCache_OverflowDoesNotCrash() public class QueryDeepDiveV3_InteractionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedInteractionDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","cat":"A","name":"Alice","val":10}""", - """{"id":"2","partitionKey":"pk1","cat":"A","name":"Adam","val":20}""", - """{"id":"3","partitionKey":"pk1","cat":"B","name":"Bob","val":30}""", - """{"id":"4","partitionKey":"pk1","cat":"B","name":"Beth","val":5}""", - """{"id":"5","partitionKey":"pk1","cat":"C","name":"Charlie","val":50}""", - }; - foreach (var doc in docs) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), - new PartitionKey("pk1")); - } - } - - // ── G3: GROUP BY + ORDER BY + TOP combined ── - - [Fact] - public async Task GroupBy_OrderBy_Top_Combined() - { - await SeedInteractionDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC OFFSET 0 LIMIT 2"); - results.Should().HaveCount(2); - // A and B each have 2 docs, C has 1 — top 2 by count DESC should be A and B - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cnt"]!.Value().Should().Be(2); - } - - // ── G5: GROUP BY with nested function ── - - [Fact] - public async Task GroupBy_NestedFunction_Works() - { - await SeedInteractionDocs(); - var results = await RunQuery( - "SELECT UPPER(SUBSTRING(c.name, 0, 1)) AS initial, COUNT(1) AS cnt FROM c GROUP BY UPPER(SUBSTRING(c.name, 0, 1))"); - results.Should().HaveCountGreaterThanOrEqualTo(3); // A, B, C - } - - // ── G6: HAVING with multiple aggregate conditions ── - - [Fact] - public async Task Having_MultipleAggregateConditions() - { - await SeedInteractionDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 AND SUM(c.val) > 20"); - // Cat A: cnt=2, total=30 → passes both. Cat B: cnt=2, total=35 → passes both. Cat C: cnt=1 → fails. - results.Should().HaveCount(2); - } - - /// - /// Sister test: HAVING with single condition works correctly. - /// - [Fact] - public async Task Having_SingleCondition_Works() - { - await SeedInteractionDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); - // Cat A: cnt=2 → passes. Cat B: cnt=2 → passes. Cat C: cnt=1 → fails. - results.Should().HaveCount(2); - } - - // ── G7: SELECT VALUE with ternary + aggregate ── - - [Fact] - public async Task SelectValue_TernaryWithAggregate() - { - await SeedInteractionDocs(); - var results = await RunQuery( - """SELECT VALUE (COUNT(1) > 3 ? "many" : "few") FROM c"""); - results.Should().ContainSingle().Which.Should().Be("many"); // 5 docs > 3 - } - - // ── G9: Multiple GROUP BY fields with mixed null/undefined ── - - [Fact] - public async Task GroupBy_MultipleFields_MixedNullUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","x":"A","y":"1"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","x":"A","y":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","x":"A"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT c.x, c.y, COUNT(1) AS cnt FROM c GROUP BY c.x, c.y"); - // 3 distinct groups: (A,"1"), (A,null), (A,undefined) - results.Should().HaveCountGreaterThanOrEqualTo(2); - } - - // ── G10: BETWEEN with parameterized bounds ── - - [Fact] - public async Task Between_WithParameterizedBounds() - { - await SeedInteractionDocs(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") - .WithParameter("@lo", 10) - .WithParameter("@hi", 30); - - var results = await RunQuery(query); - results.Should().HaveCount(3); // val 10, 20, 30 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedInteractionDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","cat":"A","name":"Alice","val":10}""", + """{"id":"2","partitionKey":"pk1","cat":"A","name":"Adam","val":20}""", + """{"id":"3","partitionKey":"pk1","cat":"B","name":"Bob","val":30}""", + """{"id":"4","partitionKey":"pk1","cat":"B","name":"Beth","val":5}""", + """{"id":"5","partitionKey":"pk1","cat":"C","name":"Charlie","val":50}""", + }; + foreach (var doc in docs) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), + new PartitionKey("pk1")); + } + } + + // ── G3: GROUP BY + ORDER BY + TOP combined ── + + [Fact] + public async Task GroupBy_OrderBy_Top_Combined() + { + await SeedInteractionDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC OFFSET 0 LIMIT 2"); + results.Should().HaveCount(2); + // A and B each have 2 docs, C has 1 — top 2 by count DESC should be A and B + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cnt"]!.Value().Should().Be(2); + } + + // ── G5: GROUP BY with nested function ── + + [Fact] + public async Task GroupBy_NestedFunction_Works() + { + await SeedInteractionDocs(); + var results = await RunQuery( + "SELECT UPPER(SUBSTRING(c.name, 0, 1)) AS initial, COUNT(1) AS cnt FROM c GROUP BY UPPER(SUBSTRING(c.name, 0, 1))"); + results.Should().HaveCountGreaterThanOrEqualTo(3); // A, B, C + } + + // ── G6: HAVING with multiple aggregate conditions ── + + [Fact] + public async Task Having_MultipleAggregateConditions() + { + await SeedInteractionDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 AND SUM(c.val) > 20"); + // Cat A: cnt=2, total=30 → passes both. Cat B: cnt=2, total=35 → passes both. Cat C: cnt=1 → fails. + results.Should().HaveCount(2); + } + + /// + /// Sister test: HAVING with single condition works correctly. + /// + [Fact] + public async Task Having_SingleCondition_Works() + { + await SeedInteractionDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); + // Cat A: cnt=2 → passes. Cat B: cnt=2 → passes. Cat C: cnt=1 → fails. + results.Should().HaveCount(2); + } + + // ── G7: SELECT VALUE with ternary + aggregate ── + + [Fact] + public async Task SelectValue_TernaryWithAggregate() + { + await SeedInteractionDocs(); + var results = await RunQuery( + """SELECT VALUE (COUNT(1) > 3 ? "many" : "few") FROM c"""); + results.Should().ContainSingle().Which.Should().Be("many"); // 5 docs > 3 + } + + // ── G9: Multiple GROUP BY fields with mixed null/undefined ── + + [Fact] + public async Task GroupBy_MultipleFields_MixedNullUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","x":"A","y":"1"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","x":"A","y":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","x":"A"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT c.x, c.y, COUNT(1) AS cnt FROM c GROUP BY c.x, c.y"); + // 3 distinct groups: (A,"1"), (A,null), (A,undefined) + results.Should().HaveCountGreaterThanOrEqualTo(2); + } + + // ── G10: BETWEEN with parameterized bounds ── + + [Fact] + public async Task Between_WithParameterizedBounds() + { + await SeedInteractionDocs(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") + .WithParameter("@lo", 10) + .WithParameter("@hi", 30); + + var results = await RunQuery(query); + results.Should().HaveCount(3); // val 10, 20, 30 + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9230,483 +9240,483 @@ public async Task Between_WithParameterizedBounds() public class QueryDeepDiveV4_UntestedFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"tags":["a","b","c"]}""")), - new PartitionKey("pk1")); - } - - private async Task SeedMultiple() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"text":"The quick brown fox jumps over the lazy dog"}""", - """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"text":"A quick red fox"}""", - """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"text":"The lazy dog sleeps all day"}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); - } - - // ── REPLICATE ── - - [Fact] - public async Task Replicate_RepeatsStringNTimes() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ababab"); - } - - [Fact] - public async Task Replicate_ZeroTimes_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLICATE('x', 0) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task Replicate_NegativeCount_ReturnsUndefined() - { - await SeedOne(); - // REPLICATE with negative count returns undefined in Cosmos DB - var results = await RunQuery("SELECT REPLICATE('x', -1) AS r FROM c"); - results.Should().ContainSingle(); - // undefined field is omitted from the result object - results[0]["r"].Should().BeNull(); - } - - // ── STRING_EQUALS ── - - [Fact] - public async Task StringEquals_CaseSensitive_Default() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'Alice') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StringEquals_CaseSensitive_Mismatch() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'alice') FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task StringEquals_CaseInsensitive_WhenSpecified() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'alice', true) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - // ── NUMBERBIN ── - - [Fact] - public async Task NumberBin_RoundsToNearestBin() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(c.val, 7) FROM c"); - // val=10, binSize=7 → floor(10/7)*7 = floor(1.428)*7 = 1*7 = 7 - results.Should().ContainSingle().Which.Should().Be(7); - } - - [Fact] - public async Task NumberBin_NegativeValues_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(-15, 10) FROM c"); - // floor(-15/10)*10 = floor(-1.5)*10 = -2*10 = -20 - results.Should().ContainSingle().Which.Should().Be(-20); - } - - [Fact] - public async Task NumberBin_BinSizeOne_NoChange() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(c.val, 1) FROM c"); - results.Should().ContainSingle().Which.Should().Be(10); - } - - // ── INT Arithmetic ── - - [Fact] - public async Task IntAdd_TwoIntegers_ReturnsSum() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntAdd(3, 4) FROM c"); - results.Should().ContainSingle().Which.Should().Be(7); - } - - [Fact] - public async Task IntSub_ReturnsSubtraction() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntSub(10, 4) FROM c"); - results.Should().ContainSingle().Which.Should().Be(6); - } - - [Fact] - public async Task IntMul_ReturnsProduct() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntMul(3, 7) FROM c"); - results.Should().ContainSingle().Which.Should().Be(21); - } - - [Fact] - public async Task IntDiv_ReturnsQuotient() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntDiv(10, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3); // integer division - } - - [Fact] - public async Task IntMod_ReturnsRemainder() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(1); - } - - [Fact] - public async Task IntDiv_ByZero_ReturnsUndefined() - { - await SeedOne(); - // IntDiv by zero returns undefined (property excluded from result) - var results = await RunQuery("SELECT IntDiv(10, 0) AS r FROM c"); - results.Should().ContainSingle(); - results[0]["r"].Should().BeNull(); - } - - // ── DATETIMEFROMPARTS ── - - [Fact] - public async Task DateTimeFromParts_ConstructsDateTime() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE DateTimeFromParts(2026, 4, 7, 12, 30, 0) FROM c"); - results.Should().ContainSingle().Which.Should().StartWith("2026-04-07T12:30:00"); - } - - [Fact] - public async Task DateTimeFromParts_InvalidDate_ReturnsUndefined() - { - await SeedOne(); - // month 13 → ArgumentOutOfRangeException → undefined (omitted from projection) - var results = await RunQuery("SELECT DateTimeFromParts(2026, 13, 1) AS r FROM c"); - results.Should().ContainSingle(); - results[0]["r"].Should().BeNull(); // property omitted = undefined - } - - // ── DATETIMETOTIMESTAMP ── - - [Fact] - public async Task DateTimeToTimestamp_ReturnsEpochMs() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(1704067200000L); // 2024-01-01 epoch ms - } - - // ── TIMESTAMPTODATETIME ── - - [Fact] - public async Task TimestampToDateTime_ConvertsFromEpochMs() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE TimestampToDateTime(1704067200000) FROM c"); - results.Should().ContainSingle().Which.Should().StartWith("2024-01-01T00:00:00"); - } - - // ── Static date/time functions ── - - [Fact] - public async Task GetCurrentDateTimeStatic_ReturnsUtcString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentDateTimeStatic() FROM c"); - results.Should().ContainSingle(); - // Should be parseable as a valid UTC datetime - DateTime.TryParse(results[0], out var dt).Should().BeTrue(); - } - - [Fact] - public async Task GetCurrentTicksStatic_ReturnsPositiveValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentTicksStatic() FROM c"); - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } - - [Fact] - public async Task GetCurrentTimestampStatic_ReturnsPositiveValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE GetCurrentTimestampStatic() FROM c"); - results.Should().ContainSingle().Which.Should().BeGreaterThan(0); - } - - // ── STRINGTOx ── - - [Fact] - public async Task StringToArray_ValidJson_ReturnsArray() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToArray('[1, 2, 3]') FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeOfType(); - ((JArray)results[0]).Should().HaveCount(3); - } - - [Fact] - public async Task StringToArray_InvalidJson_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToArray('not an array') FROM c"); - results.Should().BeEmpty(); // undefined → skipped by SELECT VALUE - } - - [Fact] - public async Task StringToBoolean_True_ReturnsTrue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToBoolean('true') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StringToBoolean_InvalidString_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToBoolean('yes') FROM c"); - results.Should().BeEmpty(); // undefined → skipped - } - - [Fact] - public async Task StringToNull_ValidNull_ReturnsNull() - { - await SeedOne(); - // StringToNull('null') returns null. SELECT VALUE null → skips null. - // Test by projecting in an object so null is visible. - var results = await RunQuery("SELECT StringToNull('null') AS n FROM c"); - results.Should().ContainSingle(); - results[0]["n"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task StringToNull_InvalidString_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNull('not null') FROM c"); - results.Should().BeEmpty(); // undefined → skipped - } - - [Fact] - public async Task StringToNumber_ValidNumber_ReturnsNumber() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNumber('42') FROM c"); - results.Should().ContainSingle().Which.Should().Be(42); - } - - [Fact] - public async Task StringToNumber_ValidDouble_ReturnsDouble() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNumber('3.14') FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(3.14, 0.001); - } - - [Fact] - public async Task StringToNumber_InvalidString_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNumber('abc') FROM c"); - results.Should().BeEmpty(); // undefined → skipped - } - - [Fact] - public async Task StringToObject_ValidJson_ReturnsObject() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE StringToObject('{"a":1}') FROM c"""); - results.Should().ContainSingle(); - results[0].Should().BeOfType(); - results[0]["a"]!.Value().Should().Be(1); - } - - [Fact] - public async Task StringToObject_InvalidJson_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToObject('not json') FROM c"); - results.Should().BeEmpty(); // undefined → skipped - } - - // ── FULLTEXTCONTAINSALL / FULLTEXTCONTAINSANY ── - - [Fact] - public async Task FullTextContainsAll_AllTermsPresent_ReturnsTrue() - { - await SeedMultiple(); - var results = await RunQuery( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'fox')"); - results.Should().HaveCount(2); // Alice and Bob have both "quick" and "fox" - } - - [Fact] - public async Task FullTextContainsAll_MissingTerm_ReturnsFalse() - { - await SeedMultiple(); - var results = await RunQuery( - "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'zebra')"); - results.Should().BeEmpty(); // no doc has both "quick" and "zebra" - } - - [Fact] - public async Task FullTextContainsAny_AtLeastOneTerm_ReturnsTrue() - { - await SeedMultiple(); - var results = await RunQuery( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'red', 'lazy')"); - results.Should().HaveCount(3); // Bob has "red", Alice and Charlie have "lazy" - } - - [Fact] - public async Task FullTextContainsAny_NoTerms_ReturnsFalse() - { - await SeedMultiple(); - var results = await RunQuery( - "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'zebra', 'unicorn')"); - results.Should().BeEmpty(); - } - - // ── Spatial: ST_ISVALIDDETAILED, ST_INTERSECTS, ST_WITHIN ── - - [Fact] - public async Task StIsValidDetailed_ValidPoint_ReturnsValid() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","loc":{"type":"Point","coordinates":[0,0]}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_ISVALIDDETAILED(c.loc) FROM c"); - results.Should().ContainSingle(); - results[0]["valid"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task StIsValidDetailed_InvalidGeometry_ReturnsInvalid() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","loc":{"type":"bogus"}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_ISVALIDDETAILED(c.loc) FROM c"); - results.Should().ContainSingle(); - results[0]["valid"]!.Value().Should().BeFalse(); - results[0]["reason"]!.Value().Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StWithin_PointInsidePolygon_ReturnsTrue() - { - // Point at (0, 0), polygon bounding box around origin - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[0,0]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_WITHIN(c.point, c.poly) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StWithin_PointOutsidePolygon_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[10,10]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_WITHIN(c.point, c.poly) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task StIntersects_PointInsidePolygon_ReturnsTrue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[0,0]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_INTERSECTS(c.point, c.poly) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StIntersects_DisjointGeometries_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[10,10]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE ST_INTERSECTS(c.point, c.poly) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - // ── PI ── - - [Fact] - public async Task Pi_ReturnsCorrectValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE PI() FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI, 0.0001); - } - - // ── INTBITNOT ── - - [Fact] - public async Task IntBitNot_InvertsBits() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitNot(0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-1L); // ~0 = -1 - } - - [Fact] - public async Task IntBitNot_PositiveValue() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IntBitNot(5) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-6L); // ~5 = -6 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"tags":["a","b","c"]}""")), + new PartitionKey("pk1")); + } + + private async Task SeedMultiple() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"text":"The quick brown fox jumps over the lazy dog"}""", + """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"text":"A quick red fox"}""", + """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"text":"The lazy dog sleeps all day"}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); + } + + // ── REPLICATE ── + + [Fact] + public async Task Replicate_RepeatsStringNTimes() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ababab"); + } + + [Fact] + public async Task Replicate_ZeroTimes_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLICATE('x', 0) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task Replicate_NegativeCount_ReturnsUndefined() + { + await SeedOne(); + // REPLICATE with negative count returns undefined in Cosmos DB + var results = await RunQuery("SELECT REPLICATE('x', -1) AS r FROM c"); + results.Should().ContainSingle(); + // undefined field is omitted from the result object + results[0]["r"].Should().BeNull(); + } + + // ── STRING_EQUALS ── + + [Fact] + public async Task StringEquals_CaseSensitive_Default() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'Alice') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StringEquals_CaseSensitive_Mismatch() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'alice') FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task StringEquals_CaseInsensitive_WhenSpecified() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE STRING_EQUALS('Alice', 'alice', true) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + // ── NUMBERBIN ── + + [Fact] + public async Task NumberBin_RoundsToNearestBin() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(c.val, 7) FROM c"); + // val=10, binSize=7 → floor(10/7)*7 = floor(1.428)*7 = 1*7 = 7 + results.Should().ContainSingle().Which.Should().Be(7); + } + + [Fact] + public async Task NumberBin_NegativeValues_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(-15, 10) FROM c"); + // floor(-15/10)*10 = floor(-1.5)*10 = -2*10 = -20 + results.Should().ContainSingle().Which.Should().Be(-20); + } + + [Fact] + public async Task NumberBin_BinSizeOne_NoChange() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(c.val, 1) FROM c"); + results.Should().ContainSingle().Which.Should().Be(10); + } + + // ── INT Arithmetic ── + + [Fact] + public async Task IntAdd_TwoIntegers_ReturnsSum() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntAdd(3, 4) FROM c"); + results.Should().ContainSingle().Which.Should().Be(7); + } + + [Fact] + public async Task IntSub_ReturnsSubtraction() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntSub(10, 4) FROM c"); + results.Should().ContainSingle().Which.Should().Be(6); + } + + [Fact] + public async Task IntMul_ReturnsProduct() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntMul(3, 7) FROM c"); + results.Should().ContainSingle().Which.Should().Be(21); + } + + [Fact] + public async Task IntDiv_ReturnsQuotient() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntDiv(10, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3); // integer division + } + + [Fact] + public async Task IntMod_ReturnsRemainder() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task IntDiv_ByZero_ReturnsUndefined() + { + await SeedOne(); + // IntDiv by zero returns undefined (property excluded from result) + var results = await RunQuery("SELECT IntDiv(10, 0) AS r FROM c"); + results.Should().ContainSingle(); + results[0]["r"].Should().BeNull(); + } + + // ── DATETIMEFROMPARTS ── + + [Fact] + public async Task DateTimeFromParts_ConstructsDateTime() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE DateTimeFromParts(2026, 4, 7, 12, 30, 0) FROM c"); + results.Should().ContainSingle().Which.Should().StartWith("2026-04-07T12:30:00"); + } + + [Fact] + public async Task DateTimeFromParts_InvalidDate_ReturnsUndefined() + { + await SeedOne(); + // month 13 → ArgumentOutOfRangeException → undefined (omitted from projection) + var results = await RunQuery("SELECT DateTimeFromParts(2026, 13, 1) AS r FROM c"); + results.Should().ContainSingle(); + results[0]["r"].Should().BeNull(); // property omitted = undefined + } + + // ── DATETIMETOTIMESTAMP ── + + [Fact] + public async Task DateTimeToTimestamp_ReturnsEpochMs() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(1704067200000L); // 2024-01-01 epoch ms + } + + // ── TIMESTAMPTODATETIME ── + + [Fact] + public async Task TimestampToDateTime_ConvertsFromEpochMs() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE TimestampToDateTime(1704067200000) FROM c"); + results.Should().ContainSingle().Which.Should().StartWith("2024-01-01T00:00:00"); + } + + // ── Static date/time functions ── + + [Fact] + public async Task GetCurrentDateTimeStatic_ReturnsUtcString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentDateTimeStatic() FROM c"); + results.Should().ContainSingle(); + // Should be parseable as a valid UTC datetime + DateTime.TryParse(results[0], out var dt).Should().BeTrue(); + } + + [Fact] + public async Task GetCurrentTicksStatic_ReturnsPositiveValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentTicksStatic() FROM c"); + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetCurrentTimestampStatic_ReturnsPositiveValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE GetCurrentTimestampStatic() FROM c"); + results.Should().ContainSingle().Which.Should().BeGreaterThan(0); + } + + // ── STRINGTOx ── + + [Fact] + public async Task StringToArray_ValidJson_ReturnsArray() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToArray('[1, 2, 3]') FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeOfType(); + ((JArray)results[0]).Should().HaveCount(3); + } + + [Fact] + public async Task StringToArray_InvalidJson_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToArray('not an array') FROM c"); + results.Should().BeEmpty(); // undefined → skipped by SELECT VALUE + } + + [Fact] + public async Task StringToBoolean_True_ReturnsTrue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToBoolean('true') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StringToBoolean_InvalidString_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToBoolean('yes') FROM c"); + results.Should().BeEmpty(); // undefined → skipped + } + + [Fact] + public async Task StringToNull_ValidNull_ReturnsNull() + { + await SeedOne(); + // StringToNull('null') returns null. SELECT VALUE null → skips null. + // Test by projecting in an object so null is visible. + var results = await RunQuery("SELECT StringToNull('null') AS n FROM c"); + results.Should().ContainSingle(); + results[0]["n"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task StringToNull_InvalidString_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNull('not null') FROM c"); + results.Should().BeEmpty(); // undefined → skipped + } + + [Fact] + public async Task StringToNumber_ValidNumber_ReturnsNumber() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNumber('42') FROM c"); + results.Should().ContainSingle().Which.Should().Be(42); + } + + [Fact] + public async Task StringToNumber_ValidDouble_ReturnsDouble() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNumber('3.14') FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(3.14, 0.001); + } + + [Fact] + public async Task StringToNumber_InvalidString_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNumber('abc') FROM c"); + results.Should().BeEmpty(); // undefined → skipped + } + + [Fact] + public async Task StringToObject_ValidJson_ReturnsObject() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE StringToObject('{"a":1}') FROM c"""); + results.Should().ContainSingle(); + results[0].Should().BeOfType(); + results[0]["a"]!.Value().Should().Be(1); + } + + [Fact] + public async Task StringToObject_InvalidJson_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToObject('not json') FROM c"); + results.Should().BeEmpty(); // undefined → skipped + } + + // ── FULLTEXTCONTAINSALL / FULLTEXTCONTAINSANY ── + + [Fact] + public async Task FullTextContainsAll_AllTermsPresent_ReturnsTrue() + { + await SeedMultiple(); + var results = await RunQuery( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'fox')"); + results.Should().HaveCount(2); // Alice and Bob have both "quick" and "fox" + } + + [Fact] + public async Task FullTextContainsAll_MissingTerm_ReturnsFalse() + { + await SeedMultiple(); + var results = await RunQuery( + "SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'zebra')"); + results.Should().BeEmpty(); // no doc has both "quick" and "zebra" + } + + [Fact] + public async Task FullTextContainsAny_AtLeastOneTerm_ReturnsTrue() + { + await SeedMultiple(); + var results = await RunQuery( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'red', 'lazy')"); + results.Should().HaveCount(3); // Bob has "red", Alice and Charlie have "lazy" + } + + [Fact] + public async Task FullTextContainsAny_NoTerms_ReturnsFalse() + { + await SeedMultiple(); + var results = await RunQuery( + "SELECT * FROM c WHERE FullTextContainsAny(c.text, 'zebra', 'unicorn')"); + results.Should().BeEmpty(); + } + + // ── Spatial: ST_ISVALIDDETAILED, ST_INTERSECTS, ST_WITHIN ── + + [Fact] + public async Task StIsValidDetailed_ValidPoint_ReturnsValid() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","loc":{"type":"Point","coordinates":[0,0]}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_ISVALIDDETAILED(c.loc) FROM c"); + results.Should().ContainSingle(); + results[0]["valid"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task StIsValidDetailed_InvalidGeometry_ReturnsInvalid() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","loc":{"type":"bogus"}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_ISVALIDDETAILED(c.loc) FROM c"); + results.Should().ContainSingle(); + results[0]["valid"]!.Value().Should().BeFalse(); + results[0]["reason"]!.Value().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StWithin_PointInsidePolygon_ReturnsTrue() + { + // Point at (0, 0), polygon bounding box around origin + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[0,0]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_WITHIN(c.point, c.poly) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StWithin_PointOutsidePolygon_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[10,10]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_WITHIN(c.point, c.poly) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task StIntersects_PointInsidePolygon_ReturnsTrue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[0,0]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_INTERSECTS(c.point, c.poly) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StIntersects_DisjointGeometries_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","point":{"type":"Point","coordinates":[10,10]},"poly":{"type":"Polygon","coordinates":[[[-1,-1],[1,-1],[1,1],[-1,1],[-1,-1]]]}}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE ST_INTERSECTS(c.point, c.poly) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + // ── PI ── + + [Fact] + public async Task Pi_ReturnsCorrectValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE PI() FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(Math.PI, 0.0001); + } + + // ── INTBITNOT ── + + [Fact] + public async Task IntBitNot_InvertsBits() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitNot(0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-1L); // ~0 = -1 + } + + [Fact] + public async Task IntBitNot_PositiveValue() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IntBitNot(5) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-6L); // ~5 = -6 + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9715,158 +9725,158 @@ public async Task IntBitNot_PositiveValue() public class QueryDeepDiveV4_SyntaxCoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"isActive":true,"tags":["a","b"],"nested":{"x":{"y":{"z":1}}}}""", - """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"isActive":false,"tags":["b","c"],"nested":{"x":{"y":{"z":2}}}}""", - """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"isActive":true,"tags":["a","c"]}""", - """{"id":"4","partitionKey":"pk2","name":"Diana","val":40,"isActive":true}""", - """{"id":"5","partitionKey":"pk1","name":"Eve","val":50,"isActive":false,"extra":null}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey(doc.Contains("pk2") ? "pk2" : "pk1")); - } - - // ── SELECT VALUE edge cases ── - - [Fact] - public async Task SelectTop_Zero_ReturnsEmpty() - { - await SeedDocs(); - var results = await RunQuery("SELECT TOP 0 * FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task SelectValue_ArithmeticExpression_ReturnsComputedValues() - { - await SeedDocs(); - var results = await RunQuery("SELECT VALUE c.val * 2 + 1 FROM c"); - results.Should().HaveCount(5); - results.Should().Contain(21); // 10*2+1 - results.Should().Contain(41); // 20*2+1 - } - - [Fact] - public async Task SelectDistinctValue_ReturnsUniqueScalars() - { - await SeedDocs(); - var results = await RunQuery("SELECT DISTINCT VALUE c.isActive FROM c"); - results.Should().HaveCount(2); - results.Should().Contain(true); - results.Should().Contain(false); - } - - [Fact] - public async Task Select_ObjectLiteral_WithFunctionCallValues() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE {\"upper\": UPPER(c.name), \"len\": LENGTH(c.name)} FROM c"); - results.Should().HaveCount(5); - var alice = results.First(r => r["upper"]!.Value() == "ALICE"); - alice["len"]!.Value().Should().Be(5); - } - - // ── IS NOT NULL, NOT BETWEEN, NOT IN ── - - [Fact] - public async Task Where_IsNotNull_FiltersCorrectly() - { - await SeedDocs(); - // Only Eve has explicit null extra field; others lack it or have values - var results = await RunQuery("SELECT * FROM c WHERE c.extra IS NOT NULL"); - // Eve has extra=null → IS NOT NULL = false → excluded - // Others lack "extra" entirely → IS NOT NULL on non-existent → false (IS_NULL returns false for undefined, so IS NOT NULL returns true... let's verify) - // Actually: IS NULL checks for actual null JToken. Missing field → SelectToken = null → that's `undefined`, not `null` - // IS NOT NULL on undefined → NOT (false) = true... but wait, it depends on implementation - // Let's just verify the query runs and Eve is NOT included - results.Should().NotContain(r => r["id"]!.Value() == "5" - && r["extra"] != null && r["extra"]!.Type == JTokenType.Null); - } - - [Fact] - public async Task Where_NotBetween_ExcludesRange() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE NOT (c.val BETWEEN 20 AND 40)"); - // Should include val=10 and val=50 - results.Should().HaveCount(2); - var vals = results.Select(r => r["val"]!.Value()).ToList(); - vals.Should().Contain(10); - vals.Should().Contain(50); - } - - [Fact] - public async Task Where_NotIn_ExcludesMatchingValues() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 30, 50)"); - results.Should().HaveCount(2); - var vals = results.Select(r => r["val"]!.Value()).ToList(); - vals.Should().Contain(20); - vals.Should().Contain(40); - } - - // ── Deep nesting ── - - [Fact] - public async Task Where_DeeplyNestedProperty_NavigatesCorrectly() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.nested.x.y.z = 1"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── GROUP BY + ORDER BY ── - - [Fact] - public async Task GroupBy_OrderByAggregate_SortsGroups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive ORDER BY cnt DESC"); - results.Should().HaveCount(2); - // true (3 docs) should come first, false (2 docs) second - results[0]["cnt"]!.Value().Should().Be(3); - results[1]["cnt"]!.Value().Should().Be(2); - } - - // ── Escaped single quotes ── - - [Fact] - public async Task Where_EscapedSingleQuote_MatchesCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"O'Brien"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.name = 'O''Brien'"); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("O'Brien"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"isActive":true,"tags":["a","b"],"nested":{"x":{"y":{"z":1}}}}""", + """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"isActive":false,"tags":["b","c"],"nested":{"x":{"y":{"z":2}}}}""", + """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"isActive":true,"tags":["a","c"]}""", + """{"id":"4","partitionKey":"pk2","name":"Diana","val":40,"isActive":true}""", + """{"id":"5","partitionKey":"pk1","name":"Eve","val":50,"isActive":false,"extra":null}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey(doc.Contains("pk2") ? "pk2" : "pk1")); + } + + // ── SELECT VALUE edge cases ── + + [Fact] + public async Task SelectTop_Zero_ReturnsEmpty() + { + await SeedDocs(); + var results = await RunQuery("SELECT TOP 0 * FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task SelectValue_ArithmeticExpression_ReturnsComputedValues() + { + await SeedDocs(); + var results = await RunQuery("SELECT VALUE c.val * 2 + 1 FROM c"); + results.Should().HaveCount(5); + results.Should().Contain(21); // 10*2+1 + results.Should().Contain(41); // 20*2+1 + } + + [Fact] + public async Task SelectDistinctValue_ReturnsUniqueScalars() + { + await SeedDocs(); + var results = await RunQuery("SELECT DISTINCT VALUE c.isActive FROM c"); + results.Should().HaveCount(2); + results.Should().Contain(true); + results.Should().Contain(false); + } + + [Fact] + public async Task Select_ObjectLiteral_WithFunctionCallValues() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE {\"upper\": UPPER(c.name), \"len\": LENGTH(c.name)} FROM c"); + results.Should().HaveCount(5); + var alice = results.First(r => r["upper"]!.Value() == "ALICE"); + alice["len"]!.Value().Should().Be(5); + } + + // ── IS NOT NULL, NOT BETWEEN, NOT IN ── + + [Fact] + public async Task Where_IsNotNull_FiltersCorrectly() + { + await SeedDocs(); + // Only Eve has explicit null extra field; others lack it or have values + var results = await RunQuery("SELECT * FROM c WHERE c.extra IS NOT NULL"); + // Eve has extra=null → IS NOT NULL = false → excluded + // Others lack "extra" entirely → IS NOT NULL on non-existent → false (IS_NULL returns false for undefined, so IS NOT NULL returns true... let's verify) + // Actually: IS NULL checks for actual null JToken. Missing field → SelectToken = null → that's `undefined`, not `null` + // IS NOT NULL on undefined → NOT (false) = true... but wait, it depends on implementation + // Let's just verify the query runs and Eve is NOT included + results.Should().NotContain(r => r["id"]!.Value() == "5" + && r["extra"] != null && r["extra"]!.Type == JTokenType.Null); + } + + [Fact] + public async Task Where_NotBetween_ExcludesRange() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE NOT (c.val BETWEEN 20 AND 40)"); + // Should include val=10 and val=50 + results.Should().HaveCount(2); + var vals = results.Select(r => r["val"]!.Value()).ToList(); + vals.Should().Contain(10); + vals.Should().Contain(50); + } + + [Fact] + public async Task Where_NotIn_ExcludesMatchingValues() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 30, 50)"); + results.Should().HaveCount(2); + var vals = results.Select(r => r["val"]!.Value()).ToList(); + vals.Should().Contain(20); + vals.Should().Contain(40); + } + + // ── Deep nesting ── + + [Fact] + public async Task Where_DeeplyNestedProperty_NavigatesCorrectly() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.nested.x.y.z = 1"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── GROUP BY + ORDER BY ── + + [Fact] + public async Task GroupBy_OrderByAggregate_SortsGroups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive ORDER BY cnt DESC"); + results.Should().HaveCount(2); + // true (3 docs) should come first, false (2 docs) second + results[0]["cnt"]!.Value().Should().Be(3); + results[1]["cnt"]!.Value().Should().Be(2); + } + + // ── Escaped single quotes ── + + [Fact] + public async Task Where_EscapedSingleQuote_MatchesCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"O'Brien"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.name = 'O''Brien'"); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("O'Brien"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9875,117 +9885,117 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV4_CrossFeatureInteractionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","cat":"A","val":10,"tags":["x","y"]}""", - """{"id":"2","partitionKey":"pk1","cat":"A","val":20,"tags":["y","z"]}""", - """{"id":"3","partitionKey":"pk1","cat":"B","val":30,"tags":["x"]}""", - """{"id":"4","partitionKey":"pk1","cat":"B","val":5,"tags":["x","y","z"]}""", - """{"id":"5","partitionKey":"pk1","cat":"C","val":50,"tags":["z"]}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); - } - - // ── JOIN + DISTINCT ── - - [Fact] - public async Task Join_WithDistinct_DeduplicatesExpandedRows() - { - await SeedDocs(); - var results = await RunQuery("SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); - // Tags across all docs: x,y,y,z,x,x,y,z,z → distinct: x,y,z - results.Should().HaveCount(3); - results.Should().Contain("x"); - results.Should().Contain("y"); - results.Should().Contain("z"); - } - - // ── JOIN + ORDER BY + OFFSET LIMIT ── - - [Fact] - public async Task Join_OrderBy_OffsetLimit_PaginatesJoinResults() - { - await SeedDocs(); - // Total expanded rows: 2+2+1+3+1=9 tag rows. Order by tag ASC, skip 2, take 3 - var results = await RunQuery( - "SELECT c.id, t AS tag FROM c JOIN t IN c.tags ORDER BY t ASC OFFSET 2 LIMIT 3"); - results.Should().HaveCount(3); - } - - // ── DISTINCT + ORDER BY + OFFSET LIMIT ── - - [Fact] - public async Task Distinct_OrderBy_OffsetLimit_Combined() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.val FROM c ORDER BY c.val ASC OFFSET 1 LIMIT 2"); - // Distinct vals: 5,10,20,30,50 → ordered → skip 1 → take 2 → [10, 20] - results.Should().HaveCount(2); - results[0].Should().Be(10); - results[1].Should().Be(20); - } - - // ── Multiple subqueries ── - - [Fact] - public async Task Select_MultipleSubqueries_IndependentEvaluation() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE {\"tagCount\": ARRAY_LENGTH(c.tags), \"firstTag\": c.tags[0]} FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["tagCount"]!.Value().Should().Be(2); - results[0]["firstTag"]!.Value().Should().Be("x"); - } - - // ── GROUP BY + OFFSET LIMIT ── - - [Fact] - public async Task GroupBy_OffsetLimit_PaginatesGroups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC OFFSET 1 LIMIT 1"); - results.Should().ContainSingle(); - results[0]["cat"]!.Value().Should().Be("B"); // A(skip),B(take),C(skip) - } - - // ── WHERE EXISTS + NOT EXISTS combined ── - - [Fact] - public async Task Where_ExistsAndNotExists_CombinedCorrectly() - { - await SeedDocs(); - // Docs where tags contain 'x' AND tags do NOT contain 'z' - var results = await RunQuery( - "SELECT * FROM c WHERE " + - "EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x') " + - "AND NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'z')"); - // id=1 tags [x,y] → has x, no z ✓ - // id=2 tags [y,z] → no x ✗ - // id=3 tags [x] → has x, no z ✓ - // id=4 tags [x,y,z] → has x, has z ✗ - // id=5 tags [z] → no x ✗ - results.Should().HaveCount(2); - var ids = results.Select(r => r["id"]!.Value()).ToList(); - ids.Should().Contain("1"); - ids.Should().Contain("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","cat":"A","val":10,"tags":["x","y"]}""", + """{"id":"2","partitionKey":"pk1","cat":"A","val":20,"tags":["y","z"]}""", + """{"id":"3","partitionKey":"pk1","cat":"B","val":30,"tags":["x"]}""", + """{"id":"4","partitionKey":"pk1","cat":"B","val":5,"tags":["x","y","z"]}""", + """{"id":"5","partitionKey":"pk1","cat":"C","val":50,"tags":["z"]}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); + } + + // ── JOIN + DISTINCT ── + + [Fact] + public async Task Join_WithDistinct_DeduplicatesExpandedRows() + { + await SeedDocs(); + var results = await RunQuery("SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); + // Tags across all docs: x,y,y,z,x,x,y,z,z → distinct: x,y,z + results.Should().HaveCount(3); + results.Should().Contain("x"); + results.Should().Contain("y"); + results.Should().Contain("z"); + } + + // ── JOIN + ORDER BY + OFFSET LIMIT ── + + [Fact] + public async Task Join_OrderBy_OffsetLimit_PaginatesJoinResults() + { + await SeedDocs(); + // Total expanded rows: 2+2+1+3+1=9 tag rows. Order by tag ASC, skip 2, take 3 + var results = await RunQuery( + "SELECT c.id, t AS tag FROM c JOIN t IN c.tags ORDER BY t ASC OFFSET 2 LIMIT 3"); + results.Should().HaveCount(3); + } + + // ── DISTINCT + ORDER BY + OFFSET LIMIT ── + + [Fact] + public async Task Distinct_OrderBy_OffsetLimit_Combined() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.val FROM c ORDER BY c.val ASC OFFSET 1 LIMIT 2"); + // Distinct vals: 5,10,20,30,50 → ordered → skip 1 → take 2 → [10, 20] + results.Should().HaveCount(2); + results[0].Should().Be(10); + results[1].Should().Be(20); + } + + // ── Multiple subqueries ── + + [Fact] + public async Task Select_MultipleSubqueries_IndependentEvaluation() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE {\"tagCount\": ARRAY_LENGTH(c.tags), \"firstTag\": c.tags[0]} FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["tagCount"]!.Value().Should().Be(2); + results[0]["firstTag"]!.Value().Should().Be("x"); + } + + // ── GROUP BY + OFFSET LIMIT ── + + [Fact] + public async Task GroupBy_OffsetLimit_PaginatesGroups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC OFFSET 1 LIMIT 1"); + results.Should().ContainSingle(); + results[0]["cat"]!.Value().Should().Be("B"); // A(skip),B(take),C(skip) + } + + // ── WHERE EXISTS + NOT EXISTS combined ── + + [Fact] + public async Task Where_ExistsAndNotExists_CombinedCorrectly() + { + await SeedDocs(); + // Docs where tags contain 'x' AND tags do NOT contain 'z' + var results = await RunQuery( + "SELECT * FROM c WHERE " + + "EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x') " + + "AND NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'z')"); + // id=1 tags [x,y] → has x, no z ✓ + // id=2 tags [y,z] → no x ✗ + // id=3 tags [x] → has x, no z ✓ + // id=4 tags [x,y,z] → has x, has z ✗ + // id=5 tags [z] → no x ✗ + results.Should().HaveCount(2); + var ids = results.Select(r => r["id"]!.Value()).ToList(); + ids.Should().Contain("1"); + ids.Should().Contain("3"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -9994,73 +10004,73 @@ public async Task Where_ExistsAndNotExists_CombinedCorrectly() public class QueryDeepDiveV4_BugFixTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","cat":"A","counter":10,"val":5}""", - """{"id":"2","partitionKey":"pk1","cat":"A","counter":20,"val":15}""", - """{"id":"3","partitionKey":"pk1","cat":"B","counter":30,"val":25}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); - } - - // ── B1: GROUP BY aggregate detection (field named 'counter' should not confuse with COUNT) ── - - [Fact] - public async Task GroupBy_FieldNameStartingWithAggregateName_NotConfusedWithAggregate() - { - await SeedDocs(); - // 'counter' starts with 'COUNT' — should NOT be detected as an aggregate - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat"); - results.Should().HaveCount(2); - results[0]["cat"]!.Value().Should().Be("A"); - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cat"]!.Value().Should().Be("B"); - results[1]["cnt"]!.Value().Should().Be(1); - } - - // ── B2: RAND() multiple calls produce different values ── - - [Fact] - public async Task Rand_CalledMultipleTimes_ProducesSomeVariation() - { - await SeedDocs(); - // Call RAND() 3 times (one per doc) — at least one pair should differ - var results = await RunQuery("SELECT VALUE RAND() FROM c"); - results.Should().HaveCount(3); - // Statistically, having all 3 identical is astronomically unlikely - results.Distinct().Count().Should().BeGreaterThan(1); - } - - // ── B10: DateTimeBin with and without origin ── - - [Fact] - public async Task DateTimeBin_WithoutOrigin_BinsRelativeToEpoch() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT VALUE DateTimeBin('2026-04-07T14:35:00Z', 'hh', 1) FROM c"); - results.Should().ContainSingle(); - // Should bin to the start of the hour: 2026-04-07T14:00:00Z - results[0].Should().StartWith("2026-04-07T14:00:00"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","cat":"A","counter":10,"val":5}""", + """{"id":"2","partitionKey":"pk1","cat":"A","counter":20,"val":15}""", + """{"id":"3","partitionKey":"pk1","cat":"B","counter":30,"val":25}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); + } + + // ── B1: GROUP BY aggregate detection (field named 'counter' should not confuse with COUNT) ── + + [Fact] + public async Task GroupBy_FieldNameStartingWithAggregateName_NotConfusedWithAggregate() + { + await SeedDocs(); + // 'counter' starts with 'COUNT' — should NOT be detected as an aggregate + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat"); + results.Should().HaveCount(2); + results[0]["cat"]!.Value().Should().Be("A"); + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cat"]!.Value().Should().Be("B"); + results[1]["cnt"]!.Value().Should().Be(1); + } + + // ── B2: RAND() multiple calls produce different values ── + + [Fact] + public async Task Rand_CalledMultipleTimes_ProducesSomeVariation() + { + await SeedDocs(); + // Call RAND() 3 times (one per doc) — at least one pair should differ + var results = await RunQuery("SELECT VALUE RAND() FROM c"); + results.Should().HaveCount(3); + // Statistically, having all 3 identical is astronomically unlikely + results.Distinct().Count().Should().BeGreaterThan(1); + } + + // ── B10: DateTimeBin with and without origin ── + + [Fact] + public async Task DateTimeBin_WithoutOrigin_BinsRelativeToEpoch() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT VALUE DateTimeBin('2026-04-07T14:35:00Z', 'hh', 1) FROM c"); + results.Should().ContainSingle(); + // Should bin to the start of the hour: 2026-04-07T14:00:00Z + results[0].Should().StartWith("2026-04-07T14:00:00"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10069,102 +10079,102 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV4_NullUndefinedEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","val":10,"extra":"present"}""", - """{"id":"2","partitionKey":"pk1","val":null}""", - """{"id":"3","partitionKey":"pk1"}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); - } - - [Fact] - public async Task Where_EqualsNull_MatchesExplicitNull() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.val = null"); - // id=2 has val=null → should match - // id=3 has no val (undefined) → should NOT match - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task Where_MissingFieldEqualsNull_ReturnsFalse() - { - await SeedDocs(); - // c.missing doesn't exist on any doc → undefined = null → false - var results = await RunQuery("SELECT * FROM c WHERE c.missing = null"); - // Undefined ≠ null in Cosmos — should return 0 rows (or maybe 1 row if the emulator treats undefined comparison differently) - // Let the test discover the actual behavior - results.Should().BeEmpty(); - } - - [Fact] - public async Task StringConcat_NullPlusString_ReturnsUndefined() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE null || 'hello' FROM c WHERE c.id = '1'"); - // null || 'text' → undefined → omitted by SELECT VALUE - results.Should().BeEmpty(); - } - - // ── Aggregate with mixed null/undefined ── - - [Fact] - public async Task Sum_MixedNullAndUndefined_IgnoresBoth() - { - await SeedDocs(); - // id=1 val=10, id=2 val=null, id=3 val=undefined - // SUM should only include 10 - var results = await RunQuery("SELECT SUM(c.val) AS total FROM c"); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(10); - } - - [Fact] - public async Task Avg_WithNulls_DenominatorExcludesNulls() - { - await SeedDocs(); - // Only id=1 has numeric val (10). AVG = 10/1 = 10 - var results = await RunQuery("SELECT AVG(c.val) AS avg FROM c"); - results.Should().ContainSingle(); - results[0]["avg"]!.Value().Should().Be(10); - } - - [Fact] - public async Task MinMax_AllNulls_ReturnsUndefined() - { - var container = new InMemoryContainer("test-empty", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - - var iterator = container.GetItemQueryIterator("SELECT MIN(c.val) AS m FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - // MIN of all nulls → undefined → field omitted from result - results.Should().ContainSingle(); - // The "m" field should either be null or absent - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","val":10,"extra":"present"}""", + """{"id":"2","partitionKey":"pk1","val":null}""", + """{"id":"3","partitionKey":"pk1"}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); + } + + [Fact] + public async Task Where_EqualsNull_MatchesExplicitNull() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.val = null"); + // id=2 has val=null → should match + // id=3 has no val (undefined) → should NOT match + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task Where_MissingFieldEqualsNull_ReturnsFalse() + { + await SeedDocs(); + // c.missing doesn't exist on any doc → undefined = null → false + var results = await RunQuery("SELECT * FROM c WHERE c.missing = null"); + // Undefined ≠ null in Cosmos — should return 0 rows (or maybe 1 row if the emulator treats undefined comparison differently) + // Let the test discover the actual behavior + results.Should().BeEmpty(); + } + + [Fact] + public async Task StringConcat_NullPlusString_ReturnsUndefined() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE null || 'hello' FROM c WHERE c.id = '1'"); + // null || 'text' → undefined → omitted by SELECT VALUE + results.Should().BeEmpty(); + } + + // ── Aggregate with mixed null/undefined ── + + [Fact] + public async Task Sum_MixedNullAndUndefined_IgnoresBoth() + { + await SeedDocs(); + // id=1 val=10, id=2 val=null, id=3 val=undefined + // SUM should only include 10 + var results = await RunQuery("SELECT SUM(c.val) AS total FROM c"); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(10); + } + + [Fact] + public async Task Avg_WithNulls_DenominatorExcludesNulls() + { + await SeedDocs(); + // Only id=1 has numeric val (10). AVG = 10/1 = 10 + var results = await RunQuery("SELECT AVG(c.val) AS avg FROM c"); + results.Should().ContainSingle(); + results[0]["avg"]!.Value().Should().Be(10); + } + + [Fact] + public async Task MinMax_AllNulls_ReturnsUndefined() + { + var container = new InMemoryContainer("test-empty", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + + var iterator = container.GetItemQueryIterator("SELECT MIN(c.val) AS m FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + // MIN of all nulls → undefined → field omitted from result + results.Should().ContainSingle(); + // The "m" field should either be null or absent + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10173,100 +10183,100 @@ await container.CreateItemStreamAsync( public class QueryDeepDiveV4_NumericEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Where_ScientificNotation_ComparesCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1500}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val > 1e3"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Where_NegativeZero_EqualsPositiveZero() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":0}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE c.val = -0.0"); - // -0.0 == 0.0 in IEEE 754 - results.Should().ContainSingle(); - } - - [Fact] - public async Task Floor_NegativeValue_RoundsDown() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE FLOOR(-2.3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-3); - } - - [Fact] - public async Task Ceiling_NegativeValue_RoundsUp() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE CEILING(-2.3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-2); - } - - [Fact] - public async Task Where_DivisionByZero_Behavior() - { - await SeedOne(); - // c.val / 0 → depends on implementation: NaN, Infinity, or error - // Since val=10 (integer), 10/0 likely throws or returns Infinity/null - var results = await RunQuery("SELECT * FROM c WHERE c.val / 0 > 0"); - // Expected: undefined or error → no match - results.Should().BeEmpty(); - } - - [Fact] - public async Task Power_ZeroToZero_ReturnsOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); - // C# Math.Pow(0, 0) = 1 - results.Should().ContainSingle().Which.Should().Be(1); - } - - [Fact] - public async Task Sqrt_NegativeNumber_ReturnsNaN_OrUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SQRT(-1) FROM c"); - // Math.Sqrt(-1) = NaN → should produce undefined or NaN - // NaN in JSON is typically undefined/null - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Log_Zero_Behavior() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG(0) FROM c"); - // Math.Log(0) = -Infinity → should produce undefined/null - results.Should().HaveCountLessThanOrEqualTo(1); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Where_ScientificNotation_ComparesCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":1500}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val > 1e3"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Where_NegativeZero_EqualsPositiveZero() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":0}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE c.val = -0.0"); + // -0.0 == 0.0 in IEEE 754 + results.Should().ContainSingle(); + } + + [Fact] + public async Task Floor_NegativeValue_RoundsDown() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE FLOOR(-2.3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-3); + } + + [Fact] + public async Task Ceiling_NegativeValue_RoundsUp() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE CEILING(-2.3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-2); + } + + [Fact] + public async Task Where_DivisionByZero_Behavior() + { + await SeedOne(); + // c.val / 0 → depends on implementation: NaN, Infinity, or error + // Since val=10 (integer), 10/0 likely throws or returns Infinity/null + var results = await RunQuery("SELECT * FROM c WHERE c.val / 0 > 0"); + // Expected: undefined or error → no match + results.Should().BeEmpty(); + } + + [Fact] + public async Task Power_ZeroToZero_ReturnsOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); + // C# Math.Pow(0, 0) = 1 + results.Should().ContainSingle().Which.Should().Be(1); + } + + [Fact] + public async Task Sqrt_NegativeNumber_ReturnsNaN_OrUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SQRT(-1) FROM c"); + // Math.Sqrt(-1) = NaN → should produce undefined or NaN + // NaN in JSON is typically undefined/null + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Log_Zero_Behavior() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG(0) FROM c"); + // Math.Log(0) = -Infinity → should produce undefined/null + results.Should().HaveCountLessThanOrEqualTo(1); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10275,81 +10285,81 @@ public async Task Log_Zero_Behavior() public class QueryDeepDiveV4_StringEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Hello World"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Contains_EmptySearchString_MatchesAll() - { - await SeedOne(); - var results = await RunQuery("SELECT * FROM c WHERE CONTAINS(c.name, '')"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task StartsWith_EmptyString_MatchesAll() - { - await SeedOne(); - var results = await RunQuery("SELECT * FROM c WHERE STARTSWITH(c.name, '')"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Substring_BeyondLength_ReturnsTruncated() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 6, 100) FROM c"); - results.Should().ContainSingle().Which.Should().Be("World"); // Takes what's available - } - - [Fact] - public async Task IndexOf_NoMatch_ReturnsMinusOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'xyz') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-1); - } - - [Fact] - public async Task Left_Zero_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LEFT(c.name, 0) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task Right_Zero_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE RIGHT(c.name, 0) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task Contains_EmojiCharacter_MatchesCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Hello 🌍 World"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, '🌍')"""); - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Hello World"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Contains_EmptySearchString_MatchesAll() + { + await SeedOne(); + var results = await RunQuery("SELECT * FROM c WHERE CONTAINS(c.name, '')"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task StartsWith_EmptyString_MatchesAll() + { + await SeedOne(); + var results = await RunQuery("SELECT * FROM c WHERE STARTSWITH(c.name, '')"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Substring_BeyondLength_ReturnsTruncated() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 6, 100) FROM c"); + results.Should().ContainSingle().Which.Should().Be("World"); // Takes what's available + } + + [Fact] + public async Task IndexOf_NoMatch_ReturnsMinusOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'xyz') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-1); + } + + [Fact] + public async Task Left_Zero_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LEFT(c.name, 0) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task Right_Zero_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE RIGHT(c.name, 0) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task Contains_EmojiCharacter_MatchesCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Hello 🌍 World"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, '🌍')"""); + results.Should().ContainSingle(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10358,84 +10368,84 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV4_ArrayEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3,4,5],"name":"test"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task ArraySlice_NegativeStart_CountsFromEnd() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.arr, -2) FROM c"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(2); - arr[0]!.Value().Should().Be(4); - arr[1]!.Value().Should().Be(5); - } - - [Fact] - public async Task ArrayLength_NonArrayField_ReturnsUndefined() - { - await SeedOne(); - // ARRAY_LENGTH on a non-array field returns undefined (property excluded from result) - var results = await RunQuery("SELECT ARRAY_LENGTH(c.name) AS r FROM c"); - results.Should().ContainSingle(); - results[0]["r"].Should().BeNull(); - } - - [Fact] - public async Task SetIntersect_EmptyArray_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SetIntersect(c.arr, []) FROM c"); - results.Should().ContainSingle(); - ((JArray)results[0]).Should().BeEmpty(); - } - - [Fact] - public async Task SetUnion_WithDuplicates_DeduplicatesCorrectly() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SetUnion([1, 2, 3], [2, 3, 4]) FROM c"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(4); // 1,2,3,4 - } - - [Fact] - public async Task SetDifference_OrderMatters() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SetDifference([1, 2, 3], [2, 3, 4]) FROM c"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(1); // only 1 - arr[0]!.Value().Should().Be(1); - } - - [Fact] - public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ARRAY_CONCAT([1], [2], [3]) FROM c"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(3); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3,4,5],"name":"test"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task ArraySlice_NegativeStart_CountsFromEnd() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.arr, -2) FROM c"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(2); + arr[0]!.Value().Should().Be(4); + arr[1]!.Value().Should().Be(5); + } + + [Fact] + public async Task ArrayLength_NonArrayField_ReturnsUndefined() + { + await SeedOne(); + // ARRAY_LENGTH on a non-array field returns undefined (property excluded from result) + var results = await RunQuery("SELECT ARRAY_LENGTH(c.name) AS r FROM c"); + results.Should().ContainSingle(); + results[0]["r"].Should().BeNull(); + } + + [Fact] + public async Task SetIntersect_EmptyArray_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SetIntersect(c.arr, []) FROM c"); + results.Should().ContainSingle(); + ((JArray)results[0]).Should().BeEmpty(); + } + + [Fact] + public async Task SetUnion_WithDuplicates_DeduplicatesCorrectly() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SetUnion([1, 2, 3], [2, 3, 4]) FROM c"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(4); // 1,2,3,4 + } + + [Fact] + public async Task SetDifference_OrderMatters() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SetDifference([1, 2, 3], [2, 3, 4]) FROM c"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(1); // only 1 + arr[0]!.Value().Should().Be(1); + } + + [Fact] + public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ARRAY_CONCAT([1], [2], [3]) FROM c"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(3); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10444,73 +10454,73 @@ public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() public class QueryDeepDiveV4_DateTimeEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task DateTimeAdd_EndOfMonth_Rollover() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('mm', 1, '2026-01-31T00:00:00Z') FROM c"); - results.Should().ContainSingle(); - // Adding 1 month to Jan 31 → Feb 28 (2026 is not a leap year) - results[0].Should().StartWith("2026-02-28"); - } - - [Fact] - public async Task DateTimeAdd_LeapYear_Rollover() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('yyyy', 1, '2024-02-29T00:00:00Z') FROM c"); - results.Should().ContainSingle(); - // 2025 is not a leap year, so Feb 29 rolls to Feb 28 - results[0].Should().StartWith("2025-02-28"); - } - - [Fact] - public async Task DateTimeDiff_AcrossMonthBoundary_CalculatesCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', '2026-01-28T00:00:00Z', '2026-02-03T00:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(6); - } - - [Fact] - public async Task DateTimePart_DayOfWeek_ReturnsWeekdayNumber() - { - await SeedOne(); - // 2026-04-07 is a Tuesday → Cosmos DB weekday: Sunday=1..Saturday=7 → Tuesday=3 - var results = await RunQuery( - "SELECT VALUE DateTimePart('weekday', '2026-04-07T00:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(3); - } - - [Fact] - public async Task TicksToDateTime_DateTimeToTicks_RoundTrip() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE TicksToDateTime(DateTimeToTicks('2026-04-07T12:00:00.0000000Z')) FROM c"); - results.Should().ContainSingle(); - results[0].Should().StartWith("2026-04-07T12:00:00"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task DateTimeAdd_EndOfMonth_Rollover() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('mm', 1, '2026-01-31T00:00:00Z') FROM c"); + results.Should().ContainSingle(); + // Adding 1 month to Jan 31 → Feb 28 (2026 is not a leap year) + results[0].Should().StartWith("2026-02-28"); + } + + [Fact] + public async Task DateTimeAdd_LeapYear_Rollover() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('yyyy', 1, '2024-02-29T00:00:00Z') FROM c"); + results.Should().ContainSingle(); + // 2025 is not a leap year, so Feb 29 rolls to Feb 28 + results[0].Should().StartWith("2025-02-28"); + } + + [Fact] + public async Task DateTimeDiff_AcrossMonthBoundary_CalculatesCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', '2026-01-28T00:00:00Z', '2026-02-03T00:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(6); + } + + [Fact] + public async Task DateTimePart_DayOfWeek_ReturnsWeekdayNumber() + { + await SeedOne(); + // 2026-04-07 is a Tuesday → Cosmos DB weekday: Sunday=1..Saturday=7 → Tuesday=3 + var results = await RunQuery( + "SELECT VALUE DateTimePart('weekday', '2026-04-07T00:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(3); + } + + [Fact] + public async Task TicksToDateTime_DateTimeToTicks_RoundTrip() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE TicksToDateTime(DateTimeToTicks('2026-04-07T12:00:00.0000000Z')) FROM c"); + results.Should().ContainSingle(); + results[0].Should().StartWith("2026-04-07T12:00:00"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10519,53 +10529,53 @@ public async Task TicksToDateTime_DateTimeToTicks_RoundTrip() public class QueryDeepDiveV4_SubqueryEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","vals":[5,3,8,1],"name":"Alice"}""", - """{"id":"2","partitionKey":"pk1","vals":[2,7],"name":"Bob"}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); - } - - [Fact] - public async Task ArraySubquery_WithDistinct_DeduplicatesInSubquery() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":["a","b","a","c","b"]}""")), - new PartitionKey("pk1")); - // ARRAY(subquery) syntax returns an actual JArray - var results = await RunQuery( - "SELECT VALUE ARRAY(SELECT DISTINCT VALUE t FROM t IN c.tags) FROM c"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(3); // a, b, c - } - - [Fact] - public async Task Subquery_ReferencingOuterAlias_CorrelatedCorrectly() - { - await SeedDocs(); - // ARRAY(subquery) to get array result that filters inner elements using outer doc's values - var results = await RunQuery( - "SELECT VALUE ARRAY(SELECT VALUE v FROM v IN c.vals WHERE v > 3) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - var arr = (JArray)results[0]; - arr.Should().HaveCount(2); // 5, 8 (from [5,3,8,1] where > 3) - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","vals":[5,3,8,1],"name":"Alice"}""", + """{"id":"2","partitionKey":"pk1","vals":[2,7],"name":"Bob"}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), new PartitionKey("pk1")); + } + + [Fact] + public async Task ArraySubquery_WithDistinct_DeduplicatesInSubquery() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":["a","b","a","c","b"]}""")), + new PartitionKey("pk1")); + // ARRAY(subquery) syntax returns an actual JArray + var results = await RunQuery( + "SELECT VALUE ARRAY(SELECT DISTINCT VALUE t FROM t IN c.tags) FROM c"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(3); // a, b, c + } + + [Fact] + public async Task Subquery_ReferencingOuterAlias_CorrelatedCorrectly() + { + await SeedDocs(); + // ARRAY(subquery) to get array result that filters inner elements using outer doc's values + var results = await RunQuery( + "SELECT VALUE ARRAY(SELECT VALUE v FROM v IN c.vals WHERE v > 3) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + var arr = (JArray)results[0]; + arr.Should().HaveCount(2); // 5, 8 (from [5,3,8,1] where > 3) + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10574,46 +10584,46 @@ public async Task Subquery_ReferencingOuterAlias_CorrelatedCorrectly() public class QueryDeepDiveV4_PaginationEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedDocs() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\",\"val\":{i * 10}}}")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Pagination_MaxItemCountNegativeOne_ReturnsAllItems() - { - await SeedDocs(); - var options = new QueryRequestOptions { MaxItemCount = -1 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(5); - } - - [Fact] - public async Task OffsetLimit_OffsetBeyondResultCount_ReturnsEmpty() - { - await SeedDocs(); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c OFFSET 100 LIMIT 10"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - } - - [Fact] - public async Task OffsetLimit_ZeroOffset_ZeroLimit_ReturnsEmpty() - { - await SeedDocs(); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c OFFSET 0 LIMIT 0"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedDocs() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\",\"val\":{i * 10}}}")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Pagination_MaxItemCountNegativeOne_ReturnsAllItems() + { + await SeedDocs(); + var options = new QueryRequestOptions { MaxItemCount = -1 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(5); + } + + [Fact] + public async Task OffsetLimit_OffsetBeyondResultCount_ReturnsEmpty() + { + await SeedDocs(); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c OFFSET 100 LIMIT 10"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + } + + [Fact] + public async Task OffsetLimit_ZeroOffset_ZeroLimit_ReturnsEmpty() + { + await SeedDocs(); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c OFFSET 0 LIMIT 0"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10622,78 +10632,78 @@ public async Task OffsetLimit_ZeroOffset_ZeroLimit_ReturnsEmpty() public class QueryDeepDiveV4_DivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── D1: NOT comparison on undefined field ── - - [Fact] - public async Task NotComparison_UndefinedField_ExcludesRow() - { - // Real Cosmos DB: NOT (c.missing > 5) → NOT undefined → undefined → row excluded - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c WHERE NOT (c.val > 5)"); - // Real Cosmos: only id=2 is excluded (undefined propagated); id=1 is excluded because 10>5 is true, NOT true = false - // So real Cosmos returns 0 rows - results.Should().BeEmpty(); - } - - // ── D4: ORDER BY with array values ── - - [Fact] - public async Task OrderBy_ArrayValues_CosmosComparison() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","arr":[1,2,4]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","arr":[1,1]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.arr ASC"); - // Element-by-element comparison: [1,1] < [1,2,3] < [1,2,4] - results[0]["id"]!.Value().Should().Be("3"); - results[1]["id"]!.Value().Should().Be("1"); - results[2]["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task OrderBy_ArrayValues_ElementByElement_MatchesCosmos() - { - // Now uses element-by-element comparison matching Cosmos DB behavior. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","arr":[1,2,4]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","arr":[1,1]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.arr ASC"); - results.Should().HaveCount(3); - // Element-by-element: [1,1] < [1,2,3] < [1,2,4] - results[0]["id"]!.Value().Should().Be("3"); - results[1]["id"]!.Value().Should().Be("1"); - results[2]["id"]!.Value().Should().Be("2"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── D1: NOT comparison on undefined field ── + + [Fact] + public async Task NotComparison_UndefinedField_ExcludesRow() + { + // Real Cosmos DB: NOT (c.missing > 5) → NOT undefined → undefined → row excluded + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c WHERE NOT (c.val > 5)"); + // Real Cosmos: only id=2 is excluded (undefined propagated); id=1 is excluded because 10>5 is true, NOT true = false + // So real Cosmos returns 0 rows + results.Should().BeEmpty(); + } + + // ── D4: ORDER BY with array values ── + + [Fact] + public async Task OrderBy_ArrayValues_CosmosComparison() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","arr":[1,2,4]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","arr":[1,1]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.arr ASC"); + // Element-by-element comparison: [1,1] < [1,2,3] < [1,2,4] + results[0]["id"]!.Value().Should().Be("3"); + results[1]["id"]!.Value().Should().Be("1"); + results[2]["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task OrderBy_ArrayValues_ElementByElement_MatchesCosmos() + { + // Now uses element-by-element comparison matching Cosmos DB behavior. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","arr":[1,2,3]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","arr":[1,2,4]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","arr":[1,1]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.arr ASC"); + results.Should().HaveCount(3); + // Element-by-element: [1,1] < [1,2,3] < [1,2,4] + results[0]["id"]!.Value().Should().Be("3"); + results[1]["id"]!.Value().Should().Be("1"); + results[2]["id"]!.Value().Should().Be("2"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -10702,351 +10712,351 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV5_FinalCoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"cat":"A","isActive":true,"tags":["x","y"],"text":"The quick brown fox jumps over the lazy dog","nested":{"items":[1,2,3]}}""", - """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"cat":"B","isActive":false,"tags":["y","z"],"text":"A fox and a dog","nested":{"items":[4,5]}}""", - """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"cat":"A","isActive":true,"tags":["x"],"text":"No animals here","nested":{"items":[6]}}""", - """{"id":"4","partitionKey":"pk1","name":"Diana","val":9.7,"cat":"B","isActive":true,"tags":["z"],"text":"The fox","nested":{"items":[]}}""", - """{"id":"5","partitionKey":"pk1","name":"Eve","val":10.3,"cat":"C","isActive":false,"tags":[],"text":"Dogs and cats","nested":{"items":[7,8,9]}}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), - new PartitionKey("pk1")); - } - - // ════════════════════════════════════════════════════════════════════ - // Category 1: Untested Built-in Functions - // ════════════════════════════════════════════════════════════════════ - - // ── F1: FULLTEXTSCORE ── - - [Fact] - public async Task FullTextScore_ReturnsPositiveScore_ForMatchingDocument() - { - await SeedDocs(); - // FULLTEXTSCORE should return a positive numeric score for docs containing "fox" - var results = await RunQuery( - "SELECT c.id, FULLTEXTSCORE(c.text, ['fox']) AS score FROM c WHERE FULLTEXTCONTAINS(c.text, 'fox')"); - results.Should().HaveCountGreaterThanOrEqualTo(1); - foreach (var r in results) - { - r["score"]!.Value().Should().BeGreaterThan(0.0); - } - } - - [Fact] - public async Task FullTextScore_ReturnsZero_ForNonMatchingTerm() - { - await SeedDocs(); - // Score for a term that doesn't appear should be 0 - var results = await RunQuery( - "SELECT FULLTEXTSCORE(c.text, ['elephant']) AS score FROM c"); - results.Should().HaveCountGreaterThanOrEqualTo(1); - foreach (var r in results) - { - r["score"]!.Value().Should().Be(0.0); - } - } - - // ── F2: ROUND with precision ── - - [Fact] - public async Task Round_WithPrecision_RoundsToNDecimalPlaces() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ROUND(3.14159, 2) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3.14); - } - - [Fact] - public async Task Round_WithPrecisionZero_RoundsToInteger() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE ROUND(3.7, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(4.0); - } - - // ── F3: LOG with base ── - - [Fact] - public async Task Log_WithBase_ReturnsLogInGivenBase() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG(8, 2) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); - } - - [Fact] - public async Task Log_WithBase10_MatchesLog10() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG(1000, 10) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); - } - - // ── F4: COALESCE with 3+ args ── - - [Fact] - public async Task Coalesce_MultipleArgs_ReturnsFirstNonUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","a":null}""")), - new PartitionKey("pk1")); - // COALESCE returns the first non-undefined value. null IS defined, so c.a (null) is returned. - var results = await RunQuery( - "SELECT VALUE COALESCE(c.missing, c.a, 'fallback') FROM c"); - results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Coalesce_FirstArgDefined_ReturnsFirst() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","a":"hello"}""")), - new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT VALUE COALESCE(c.a, 'fallback1', 'fallback2') FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - // ── F5: IIF with non-boolean condition ── - - [Fact] - public async Task IIF_NonBooleanCondition_ReturnsFalseBranch() - { - await SeedOne(); - // 42 is not boolean true → false branch - var results = await RunQuery("SELECT VALUE IIF(42, 'yes', 'no') FROM c"); - results.Should().ContainSingle().Which.Should().Be("no"); - } - - [Fact] - public async Task IIF_StringCondition_ReturnsFalseBranch() - { - await SeedOne(); - // 'truthy' is a string, not boolean → false branch - var results = await RunQuery("SELECT VALUE IIF('truthy', 'yes', 'no') FROM c"); - results.Should().ContainSingle().Which.Should().Be("no"); - } - - [Fact] - public async Task IIF_NullCondition_ReturnsFalseBranch() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c"); - results.Should().ContainSingle().Which.Should().Be("no"); - } - - // ════════════════════════════════════════════════════════════════════ - // Category 2: Untested Syntax Combinations - // ════════════════════════════════════════════════════════════════════ - - // ── S1: TOP with GROUP BY ── - - [Fact] - public async Task Top_WithGroupBy_LimitsGroupResults() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT TOP 1 c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); - results.Should().ContainSingle(); - // A has 2 docs, B has 2 docs, C has 1 — TOP 1 DESC gives one of the groups with count 2 - results[0]["cnt"]!.Value().Should().Be(2); - } - - // ── S2: TOP with DISTINCT ── - - [Fact] - public async Task Top_WithDistinct_LimitsDistinctResults() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT DISTINCT TOP 2 VALUE c.cat FROM c"); - results.Should().HaveCount(2); - results.Distinct().Should().HaveCount(2); // all unique - } - - // ── S3: DISTINCT VALUE with function ── - - [Fact] - public async Task DistinctValue_WithFunction_ReturnsUniqueResults() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT DISTINCT VALUE LOWER(c.name) FROM c"); - results.Should().HaveCount(5); // alice, bob, charlie, diana, eve - results.Should().OnlyHaveUniqueItems(); - } - - // ── S4: Nested object literal in SELECT ── - - [Fact] - public async Task Select_NestedObjectLiteral_Works() - { - await SeedDocs(); - var results = await RunQuery( - """SELECT VALUE {"outer": {"inner": c.val}} FROM c WHERE c.id = '1'"""); - results.Should().ContainSingle(); - results[0]["outer"]!["inner"]!.Value().Should().Be(10); - } - - // ── S5: Array literal with mixed types ── - - [Fact] - public async Task Select_MixedTypeArrayLiteral_Works() - { - await SeedDocs(); - var results = await RunQuery( - """SELECT VALUE [c.name, c.val, c.isActive] FROM c WHERE c.id = '1'"""); - results.Should().ContainSingle(); - var arr = results[0]; - arr[0]!.Value().Should().Be("Alice"); - arr[1]!.Value().Should().Be(10); - arr[2]!.Value().Should().BeTrue(); - } - - // ── S6: Chained string concat || ── - - [Fact] - public async Task StringConcat_Chained_Works() - { - await SeedDocs(); - var results = await RunQuery( - """SELECT VALUE c.name || '-' || c.cat FROM c WHERE c.id = '1'"""); - results.Should().ContainSingle().Which.Should().Be("Alice-A"); - } - - // ════════════════════════════════════════════════════════════════════ - // Category 3: Edge Cases - // ════════════════════════════════════════════════════════════════════ - - // ── E1: BETWEEN with floating-point values ── - - [Fact] - public async Task Between_FloatingPoint_Works() - { - await SeedDocs(); - // id=4 has val=9.7, id=5 has val=10.3, id=1 has val=10 - var results = await RunQuery( - "SELECT * FROM c WHERE c.val BETWEEN 9.5 AND 10.5"); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("1", "4", "5"); - } - - // ── E2: IN with empty list ── - - [Fact] - public async Task In_EmptyList_ReturnsNoResults() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.name IN ()"); - results.Should().BeEmpty(); - } - - // ── E3: LIKE with consecutive wildcards ── - - [Fact] - public async Task Like_ConsecutiveWildcards_MatchesCorrectly() - { - await SeedDocs(); - // %l%c% should match "Alice" (has 'l' then 'c' then anything) - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%l%c%'"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); // Alice - } - - // ── E4: ORDER BY with string concat expression ── - - [Fact] - public async Task OrderBy_StringConcatExpression_Sorts() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.cat || c.name ASC"); - // A-Alice, A-Charlie, B-Bob, B-Diana, C-Eve - results[0]["name"]!.Value().Should().Be("Alice"); - results[1]["name"]!.Value().Should().Be("Charlie"); - results[2]["name"]!.Value().Should().Be("Bob"); - results[3]["name"]!.Value().Should().Be("Diana"); - results[4]["name"]!.Value().Should().Be("Eve"); - } - - // ── E5: GROUP BY VALUE ── - - [Fact] - public async Task GroupBy_WithValueSelect_ReturnsUniqueKeys() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE c.cat FROM c GROUP BY c.cat ORDER BY c.cat ASC"); - results.Should().BeEquivalentTo("A", "B", "C"); - } - - // ── E6: JOIN on nested array ── - - [Fact] - public async Task Join_NestedArrayPath_Works() - { - await SeedDocs(); - // id=1 has nested.items=[1,2,3], id=2 has [4,5], id=3 has [6], id=4 has [], id=5 has [7,8,9] - var results = await RunQuery( - "SELECT VALUE t FROM c JOIN t IN c.nested.items"); - // Total: 3+2+1+0+3 = 9 elements - results.Should().HaveCount(9); - } - - // ── E7: Subquery aggregation in SELECT ── - - [Fact] - public async Task Subquery_AggregationInSelect_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","scores":[10,20,30]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"Bob","scores":[5,15]}""")), - new PartitionKey("pk1")); - - var results = await RunQuery( - "SELECT c.name, (SELECT VALUE SUM(s) FROM s IN c.scores) AS total FROM c ORDER BY c.name ASC"); - results.Should().HaveCount(2); - results[0]["name"]!.Value().Should().Be("Alice"); - results[0]["total"]!.Value().Should().Be(60); - results[1]["name"]!.Value().Should().Be("Bob"); - results[1]["total"]!.Value().Should().Be(20); - } - - // ── E8: DateTimeFromParts with fractional seconds (7th arg = ticks) ── - - [Fact] - public async Task DateTimeFromParts_WithFractionalSeconds_Works() - { - await SeedOne(); - // 7th arg is ticks (100-nanosecond intervals). 5000000 ticks = 0.5 seconds = 500ms - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2026, 1, 15, 10, 30, 45, 5000000) FROM c"); - results.Should().ContainSingle().Which.Should().Be("2026-01-15T10:30:45.5000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","name":"Alice","val":10,"cat":"A","isActive":true,"tags":["x","y"],"text":"The quick brown fox jumps over the lazy dog","nested":{"items":[1,2,3]}}""", + """{"id":"2","partitionKey":"pk1","name":"Bob","val":20,"cat":"B","isActive":false,"tags":["y","z"],"text":"A fox and a dog","nested":{"items":[4,5]}}""", + """{"id":"3","partitionKey":"pk1","name":"Charlie","val":30,"cat":"A","isActive":true,"tags":["x"],"text":"No animals here","nested":{"items":[6]}}""", + """{"id":"4","partitionKey":"pk1","name":"Diana","val":9.7,"cat":"B","isActive":true,"tags":["z"],"text":"The fox","nested":{"items":[]}}""", + """{"id":"5","partitionKey":"pk1","name":"Eve","val":10.3,"cat":"C","isActive":false,"tags":[],"text":"Dogs and cats","nested":{"items":[7,8,9]}}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), + new PartitionKey("pk1")); + } + + // ════════════════════════════════════════════════════════════════════ + // Category 1: Untested Built-in Functions + // ════════════════════════════════════════════════════════════════════ + + // ── F1: FULLTEXTSCORE ── + + [Fact] + public async Task FullTextScore_ReturnsPositiveScore_ForMatchingDocument() + { + await SeedDocs(); + // FULLTEXTSCORE should return a positive numeric score for docs containing "fox" + var results = await RunQuery( + "SELECT c.id, FULLTEXTSCORE(c.text, ['fox']) AS score FROM c WHERE FULLTEXTCONTAINS(c.text, 'fox')"); + results.Should().HaveCountGreaterThanOrEqualTo(1); + foreach (var r in results) + { + r["score"]!.Value().Should().BeGreaterThan(0.0); + } + } + + [Fact] + public async Task FullTextScore_ReturnsZero_ForNonMatchingTerm() + { + await SeedDocs(); + // Score for a term that doesn't appear should be 0 + var results = await RunQuery( + "SELECT FULLTEXTSCORE(c.text, ['elephant']) AS score FROM c"); + results.Should().HaveCountGreaterThanOrEqualTo(1); + foreach (var r in results) + { + r["score"]!.Value().Should().Be(0.0); + } + } + + // ── F2: ROUND with precision ── + + [Fact] + public async Task Round_WithPrecision_RoundsToNDecimalPlaces() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ROUND(3.14159, 2) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3.14); + } + + [Fact] + public async Task Round_WithPrecisionZero_RoundsToInteger() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE ROUND(3.7, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(4.0); + } + + // ── F3: LOG with base ── + + [Fact] + public async Task Log_WithBase_ReturnsLogInGivenBase() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG(8, 2) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); + } + + [Fact] + public async Task Log_WithBase10_MatchesLog10() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG(1000, 10) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); + } + + // ── F4: COALESCE with 3+ args ── + + [Fact] + public async Task Coalesce_MultipleArgs_ReturnsFirstNonUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","a":null}""")), + new PartitionKey("pk1")); + // COALESCE returns the first non-undefined value. null IS defined, so c.a (null) is returned. + var results = await RunQuery( + "SELECT VALUE COALESCE(c.missing, c.a, 'fallback') FROM c"); + results.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Coalesce_FirstArgDefined_ReturnsFirst() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","a":"hello"}""")), + new PartitionKey("pk1")); + var results = await RunQuery( + "SELECT VALUE COALESCE(c.a, 'fallback1', 'fallback2') FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + // ── F5: IIF with non-boolean condition ── + + [Fact] + public async Task IIF_NonBooleanCondition_ReturnsFalseBranch() + { + await SeedOne(); + // 42 is not boolean true → false branch + var results = await RunQuery("SELECT VALUE IIF(42, 'yes', 'no') FROM c"); + results.Should().ContainSingle().Which.Should().Be("no"); + } + + [Fact] + public async Task IIF_StringCondition_ReturnsFalseBranch() + { + await SeedOne(); + // 'truthy' is a string, not boolean → false branch + var results = await RunQuery("SELECT VALUE IIF('truthy', 'yes', 'no') FROM c"); + results.Should().ContainSingle().Which.Should().Be("no"); + } + + [Fact] + public async Task IIF_NullCondition_ReturnsFalseBranch() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c"); + results.Should().ContainSingle().Which.Should().Be("no"); + } + + // ════════════════════════════════════════════════════════════════════ + // Category 2: Untested Syntax Combinations + // ════════════════════════════════════════════════════════════════════ + + // ── S1: TOP with GROUP BY ── + + [Fact] + public async Task Top_WithGroupBy_LimitsGroupResults() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT TOP 1 c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); + results.Should().ContainSingle(); + // A has 2 docs, B has 2 docs, C has 1 — TOP 1 DESC gives one of the groups with count 2 + results[0]["cnt"]!.Value().Should().Be(2); + } + + // ── S2: TOP with DISTINCT ── + + [Fact] + public async Task Top_WithDistinct_LimitsDistinctResults() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT DISTINCT TOP 2 VALUE c.cat FROM c"); + results.Should().HaveCount(2); + results.Distinct().Should().HaveCount(2); // all unique + } + + // ── S3: DISTINCT VALUE with function ── + + [Fact] + public async Task DistinctValue_WithFunction_ReturnsUniqueResults() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT DISTINCT VALUE LOWER(c.name) FROM c"); + results.Should().HaveCount(5); // alice, bob, charlie, diana, eve + results.Should().OnlyHaveUniqueItems(); + } + + // ── S4: Nested object literal in SELECT ── + + [Fact] + public async Task Select_NestedObjectLiteral_Works() + { + await SeedDocs(); + var results = await RunQuery( + """SELECT VALUE {"outer": {"inner": c.val}} FROM c WHERE c.id = '1'"""); + results.Should().ContainSingle(); + results[0]["outer"]!["inner"]!.Value().Should().Be(10); + } + + // ── S5: Array literal with mixed types ── + + [Fact] + public async Task Select_MixedTypeArrayLiteral_Works() + { + await SeedDocs(); + var results = await RunQuery( + """SELECT VALUE [c.name, c.val, c.isActive] FROM c WHERE c.id = '1'"""); + results.Should().ContainSingle(); + var arr = results[0]; + arr[0]!.Value().Should().Be("Alice"); + arr[1]!.Value().Should().Be(10); + arr[2]!.Value().Should().BeTrue(); + } + + // ── S6: Chained string concat || ── + + [Fact] + public async Task StringConcat_Chained_Works() + { + await SeedDocs(); + var results = await RunQuery( + """SELECT VALUE c.name || '-' || c.cat FROM c WHERE c.id = '1'"""); + results.Should().ContainSingle().Which.Should().Be("Alice-A"); + } + + // ════════════════════════════════════════════════════════════════════ + // Category 3: Edge Cases + // ════════════════════════════════════════════════════════════════════ + + // ── E1: BETWEEN with floating-point values ── + + [Fact] + public async Task Between_FloatingPoint_Works() + { + await SeedDocs(); + // id=4 has val=9.7, id=5 has val=10.3, id=1 has val=10 + var results = await RunQuery( + "SELECT * FROM c WHERE c.val BETWEEN 9.5 AND 10.5"); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("1", "4", "5"); + } + + // ── E2: IN with empty list ── + + [Fact] + public async Task In_EmptyList_ReturnsNoResults() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.name IN ()"); + results.Should().BeEmpty(); + } + + // ── E3: LIKE with consecutive wildcards ── + + [Fact] + public async Task Like_ConsecutiveWildcards_MatchesCorrectly() + { + await SeedDocs(); + // %l%c% should match "Alice" (has 'l' then 'c' then anything) + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%l%c%'"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); // Alice + } + + // ── E4: ORDER BY with string concat expression ── + + [Fact] + public async Task OrderBy_StringConcatExpression_Sorts() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.cat || c.name ASC"); + // A-Alice, A-Charlie, B-Bob, B-Diana, C-Eve + results[0]["name"]!.Value().Should().Be("Alice"); + results[1]["name"]!.Value().Should().Be("Charlie"); + results[2]["name"]!.Value().Should().Be("Bob"); + results[3]["name"]!.Value().Should().Be("Diana"); + results[4]["name"]!.Value().Should().Be("Eve"); + } + + // ── E5: GROUP BY VALUE ── + + [Fact] + public async Task GroupBy_WithValueSelect_ReturnsUniqueKeys() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE c.cat FROM c GROUP BY c.cat ORDER BY c.cat ASC"); + results.Should().BeEquivalentTo("A", "B", "C"); + } + + // ── E6: JOIN on nested array ── + + [Fact] + public async Task Join_NestedArrayPath_Works() + { + await SeedDocs(); + // id=1 has nested.items=[1,2,3], id=2 has [4,5], id=3 has [6], id=4 has [], id=5 has [7,8,9] + var results = await RunQuery( + "SELECT VALUE t FROM c JOIN t IN c.nested.items"); + // Total: 3+2+1+0+3 = 9 elements + results.Should().HaveCount(9); + } + + // ── E7: Subquery aggregation in SELECT ── + + [Fact] + public async Task Subquery_AggregationInSelect_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","scores":[10,20,30]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"Bob","scores":[5,15]}""")), + new PartitionKey("pk1")); + + var results = await RunQuery( + "SELECT c.name, (SELECT VALUE SUM(s) FROM s IN c.scores) AS total FROM c ORDER BY c.name ASC"); + results.Should().HaveCount(2); + results[0]["name"]!.Value().Should().Be("Alice"); + results[0]["total"]!.Value().Should().Be(60); + results[1]["name"]!.Value().Should().Be("Bob"); + results[1]["total"]!.Value().Should().Be(20); + } + + // ── E8: DateTimeFromParts with fractional seconds (7th arg = ticks) ── + + [Fact] + public async Task DateTimeFromParts_WithFractionalSeconds_Works() + { + await SeedOne(); + // 7th arg is ticks (100-nanosecond intervals). 5000000 ticks = 0.5 seconds = 500ms + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2026, 1, 15, 10, 30, 45, 5000000) FROM c"); + results.Should().ContainSingle().Which.Should().Be("2026-01-15T10:30:45.5000000Z"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11055,112 +11065,112 @@ public async Task DateTimeFromParts_WithFractionalSeconds_Works() public class QueryDeepDiveV6_BugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"cat":"A","isActive":true,"tags":["a","b"]}""", - """{"id":"2","partitionKey":"pk1","name":"Bob","value":20,"cat":"B","isActive":false,"tags":["b","c"]}""", - """{"id":"3","partitionKey":"pk1","name":"Charlie","value":30,"cat":"A","isActive":true,"tags":["a","c"]}""", - """{"id":"4","partitionKey":"pk2","name":"Diana","value":40,"cat":"B","isActive":true,"tags":["d"]}""", - """{"id":"5","partitionKey":"pk1","name":"Eve","value":50,"cat":"C","isActive":false,"tags":["a"]}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), - new PartitionKey(JObject.Parse(doc)["partitionKey"]!.ToString())); - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - } - - // ── B1: IS NOT DEFINED syntax ── - // Cosmos DB does NOT support `IS DEFINED` / `IS NOT DEFINED` as SQL operator syntax. - // Only the IS_DEFINED() / NOT IS_DEFINED() function form is supported. - // Skipping — no sister test needed since NOT IS_DEFINED() is already well-covered. - - // ── B4: DISTINCT with ORDER BY on non-projected field ── - - [Fact] - public async Task Distinct_OrderByNonProjectedField_EmulatorBehavior() - { - // Real Cosmos DB would reject: ORDER BY field must be in SELECT for DISTINCT queries. - // Emulator may silently allow this. Document the emulator's behavior. - await SeedDocs(); - var results = await RunQuery( - "SELECT DISTINCT c.cat FROM c ORDER BY c.value ASC"); - // Emulator allows it — documents keep their order by value, then dedup by cat - // Cat order based on first appearance by value: A(10), B(20), C(50) - results.Should().HaveCount(3); - } - - // ── B5: ORDER BY + OFFSET/LIMIT + WHERE combined ── - - [Fact] - public async Task OffsetLimit_WithWhere_AndOrderBy_AppliesCorrectly() - { - await SeedDocs(); // values: 10,20,30,40,50 - var results = await RunQuery( - "SELECT * FROM c WHERE c.value >= 20 ORDER BY c.value ASC OFFSET 1 LIMIT 2"); - // After WHERE: [20,30,40,50] → ORDER BY ASC → OFFSET 1: [30,40,50] → LIMIT 2: [30,40] - results.Should().HaveCount(2); - results[0]["value"]!.Value().Should().Be(30); - results[1]["value"]!.Value().Should().Be(40); - } - - // ── B6: SELECT VALUE aggregate + WHERE ── - - [Fact] - public async Task SelectValueCount_WithWhere_CountsFilteredOnly() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE COUNT(1) FROM c WHERE c.isActive = true"); - results.Should().ContainSingle().Which.Should().Be(3L); - } - - [Fact] - public async Task SelectValueSum_WithWhere_SumsFilteredOnly() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE SUM(c.value) FROM c WHERE c.isActive = true"); - // Active: Alice(10) + Charlie(30) + Diana(40) = 80 - results.Should().ContainSingle().Which.Should().Be(80L); - } - - [Fact] - public async Task SelectValueAvg_WithWhere_AveragesFilteredOnly() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE AVG(c.value) FROM c WHERE c.isActive = true"); - // Active: (10 + 30 + 40) / 3 ≈ 26.666... - results.Should().ContainSingle(); - results[0].Should().BeApproximately(80.0 / 3.0, 0.001); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"cat":"A","isActive":true,"tags":["a","b"]}""", + """{"id":"2","partitionKey":"pk1","name":"Bob","value":20,"cat":"B","isActive":false,"tags":["b","c"]}""", + """{"id":"3","partitionKey":"pk1","name":"Charlie","value":30,"cat":"A","isActive":true,"tags":["a","c"]}""", + """{"id":"4","partitionKey":"pk2","name":"Diana","value":40,"cat":"B","isActive":true,"tags":["d"]}""", + """{"id":"5","partitionKey":"pk1","name":"Eve","value":50,"cat":"C","isActive":false,"tags":["a"]}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), + new PartitionKey(JObject.Parse(doc)["partitionKey"]!.ToString())); + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + } + + // ── B1: IS NOT DEFINED syntax ── + // Cosmos DB does NOT support `IS DEFINED` / `IS NOT DEFINED` as SQL operator syntax. + // Only the IS_DEFINED() / NOT IS_DEFINED() function form is supported. + // Skipping — no sister test needed since NOT IS_DEFINED() is already well-covered. + + // ── B4: DISTINCT with ORDER BY on non-projected field ── + + [Fact] + public async Task Distinct_OrderByNonProjectedField_EmulatorBehavior() + { + // Real Cosmos DB would reject: ORDER BY field must be in SELECT for DISTINCT queries. + // Emulator may silently allow this. Document the emulator's behavior. + await SeedDocs(); + var results = await RunQuery( + "SELECT DISTINCT c.cat FROM c ORDER BY c.value ASC"); + // Emulator allows it — documents keep their order by value, then dedup by cat + // Cat order based on first appearance by value: A(10), B(20), C(50) + results.Should().HaveCount(3); + } + + // ── B5: ORDER BY + OFFSET/LIMIT + WHERE combined ── + + [Fact] + public async Task OffsetLimit_WithWhere_AndOrderBy_AppliesCorrectly() + { + await SeedDocs(); // values: 10,20,30,40,50 + var results = await RunQuery( + "SELECT * FROM c WHERE c.value >= 20 ORDER BY c.value ASC OFFSET 1 LIMIT 2"); + // After WHERE: [20,30,40,50] → ORDER BY ASC → OFFSET 1: [30,40,50] → LIMIT 2: [30,40] + results.Should().HaveCount(2); + results[0]["value"]!.Value().Should().Be(30); + results[1]["value"]!.Value().Should().Be(40); + } + + // ── B6: SELECT VALUE aggregate + WHERE ── + + [Fact] + public async Task SelectValueCount_WithWhere_CountsFilteredOnly() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE COUNT(1) FROM c WHERE c.isActive = true"); + results.Should().ContainSingle().Which.Should().Be(3L); + } + + [Fact] + public async Task SelectValueSum_WithWhere_SumsFilteredOnly() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE SUM(c.value) FROM c WHERE c.isActive = true"); + // Active: Alice(10) + Charlie(30) + Diana(40) = 80 + results.Should().ContainSingle().Which.Should().Be(80L); + } + + [Fact] + public async Task SelectValueAvg_WithWhere_AveragesFilteredOnly() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE AVG(c.value) FROM c WHERE c.isActive = true"); + // Active: (10 + 30 + 40) / 3 ≈ 26.666... + results.Should().ContainSingle(); + results[0].Should().BeApproximately(80.0 / 3.0, 0.001); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11169,519 +11179,519 @@ public async Task SelectValueAvg_WithWhere_AveragesFilteredOnly() public class QueryDeepDiveV6_CoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"cat":"A","isActive":true,"tags":["a","b"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", - """{"id":"2","partitionKey":"pk1","name":"Bob","value":20,"cat":"B","isActive":false,"tags":["b","c"],"nested":{"level2":{"level3":{"level4":"also-deep"}}}}""", - """{"id":"3","partitionKey":"pk1","name":"Charlie","value":30,"cat":"A","isActive":true,"tags":["a","c"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", - """{"id":"4","partitionKey":"pk2","name":"Diana","value":40,"cat":"B","isActive":true,"tags":["d"],"nested":{"level2":{"level3":{"level4":"shallow"}}}}""", - """{"id":"5","partitionKey":"pk1","name":"Eve","value":50,"cat":"C","isActive":false,"tags":["a"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), - new PartitionKey(JObject.Parse(doc)["partitionKey"]!.ToString())); - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","value":10}""")), - new PartitionKey("pk1")); - } - - // ── T1: Arithmetic in SELECT projection ── - - [Fact] - public async Task Select_ArithmeticSubtraction_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT c.value - 5 AS result FROM c"); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().Be(5); - } - - [Fact] - public async Task Select_ArithmeticDivision_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT c.value / 4 AS result FROM c"); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().Be(2.5); - } - - [Fact] - public async Task Select_ArithmeticModulo_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT c.value % 3 AS result FROM c"); - results.Should().ContainSingle(); - results[0]["result"]!.Value().Should().Be(1); - } - - // ── T2: Nested aggregate expressions ── - - [Fact] - public async Task Aggregate_SumWithExpression_Works() - { - await SeedDocs(); // values 10+20+30+40+50 = 150 - var results = await RunQuery("SELECT SUM(c.value * 2) AS total FROM c"); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(300); - } - - [Fact] - public async Task Aggregate_AvgWithExpression_Works() - { - await SeedDocs(); - var results = await RunQuery("SELECT AVG(c.value + 10) AS avg FROM c"); - // (20+30+40+50+60)/5 = 40 - results.Should().ContainSingle(); - results[0]["avg"]!.Value().Should().Be(40); - } - - // ── T3: GROUP BY with WHERE ── - - [Fact] - public async Task GroupBy_WithWhere_FiltersBeforeGrouping() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c WHERE c.isActive = true GROUP BY c.cat"); - // Active: Alice(A), Charlie(A), Diana(B) → A:2, B:1 - results.Should().HaveCount(2); - var catA = results.First(r => r["cat"]!.ToString() == "A"); - catA["cnt"]!.Value().Should().Be(2); - var catB = results.First(r => r["cat"]!.ToString() == "B"); - catB["cnt"]!.Value().Should().Be(1); - } - - // ── T4: GROUP BY with ORDER BY on group key ── - - [Fact] - public async Task GroupBy_WithOrderByGroupKey_SortsGroups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat DESC"); - results[0]["cat"]!.ToString().Should().Be("C"); - results[1]["cat"]!.ToString().Should().Be("B"); - results[2]["cat"]!.ToString().Should().Be("A"); - } - - // ── T5: DISTINCT on multiple projected fields ── - - [Fact] - public async Task Distinct_MultipleFields_DeduplicatesByAllFields() - { - await SeedDocs(); - var results = await RunQuery("SELECT DISTINCT c.cat, c.isActive FROM c"); - // A+true(Alice,Charlie), B+false(Bob), B+true(Diana), C+false(Eve) → 4 combos - results.Should().HaveCount(4); - } - - // ── T6: DISTINCT VALUE with object literal ── - - [Fact] - public async Task DistinctValue_ObjectLiteral_DeduplicatesObjects() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT DISTINCT VALUE {'cat': c.cat, 'active': c.isActive} FROM c"); - results.Should().HaveCount(4); - } - - // ── T7: TOP with WHERE ── - - [Fact] - public async Task Top_WithWhere_AppliesAfterFiltering() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT TOP 2 * FROM c WHERE c.isActive = true ORDER BY c.value ASC"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Alice"); // value=10 - results[1]["name"]!.ToString().Should().Be("Charlie"); // value=30 - } - - // ── T8: SELECT * with JOIN includes parent fields ── - - [Fact] - public async Task Join_SelectStar_IncludesParentFields() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test","tags":["a","b"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c JOIN t IN c.tags"); - results.Should().HaveCount(2); - results[0].ContainsKey("id").Should().BeTrue(); - results[0].ContainsKey("name").Should().BeTrue(); - } - - // ── T9: Three-way JOIN ── - - [Fact] - public async Task ThreeWayJoin_ProducesFullCrossProduct() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","colors":["red","blue"],"sizes":["S"],"shapes":["circle","square"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT co, sz, sh FROM c JOIN co IN c.colors JOIN sz IN c.sizes JOIN sh IN c.shapes"); - // 2 × 1 × 2 = 4 - results.Should().HaveCount(4); - } - - // ── T11: Multiple ARRAY_CONTAINS in WHERE ── - - [Fact] - public async Task Where_MultipleArrayContains_CombinesCorrectly() - { - await SeedDocs(); // Alice has tags [a,b], Bob [b,c], Charlie [a,c] - var results = await RunQuery( - "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'a') AND ARRAY_CONTAINS(c.tags, 'b')"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - // ── T14: SELECT VALUE with OFFSET/LIMIT ── - - [Fact] - public async Task SelectValue_WithOffsetLimit_Paginates() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE c.name FROM c ORDER BY c.name ASC OFFSET 1 LIMIT 2"); - results.Should().HaveCount(2); - results.Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - // ── T15: Multiple aggregates with GROUP BY ── - - [Fact] - public async Task GroupBy_MultipleAggregates_AllComputed() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY c.cat ASC"); - // A: Alice(10)+Charlie(30)=40, cnt=2; B: Bob(20)+Diana(40)=60, cnt=2; C: Eve(50), cnt=1 - var catA = results[0]; - catA["cat"]!.ToString().Should().Be("A"); - catA["cnt"]!.Value().Should().Be(2); - catA["total"]!.Value().Should().Be(40); - - var catB = results[1]; - catB["cat"]!.ToString().Should().Be("B"); - catB["cnt"]!.Value().Should().Be(2); - catB["total"]!.Value().Should().Be(60); - - var catC = results[2]; - catC["cat"]!.ToString().Should().Be("C"); - catC["cnt"]!.Value().Should().Be(1); - catC["total"]!.Value().Should().Be(50); - } - - // ── T16: HAVING with OR condition ── - - [Fact] - public async Task Having_WithOr_FiltersGroups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 OR SUM(c.value) >= 50"); - // A: cnt=2 (meets first), B: cnt=2 (meets first), C: total=50 (meets second) - results.Should().HaveCount(3); - } - - // ── T17: HAVING with arithmetic expression ── - - [Fact] - public async Task Having_WithArithmeticExpression_Works() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, SUM(c.value) AS total, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING SUM(c.value) / COUNT(1) > 25"); - // A: 40/2=20 (no), B: 60/2=30 (yes), C: 50/1=50 (yes) - results.Should().HaveCount(2); - results.Select(r => r["cat"]!.ToString()).Should().BeEquivalentTo("B", "C"); - } - - // ── T18: Continuation token with GROUP BY ── - - [Fact] - public async Task ContinuationToken_WithGroupBy_PaginatesGroups() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$"""{"id":"{{i}}","partitionKey":"pk1","cat":"{{(char)('A' + i % 5)}}"}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var allResults = new List(); - var pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - pageCount++; - } - allResults.Should().HaveCount(5); // 5 groups (A-E) - pageCount.Should().BeGreaterThan(1); - } - - // ── T19: Continuation token with DISTINCT ── - - [Fact] - public async Task ContinuationToken_WithDistinct_PaginatesDistinctResults() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$"""{"id":"{{i}}","partitionKey":"pk1","cat":{{i % 3}}}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT DISTINCT c.cat FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var allResults = new List(); - while (iterator.HasMoreResults) - allResults.AddRange(await iterator.ReadNextAsync()); - allResults.Should().HaveCount(3); - } - - // ── T20: SELECT VALUE ternary + field access ── - - [Fact] - public async Task SelectValue_Ternary_WithFieldAccess_Works() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE c.isActive ? c.name : 'N/A' FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Should().Be("Alice"); - } - - [Fact] - public async Task SelectValue_Ternary_FalseBranch_Works() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE c.isActive ? c.name : 'N/A' FROM c WHERE c.id = '2'"); - results.Should().ContainSingle().Which.Should().Be("N/A"); - } - - // ── T21: LIKE with underscore wildcard ── - - [Fact] - public async Task Like_UnderscoreMiddle_MatchesSingleChar() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A_i_e'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - // ── T22: LIKE suffix match ── - - [Fact] - public async Task Like_SuffixMatch_Works() - { - await SeedDocs(); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%ice'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - // ── T23: ISO date string ORDER BY ── - - [Fact] - public async Task OrderBy_DateStrings_SortsLexicographically() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","createdAt":"2026-03-15T10:00:00Z"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","createdAt":"2026-01-01T00:00:00Z"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","createdAt":"2026-06-20T15:30:00Z"}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c ORDER BY c.createdAt ASC"); - results[0]["id"]!.ToString().Should().Be("2"); // Jan - results[1]["id"]!.ToString().Should().Be("1"); // Mar - results[2]["id"]!.ToString().Should().Be("3"); // Jun - } - - // ── T24: Deep nested property access (4+ levels) ── - - [Fact] - public async Task Where_DeeplyNestedProperty_Works() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.nested.level2.level3.level4 = 'deep'"); - results.Should().HaveCount(3); // Alice, Charlie, Eve - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); - } - - // ── T25: SELECT c.* alias dot star ── - - [Fact] - public async Task Select_AliasDotStar_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT c.* FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("id").Should().BeTrue(); - results[0].ContainsKey("value").Should().BeTrue(); - } - - // ── T26: Parameter in ORDER BY expression ── - - [Fact] - public async Task OrderBy_WithParameterExpression_Works() - { - await SeedDocs(); - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value + @offset ASC") - .WithParameter("@offset", 100); - var results = await RunQuery(query); - results.Should().HaveCount(5); - // Adding 100 to all values doesn't change relative order - results[0]["name"]!.ToString().Should().Be("Alice"); // 10+100=110 - results[4]["name"]!.ToString().Should().Be("Eve"); // 50+100=150 - } - - // ── T27: SELECT VALUE with boolean expression ── - - [Fact] - public async Task SelectValue_BooleanExpression_ReturnsTrueFalse() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT VALUE c.value > 20 FROM c ORDER BY c.value ASC"); - // values: 10,20,30,40,50 → false,false,true,true,true - results.Should().BeEquivalentTo([false, false, true, true, true]); - } - - // ── T28: IN with null in list ── - - [Fact] - public async Task In_WithNullInList_MatchesNullValues() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":"active"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","status":"inactive"}""")), - new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.status IN ('active', null)"); - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); - } - - // ── T29: BETWEEN with string values ── - - [Fact] - public async Task Between_Strings_ComparesLexicographically() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'C'"); - // "Alice" and "Bob" are between 'A' and 'C' lexicographically - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Bob"); - } - - // ── T31: Empty LIKE pattern ── - - [Fact] - public async Task Like_EmptyPattern_MatchesOnlyEmptyStrings() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":""}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("2"); - } - - // ── T32: Multiple HAVING conditions ── - - [Fact] - public async Task Having_MultipleConditions_FiltersCorrectly() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 AND SUM(c.value) > 50"); - // A: cnt=2, total=40 (no — total≤50), B: cnt=2, total=60 (yes), C: cnt=1 (no) - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("B"); - } - - // ── T33: SELECT VALUE constant null ── - - [Fact] - public async Task SelectValue_ConstantNull_ReturnsNull() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE null FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Null); - } - - // ── T34: SELECT VALUE constant array ── - - [Fact] - public async Task SelectValue_ConstantArray_ReturnsArray() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE [1, 2, 3] FROM c"); - results.Should().ContainSingle(); - results[0].Should().HaveCount(3); - results[0][0]!.Value().Should().Be(1); - results[0][2]!.Value().Should().Be(3); - } - - // ── T35: SELECT VALUE constant object ── - - [Fact] - public async Task SelectValue_ConstantObject_ReturnsObject() - { - await SeedOne(); - var results = await RunQuery("""SELECT VALUE {"a": 1, "b": 2} FROM c"""); - results.Should().ContainSingle(); - results[0]["a"]!.Value().Should().Be(1); - results[0]["b"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"cat":"A","isActive":true,"tags":["a","b"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", + """{"id":"2","partitionKey":"pk1","name":"Bob","value":20,"cat":"B","isActive":false,"tags":["b","c"],"nested":{"level2":{"level3":{"level4":"also-deep"}}}}""", + """{"id":"3","partitionKey":"pk1","name":"Charlie","value":30,"cat":"A","isActive":true,"tags":["a","c"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", + """{"id":"4","partitionKey":"pk2","name":"Diana","value":40,"cat":"B","isActive":true,"tags":["d"],"nested":{"level2":{"level3":{"level4":"shallow"}}}}""", + """{"id":"5","partitionKey":"pk1","name":"Eve","value":50,"cat":"C","isActive":false,"tags":["a"],"nested":{"level2":{"level3":{"level4":"deep"}}}}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), + new PartitionKey(JObject.Parse(doc)["partitionKey"]!.ToString())); + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","value":10}""")), + new PartitionKey("pk1")); + } + + // ── T1: Arithmetic in SELECT projection ── + + [Fact] + public async Task Select_ArithmeticSubtraction_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT c.value - 5 AS result FROM c"); + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().Be(5); + } + + [Fact] + public async Task Select_ArithmeticDivision_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT c.value / 4 AS result FROM c"); + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().Be(2.5); + } + + [Fact] + public async Task Select_ArithmeticModulo_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT c.value % 3 AS result FROM c"); + results.Should().ContainSingle(); + results[0]["result"]!.Value().Should().Be(1); + } + + // ── T2: Nested aggregate expressions ── + + [Fact] + public async Task Aggregate_SumWithExpression_Works() + { + await SeedDocs(); // values 10+20+30+40+50 = 150 + var results = await RunQuery("SELECT SUM(c.value * 2) AS total FROM c"); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(300); + } + + [Fact] + public async Task Aggregate_AvgWithExpression_Works() + { + await SeedDocs(); + var results = await RunQuery("SELECT AVG(c.value + 10) AS avg FROM c"); + // (20+30+40+50+60)/5 = 40 + results.Should().ContainSingle(); + results[0]["avg"]!.Value().Should().Be(40); + } + + // ── T3: GROUP BY with WHERE ── + + [Fact] + public async Task GroupBy_WithWhere_FiltersBeforeGrouping() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c WHERE c.isActive = true GROUP BY c.cat"); + // Active: Alice(A), Charlie(A), Diana(B) → A:2, B:1 + results.Should().HaveCount(2); + var catA = results.First(r => r["cat"]!.ToString() == "A"); + catA["cnt"]!.Value().Should().Be(2); + var catB = results.First(r => r["cat"]!.ToString() == "B"); + catB["cnt"]!.Value().Should().Be(1); + } + + // ── T4: GROUP BY with ORDER BY on group key ── + + [Fact] + public async Task GroupBy_WithOrderByGroupKey_SortsGroups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat DESC"); + results[0]["cat"]!.ToString().Should().Be("C"); + results[1]["cat"]!.ToString().Should().Be("B"); + results[2]["cat"]!.ToString().Should().Be("A"); + } + + // ── T5: DISTINCT on multiple projected fields ── + + [Fact] + public async Task Distinct_MultipleFields_DeduplicatesByAllFields() + { + await SeedDocs(); + var results = await RunQuery("SELECT DISTINCT c.cat, c.isActive FROM c"); + // A+true(Alice,Charlie), B+false(Bob), B+true(Diana), C+false(Eve) → 4 combos + results.Should().HaveCount(4); + } + + // ── T6: DISTINCT VALUE with object literal ── + + [Fact] + public async Task DistinctValue_ObjectLiteral_DeduplicatesObjects() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT DISTINCT VALUE {'cat': c.cat, 'active': c.isActive} FROM c"); + results.Should().HaveCount(4); + } + + // ── T7: TOP with WHERE ── + + [Fact] + public async Task Top_WithWhere_AppliesAfterFiltering() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT TOP 2 * FROM c WHERE c.isActive = true ORDER BY c.value ASC"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Alice"); // value=10 + results[1]["name"]!.ToString().Should().Be("Charlie"); // value=30 + } + + // ── T8: SELECT * with JOIN includes parent fields ── + + [Fact] + public async Task Join_SelectStar_IncludesParentFields() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test","tags":["a","b"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT * FROM c JOIN t IN c.tags"); + results.Should().HaveCount(2); + results[0].ContainsKey("id").Should().BeTrue(); + results[0].ContainsKey("name").Should().BeTrue(); + } + + // ── T9: Three-way JOIN ── + + [Fact] + public async Task ThreeWayJoin_ProducesFullCrossProduct() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","colors":["red","blue"],"sizes":["S"],"shapes":["circle","square"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery( + "SELECT co, sz, sh FROM c JOIN co IN c.colors JOIN sz IN c.sizes JOIN sh IN c.shapes"); + // 2 × 1 × 2 = 4 + results.Should().HaveCount(4); + } + + // ── T11: Multiple ARRAY_CONTAINS in WHERE ── + + [Fact] + public async Task Where_MultipleArrayContains_CombinesCorrectly() + { + await SeedDocs(); // Alice has tags [a,b], Bob [b,c], Charlie [a,c] + var results = await RunQuery( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'a') AND ARRAY_CONTAINS(c.tags, 'b')"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + // ── T14: SELECT VALUE with OFFSET/LIMIT ── + + [Fact] + public async Task SelectValue_WithOffsetLimit_Paginates() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE c.name FROM c ORDER BY c.name ASC OFFSET 1 LIMIT 2"); + results.Should().HaveCount(2); + results.Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + // ── T15: Multiple aggregates with GROUP BY ── + + [Fact] + public async Task GroupBy_MultipleAggregates_AllComputed() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY c.cat ASC"); + // A: Alice(10)+Charlie(30)=40, cnt=2; B: Bob(20)+Diana(40)=60, cnt=2; C: Eve(50), cnt=1 + var catA = results[0]; + catA["cat"]!.ToString().Should().Be("A"); + catA["cnt"]!.Value().Should().Be(2); + catA["total"]!.Value().Should().Be(40); + + var catB = results[1]; + catB["cat"]!.ToString().Should().Be("B"); + catB["cnt"]!.Value().Should().Be(2); + catB["total"]!.Value().Should().Be(60); + + var catC = results[2]; + catC["cat"]!.ToString().Should().Be("C"); + catC["cnt"]!.Value().Should().Be(1); + catC["total"]!.Value().Should().Be(50); + } + + // ── T16: HAVING with OR condition ── + + [Fact] + public async Task Having_WithOr_FiltersGroups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 OR SUM(c.value) >= 50"); + // A: cnt=2 (meets first), B: cnt=2 (meets first), C: total=50 (meets second) + results.Should().HaveCount(3); + } + + // ── T17: HAVING with arithmetic expression ── + + [Fact] + public async Task Having_WithArithmeticExpression_Works() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, SUM(c.value) AS total, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING SUM(c.value) / COUNT(1) > 25"); + // A: 40/2=20 (no), B: 60/2=30 (yes), C: 50/1=50 (yes) + results.Should().HaveCount(2); + results.Select(r => r["cat"]!.ToString()).Should().BeEquivalentTo("B", "C"); + } + + // ── T18: Continuation token with GROUP BY ── + + [Fact] + public async Task ContinuationToken_WithGroupBy_PaginatesGroups() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$"""{"id":"{{i}}","partitionKey":"pk1","cat":"{{(char)('A' + i % 5)}}"}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var allResults = new List(); + var pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + pageCount++; + } + allResults.Should().HaveCount(5); // 5 groups (A-E) + pageCount.Should().BeGreaterThan(1); + } + + // ── T19: Continuation token with DISTINCT ── + + [Fact] + public async Task ContinuationToken_WithDistinct_PaginatesDistinctResults() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$"""{"id":"{{i}}","partitionKey":"pk1","cat":{{i % 3}}}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT DISTINCT c.cat FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var allResults = new List(); + while (iterator.HasMoreResults) + allResults.AddRange(await iterator.ReadNextAsync()); + allResults.Should().HaveCount(3); + } + + // ── T20: SELECT VALUE ternary + field access ── + + [Fact] + public async Task SelectValue_Ternary_WithFieldAccess_Works() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE c.isActive ? c.name : 'N/A' FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Should().Be("Alice"); + } + + [Fact] + public async Task SelectValue_Ternary_FalseBranch_Works() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE c.isActive ? c.name : 'N/A' FROM c WHERE c.id = '2'"); + results.Should().ContainSingle().Which.Should().Be("N/A"); + } + + // ── T21: LIKE with underscore wildcard ── + + [Fact] + public async Task Like_UnderscoreMiddle_MatchesSingleChar() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A_i_e'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + // ── T22: LIKE suffix match ── + + [Fact] + public async Task Like_SuffixMatch_Works() + { + await SeedDocs(); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%ice'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + // ── T23: ISO date string ORDER BY ── + + [Fact] + public async Task OrderBy_DateStrings_SortsLexicographically() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","createdAt":"2026-03-15T10:00:00Z"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","createdAt":"2026-01-01T00:00:00Z"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","createdAt":"2026-06-20T15:30:00Z"}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT * FROM c ORDER BY c.createdAt ASC"); + results[0]["id"]!.ToString().Should().Be("2"); // Jan + results[1]["id"]!.ToString().Should().Be("1"); // Mar + results[2]["id"]!.ToString().Should().Be("3"); // Jun + } + + // ── T24: Deep nested property access (4+ levels) ── + + [Fact] + public async Task Where_DeeplyNestedProperty_Works() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.nested.level2.level3.level4 = 'deep'"); + results.Should().HaveCount(3); // Alice, Charlie, Eve + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie", "Eve"); + } + + // ── T25: SELECT c.* alias dot star ── + + [Fact] + public async Task Select_AliasDotStar_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT c.* FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("id").Should().BeTrue(); + results[0].ContainsKey("value").Should().BeTrue(); + } + + // ── T26: Parameter in ORDER BY expression ── + + [Fact] + public async Task OrderBy_WithParameterExpression_Works() + { + await SeedDocs(); + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value + @offset ASC") + .WithParameter("@offset", 100); + var results = await RunQuery(query); + results.Should().HaveCount(5); + // Adding 100 to all values doesn't change relative order + results[0]["name"]!.ToString().Should().Be("Alice"); // 10+100=110 + results[4]["name"]!.ToString().Should().Be("Eve"); // 50+100=150 + } + + // ── T27: SELECT VALUE with boolean expression ── + + [Fact] + public async Task SelectValue_BooleanExpression_ReturnsTrueFalse() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT VALUE c.value > 20 FROM c ORDER BY c.value ASC"); + // values: 10,20,30,40,50 → false,false,true,true,true + results.Should().BeEquivalentTo([false, false, true, true, true]); + } + + // ── T28: IN with null in list ── + + [Fact] + public async Task In_WithNullInList_MatchesNullValues() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":"active"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","status":"inactive"}""")), + new PartitionKey("pk1")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.status IN ('active', null)"); + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "2"); + } + + // ── T29: BETWEEN with string values ── + + [Fact] + public async Task Between_Strings_ComparesLexicographically() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'C'"); + // "Alice" and "Bob" are between 'A' and 'C' lexicographically + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Bob"); + } + + // ── T31: Empty LIKE pattern ── + + [Fact] + public async Task Like_EmptyPattern_MatchesOnlyEmptyStrings() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":""}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("2"); + } + + // ── T32: Multiple HAVING conditions ── + + [Fact] + public async Task Having_MultipleConditions_FiltersCorrectly() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.value) AS total FROM c GROUP BY c.cat HAVING COUNT(1) >= 2 AND SUM(c.value) > 50"); + // A: cnt=2, total=40 (no — total≤50), B: cnt=2, total=60 (yes), C: cnt=1 (no) + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("B"); + } + + // ── T33: SELECT VALUE constant null ── + + [Fact] + public async Task SelectValue_ConstantNull_ReturnsNull() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE null FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Null); + } + + // ── T34: SELECT VALUE constant array ── + + [Fact] + public async Task SelectValue_ConstantArray_ReturnsArray() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE [1, 2, 3] FROM c"); + results.Should().ContainSingle(); + results[0].Should().HaveCount(3); + results[0][0]!.Value().Should().Be(1); + results[0][2]!.Value().Should().Be(3); + } + + // ── T35: SELECT VALUE constant object ── + + [Fact] + public async Task SelectValue_ConstantObject_ReturnsObject() + { + await SeedOne(); + var results = await RunQuery("""SELECT VALUE {"a": 1, "b": 2} FROM c"""); + results.Should().ContainSingle(); + results[0]["a"]!.Value().Should().Be(1); + results[0]["b"]!.Value().Should().Be(2); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11690,104 +11700,104 @@ public async Task SelectValue_ConstantObject_ReturnsObject() public class QueryDeepDiveV6_EdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── E1: SUM with all undefined values ── - - [Fact] - public async Task Sum_AllUndefined_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT SUM(c.score) AS total FROM c"); - results.Should().ContainSingle(); - // SUM of all-undefined values should produce undefined (key absent) in Cosmos DB - results[0].ContainsKey("total").Should().BeFalse("SUM of all-undefined values is undefined"); - } - - // ── E2: MIN/MAX on empty set ── - - [Fact] - public async Task Min_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT MIN(c.value) AS mn FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("mn").Should().BeFalse("MIN on empty set is undefined"); - } - - [Fact] - public async Task Max_EmptyContainer_ReturnsUndefined() - { - var results = await RunQuery("SELECT MAX(c.value) AS mx FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("mx").Should().BeFalse("MAX on empty set is undefined"); - } - - // ── E3: MAX with mixed types ── - - [Fact] - public async Task Max_MixedTypes_StringHasHigherRank() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"hello"}""")), - new PartitionKey("pk1")); - // Cosmos: string has higher type rank than number → MAX returns "hello" - var results = await RunQuery("SELECT MAX(c.val) AS mx FROM c"); - results.Should().ContainSingle(); - results[0]["mx"]!.Value().Should().Be("hello"); - } - - // ── E4: ORDER BY expression with undefined ── - - [Fact] - public async Task OrderBy_Expression_WithUndefined_SortsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Bob"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Alice"}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); - results[0]["id"]!.ToString().Should().Be("2"); // undefined sorts first - results[1]["id"]!.ToString().Should().Be("3"); // alice - results[2]["id"]!.ToString().Should().Be("1"); // bob - } - - // ── E5: Cross-partition GROUP BY + HAVING ── - - [Fact] - public async Task CrossPartition_GroupBy_Having_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk2","cat":"A","val":20}""")), - new PartitionKey("pk2")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","val":5}""")), - new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("A"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── E1: SUM with all undefined values ── + + [Fact] + public async Task Sum_AllUndefined_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Test"}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT SUM(c.score) AS total FROM c"); + results.Should().ContainSingle(); + // SUM of all-undefined values should produce undefined (key absent) in Cosmos DB + results[0].ContainsKey("total").Should().BeFalse("SUM of all-undefined values is undefined"); + } + + // ── E2: MIN/MAX on empty set ── + + [Fact] + public async Task Min_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT MIN(c.value) AS mn FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("mn").Should().BeFalse("MIN on empty set is undefined"); + } + + [Fact] + public async Task Max_EmptyContainer_ReturnsUndefined() + { + var results = await RunQuery("SELECT MAX(c.value) AS mx FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("mx").Should().BeFalse("MAX on empty set is undefined"); + } + + // ── E3: MAX with mixed types ── + + [Fact] + public async Task Max_MixedTypes_StringHasHigherRank() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","val":"hello"}""")), + new PartitionKey("pk1")); + // Cosmos: string has higher type rank than number → MAX returns "hello" + var results = await RunQuery("SELECT MAX(c.val) AS mx FROM c"); + results.Should().ContainSingle(); + results[0]["mx"]!.Value().Should().Be("hello"); + } + + // ── E4: ORDER BY expression with undefined ── + + [Fact] + public async Task OrderBy_Expression_WithUndefined_SortsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Bob"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Alice"}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT * FROM c ORDER BY LOWER(c.name) ASC"); + results[0]["id"]!.ToString().Should().Be("2"); // undefined sorts first + results[1]["id"]!.ToString().Should().Be("3"); // alice + results[2]["id"]!.ToString().Should().Be("1"); // bob + } + + // ── E5: Cross-partition GROUP BY + HAVING ── + + [Fact] + public async Task CrossPartition_GroupBy_Having_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk2","cat":"A","val":20}""")), + new PartitionKey("pk2")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"B","val":5}""")), + new PartitionKey("pk1")); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) >= 2"); + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("A"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11796,42 +11806,42 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV6_ParserTests { - // ── P1: Complex query round-trip through SimplifySdkQuery ── - - [Fact] - public void SimplifySdkQuery_ComplexQueries_RoundTripSuccessfully() - { - var queries = new[] - { - "SELECT * FROM c WHERE c.a > 1 AND c.b < 10 ORDER BY c.a ASC, c.b DESC", - "SELECT DISTINCT VALUE c.cat FROM c GROUP BY c.cat HAVING COUNT(1) > 1", - "SELECT TOP 5 c.id FROM c ORDER BY c.id ASC", - "SELECT c.name FROM c ORDER BY c.name ASC OFFSET 0 LIMIT 10", - }; - foreach (var q in queries) - { - var parsed = CosmosSqlParser.Parse(q); - var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); - var reparsed = CosmosSqlParser.Parse(simplified); - reparsed.Should().NotBeNull(); - } - } - - // ── P2: Parse cache returns same instance ── - - [Fact] - public void ParseCache_SameQuery_ReturnsCachedWhenNotFull() - { - // The cache has a max of 256 entries; when full, new entries are not added. - // Parse two consecutive calls and verify that both produce valid results. - var sql = "SELECT * FROM c WHERE c.id = 'cache-test-v6-" + Guid.NewGuid() + "'"; - var q1 = CosmosSqlParser.Parse(sql); - var q2 = CosmosSqlParser.Parse(sql); - q1.Should().NotBeNull(); - q2.Should().NotBeNull(); - // Both should produce structurally equivalent results - q1.FromAlias.Should().Be(q2.FromAlias); - } + // ── P1: Complex query round-trip through SimplifySdkQuery ── + + [Fact] + public void SimplifySdkQuery_ComplexQueries_RoundTripSuccessfully() + { + var queries = new[] + { + "SELECT * FROM c WHERE c.a > 1 AND c.b < 10 ORDER BY c.a ASC, c.b DESC", + "SELECT DISTINCT VALUE c.cat FROM c GROUP BY c.cat HAVING COUNT(1) > 1", + "SELECT TOP 5 c.id FROM c ORDER BY c.id ASC", + "SELECT c.name FROM c ORDER BY c.name ASC OFFSET 0 LIMIT 10", + }; + foreach (var q in queries) + { + var parsed = CosmosSqlParser.Parse(q); + var simplified = CosmosSqlParser.SimplifySdkQuery(parsed); + var reparsed = CosmosSqlParser.Parse(simplified); + reparsed.Should().NotBeNull(); + } + } + + // ── P2: Parse cache returns same instance ── + + [Fact] + public void ParseCache_SameQuery_ReturnsCachedWhenNotFull() + { + // The cache has a max of 256 entries; when full, new entries are not added. + // Parse two consecutive calls and verify that both produce valid results. + var sql = "SELECT * FROM c WHERE c.id = 'cache-test-v6-" + Guid.NewGuid() + "'"; + var q1 = CosmosSqlParser.Parse(sql); + var q2 = CosmosSqlParser.Parse(sql); + q1.Should().NotBeNull(); + q2.Should().NotBeNull(); + // Both should produce structurally equivalent results + q1.FromAlias.Should().Be(q2.FromAlias); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11840,104 +11850,104 @@ public void ParseCache_SameQuery_ReturnsCachedWhenNotFull() public class QueryDeepDiveV7_GroupByNullUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_NullVsUndefined_ProducesSeparateGroups() - { - // 3 docs: one with value, one with explicit null, one with missing field - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); - - // Real Cosmos DB produces 3 separate groups: - // - "fruits" / 1 - // - null / 1 - // - (undefined → omitted from projection) / 1 - results.Should().HaveCount(3); - } - - [Fact] - public async Task GroupBy_MultipleNullsAndUndefineds_CountsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"A"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a"}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); - - // 3 groups: "A"/1, null/2, undefined/2 - results.Should().HaveCount(3); - var catA = results.First(r => r["category"]?.ToString() == "A"); - catA["cnt"]!.Value().Should().Be(1); - - var catNull = results.First(r => r["category"]?.Type == JTokenType.Null); - catNull["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_AllNull_SingleGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); - - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_AllUndefined_SingleGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); - - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_NullVsUndefined_ProducesSeparateGroups() + { + // 3 docs: one with value, one with explicit null, one with missing field + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); + + // Real Cosmos DB produces 3 separate groups: + // - "fruits" / 1 + // - null / 1 + // - (undefined → omitted from projection) / 1 + results.Should().HaveCount(3); + } + + [Fact] + public async Task GroupBy_MultipleNullsAndUndefineds_CountsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"A"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a"}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); + + // 3 groups: "A"/1, null/2, undefined/2 + results.Should().HaveCount(3); + var catA = results.First(r => r["category"]?.ToString() == "A"); + catA["cnt"]!.Value().Should().Be(1); + + var catNull = results.First(r => r["category"]?.Type == JTokenType.Null); + catNull["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_AllNull_SingleGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); + + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_AllUndefined_SingleGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT c.category, COUNT(1) as cnt FROM c GROUP BY c.category"); + + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(2); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11946,34 +11956,34 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_SubqueryDepthTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Subquery_NestedExists_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","tags":["a","b"],"scores":[10,20]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","partitionKey":"pk1","tags":["c"],"scores":[1]}""")), - new PartitionKey("pk1")); - - // Nested EXISTS: find docs with a tag where ANY score > 5 - var results = await RunQuery( - "SELECT * FROM c WHERE EXISTS (SELECT VALUE t FROM t IN c.tags WHERE t = 'a') AND EXISTS (SELECT VALUE s FROM s IN c.scores WHERE s > 5)"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Subquery_NestedExists_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","tags":["a","b"],"scores":[10,20]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","partitionKey":"pk1","tags":["c"],"scores":[1]}""")), + new PartitionKey("pk1")); + + // Nested EXISTS: find docs with a tag where ANY score > 5 + var results = await RunQuery( + "SELECT * FROM c WHERE EXISTS (SELECT VALUE t FROM t IN c.tags WHERE t = 'a') AND EXISTS (SELECT VALUE s FROM s IN c.scores WHERE s > 5)"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -11982,229 +11992,229 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_DateTimeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - } - - // ── 2.1: DateTimeAdd negative interval ── - - [Fact] - public async Task DateTimeAdd_NegativeInterval_SubtractsTime() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('dd', -5, '2024-01-10T00:00:00.0000000Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be("2024-01-05T00:00:00.0000000Z"); - } - - // ── 2.2: DateTimeAdd months crossing year boundary ── - - [Fact] - public async Task DateTimeAdd_MonthsCrossYear_Works() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('mm', 3, '2024-11-15T00:00:00.0000000Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be("2025-02-15T00:00:00.0000000Z"); - } - - // ── 2.3: DateTimeAdd null input ── - - [Fact] - public async Task DateTimeAdd_NullInput_ReturnsUndefined() - { - await SeedOne(); - // null datetime → undefined → empty VALUE result - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('dd', 1, null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.4-2.12: DateTimePart all parts ── - - [Fact] - public async Task DateTimePart_Year_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('yyyy', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(2024); - } - - [Fact] - public async Task DateTimePart_Month_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('mm', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(6); - } - - [Fact] - public async Task DateTimePart_Day_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('dd', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(15); - } - - [Fact] - public async Task DateTimePart_Hour_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('hh', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(10); - } - - [Fact] - public async Task DateTimePart_Minute_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('mi', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(30); - } - - [Fact] - public async Task DateTimePart_Second_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('ss', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(45); - } - - [Fact] - public async Task DateTimePart_Millisecond_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('ms', '2024-06-15T10:30:45.1230000Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(123); - } - - [Fact] - public async Task DateTimePart_Microsecond_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('mcs', '2024-06-15T10:30:45.1234560Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(123456); - } - - [Fact] - public async Task DateTimePart_Nanosecond_ExtractsCorrectly() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('ns', '2024-06-15T10:30:45.1234567Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(123456700); - } - - [Fact] - public async Task DateTimePart_NullInput_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('yyyy', null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.16-2.18: DateTimeDiff edge cases ── - - [Fact] - public async Task DateTimeDiff_SameDate_ReturnsZero() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task DateTimeDiff_Reversed_ReturnsNegative() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', '2024-01-10T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-9); - } - - [Fact] - public async Task DateTimeDiff_Hours_Works() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('hh', '2024-01-01T00:00:00Z', '2024-01-01T05:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(5); - } - - // ── 2.19-2.21: DateTimeBin edge cases ── - - [Fact] - public async Task DateTimeBin_ToMinuteInterval_Works() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin('2024-06-15T10:37:42Z', 'mi', 15) FROM c"); - results.Should().ContainSingle().Which.Should().Be("2024-06-15T10:30:00.0000000Z"); - } - - [Fact] - public async Task DateTimeBin_NullInput_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin(null, 'hh', 1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.22-2.24: DateTimeFromParts edge cases ── - - [Fact] - public async Task DateTimeFromParts_LeapYear_Feb29_Works() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2024, 2, 29, 0, 0, 0) FROM c"); - results.Should().ContainSingle(); - results[0].Should().StartWith("2024-02-29"); - } - - [Fact] - public async Task DateTimeFromParts_NonLeapYear_Feb29_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2023, 2, 29, 0, 0, 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.25: Round-trip timestamp conversions ── - - [Fact] - public async Task DateTimeToTimestamp_RoundTrips_WithTimestampToDateTime() - { - await SeedOne(); - var results = await RunQuery( - "SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-06-15T10:30:00.0000000Z')) FROM c"); - results.Should().ContainSingle().Which.Should().Be("2024-06-15T10:30:00.0000000Z"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + } + + // ── 2.1: DateTimeAdd negative interval ── + + [Fact] + public async Task DateTimeAdd_NegativeInterval_SubtractsTime() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('dd', -5, '2024-01-10T00:00:00.0000000Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be("2024-01-05T00:00:00.0000000Z"); + } + + // ── 2.2: DateTimeAdd months crossing year boundary ── + + [Fact] + public async Task DateTimeAdd_MonthsCrossYear_Works() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('mm', 3, '2024-11-15T00:00:00.0000000Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be("2025-02-15T00:00:00.0000000Z"); + } + + // ── 2.3: DateTimeAdd null input ── + + [Fact] + public async Task DateTimeAdd_NullInput_ReturnsUndefined() + { + await SeedOne(); + // null datetime → undefined → empty VALUE result + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('dd', 1, null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.4-2.12: DateTimePart all parts ── + + [Fact] + public async Task DateTimePart_Year_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('yyyy', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(2024); + } + + [Fact] + public async Task DateTimePart_Month_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('mm', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(6); + } + + [Fact] + public async Task DateTimePart_Day_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('dd', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(15); + } + + [Fact] + public async Task DateTimePart_Hour_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('hh', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(10); + } + + [Fact] + public async Task DateTimePart_Minute_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('mi', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(30); + } + + [Fact] + public async Task DateTimePart_Second_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('ss', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(45); + } + + [Fact] + public async Task DateTimePart_Millisecond_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('ms', '2024-06-15T10:30:45.1230000Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(123); + } + + [Fact] + public async Task DateTimePart_Microsecond_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('mcs', '2024-06-15T10:30:45.1234560Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(123456); + } + + [Fact] + public async Task DateTimePart_Nanosecond_ExtractsCorrectly() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('ns', '2024-06-15T10:30:45.1234567Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(123456700); + } + + [Fact] + public async Task DateTimePart_NullInput_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('yyyy', null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.16-2.18: DateTimeDiff edge cases ── + + [Fact] + public async Task DateTimeDiff_SameDate_ReturnsZero() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task DateTimeDiff_Reversed_ReturnsNegative() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', '2024-01-10T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-9); + } + + [Fact] + public async Task DateTimeDiff_Hours_Works() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('hh', '2024-01-01T00:00:00Z', '2024-01-01T05:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(5); + } + + // ── 2.19-2.21: DateTimeBin edge cases ── + + [Fact] + public async Task DateTimeBin_ToMinuteInterval_Works() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin('2024-06-15T10:37:42Z', 'mi', 15) FROM c"); + results.Should().ContainSingle().Which.Should().Be("2024-06-15T10:30:00.0000000Z"); + } + + [Fact] + public async Task DateTimeBin_NullInput_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin(null, 'hh', 1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.22-2.24: DateTimeFromParts edge cases ── + + [Fact] + public async Task DateTimeFromParts_LeapYear_Feb29_Works() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2024, 2, 29, 0, 0, 0) FROM c"); + results.Should().ContainSingle(); + results[0].Should().StartWith("2024-02-29"); + } + + [Fact] + public async Task DateTimeFromParts_NonLeapYear_Feb29_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2023, 2, 29, 0, 0, 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.25: Round-trip timestamp conversions ── + + [Fact] + public async Task DateTimeToTimestamp_RoundTrips_WithTimestampToDateTime() + { + await SeedOne(); + var results = await RunQuery( + "SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-06-15T10:30:00.0000000Z')) FROM c"); + results.Should().ContainSingle().Which.Should().Be("2024-06-15T10:30:00.0000000Z"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12213,142 +12223,142 @@ public async Task DateTimeToTimestamp_RoundTrips_WithTimestampToDateTime() public class QueryDeepDiveV7_MathEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Division_IntegerByZero_ReturnsUndefined() - { - await SeedOne(); - // Cosmos DB returns undefined for integer division by zero - var results = await RunQuery("SELECT VALUE c.val / 0 FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Modulo_ByZero_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE c.val % 0 FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Sqrt_NegativeNumber_ReturnsUndefined() - { - await SeedOne(); - // Math.Sqrt(-1) = NaN → should produce undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE SQRT(-1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Log_Zero_ReturnsUndefined() - { - await SeedOne(); - // Math.Log(0) = -Infinity → should produce undefined - var results = await RunQuery("SELECT VALUE LOG(0) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Log_NegativeNumber_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG(-1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Power_ZeroToZero_ReturnsOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(1.0); - } - - [Fact] - public async Task Power_ZeroToNegative_ReturnsUndefined() - { - await SeedOne(); - // POWER(0, -1) = Infinity → undefined - var results = await RunQuery("SELECT VALUE POWER(0, -1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Round_HalfUp_StandardRounding() - { - await SeedOne(); - // Cosmos DB uses standard "round half away from zero" → 2.5 → 3 - var results = await RunQuery("SELECT VALUE ROUND(2.5) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3.0); - } - - [Fact] - public async Task Trunc_Positive_FloorBehaviour() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE TRUNC(3.7) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3.0); - } - - [Fact] - public async Task Trunc_Negative_TruncatesTowardZero() - { - await SeedOne(); - // TRUNC(-3.7) should be -3 (truncate toward zero, not floor which is -4) - var results = await RunQuery("SELECT VALUE TRUNC(-3.7) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-3.0); - } - - [Fact] - public async Task NumberBin_NegativeValue_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(-7, 5) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-10.0); - } - - [Fact] - public async Task NumberBin_FloatBinSize_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE NumberBin(7.3, 2.5) FROM c"); - results.Should().ContainSingle().Which.Should().Be(5.0); - } - - [Fact] - public async Task Log10_Zero_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOG10(0) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Division_FloatByZero_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10.5}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE c.val / 0 FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Division_IntegerByZero_ReturnsUndefined() + { + await SeedOne(); + // Cosmos DB returns undefined for integer division by zero + var results = await RunQuery("SELECT VALUE c.val / 0 FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Modulo_ByZero_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE c.val % 0 FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Sqrt_NegativeNumber_ReturnsUndefined() + { + await SeedOne(); + // Math.Sqrt(-1) = NaN → should produce undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE SQRT(-1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Log_Zero_ReturnsUndefined() + { + await SeedOne(); + // Math.Log(0) = -Infinity → should produce undefined + var results = await RunQuery("SELECT VALUE LOG(0) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Log_NegativeNumber_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG(-1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Power_ZeroToZero_ReturnsOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(1.0); + } + + [Fact] + public async Task Power_ZeroToNegative_ReturnsUndefined() + { + await SeedOne(); + // POWER(0, -1) = Infinity → undefined + var results = await RunQuery("SELECT VALUE POWER(0, -1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Round_HalfUp_StandardRounding() + { + await SeedOne(); + // Cosmos DB uses standard "round half away from zero" → 2.5 → 3 + var results = await RunQuery("SELECT VALUE ROUND(2.5) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3.0); + } + + [Fact] + public async Task Trunc_Positive_FloorBehaviour() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE TRUNC(3.7) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3.0); + } + + [Fact] + public async Task Trunc_Negative_TruncatesTowardZero() + { + await SeedOne(); + // TRUNC(-3.7) should be -3 (truncate toward zero, not floor which is -4) + var results = await RunQuery("SELECT VALUE TRUNC(-3.7) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-3.0); + } + + [Fact] + public async Task NumberBin_NegativeValue_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(-7, 5) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-10.0); + } + + [Fact] + public async Task NumberBin_FloatBinSize_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE NumberBin(7.3, 2.5) FROM c"); + results.Should().ContainSingle().Which.Should().Be(5.0); + } + + [Fact] + public async Task Log10_Zero_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOG10(0) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Division_FloatByZero_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":10.5}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE c.val / 0 FROM c"); + results.Should().BeEmpty(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12357,177 +12367,177 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_StringEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Substring_StartEqualsLength_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 5, 10) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Substring_LengthZero_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Substring_NullInput_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE SUBSTRING(null, 0, 3) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Left_CountZero_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Left_CountExceedsLength_ReturnsFullString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LEFT('hello', 100) FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - [Fact] - public async Task Right_CountExceedsLength_ReturnsFullString() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE RIGHT('hello', 100) FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - [Fact] - public async Task IndexOf_EmptySubstring_ReturnsZero() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task IndexOf_NotFound_ReturnsMinusOne() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE INDEX_OF('hello', 'xyz') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-1); - } - - [Fact] - public async Task Replace_OldNotFound_ReturnsOriginal() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLACE('hello', 'xyz', 'abc') FROM c"); - results.Should().ContainSingle().Which.Should().Be("hello"); - } - - [Fact] - public async Task Replace_MultipleOccurrences_ReplacesAll() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLACE('hello world hello', 'hello', 'hi') FROM c"); - results.Should().ContainSingle().Which.Should().Be("hi world hi"); - } - - [Fact] - public async Task Replace_WithEmpty_DeletesOccurrences() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLACE('hello', 'l', '') FROM c"); - results.Should().ContainSingle().Which.Should().Be("heo"); - } - - [Fact] - public async Task Replicate_CountZero_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REPLICATE('abc', 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task StringToNumber_ScientificNotation_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNumber('1.5e2') FROM c"); - results.Should().ContainSingle().Which.Should().Be(150.0); - } - - [Fact] - public async Task StringToNumber_InvalidInput_ReturnsUndefined() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNumber('not-a-number') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task StringToBoolean_CaseSensitive_TrueUppercase_ReturnsUndefined() - { - await SeedOne(); - // Cosmos DB StringToBoolean is case-sensitive: only "true"/"false" work - var results = await RunQuery("SELECT VALUE StringToBoolean('TRUE') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task StringToNull_ExactlyNull_ReturnsNull() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE StringToNull('null') FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task StringToNull_NotNull_ReturnsUndefined() - { - await SeedOne(); - // "NULL" (uppercase) is not valid → undefined - var results = await RunQuery("SELECT VALUE StringToNull('NULL') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Length_EmptyString_ReturnsZero() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LENGTH('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task Reverse_EmptyString_ReturnsEmpty() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE REVERSE('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Substring_StartEqualsLength_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 5, 10) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Substring_LengthZero_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Substring_NullInput_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE SUBSTRING(null, 0, 3) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Left_CountZero_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Left_CountExceedsLength_ReturnsFullString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LEFT('hello', 100) FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + [Fact] + public async Task Right_CountExceedsLength_ReturnsFullString() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE RIGHT('hello', 100) FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + [Fact] + public async Task IndexOf_EmptySubstring_ReturnsZero() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task IndexOf_NotFound_ReturnsMinusOne() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE INDEX_OF('hello', 'xyz') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-1); + } + + [Fact] + public async Task Replace_OldNotFound_ReturnsOriginal() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLACE('hello', 'xyz', 'abc') FROM c"); + results.Should().ContainSingle().Which.Should().Be("hello"); + } + + [Fact] + public async Task Replace_MultipleOccurrences_ReplacesAll() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLACE('hello world hello', 'hello', 'hi') FROM c"); + results.Should().ContainSingle().Which.Should().Be("hi world hi"); + } + + [Fact] + public async Task Replace_WithEmpty_DeletesOccurrences() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLACE('hello', 'l', '') FROM c"); + results.Should().ContainSingle().Which.Should().Be("heo"); + } + + [Fact] + public async Task Replicate_CountZero_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REPLICATE('abc', 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task StringToNumber_ScientificNotation_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNumber('1.5e2') FROM c"); + results.Should().ContainSingle().Which.Should().Be(150.0); + } + + [Fact] + public async Task StringToNumber_InvalidInput_ReturnsUndefined() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNumber('not-a-number') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task StringToBoolean_CaseSensitive_TrueUppercase_ReturnsUndefined() + { + await SeedOne(); + // Cosmos DB StringToBoolean is case-sensitive: only "true"/"false" work + var results = await RunQuery("SELECT VALUE StringToBoolean('TRUE') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task StringToNull_ExactlyNull_ReturnsNull() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE StringToNull('null') FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task StringToNull_NotNull_ReturnsUndefined() + { + await SeedOne(); + // "NULL" (uppercase) is not valid → undefined + var results = await RunQuery("SELECT VALUE StringToNull('NULL') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Length_EmptyString_ReturnsZero() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LENGTH('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task Reverse_EmptyString_ReturnsEmpty() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE REVERSE('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12536,176 +12546,176 @@ public async Task Reverse_EmptyString_ReturnsEmpty() public class QueryDeepDiveV7_WhereEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ThreeValueLogic_TrueAndUndefined_ReturnsFalse() - { - // Doc with missing field — "true AND (undefined = 'x')" → false - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name = 'Alice' AND c.missingField = 'x'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ThreeValueLogic_FalseOrUndefined_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name = 'Bob' OR c.missingField = 'x'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ThreeValueLogic_NotUndefined_ReturnsNothing() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - // NOT (missingField = 'x') → NOT undefined → undefined → excluded - var results = await RunQuery( - "SELECT * FROM c WHERE NOT (c.missingField = 'x')"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task In_WithNullValue_MatchesNullField() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.category IN (null, 'vegetables')"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Like_SpecialRegexChars_TreatedAsLiterals() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test.name"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"testXname"}""")), - new PartitionKey("a")); - // Dot should be literal, not regex wildcard - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'test.name'"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Like_CaseSensitive_Default() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - // LIKE is case-sensitive in Cosmos DB - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'alice'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Between_NullField_ExcludesDoc() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":5}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.value BETWEEN 1 AND 10"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task Where_UndefinedField_ComparisonAlwaysFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.nonexistent > 5"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_NullEqualsNull_Matches() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT * FROM c WHERE c.category = null"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_IsNull_MatchesOnlyNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - // IS NULL should match only explicit null, not undefined - var results = await RunQuery( - "SELECT * FROM c WHERE c.category IS NULL"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_ParameterNull_MatchesNullField() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat") - .WithParameter("@cat", null); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ThreeValueLogic_TrueAndUndefined_ReturnsFalse() + { + // Doc with missing field — "true AND (undefined = 'x')" → false + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name = 'Alice' AND c.missingField = 'x'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ThreeValueLogic_FalseOrUndefined_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name = 'Bob' OR c.missingField = 'x'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ThreeValueLogic_NotUndefined_ReturnsNothing() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + // NOT (missingField = 'x') → NOT undefined → undefined → excluded + var results = await RunQuery( + "SELECT * FROM c WHERE NOT (c.missingField = 'x')"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task In_WithNullValue_MatchesNullField() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.category IN (null, 'vegetables')"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Like_SpecialRegexChars_TreatedAsLiterals() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test.name"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"testXname"}""")), + new PartitionKey("a")); + // Dot should be literal, not regex wildcard + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'test.name'"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Like_CaseSensitive_Default() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + // LIKE is case-sensitive in Cosmos DB + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'alice'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Between_NullField_ExcludesDoc() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":5}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.value BETWEEN 1 AND 10"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task Where_UndefinedField_ComparisonAlwaysFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.nonexistent > 5"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_NullEqualsNull_Matches() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT * FROM c WHERE c.category = null"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_IsNull_MatchesOnlyNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + // IS NULL should match only explicit null, not undefined + var results = await RunQuery( + "SELECT * FROM c WHERE c.category IS NULL"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_ParameterNull_MatchesNullField() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat") + .WithParameter("@cat", null); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12714,136 +12724,136 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_AggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Sum_AllUndefined_ReturnsUndefined() - { - // Docs without the 'value' field - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob"}""")), - new PartitionKey("a")); - var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Avg_WithNulls_ExcludesNulls() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":20}""")), - new PartitionKey("a")); - - var results = await RunQuery("SELECT AVG(c.value) as avg FROM c"); - results.Should().ContainSingle(); - // AVG should be (10+20)/2 = 15, excluding null - results[0]["avg"]!.Value().Should().Be(15.0); - } - - [Fact] - public async Task Sum_MixedTypes_IgnoresNonNumeric() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":"hello"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":20}""")), - new PartitionKey("a")); - - var results = await RunQuery("SELECT SUM(c.value) as total FROM c"); - results.Should().ContainSingle(); - results[0]["total"]!.Value().Should().Be(30.0); - } - - [Fact] - public async Task Count_Field_ExcludesUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":null}""")), - new PartitionKey("a")); - - // COUNT(c.value) should exclude undefined AND null in Cosmos DB - var results = await RunQuery("SELECT COUNT(c.value) as cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Avg_EmptySet_ReturnsUndefined() - { - // No docs at all - var results = await RunQuery("SELECT VALUE AVG(c.value) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Sum_EmptySet_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Min_AllNull_ReturnsNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":null}""")), - new PartitionKey("a")); - - var results = await RunQuery("SELECT VALUE MIN(c.value) FROM c"); - // MIN of all nulls — Cosmos returns undefined (all non-comparable) - // This might return null or empty depending on implementation - // Accept either null or empty (undefined) - if (results.Count > 0) - results[0].Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Max_MixedTypes_CosmosTypeOrdering() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":5}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":"zebra"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":true}""")), - new PartitionKey("a")); - - // Cosmos type ordering for MAX: bool < number < string → MAX should be "zebra" - var results = await RunQuery("SELECT VALUE MAX(c.value) FROM c"); - results.Should().ContainSingle().Which.Should().Be("zebra"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Sum_AllUndefined_ReturnsUndefined() + { + // Docs without the 'value' field + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob"}""")), + new PartitionKey("a")); + var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Avg_WithNulls_ExcludesNulls() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":20}""")), + new PartitionKey("a")); + + var results = await RunQuery("SELECT AVG(c.value) as avg FROM c"); + results.Should().ContainSingle(); + // AVG should be (10+20)/2 = 15, excluding null + results[0]["avg"]!.Value().Should().Be(15.0); + } + + [Fact] + public async Task Sum_MixedTypes_IgnoresNonNumeric() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":"hello"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":20}""")), + new PartitionKey("a")); + + var results = await RunQuery("SELECT SUM(c.value) as total FROM c"); + results.Should().ContainSingle(); + results[0]["total"]!.Value().Should().Be(30.0); + } + + [Fact] + public async Task Count_Field_ExcludesUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":null}""")), + new PartitionKey("a")); + + // COUNT(c.value) should exclude undefined AND null in Cosmos DB + var results = await RunQuery("SELECT COUNT(c.value) as cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Avg_EmptySet_ReturnsUndefined() + { + // No docs at all + var results = await RunQuery("SELECT VALUE AVG(c.value) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Sum_EmptySet_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE SUM(c.value) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Min_AllNull_ReturnsNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":null}""")), + new PartitionKey("a")); + + var results = await RunQuery("SELECT VALUE MIN(c.value) FROM c"); + // MIN of all nulls — Cosmos returns undefined (all non-comparable) + // This might return null or empty depending on implementation + // Accept either null or empty (undefined) + if (results.Count > 0) + results[0].Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Max_MixedTypes_CosmosTypeOrdering() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":5}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":"zebra"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":true}""")), + new PartitionKey("a")); + + // Cosmos type ordering for MAX: bool < number < string → MAX should be "zebra" + var results = await RunQuery("SELECT VALUE MAX(c.value) FROM c"); + results.Should().ContainSingle().Which.Should().Be("zebra"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12852,81 +12862,81 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_OrderByEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_Coalesce_DefinedBeforeUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.name ?? 'zzz' ASC"); - // Alice (sorted), Charlie (sorted), then doc2 mapped to 'zzz' - results[0]["id"]!.ToString().Should().Be("3"); // Alice - results[1]["id"]!.ToString().Should().Be("1"); // Charlie - results[2]["id"]!.ToString().Should().Be("2"); // zzz (missing name) - } - - [Fact] - public async Task OrderBy_MixedTypes_CosmosTypeOrdering() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":"hello"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":42}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":true}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","value":null}""")), - new PartitionKey("a")); - - // Cosmos type ordering ASC: null, bool, number, string - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.value ASC"); - results[0]["id"]!.ToString().Should().Be("4"); // null - results[1]["id"]!.ToString().Should().Be("3"); // true (bool) - results[2]["id"]!.ToString().Should().Be("2"); // 42 (number) - results[3]["id"]!.ToString().Should().Be("1"); // "hello" (string) - } - - [Fact] - public async Task OrderBy_Arithmetic_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":10,"b":1}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","a":5,"b":2}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","a":3,"b":20}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.a + c.b ASC"); - results[0]["id"]!.ToString().Should().Be("2"); // 5+2=7 - results[1]["id"]!.ToString().Should().Be("1"); // 10+1=11 - results[2]["id"]!.ToString().Should().Be("3"); // 3+20=23 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_Coalesce_DefinedBeforeUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Charlie"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.name ?? 'zzz' ASC"); + // Alice (sorted), Charlie (sorted), then doc2 mapped to 'zzz' + results[0]["id"]!.ToString().Should().Be("3"); // Alice + results[1]["id"]!.ToString().Should().Be("1"); // Charlie + results[2]["id"]!.ToString().Should().Be("2"); // zzz (missing name) + } + + [Fact] + public async Task OrderBy_MixedTypes_CosmosTypeOrdering() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":"hello"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","value":42}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","value":true}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","value":null}""")), + new PartitionKey("a")); + + // Cosmos type ordering ASC: null, bool, number, string + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.value ASC"); + results[0]["id"]!.ToString().Should().Be("4"); // null + results[1]["id"]!.ToString().Should().Be("3"); // true (bool) + results[2]["id"]!.ToString().Should().Be("2"); // 42 (number) + results[3]["id"]!.ToString().Should().Be("1"); // "hello" (string) + } + + [Fact] + public async Task OrderBy_Arithmetic_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":10,"b":1}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","a":5,"b":2}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","a":3,"b":20}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.a + c.b ASC"); + results[0]["id"]!.ToString().Should().Be("2"); // 5+2=7 + results[1]["id"]!.ToString().Should().Be("1"); // 10+1=11 + results[2]["id"]!.ToString().Should().Be("3"); // 3+20=23 + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -12935,93 +12945,93 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_JoinEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_EmptyArray_ExcludesDocument() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":[]}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["a"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - results.Should().ContainSingle().Which.Should().Be("a"); - } - - [Fact] - public async Task Join_NullField_ExcludesDocument() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["b"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Join_UndefinedField_ExcludesDocument() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["c"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Join_NonArrayField_ExcludesDocument() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":"not-an-array"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["d"]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Join_ArrayOfArrays_NoAutoFlatten() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","matrix":[[1,2],[3,4]]}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.matrix"); - results.Should().HaveCount(2); - // Each result should be an array, not individual elements - results[0].Should().HaveCount(2); - results[1].Should().HaveCount(2); - } - - [Fact] - public async Task Join_NestedArrays_DoubleJoin() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","groups":[{"tags":["a","b"]},{"tags":["c"]}]}""")), - new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT VALUE t FROM c JOIN g IN c.groups JOIN t IN g.tags"); - results.Should().HaveCount(3); - results.Should().BeEquivalentTo(["a", "b", "c"]); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_EmptyArray_ExcludesDocument() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":[]}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["a"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + results.Should().ContainSingle().Which.Should().Be("a"); + } + + [Fact] + public async Task Join_NullField_ExcludesDocument() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["b"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Join_UndefinedField_ExcludesDocument() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["c"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Join_NonArrayField_ExcludesDocument() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":"not-an-array"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","tags":["d"]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Join_ArrayOfArrays_NoAutoFlatten() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","matrix":[[1,2],[3,4]]}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.matrix"); + results.Should().HaveCount(2); + // Each result should be an array, not individual elements + results[0].Should().HaveCount(2); + results[1].Should().HaveCount(2); + } + + [Fact] + public async Task Join_NestedArrays_DoubleJoin() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","groups":[{"tags":["a","b"]},{"tags":["c"]}]}""")), + new PartitionKey("pk1")); + var results = await RunQuery( + "SELECT VALUE t FROM c JOIN g IN c.groups JOIN t IN g.tags"); + results.Should().HaveCount(3); + results.Should().BeEquivalentTo(["a", "b", "c"]); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -13030,66 +13040,66 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_ParameterEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Parameter_NullValue_MatchesNullField() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat") - .WithParameter("@cat", null); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Parameter_ArrayForArrayContains_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Charlie"}""")), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(@names, c.name)") - .WithParameter("@names", new[] { "Alice", "Charlie" }); - var results = await RunQuery(query); - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task Parameter_InSelectProjection_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT c.name, @label as label FROM c") - .WithParameter("@label", "test-label"); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["label"]!.ToString().Should().Be("test-label"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Parameter_NullValue_MatchesNullField() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":"fruits"}""")), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat") + .WithParameter("@cat", null); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Parameter_ArrayForArrayContains_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Charlie"}""")), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(@names, c.name)") + .WithParameter("@names", new[] { "Alice", "Charlie" }); + var results = await RunQuery(query); + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task Parameter_InSelectProjection_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT c.name, @label as label FROM c") + .WithParameter("@label", "test-label"); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["label"]!.ToString().Should().Be("test-label"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -13098,74 +13108,74 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_UnicodeTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedOne() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task Upper_NonAscii_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE UPPER('café') FROM c"); - results.Should().ContainSingle().Which.Should().Be("CAFÉ"); - } - - [Fact] - public async Task Lower_NonAscii_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LOWER('NAÏVE') FROM c"); - results.Should().ContainSingle().Which.Should().Be("naïve"); - } - - [Fact] - public async Task Length_NonAscii_CountsChars() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE LENGTH('café') FROM c"); - results.Should().ContainSingle().Which.Should().Be(4); - } - - [Fact] - public async Task Contains_AccentedChars_Works() - { - await SeedOne(); - var results = await RunQuery("SELECT VALUE CONTAINS('café', 'é') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task OrderBy_UnicodeStrings_OrdinalSort() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Ångström"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"apple"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Zebra"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT * FROM c ORDER BY c.name ASC"); - // Ordinal sort: uppercase before lowercase in ASCII - results.Should().HaveCount(3); - // Just verify deterministic ordering (exact order depends on ordinal comparison) - results.Select(r => r["name"]!.ToString()).Should().OnlyHaveUniqueItems(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedOne() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task Upper_NonAscii_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE UPPER('café') FROM c"); + results.Should().ContainSingle().Which.Should().Be("CAFÉ"); + } + + [Fact] + public async Task Lower_NonAscii_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LOWER('NAÏVE') FROM c"); + results.Should().ContainSingle().Which.Should().Be("naïve"); + } + + [Fact] + public async Task Length_NonAscii_CountsChars() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE LENGTH('café') FROM c"); + results.Should().ContainSingle().Which.Should().Be(4); + } + + [Fact] + public async Task Contains_AccentedChars_Works() + { + await SeedOne(); + var results = await RunQuery("SELECT VALUE CONTAINS('café', 'é') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task OrderBy_UnicodeStrings_OrdinalSort() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Ångström"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"apple"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","name":"Zebra"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT * FROM c ORDER BY c.name ASC"); + // Ordinal sort: uppercase before lowercase in ASCII + results.Should().HaveCount(3); + // Just verify deterministic ordering (exact order depends on ordinal comparison) + results.Select(r => r["name"]!.ToString()).Should().OnlyHaveUniqueItems(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -13174,47 +13184,47 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_ErrorHandlingTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task UnparseableSql_ThrowsBadRequest() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - Func act = async () => - { - var iterator = _container.GetItemQueryIterator("NOT VALID SQL AT ALL !!!"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); - } - - [Fact] - public async Task FunctionWrongArgCount_HandlesGracefully() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - // UPPER with no args — should return undefined/null or throw BadRequest, but not crash - var iterator = _container.GetItemQueryIterator( - "SELECT UPPER() as result FROM c"); - try - { - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - // If it doesn't throw, each result should have "result" as null/missing (undefined projected) - results.Should().ContainSingle(); - } - catch (CosmosException ex) - { - // BadRequest is also acceptable - ex.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest); - } - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task UnparseableSql_ThrowsBadRequest() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + Func act = async () => + { + var iterator = _container.GetItemQueryIterator("NOT VALID SQL AT ALL !!!"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); + } + + [Fact] + public async Task FunctionWrongArgCount_HandlesGracefully() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + // UPPER with no args — should return undefined/null or throw BadRequest, but not crash + var iterator = _container.GetItemQueryIterator( + "SELECT UPPER() as result FROM c"); + try + { + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + // If it doesn't throw, each result should have "result" as null/missing (undefined projected) + results.Should().ContainSingle(); + } + catch (CosmosException ex) + { + // BadRequest is also acceptable + ex.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest); + } + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -13223,89 +13233,89 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV7_GroupByComplexTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedDocs() - { - var docs = new[] - { - """{"id":"1","pk":"a","name":"Alice","value":10,"cat":"A","isActive":true}""", - """{"id":"2","pk":"a","name":"Bob","value":20,"cat":"B","isActive":false}""", - """{"id":"3","pk":"a","name":"Charlie","value":30,"cat":"A","isActive":true}""", - """{"id":"4","pk":"a","name":"Diana","value":40,"cat":"B","isActive":true}""", - """{"id":"5","pk":"a","name":"Eve","value":50,"cat":"C","isActive":false}""", - }; - foreach (var doc in docs) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(doc)), - new PartitionKey("a")); - } - - [Fact] - public async Task GroupBy_WithOrderByCnt_SortsGroups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); - // A:2, B:2, C:1 → after ORDER BY DESC: A/B (2 each), then C (1) - results.Last()["cat"]!.ToString().Should().Be("C"); - results.Last()["cnt"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Having_MultipleConditions_FiltersCorrectly() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt, SUM(c.value) as total " + - "FROM c GROUP BY c.cat HAVING COUNT(1) > 1 AND SUM(c.value) > 50"); - // A: cnt=2, total=40 → fails SUM>50 - // B: cnt=2, total=60 → passes both - // C: cnt=1 → fails COUNT>1 - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("B"); - } - - [Fact] - public async Task GroupBy_WithFunction_Groups() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT LOWER(c.cat) as lcat, COUNT(1) as cnt FROM c GROUP BY LOWER(c.cat)"); - // Cats are already single-case but this tests function-based GROUP BY - results.Should().HaveCount(3); - } - - [Fact] - public async Task GroupBy_WithWhere_FiltersFirst() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.isActive = true GROUP BY c.cat"); - // Active: Alice(A), Charlie(A), Diana(B) → A:2, B:1 - results.Should().HaveCount(2); - var catA = results.First(r => r["cat"]!.ToString() == "A"); - catA["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_WithOrderByGroupKey_Works() - { - await SeedDocs(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC"); - results[0]["cat"]!.ToString().Should().Be("A"); - results[1]["cat"]!.ToString().Should().Be("B"); - results[2]["cat"]!.ToString().Should().Be("C"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedDocs() + { + var docs = new[] + { + """{"id":"1","pk":"a","name":"Alice","value":10,"cat":"A","isActive":true}""", + """{"id":"2","pk":"a","name":"Bob","value":20,"cat":"B","isActive":false}""", + """{"id":"3","pk":"a","name":"Charlie","value":30,"cat":"A","isActive":true}""", + """{"id":"4","pk":"a","name":"Diana","value":40,"cat":"B","isActive":true}""", + """{"id":"5","pk":"a","name":"Eve","value":50,"cat":"C","isActive":false}""", + }; + foreach (var doc in docs) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(doc)), + new PartitionKey("a")); + } + + [Fact] + public async Task GroupBy_WithOrderByCnt_SortsGroups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); + // A:2, B:2, C:1 → after ORDER BY DESC: A/B (2 each), then C (1) + results.Last()["cat"]!.ToString().Should().Be("C"); + results.Last()["cnt"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Having_MultipleConditions_FiltersCorrectly() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt, SUM(c.value) as total " + + "FROM c GROUP BY c.cat HAVING COUNT(1) > 1 AND SUM(c.value) > 50"); + // A: cnt=2, total=40 → fails SUM>50 + // B: cnt=2, total=60 → passes both + // C: cnt=1 → fails COUNT>1 + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("B"); + } + + [Fact] + public async Task GroupBy_WithFunction_Groups() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT LOWER(c.cat) as lcat, COUNT(1) as cnt FROM c GROUP BY LOWER(c.cat)"); + // Cats are already single-case but this tests function-based GROUP BY + results.Should().HaveCount(3); + } + + [Fact] + public async Task GroupBy_WithWhere_FiltersFirst() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.isActive = true GROUP BY c.cat"); + // Active: Alice(A), Charlie(A), Diana(B) → A:2, B:1 + results.Should().HaveCount(2); + var catA = results.First(r => r["cat"]!.ToString() == "A"); + catA["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_WithOrderByGroupKey_Works() + { + await SeedDocs(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC"); + results[0]["cat"]!.ToString().Should().Be("A"); + results[1]["cat"]!.ToString().Should().Be("B"); + results[2]["cat"]!.ToString().Should().Be("C"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -13314,61 +13324,61 @@ public async Task GroupBy_WithOrderByGroupKey_Works() public class QueryDeepDiveV7_SelectProjectionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectDistinctValue_WithNulls_IncludesOneNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"A"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":"B"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","category":null}""")), - new PartitionKey("a")); - - var results = await RunQuery("SELECT DISTINCT VALUE c.category FROM c"); - // Should have 3: "A", null, "B" (null appears once) - results.Should().HaveCount(3); - } - - [Fact] - public async Task SelectAlias_SameAsExistingField_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var results = await RunQuery("SELECT c.name AS id FROM c"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task SelectValue_ObjectLiteral_WithUndefinedField_OmitsUndefinedProperty() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var results = await RunQuery( - "SELECT VALUE {'name': c.name, 'missing': c.nonexistent} FROM c"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - // undefined property should be omitted from the object, or set to null - // Cosmos DB omits undefined properties from object literals - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectDistinctValue_WithNulls_IncludesOneNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","category":"A"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","category":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","category":"B"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","category":null}""")), + new PartitionKey("a")); + + var results = await RunQuery("SELECT DISTINCT VALUE c.category FROM c"); + // Should have 3: "A", null, "B" (null appears once) + results.Should().HaveCount(3); + } + + [Fact] + public async Task SelectAlias_SameAsExistingField_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var results = await RunQuery("SELECT c.name AS id FROM c"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task SelectValue_ObjectLiteral_WithUndefinedField_OmitsUndefinedProperty() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var results = await RunQuery( + "SELECT VALUE {'name': c.name, 'missing': c.nonexistent} FROM c"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + // undefined property should be omitted from the object, or set to null + // Cosmos DB omits undefined properties from object literals + } } // Phase 14 (Computed Properties) — already covered in ComputedPropertyTests.cs @@ -13385,1067 +13395,1067 @@ await _container.CreateItemStreamAsync( // ── Phase 1: NULL Input to Functions ── public class QueryDeepDiveV8_NullInputTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Null_Upper_ReturnsUndefined() - { - // Cosmos DB: UPPER(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE UPPER(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_Lower_ReturnsUndefined() - { - // Cosmos DB: LOWER(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE LOWER(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_Length_ReturnsUndefined() - { - // Cosmos DB: LENGTH(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE LENGTH(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_Reverse_ReturnsUndefined() - { - // Cosmos DB: REVERSE(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE REVERSE(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_Trim_ReturnsUndefined() - { - // Cosmos DB: TRIM(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE TRIM(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_LTrim_ReturnsUndefined() - { - // Cosmos DB: LTRIM(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE LTRIM(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_RTrim_ReturnsUndefined() - { - // Cosmos DB: RTRIM(null) → undefined (no result row) - var results = await RunQuery("SELECT VALUE RTRIM(null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_Contains_FirstArg_ReturnsUndefined() - { - // Cosmos DB: CONTAINS(null, 'x') → undefined (no result row) - var results = await RunQuery("SELECT VALUE CONTAINS(null, 'x') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_StartsWith_FirstArg_ReturnsUndefined() - { - // Cosmos DB: STARTSWITH(null, 'x') → undefined (no result row) - var results = await RunQuery("SELECT VALUE STARTSWITH(null, 'x') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_EndsWith_FirstArg_ReturnsUndefined() - { - // Cosmos DB: ENDSWITH(null, 'x') → undefined (no result row) - var results = await RunQuery("SELECT VALUE ENDSWITH(null, 'x') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Null_IndexOf_FirstArg_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE INDEX_OF(null, 'x') FROM c"); - results.Should().BeEmpty("INDEX_OF with null returns undefined, which is omitted from SELECT VALUE"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Null_Upper_ReturnsUndefined() + { + // Cosmos DB: UPPER(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE UPPER(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_Lower_ReturnsUndefined() + { + // Cosmos DB: LOWER(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE LOWER(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_Length_ReturnsUndefined() + { + // Cosmos DB: LENGTH(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE LENGTH(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_Reverse_ReturnsUndefined() + { + // Cosmos DB: REVERSE(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE REVERSE(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_Trim_ReturnsUndefined() + { + // Cosmos DB: TRIM(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE TRIM(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_LTrim_ReturnsUndefined() + { + // Cosmos DB: LTRIM(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE LTRIM(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_RTrim_ReturnsUndefined() + { + // Cosmos DB: RTRIM(null) → undefined (no result row) + var results = await RunQuery("SELECT VALUE RTRIM(null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_Contains_FirstArg_ReturnsUndefined() + { + // Cosmos DB: CONTAINS(null, 'x') → undefined (no result row) + var results = await RunQuery("SELECT VALUE CONTAINS(null, 'x') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_StartsWith_FirstArg_ReturnsUndefined() + { + // Cosmos DB: STARTSWITH(null, 'x') → undefined (no result row) + var results = await RunQuery("SELECT VALUE STARTSWITH(null, 'x') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_EndsWith_FirstArg_ReturnsUndefined() + { + // Cosmos DB: ENDSWITH(null, 'x') → undefined (no result row) + var results = await RunQuery("SELECT VALUE ENDSWITH(null, 'x') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Null_IndexOf_FirstArg_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE INDEX_OF(null, 'x') FROM c"); + results.Should().BeEmpty("INDEX_OF with null returns undefined, which is omitted from SELECT VALUE"); + } } // ── Phase 2: Undefined (Missing Field) Input to Functions ── public class QueryDeepDiveV8_UndefinedInputTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Undefined_Upper_ReturnsUndefined() - { - // SELECT VALUE on an undefined expression should omit the row (no result for that doc) - await Seed(); - var results = await RunQuery("SELECT VALUE UPPER(c.missing) FROM c"); - // Undefined VALUE results are omitted, so result count may be 0 - // or the function may return null. Either is acceptable. - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Lower_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LOWER(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Length_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LENGTH(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Reverse_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REVERSE(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Left_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LEFT(c.missing, 3) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Right_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE RIGHT(c.missing, 3) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Replace_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REPLACE(c.missing, 'a', 'b') FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Trim_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TRIM(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Contains_ReturnsFalse() - { - await Seed(); - // CONTAINS on an undefined field — should return false (not crash) - var results = await RunQuery("SELECT VALUE CONTAINS(c.missing, 'x') FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Undefined_Abs_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ABS(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Undefined_Upper_ReturnsUndefined() + { + // SELECT VALUE on an undefined expression should omit the row (no result for that doc) + await Seed(); + var results = await RunQuery("SELECT VALUE UPPER(c.missing) FROM c"); + // Undefined VALUE results are omitted, so result count may be 0 + // or the function may return null. Either is acceptable. + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Lower_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LOWER(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Length_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LENGTH(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Reverse_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REVERSE(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Left_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LEFT(c.missing, 3) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Right_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE RIGHT(c.missing, 3) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Replace_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REPLACE(c.missing, 'a', 'b') FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Trim_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TRIM(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Contains_ReturnsFalse() + { + await Seed(); + // CONTAINS on an undefined field — should return false (not crash) + var results = await RunQuery("SELECT VALUE CONTAINS(c.missing, 'x') FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Undefined_Abs_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ABS(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } } // ── Phase 3: Empty String Edge Cases ── public class QueryDeepDiveV8_EmptyStringTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task EmptyString_Upper_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE UPPER('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task EmptyString_Lower_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE LOWER('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task EmptyString_Reverse_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE REVERSE('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task EmptyString_Length_ReturnsZero() - { - var results = await RunQuery("SELECT VALUE LENGTH('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task EmptyString_Trim_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE TRIM('') FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task EmptyString_Contains_SearchEmpty_ReturnsTrue() - { - var results = await RunQuery("SELECT VALUE CONTAINS('hello', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task EmptyString_StartsWith_EmptyPrefix_ReturnsTrue() - { - var results = await RunQuery("SELECT VALUE STARTSWITH('hello', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task EmptyString_EndsWith_EmptySuffix_ReturnsTrue() - { - var results = await RunQuery("SELECT VALUE ENDSWITH('hello', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task EmptyString_Upper_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE UPPER('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task EmptyString_Lower_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE LOWER('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task EmptyString_Reverse_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE REVERSE('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task EmptyString_Length_ReturnsZero() + { + var results = await RunQuery("SELECT VALUE LENGTH('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task EmptyString_Trim_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE TRIM('') FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task EmptyString_Contains_SearchEmpty_ReturnsTrue() + { + var results = await RunQuery("SELECT VALUE CONTAINS('hello', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task EmptyString_StartsWith_EmptyPrefix_ReturnsTrue() + { + var results = await RunQuery("SELECT VALUE STARTSWITH('hello', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task EmptyString_EndsWith_EmptySuffix_ReturnsTrue() + { + var results = await RunQuery("SELECT VALUE ENDSWITH('hello', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } } // ── Phase 4: Type Mismatch Inputs ── public class QueryDeepDiveV8_TypeMismatchTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":42}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task TypeMismatch_Upper_WithNumber_ReturnsUndefined() - { - // UPPER on a number — returns undefined in real Cosmos DB (requires string arg) - var results = await RunQuery("SELECT VALUE UPPER(123) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task TypeMismatch_Abs_WithString_ReturnsUndefined() - { - // ABS('hello') — string to math function, should return undefined - var results = await RunQuery("SELECT VALUE ABS('hello') FROM c"); - // Should be undefined (omitted from VALUE results) or null - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task TypeMismatch_Floor_WithString_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE FLOOR('abc') FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task TypeMismatch_Sqrt_WithString_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE SQRT('abc') FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task TypeMismatch_Length_WithNumber_ReturnsUndefined() - { - // LENGTH(12345) — in Cosmos, LENGTH only works on strings; non-string → undefined - var results = await RunQuery("SELECT VALUE LENGTH(12345) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task TypeMismatch_Left_WithNonNumericCount_ReturnsUndefined() - { - // LEFT('hello', 'abc') — non-numeric count → undefined - var results = await RunQuery("SELECT VALUE LEFT('hello', 'abc') FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":42}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task TypeMismatch_Upper_WithNumber_ReturnsUndefined() + { + // UPPER on a number — returns undefined in real Cosmos DB (requires string arg) + var results = await RunQuery("SELECT VALUE UPPER(123) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task TypeMismatch_Abs_WithString_ReturnsUndefined() + { + // ABS('hello') — string to math function, should return undefined + var results = await RunQuery("SELECT VALUE ABS('hello') FROM c"); + // Should be undefined (omitted from VALUE results) or null + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task TypeMismatch_Floor_WithString_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE FLOOR('abc') FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task TypeMismatch_Sqrt_WithString_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE SQRT('abc') FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task TypeMismatch_Length_WithNumber_ReturnsUndefined() + { + // LENGTH(12345) — in Cosmos, LENGTH only works on strings; non-string → undefined + var results = await RunQuery("SELECT VALUE LENGTH(12345) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task TypeMismatch_Left_WithNonNumericCount_ReturnsUndefined() + { + // LEFT('hello', 'abc') — non-numeric count → undefined + var results = await RunQuery("SELECT VALUE LEFT('hello', 'abc') FROM c"); + results.Should().BeEmpty(); + } } // ── Phase 5: Negative Index/Count Edge Cases ── public class QueryDeepDiveV8_NegativeIndexTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Substring_NegativeStart_ClampsToZero() - { - // SUBSTRING('hello', -1, 3) — negative start should clamp to 0 - var results = await RunQuery("SELECT VALUE SUBSTRING('hello', -1, 3) FROM c"); - results.Should().ContainSingle(); - // With clamping: SUBSTRING('hello', 0, 3) = "hel" - results[0].Value().Should().Be("hel"); - } - - [Fact] - public async Task Substring_NegativeLength_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, -1) FROM c"); - results.Should().ContainSingle(); - // Negative length should return empty string or clamp - results[0].Value().Should().BeEmpty(); - } - - [Fact] - public async Task Left_NegativeCount_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE LEFT('hello', -1) FROM c"); - // Negative count returns undefined, which is omitted by SELECT VALUE - results.Should().BeEmpty(); - } - - [Fact] - public async Task Right_NegativeCount_ReturnsUndefined() - { - // RIGHT('hello', -1) — negative count returns undefined - var results = await RunQuery("SELECT VALUE RIGHT('hello', -1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Replicate_NegativeCount_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); - // undefined excluded by SELECT VALUE - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Substring_NegativeStart_ClampsToZero() + { + // SUBSTRING('hello', -1, 3) — negative start should clamp to 0 + var results = await RunQuery("SELECT VALUE SUBSTRING('hello', -1, 3) FROM c"); + results.Should().ContainSingle(); + // With clamping: SUBSTRING('hello', 0, 3) = "hel" + results[0].Value().Should().Be("hel"); + } + + [Fact] + public async Task Substring_NegativeLength_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, -1) FROM c"); + results.Should().ContainSingle(); + // Negative length should return empty string or clamp + results[0].Value().Should().BeEmpty(); + } + + [Fact] + public async Task Left_NegativeCount_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE LEFT('hello', -1) FROM c"); + // Negative count returns undefined, which is omitted by SELECT VALUE + results.Should().BeEmpty(); + } + + [Fact] + public async Task Right_NegativeCount_ReturnsUndefined() + { + // RIGHT('hello', -1) — negative count returns undefined + var results = await RunQuery("SELECT VALUE RIGHT('hello', -1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Replicate_NegativeCount_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); + // undefined excluded by SELECT VALUE + results.Should().BeEmpty(); + } } // ── Phase 6: WHERE Clause Boundary Conditions ── public class QueryDeepDiveV8_WhereBoundaryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob","val":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Where_LiteralTrue_ReturnsAllDocs() - { - await Seed(); - var results = await RunQuery("SELECT * FROM c WHERE true"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Where_LiteralFalse_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT * FROM c WHERE false"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_LiteralNull_ReturnsEmpty() - { - await Seed(); - // WHERE null — null is not truthy, so no results - var results = await RunQuery("SELECT * FROM c WHERE null"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_UndefinedField_ReturnsEmpty() - { - await Seed(); - // WHERE c.nonexistent — undefined is not truthy - var results = await RunQuery("SELECT * FROM c WHERE c.nonexistent"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob","val":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Where_LiteralTrue_ReturnsAllDocs() + { + await Seed(); + var results = await RunQuery("SELECT * FROM c WHERE true"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Where_LiteralFalse_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT * FROM c WHERE false"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_LiteralNull_ReturnsEmpty() + { + await Seed(); + // WHERE null — null is not truthy, so no results + var results = await RunQuery("SELECT * FROM c WHERE null"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_UndefinedField_ReturnsEmpty() + { + await Seed(); + // WHERE c.nonexistent — undefined is not truthy + var results = await RunQuery("SELECT * FROM c WHERE c.nonexistent"); + results.Should().BeEmpty(); + } } // ── Phase 7: IN Operator Edge Cases ── public class QueryDeepDiveV8_InOperatorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1,"nullable":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":"1","nullable":"a"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":true}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a","val":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task In_MixedTypes_MatchesCorrectType() - { - await Seed(); - // Only exact type matches: 1 (number) matches id=1, '1' (string) matches id=2, true (bool) matches id=3 - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, '1', true)"); - results.Should().HaveCount(3); - } - - [Fact] - public async Task NotIn_ExcludesMatchedValues() - { - await Seed(); - // NOT IN should exclude val=10 and val=20 - var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 20)"); - // Docs with val=1, val='1', val=true remain - results.Should().HaveCount(3); - } - - [Fact] - public async Task In_WithNull_MatchesNullValue() - { - await Seed(); - // IN with null should match documents where nullable IS null - var results = await RunQuery("SELECT * FROM c WHERE c.nullable IN (null, 'a')"); - // id=1 has nullable=null, id=2 has nullable='a' - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1,"nullable":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":"1","nullable":"a"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":true}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a","val":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task In_MixedTypes_MatchesCorrectType() + { + await Seed(); + // Only exact type matches: 1 (number) matches id=1, '1' (string) matches id=2, true (bool) matches id=3 + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (1, '1', true)"); + results.Should().HaveCount(3); + } + + [Fact] + public async Task NotIn_ExcludesMatchedValues() + { + await Seed(); + // NOT IN should exclude val=10 and val=20 + var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 20)"); + // Docs with val=1, val='1', val=true remain + results.Should().HaveCount(3); + } + + [Fact] + public async Task In_WithNull_MatchesNullValue() + { + await Seed(); + // IN with null should match documents where nullable IS null + var results = await RunQuery("SELECT * FROM c WHERE c.nullable IN (null, 'a')"); + // id=1 has nullable=null, id=2 has nullable='a' + results.Should().HaveCount(2); + } } // ── Phase 8: BETWEEN Edge Cases ── public class QueryDeepDiveV8_BetweenTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob","val":20}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Charlie","val":30}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","name":"Diana","val":40}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Between_Strings_WorksLexicographically() - { - await Seed(); - // BETWEEN with strings — lexicographic comparison - var results = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'C'"); - // "Alice" >= 'A' and <= 'C': "Alice" > "A", "Alice" < "C"? - // Lex: "Alice" > "A" and "Alice" < "C" — yes, and "Bob" > "A" and < "C" — yes - // "Charlie" starts with "C" but "Charlie" > "C" lex — so BETWEEN is inclusive: "C" <= "Charlie"? No, "Charlie" > "C" - // So only Alice and Bob should match - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().Contain("Alice").And.Contain("Bob"); - } - - [Fact] - public async Task Between_WithNullLowerBound_MatchesValuesUpToUpperBound() - { - await Seed(); - // BETWEEN null AND 10 — null has rank 1, numbers have rank 3. - // CompareValues treats null < numbers, so val >= null is true for all numbers. - // Only val <= 10 filters to val=10. - // Note: Real Cosmos DB may differ — null in BETWEEN may return undefined. - // The emulator's type-rank-based comparison allows this to work. - var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN null AND 10"); - results.Should().ContainSingle(); - results[0]["val"]!.Value().Should().Be(10); - } - - [Fact] - public async Task NotBetween_ExcludesRange() - { - await Seed(); - // NOT BETWEEN 20 AND 40 — excludes val=20, val=30, val=40 - var results = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 20 AND 40"); - results.Should().ContainSingle(); - results[0]["val"]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Bob","val":20}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Charlie","val":30}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","name":"Diana","val":40}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Between_Strings_WorksLexicographically() + { + await Seed(); + // BETWEEN with strings — lexicographic comparison + var results = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'C'"); + // "Alice" >= 'A' and <= 'C': "Alice" > "A", "Alice" < "C"? + // Lex: "Alice" > "A" and "Alice" < "C" — yes, and "Bob" > "A" and < "C" — yes + // "Charlie" starts with "C" but "Charlie" > "C" lex — so BETWEEN is inclusive: "C" <= "Charlie"? No, "Charlie" > "C" + // So only Alice and Bob should match + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().Contain("Alice").And.Contain("Bob"); + } + + [Fact] + public async Task Between_WithNullLowerBound_MatchesValuesUpToUpperBound() + { + await Seed(); + // BETWEEN null AND 10 — null has rank 1, numbers have rank 3. + // CompareValues treats null < numbers, so val >= null is true for all numbers. + // Only val <= 10 filters to val=10. + // Note: Real Cosmos DB may differ — null in BETWEEN may return undefined. + // The emulator's type-rank-based comparison allows this to work. + var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN null AND 10"); + results.Should().ContainSingle(); + results[0]["val"]!.Value().Should().Be(10); + } + + [Fact] + public async Task NotBetween_ExcludesRange() + { + await Seed(); + // NOT BETWEEN 20 AND 40 — excludes val=20, val=30, val=40 + var results = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 20 AND 40"); + results.Should().ContainSingle(); + results[0]["val"]!.Value().Should().Be(10); + } } // ── Phase 9: LIKE Special Characters ── public class QueryDeepDiveV8_LikeSpecialCharsTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"A.B"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"AXB"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"test[1]"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","name":"^test$"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_DotCharacter_TreatedAsLiteral() - { - await Seed(); - // In Cosmos SQL LIKE, '.' is a literal dot, not a regex wildcard - // So 'A.B' should match only "A.B", not "AXB" - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A.B'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("A.B"); - } - - [Fact] - public async Task Like_BracketCharacter_TreatedAsLiteral() - { - await Seed(); - // Brackets in LIKE should be literal (Cosmos doesn't support bracket character classes) - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'test[1]'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("test[1]"); - } - - [Fact] - public async Task Like_CaretDollar_TreatedAsLiteral() - { - await Seed(); - // ^ and $ are regex anchors but should be literal in LIKE - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '^test$'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("^test$"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"A.B"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"AXB"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"test[1]"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","name":"^test$"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_DotCharacter_TreatedAsLiteral() + { + await Seed(); + // In Cosmos SQL LIKE, '.' is a literal dot, not a regex wildcard + // So 'A.B' should match only "A.B", not "AXB" + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A.B'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("A.B"); + } + + [Fact] + public async Task Like_BracketCharacter_TreatedAsLiteral() + { + await Seed(); + // Brackets in LIKE should be literal (Cosmos doesn't support bracket character classes) + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'test[1]'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("test[1]"); + } + + [Fact] + public async Task Like_CaretDollar_TreatedAsLiteral() + { + await Seed(); + // ^ and $ are regex anchors but should be literal in LIKE + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE '^test$'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("^test$"); + } } // ── Phase 10: Nested Function Calls ── public class QueryDeepDiveV8_NestedFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","first":"John","last":"Doe"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NestedFunctions_UpperLower_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE UPPER(LOWER(c.name)) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ALICE"); - } - - [Fact] - public async Task NestedFunctions_AbsFloor_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ABS(FLOOR(-3.7)) FROM c"); - results.Should().ContainSingle(); - results[0].Value().Should().Be(4); - } - - [Fact] - public async Task NestedFunctions_ConcatUpper_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CONCAT(UPPER(c.first), ' ', UPPER(c.last)) FROM c"); - results.Should().ContainSingle().Which.Should().Be("JOHN DOE"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice","first":"John","last":"Doe"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NestedFunctions_UpperLower_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE UPPER(LOWER(c.name)) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ALICE"); + } + + [Fact] + public async Task NestedFunctions_AbsFloor_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ABS(FLOOR(-3.7)) FROM c"); + results.Should().ContainSingle(); + results[0].Value().Should().Be(4); + } + + [Fact] + public async Task NestedFunctions_ConcatUpper_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CONCAT(UPPER(c.first), ' ', UPPER(c.last)) FROM c"); + results.Should().ContainSingle().Which.Should().Be("JOHN DOE"); + } } // ── Phase 11: Multiple Aggregates in SELECT ── public class QueryDeepDiveV8_MultipleAggregatesTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","cat":"A","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","cat":"A","val":20}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","cat":"B","val":30}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task MultipleAggregates_AllInOneSelect() - { - await Seed(); - var results = await RunQuery( - "SELECT COUNT(1) as cnt, SUM(c.val) as total, AVG(c.val) as av, MIN(c.val) as mn, MAX(c.val) as mx FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(3); - results[0]["total"]!.Value().Should().Be(60); - results[0]["av"]!.Value().Should().Be(20); - results[0]["mn"]!.Value().Should().Be(10); - results[0]["mx"]!.Value().Should().Be(30); - } - - [Fact] - public async Task MultipleAggregates_WithGroupBy() - { - await Seed(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt, SUM(c.val) as total FROM c GROUP BY c.cat ORDER BY c.cat"); - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("A"); - results[0]["cnt"]!.Value().Should().Be(2); - results[0]["total"]!.Value().Should().Be(30); - results[1]["cat"]!.ToString().Should().Be("B"); - results[1]["cnt"]!.Value().Should().Be(1); - results[1]["total"]!.Value().Should().Be(30); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","cat":"A","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","cat":"A","val":20}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","cat":"B","val":30}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task MultipleAggregates_AllInOneSelect() + { + await Seed(); + var results = await RunQuery( + "SELECT COUNT(1) as cnt, SUM(c.val) as total, AVG(c.val) as av, MIN(c.val) as mn, MAX(c.val) as mx FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(3); + results[0]["total"]!.Value().Should().Be(60); + results[0]["av"]!.Value().Should().Be(20); + results[0]["mn"]!.Value().Should().Be(10); + results[0]["mx"]!.Value().Should().Be(30); + } + + [Fact] + public async Task MultipleAggregates_WithGroupBy() + { + await Seed(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt, SUM(c.val) as total FROM c GROUP BY c.cat ORDER BY c.cat"); + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("A"); + results[0]["cnt"]!.Value().Should().Be(2); + results[0]["total"]!.Value().Should().Be(30); + results[1]["cat"]!.ToString().Should().Be("B"); + results[1]["cnt"]!.Value().Should().Be(1); + results[1]["total"]!.Value().Should().Be(30); + } } // ── Phase 12: SELECT VALUE with Aggregates ── public class QueryDeepDiveV8_SelectValueAggregateTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":20}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":30}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_Count_ReturnsScalar() - { - await Seed(); - var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - results.Should().ContainSingle().Which.Should().Be(3); - } - - [Fact] - public async Task SelectValue_Sum_ReturnsScalar() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SUM(c.val) FROM c"); - results.Should().ContainSingle().Which.Should().Be(60); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":20}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":30}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_Count_ReturnsScalar() + { + await Seed(); + var results = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + results.Should().ContainSingle().Which.Should().Be(3); + } + + [Fact] + public async Task SelectValue_Sum_ReturnsScalar() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SUM(c.val) FROM c"); + results.Should().ContainSingle().Which.Should().Be(60); + } } // ── Phase 13: Arithmetic Overflow/Edge Cases ── public class QueryDeepDiveV8_ArithmeticEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Arithmetic_ModuloByZero_ReturnsUndefined() - { - // 10 % 0 should return undefined (like division by zero) - var results = await RunQuery("SELECT VALUE 10 % 0 FROM c"); - // Should be undefined (omitted) — v2.0.85 returns undefined for div-by-zero - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Arithmetic_VeryLargeMultiply_ReturnsUndefined() - { - // 1e308 * 2 = Infinity → should be undefined - var results = await RunQuery("SELECT VALUE 1e308 * 2 FROM c"); - // Infinity → undefined per v2.0.85 semantics - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Power_LargeExponent_ReturnsUndefined() - { - // POWER(10, 1000) = Infinity → undefined - var results = await RunQuery("SELECT VALUE POWER(10, 1000) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Arithmetic_IntegerOverflow_WrapsOrReturnsNull() - { - // IntAdd(9223372036854775807, 1) — max long + 1 - var results = await RunQuery("SELECT VALUE IntAdd(9223372036854775807, 1) FROM c"); - // Should wrap (long overflow in C#) or return null - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Arithmetic_ModuloByZero_ReturnsUndefined() + { + // 10 % 0 should return undefined (like division by zero) + var results = await RunQuery("SELECT VALUE 10 % 0 FROM c"); + // Should be undefined (omitted) — v2.0.85 returns undefined for div-by-zero + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Arithmetic_VeryLargeMultiply_ReturnsUndefined() + { + // 1e308 * 2 = Infinity → should be undefined + var results = await RunQuery("SELECT VALUE 1e308 * 2 FROM c"); + // Infinity → undefined per v2.0.85 semantics + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Power_LargeExponent_ReturnsUndefined() + { + // POWER(10, 1000) = Infinity → undefined + var results = await RunQuery("SELECT VALUE POWER(10, 1000) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Arithmetic_IntegerOverflow_WrapsOrReturnsNull() + { + // IntAdd(9223372036854775807, 1) — max long + 1 + var results = await RunQuery("SELECT VALUE IntAdd(9223372036854775807, 1) FROM c"); + // Should wrap (long overflow in C#) or return null + results.Should().ContainSingle(); + } } // ── Phase 14: String Concatenation (||) Edge Cases ── public class QueryDeepDiveV8_StringConcatTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Concat_EmptyWithEmpty_ReturnsEmpty() - { - var results = await RunQuery("SELECT VALUE '' || '' FROM c"); - results.Should().ContainSingle(); - results[0].Value().Should().Be(""); - } - - [Fact] - public async Task Concat_NullWithString_ReturnsUndefined() - { - // null || 'text' → undefined per Cosmos DB behaviour - var results = await RunQuery("SELECT VALUE null || 'text' FROM c"); - // Should be undefined (omitted from VALUE results) - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task Concat_StringWithNull_ReturnsUndefined() - { - var results = await RunQuery("SELECT VALUE 'text' || null FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Concat_EmptyWithEmpty_ReturnsEmpty() + { + var results = await RunQuery("SELECT VALUE '' || '' FROM c"); + results.Should().ContainSingle(); + results[0].Value().Should().Be(""); + } + + [Fact] + public async Task Concat_NullWithString_ReturnsUndefined() + { + // null || 'text' → undefined per Cosmos DB behaviour + var results = await RunQuery("SELECT VALUE null || 'text' FROM c"); + // Should be undefined (omitted from VALUE results) + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task Concat_StringWithNull_ReturnsUndefined() + { + var results = await RunQuery("SELECT VALUE 'text' || null FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } } // ── Phase 15: ARRAY_CONTAINS & ARRAY_LENGTH Edge Cases ── public class QueryDeepDiveV8_ArrayFunctionEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","tags":["a","b",null],"val":"not-array"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArrayContains_SearchForNull_Works() - { - await Seed(); - // tags contains null element - var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS(c.tags, null) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayLength_NonArray_ReturnsUndefined() - { - await Seed(); - // ARRAY_LENGTH on a non-array value returns undefined (excluded from SELECT VALUE results) - var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.val) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayLength_MissingField_ReturnsNull() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.missing) FROM c"); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - [Fact] - public async Task ArrayContains_NullArray_ReturnsFalse() - { - await Seed(); - // ARRAY_CONTAINS on a null value should return false - var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS(null, 'x') FROM c"); - results.Should().ContainSingle(); - results[0].Value().Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","tags":["a","b",null],"val":"not-array"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArrayContains_SearchForNull_Works() + { + await Seed(); + // tags contains null element + var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS(c.tags, null) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayLength_NonArray_ReturnsUndefined() + { + await Seed(); + // ARRAY_LENGTH on a non-array value returns undefined (excluded from SELECT VALUE results) + var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.val) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayLength_MissingField_ReturnsNull() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.missing) FROM c"); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + [Fact] + public async Task ArrayContains_NullArray_ReturnsFalse() + { + await Seed(); + // ARRAY_CONTAINS on a null value should return false + var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS(null, 'x') FROM c"); + results.Should().ContainSingle(); + results[0].Value().Should().BeFalse(); + } } // ── Phase 16: Type-Check Function Edge Cases ── public class QueryDeepDiveV8_TypeCheckTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","nullField":null,"tags":["a","b"]}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsNull_UndefinedField_ReturnsFalse() - { - await Seed(); - // IS_NULL on undefined (missing) field should be false — undefined ≠ null - var results = await RunQuery("SELECT VALUE IS_NULL(c.missingField) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsDefined_NullField_ReturnsTrue() - { - await Seed(); - // Null field is defined (it exists with value null) - var results = await RunQuery("SELECT VALUE IS_DEFINED(c.nullField) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsPrimitive_Null_ReturnsTrue() - { - await Seed(); - // null is a primitive value - var results = await RunQuery("SELECT VALUE IS_PRIMITIVE(null) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsPrimitive_Array_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_PRIMITIVE(c.tags) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","nullField":null,"tags":["a","b"]}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsNull_UndefinedField_ReturnsFalse() + { + await Seed(); + // IS_NULL on undefined (missing) field should be false — undefined ≠ null + var results = await RunQuery("SELECT VALUE IS_NULL(c.missingField) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsDefined_NullField_ReturnsTrue() + { + await Seed(); + // Null field is defined (it exists with value null) + var results = await RunQuery("SELECT VALUE IS_DEFINED(c.nullField) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsPrimitive_Null_ReturnsTrue() + { + await Seed(); + // null is a primitive value + var results = await RunQuery("SELECT VALUE IS_PRIMITIVE(null) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsPrimitive_Array_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_PRIMITIVE(c.tags) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } } // ── Phase 17: ORDER BY + GROUP BY Interaction ── public class QueryDeepDiveV8_GroupByOrderByTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","cat":"A","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","cat":"A","val":20}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","cat":"B","val":30}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","cat":"B","val":40}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a","cat":"C","val":5}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_OrderByAggregate_DESC() - { - await Seed(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - results.Should().HaveCount(3); - // A:2, B:2, C:1 descending by count - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cnt"]!.Value().Should().Be(2); - results[2]["cnt"]!.Value().Should().Be(1); - results[2]["cat"]!.ToString().Should().Be("C"); - } - - [Fact] - public async Task GroupBy_Having_OrderBy_Combined() - { - await Seed(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 1 ORDER BY c.cat ASC"); - // Only A and B have count > 1 - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("A"); - results[1]["cat"]!.ToString().Should().Be("B"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","cat":"A","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","cat":"A","val":20}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","cat":"B","val":30}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","cat":"B","val":40}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"5","pk":"a","cat":"C","val":5}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_OrderByAggregate_DESC() + { + await Seed(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + results.Should().HaveCount(3); + // A:2, B:2, C:1 descending by count + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cnt"]!.Value().Should().Be(2); + results[2]["cnt"]!.Value().Should().Be(1); + results[2]["cat"]!.ToString().Should().Be("C"); + } + + [Fact] + public async Task GroupBy_Having_OrderBy_Combined() + { + await Seed(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 1 ORDER BY c.cat ASC"); + // Only A and B have count > 1 + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("A"); + results[1]["cat"]!.ToString().Should().Be("B"); + } } // ═══════════════════════════════════════════════════════════════════ @@ -14455,1308 +14465,1308 @@ public async Task GroupBy_Having_OrderBy_Combined() // ── Batch 1: Operator Coverage Gaps ── public class QueryDeepDiveV9_OperatorCoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"tags":["x","y"]}""", - """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"tags":["y","z"]}""", - """{"id":"3","pk":"a","name":"Charlie","value":30,"isActive":true,"tags":["x"]}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NotEquals_DiamondOperator_Works() - { - await Seed(); - // <> operator has zero prior test coverage but IS parsed by tokenizer - var results = await RunQuery("SELECT * FROM c WHERE c.value <> 20"); - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task NotEquals_DiamondOperator_WithStrings() - { - await Seed(); - var results = await RunQuery("SELECT * FROM c WHERE c.name <> 'Bob'"); - results.Should().HaveCount(2); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); - } - - [Fact] - public async Task NotEquals_DiamondOperator_MatchesBangEquals() - { - await Seed(); - var diamond = await RunQuery("SELECT * FROM c WHERE c.value <> 10 ORDER BY c.value"); - var bang = await RunQuery("SELECT * FROM c WHERE c.value != 10 ORDER BY c.value"); - diamond.Should().HaveCount(bang.Count); - for (int i = 0; i < diamond.Count; i++) - diamond[i]["id"]!.ToString().Should().Be(bang[i]["id"]!.ToString()); - } - - [Fact] - public async Task SelectValue_False_ReturnsAllFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE false FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().BeFalse()); - } - - [Fact] - public async Task SelectValue_Integer_ReturnsConstants() - { - await Seed(); - var results = await RunQuery("SELECT VALUE 42 FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().Be(42)); - } - - [Fact] - public async Task SelectValue_EmptyString_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE '' FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().Be("")); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"tags":["x","y"]}""", + """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"tags":["y","z"]}""", + """{"id":"3","pk":"a","name":"Charlie","value":30,"isActive":true,"tags":["x"]}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NotEquals_DiamondOperator_Works() + { + await Seed(); + // <> operator has zero prior test coverage but IS parsed by tokenizer + var results = await RunQuery("SELECT * FROM c WHERE c.value <> 20"); + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task NotEquals_DiamondOperator_WithStrings() + { + await Seed(); + var results = await RunQuery("SELECT * FROM c WHERE c.name <> 'Bob'"); + results.Should().HaveCount(2); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Charlie"); + } + + [Fact] + public async Task NotEquals_DiamondOperator_MatchesBangEquals() + { + await Seed(); + var diamond = await RunQuery("SELECT * FROM c WHERE c.value <> 10 ORDER BY c.value"); + var bang = await RunQuery("SELECT * FROM c WHERE c.value != 10 ORDER BY c.value"); + diamond.Should().HaveCount(bang.Count); + for (int i = 0; i < diamond.Count; i++) + diamond[i]["id"]!.ToString().Should().Be(bang[i]["id"]!.ToString()); + } + + [Fact] + public async Task SelectValue_False_ReturnsAllFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE false FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().BeFalse()); + } + + [Fact] + public async Task SelectValue_Integer_ReturnsConstants() + { + await Seed(); + var results = await RunQuery("SELECT VALUE 42 FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().Be(42)); + } + + [Fact] + public async Task SelectValue_EmptyString_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE '' FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().Be("")); + } } // ── Batch 2: Null Coalescing & Ternary Edge Cases ── public class QueryDeepDiveV9_CoalesceTernaryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - // Item with nickname, item without nickname, item with null nickname - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","nickname":"Ali"}""", - """{"id":"2","pk":"a","name":"Bob"}""", - """{"id":"3","pk":"a","name":"Charlie","nickname":null}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Coalesce_ChainedThreeArgs_ReturnsFirstNonNull() - { - await Seed(); - // For Alice: nickname='Ali' (returned). For Bob: nickname=undefined, name='Bob' (returned). - // For Charlie: nickname=null → skip, name='Charlie' (returned). - var results = await RunQuery( - "SELECT VALUE c.nickname ?? c.name ?? 'unknown' FROM c ORDER BY c.name"); - results.Should().HaveCount(3); - results[0].Should().Be("Ali"); // Alice: nickname exists - results[1].Should().Be("Bob"); // Bob: nickname undefined, falls to name - results[2].Should().Be("Charlie"); // Charlie: nickname null, falls to name - } - - [Fact] - public async Task Coalesce_AllNull_ReturnsNull() - { - await Seed(); - var results = await RunQuery("SELECT VALUE null ?? null ?? null FROM c"); - results.Should().HaveCount(3); - // null ?? null ?? null = null - results.Should().AllSatisfy(r => r.Type.Should().Be(JTokenType.Null)); - } - - [Fact] - public async Task Coalesce_UndefinedField_SkipsToNext() - { - await Seed(); - // c.nonExistent is undefined for all items → falls through to 'fallback' - var results = await RunQuery( - "SELECT VALUE c.nonExistent ?? 'fallback' FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().Be("fallback")); - } - - [Fact] - public async Task Ternary_UndefinedCondition_ReturnsFalseBranch() - { - await Seed(); - // c.nonExistent is undefined → falsy → false branch - var results = await RunQuery( - "SELECT VALUE (c.nonExistent ? 'yes' : 'no') FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - [Fact] - public async Task Ternary_NullCondition_ReturnsFalseBranch() - { - await Seed(); - // null is falsy → false branch - var results = await RunQuery( - "SELECT VALUE (null ? 'yes' : 'no') FROM c"); - results.Should().HaveCount(3); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - [Fact] - public async Task Ternary_NestedInSelect_Works() - { - await Seed(); - // Nested: if value > 20 then 'high', else if value > 10 then 'mid', else 'low' - var container2 = new InMemoryContainer("ternary-nest", "/pk"); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":5}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":15}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":25}""")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT VALUE (c.val > 20 ? 'high' : (c.val > 10 ? 'mid' : 'low')) FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().Equal("low", "mid", "high"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + // Item with nickname, item without nickname, item with null nickname + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","nickname":"Ali"}""", + """{"id":"2","pk":"a","name":"Bob"}""", + """{"id":"3","pk":"a","name":"Charlie","nickname":null}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Coalesce_ChainedThreeArgs_ReturnsFirstNonNull() + { + await Seed(); + // For Alice: nickname='Ali' (returned). For Bob: nickname=undefined, name='Bob' (returned). + // For Charlie: nickname=null → skip, name='Charlie' (returned). + var results = await RunQuery( + "SELECT VALUE c.nickname ?? c.name ?? 'unknown' FROM c ORDER BY c.name"); + results.Should().HaveCount(3); + results[0].Should().Be("Ali"); // Alice: nickname exists + results[1].Should().Be("Bob"); // Bob: nickname undefined, falls to name + results[2].Should().Be("Charlie"); // Charlie: nickname null, falls to name + } + + [Fact] + public async Task Coalesce_AllNull_ReturnsNull() + { + await Seed(); + var results = await RunQuery("SELECT VALUE null ?? null ?? null FROM c"); + results.Should().HaveCount(3); + // null ?? null ?? null = null + results.Should().AllSatisfy(r => r.Type.Should().Be(JTokenType.Null)); + } + + [Fact] + public async Task Coalesce_UndefinedField_SkipsToNext() + { + await Seed(); + // c.nonExistent is undefined for all items → falls through to 'fallback' + var results = await RunQuery( + "SELECT VALUE c.nonExistent ?? 'fallback' FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().Be("fallback")); + } + + [Fact] + public async Task Ternary_UndefinedCondition_ReturnsFalseBranch() + { + await Seed(); + // c.nonExistent is undefined → falsy → false branch + var results = await RunQuery( + "SELECT VALUE (c.nonExistent ? 'yes' : 'no') FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + [Fact] + public async Task Ternary_NullCondition_ReturnsFalseBranch() + { + await Seed(); + // null is falsy → false branch + var results = await RunQuery( + "SELECT VALUE (null ? 'yes' : 'no') FROM c"); + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + [Fact] + public async Task Ternary_NestedInSelect_Works() + { + await Seed(); + // Nested: if value > 20 then 'high', else if value > 10 then 'mid', else 'low' + var container2 = new InMemoryContainer("ternary-nest", "/pk"); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":5}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":15}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":25}""")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT VALUE (c.val > 20 ? 'high' : (c.val > 10 ? 'mid' : 'low')) FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().Equal("low", "mid", "high"); + } } // ── Batch 3: String Function Edge Cases ── public class QueryDeepDiveV9_StringFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Hello World","val":42,"arr":[1,2,3]}""", - """{"id":"2","pk":"a","name":"test","val":3.14}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToString_Integer_ReturnsString() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TOSTRING(c.val) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Should().Be("42"); - } - - [Fact] - public async Task ToString_Float_ReturnsString() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TOSTRING(c.val) FROM c WHERE c.id = '2'"); - results.Should().ContainSingle().Which.Should().Be("3.14"); - } - - [Fact] - public async Task ToString_Null_ReturnsNull() - { - await Seed(); - // TOSTRING(null) should return undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE TOSTRING(null) FROM c WHERE c.id = '1'"); - // Undefined values are excluded from VALUE results - results.Should().BeEmpty(); - } - - [Fact] - public async Task ToString_Array_ReturnsJsonString() - { - await Seed(); - // TOSTRING(array) → JSON string representation per Microsoft docs - var results = await RunQuery("SELECT VALUE TOSTRING(c.arr) FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Value().Should().Be("[1,2,3]"); - } - - [Fact] - public async Task RegexMatch_NoMatch_ReturnsFalse() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE RegexMatch(c.name, '^zzz')"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task RegexMatch_CaseInsensitiveFlag() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE RegexMatch(c.name, '^hello', 'i')"); - results.Should().ContainSingle() - .Which["name"]!.ToString().Should().Be("Hello World"); - } - - [Fact] - public async Task StringEquals_CaseInsensitive_WithFlag() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE STRINGEQUALS(c.name, 'hello world', true)"); - results.Should().ContainSingle() - .Which["name"]!.ToString().Should().Be("Hello World"); - } - - [Fact] - public async Task Concat_EmptyStringArgs() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CONCAT('', '', '') FROM c WHERE c.id = '1'"); - results.Should().ContainSingle().Which.Should().Be(""); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Hello World","val":42,"arr":[1,2,3]}""", + """{"id":"2","pk":"a","name":"test","val":3.14}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToString_Integer_ReturnsString() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TOSTRING(c.val) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Should().Be("42"); + } + + [Fact] + public async Task ToString_Float_ReturnsString() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TOSTRING(c.val) FROM c WHERE c.id = '2'"); + results.Should().ContainSingle().Which.Should().Be("3.14"); + } + + [Fact] + public async Task ToString_Null_ReturnsNull() + { + await Seed(); + // TOSTRING(null) should return undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE TOSTRING(null) FROM c WHERE c.id = '1'"); + // Undefined values are excluded from VALUE results + results.Should().BeEmpty(); + } + + [Fact] + public async Task ToString_Array_ReturnsJsonString() + { + await Seed(); + // TOSTRING(array) → JSON string representation per Microsoft docs + var results = await RunQuery("SELECT VALUE TOSTRING(c.arr) FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Value().Should().Be("[1,2,3]"); + } + + [Fact] + public async Task RegexMatch_NoMatch_ReturnsFalse() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE RegexMatch(c.name, '^zzz')"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task RegexMatch_CaseInsensitiveFlag() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE RegexMatch(c.name, '^hello', 'i')"); + results.Should().ContainSingle() + .Which["name"]!.ToString().Should().Be("Hello World"); + } + + [Fact] + public async Task StringEquals_CaseInsensitive_WithFlag() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE STRINGEQUALS(c.name, 'hello world', true)"); + results.Should().ContainSingle() + .Which["name"]!.ToString().Should().Be("Hello World"); + } + + [Fact] + public async Task Concat_EmptyStringArgs() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CONCAT('', '', '') FROM c WHERE c.id = '1'"); + results.Should().ContainSingle().Which.Should().Be(""); + } } // ── Batch 4: Aggregate Edge Cases ── public class QueryDeepDiveV9_AggregateTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","val":10,"optField":"x"}""", - """{"id":"2","pk":"a","val":10,"optField":null}""", - """{"id":"3","pk":"a","val":30}""", - """{"id":"4","pk":"a","val":40,"optField":"y"}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Count_Star_VsCount1_Equivalent() - { - await Seed(); - var star = await RunQuery("SELECT VALUE COUNT(*) FROM c"); - var one = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - star.Should().ContainSingle().Which.Should().Be(4); - one.Should().ContainSingle().Which.Should().Be(4); - star[0].Should().Be(one[0]); - } - - [Fact] - public async Task Sum_WithBooleans_IgnoresBooleans() - { - await Seed(); - // SUM on a field containing mixed types — booleans should be ignored - var container2 = new InMemoryContainer("sum-bool", "/pk"); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","v":10}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","v":true}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","v":20}""")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT VALUE SUM(c.v) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - // Cosmos DB SUM ignores non-numeric types, so only 10+20=30 - results.Should().ContainSingle().Which.Value().Should().Be(30); - } - - [Fact] - public async Task Avg_SingleItem_ReturnsSameValue() - { - var container2 = new InMemoryContainer("avg-single", "/pk"); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":42}""")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT VALUE AVG(c.val) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(42.0); - } - - [Fact] - public async Task Min_AllSameValue_ReturnsThatValue() - { - var container2 = new InMemoryContainer("min-same", "/pk"); - for (int i = 1; i <= 3; i++) - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""{i}"",""pk"":""a"",""val"":7}}")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT VALUE MIN(c.val) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be(7); - } - - [Fact] - public async Task Max_WithNulls_IgnoresNulls() - { - await Seed(); - // val: 10, 10, 30, 40 — optField has nulls but val doesn't - // But let's test MAX on a field with null values - var results = await RunQuery( - "SELECT VALUE MAX(c.optField) FROM c"); - // optField: "x", null, undefined, "y" — MAX of strings: "y" (null/undefined excluded) - results.Should().ContainSingle().Which.Value().Should().Be("y"); - } - - [Fact] - public async Task CountField_ExcludesUndefinedAndNull() - { - await Seed(); - // optField: "x" (id=1), null (id=2), undefined (id=3), "y" (id=4) - // COUNT(c.optField) should exclude BOTH null AND undefined = only "x" and "y" = 2 - // Note: This is Cosmos DB behavior — COUNT(field) counts defined non-null values - var results = await RunQuery("SELECT VALUE COUNT(c.optField) FROM c"); - results.Should().ContainSingle().Which.Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","val":10,"optField":"x"}""", + """{"id":"2","pk":"a","val":10,"optField":null}""", + """{"id":"3","pk":"a","val":30}""", + """{"id":"4","pk":"a","val":40,"optField":"y"}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Count_Star_VsCount1_Equivalent() + { + await Seed(); + var star = await RunQuery("SELECT VALUE COUNT(*) FROM c"); + var one = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + star.Should().ContainSingle().Which.Should().Be(4); + one.Should().ContainSingle().Which.Should().Be(4); + star[0].Should().Be(one[0]); + } + + [Fact] + public async Task Sum_WithBooleans_IgnoresBooleans() + { + await Seed(); + // SUM on a field containing mixed types — booleans should be ignored + var container2 = new InMemoryContainer("sum-bool", "/pk"); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","v":10}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","v":true}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","v":20}""")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT VALUE SUM(c.v) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + // Cosmos DB SUM ignores non-numeric types, so only 10+20=30 + results.Should().ContainSingle().Which.Value().Should().Be(30); + } + + [Fact] + public async Task Avg_SingleItem_ReturnsSameValue() + { + var container2 = new InMemoryContainer("avg-single", "/pk"); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":42}""")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT VALUE AVG(c.val) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(42.0); + } + + [Fact] + public async Task Min_AllSameValue_ReturnsThatValue() + { + var container2 = new InMemoryContainer("min-same", "/pk"); + for (int i = 1; i <= 3; i++) + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($@"{{""id"":""{i}"",""pk"":""a"",""val"":7}}")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT VALUE MIN(c.val) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be(7); + } + + [Fact] + public async Task Max_WithNulls_IgnoresNulls() + { + await Seed(); + // val: 10, 10, 30, 40 — optField has nulls but val doesn't + // But let's test MAX on a field with null values + var results = await RunQuery( + "SELECT VALUE MAX(c.optField) FROM c"); + // optField: "x", null, undefined, "y" — MAX of strings: "y" (null/undefined excluded) + results.Should().ContainSingle().Which.Value().Should().Be("y"); + } + + [Fact] + public async Task CountField_ExcludesUndefinedAndNull() + { + await Seed(); + // optField: "x" (id=1), null (id=2), undefined (id=3), "y" (id=4) + // COUNT(c.optField) should exclude BOTH null AND undefined = only "x" and "y" = 2 + // Note: This is Cosmos DB behavior — COUNT(field) counts defined non-null values + var results = await RunQuery("SELECT VALUE COUNT(c.optField) FROM c"); + results.Should().ContainSingle().Which.Should().Be(2); + } } // ── Batch 5: GROUP BY Advanced Edge Cases ── public class QueryDeepDiveV9_GroupByAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","cat":"A","active":true,"val":10,"name":"alice"}""", - """{"id":"2","pk":"a","cat":"A","active":false,"val":20,"name":"bob"}""", - """{"id":"3","pk":"a","cat":"B","active":true,"val":30,"name":"Charlie"}""", - """{"id":"4","pk":"a","cat":"B","active":true,"val":40,"name":"Dave"}""", - """{"id":"5","pk":"a","cat":"C","active":false,"val":5,"name":"eve"}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_BooleanField_GroupsCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT c.active, COUNT(1) as cnt FROM c GROUP BY c.active ORDER BY c.active"); - results.Should().HaveCount(2); - // false group: count=2, true group: count=3 - results[0]["active"]!.Value().Should().BeFalse(); - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["active"]!.Value().Should().BeTrue(); - results[1]["cnt"]!.Value().Should().Be(3); - } - - [Fact] - public async Task GroupBy_MultipleFields_CreatesCompoundGroups() - { - await Seed(); - var results = await RunQuery( - "SELECT c.cat, c.active, COUNT(1) as cnt FROM c GROUP BY c.cat, c.active ORDER BY c.cat, c.active"); - // A+true=1, A+false=1, B+true=2, C+false=1 → 4 groups - results.Should().HaveCount(4); - } - - [Fact] - public async Task GroupBy_WithTopAndOrderBy_AppliesInCorrectOrder() - { - await Seed(); - // GROUP BY → ORDER BY → TOP - var results = await RunQuery( - "SELECT TOP 2 c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - results.Should().HaveCount(2); - // A(2) and B(2) have highest counts - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_Expression_GroupsByComputedValue() - { - await Seed(); - // GROUP BY UPPER(c.name) — "alice"→"ALICE", etc. - // Each name is unique so 5 groups - var results = await RunQuery( - "SELECT UPPER(c.name) as uname, COUNT(1) as cnt FROM c GROUP BY UPPER(c.name)"); - results.Should().HaveCount(5); - results.Should().AllSatisfy(r => r["cnt"]!.Value().Should().Be(1)); - } - - [Fact] - public async Task Having_WithSumAggregate_FiltersCorrectly() - { - await Seed(); - // cat A: sum=30, cat B: sum=70, cat C: sum=5 - var results = await RunQuery( - "SELECT c.cat, SUM(c.val) as total FROM c GROUP BY c.cat HAVING SUM(c.val) > 20 ORDER BY c.cat"); - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("A"); - results[0]["total"]!.Value().Should().Be(30); - results[1]["cat"]!.ToString().Should().Be("B"); - results[1]["total"]!.Value().Should().Be(70); - } - - [Fact] - public async Task GroupBy_EmptyContainer_ReturnsNoGroups() - { - var emptyContainer = new InMemoryContainer("empty", "/pk"); - var iterator = emptyContainer.GetItemQueryIterator( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","cat":"A","active":true,"val":10,"name":"alice"}""", + """{"id":"2","pk":"a","cat":"A","active":false,"val":20,"name":"bob"}""", + """{"id":"3","pk":"a","cat":"B","active":true,"val":30,"name":"Charlie"}""", + """{"id":"4","pk":"a","cat":"B","active":true,"val":40,"name":"Dave"}""", + """{"id":"5","pk":"a","cat":"C","active":false,"val":5,"name":"eve"}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_BooleanField_GroupsCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT c.active, COUNT(1) as cnt FROM c GROUP BY c.active ORDER BY c.active"); + results.Should().HaveCount(2); + // false group: count=2, true group: count=3 + results[0]["active"]!.Value().Should().BeFalse(); + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["active"]!.Value().Should().BeTrue(); + results[1]["cnt"]!.Value().Should().Be(3); + } + + [Fact] + public async Task GroupBy_MultipleFields_CreatesCompoundGroups() + { + await Seed(); + var results = await RunQuery( + "SELECT c.cat, c.active, COUNT(1) as cnt FROM c GROUP BY c.cat, c.active ORDER BY c.cat, c.active"); + // A+true=1, A+false=1, B+true=2, C+false=1 → 4 groups + results.Should().HaveCount(4); + } + + [Fact] + public async Task GroupBy_WithTopAndOrderBy_AppliesInCorrectOrder() + { + await Seed(); + // GROUP BY → ORDER BY → TOP + var results = await RunQuery( + "SELECT TOP 2 c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + results.Should().HaveCount(2); + // A(2) and B(2) have highest counts + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_Expression_GroupsByComputedValue() + { + await Seed(); + // GROUP BY UPPER(c.name) — "alice"→"ALICE", etc. + // Each name is unique so 5 groups + var results = await RunQuery( + "SELECT UPPER(c.name) as uname, COUNT(1) as cnt FROM c GROUP BY UPPER(c.name)"); + results.Should().HaveCount(5); + results.Should().AllSatisfy(r => r["cnt"]!.Value().Should().Be(1)); + } + + [Fact] + public async Task Having_WithSumAggregate_FiltersCorrectly() + { + await Seed(); + // cat A: sum=30, cat B: sum=70, cat C: sum=5 + var results = await RunQuery( + "SELECT c.cat, SUM(c.val) as total FROM c GROUP BY c.cat HAVING SUM(c.val) > 20 ORDER BY c.cat"); + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("A"); + results[0]["total"]!.Value().Should().Be(30); + results[1]["cat"]!.ToString().Should().Be("B"); + results[1]["total"]!.Value().Should().Be(70); + } + + [Fact] + public async Task GroupBy_EmptyContainer_ReturnsNoGroups() + { + var emptyContainer = new InMemoryContainer("empty", "/pk"); + var iterator = emptyContainer.GetItemQueryIterator( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + } } // ── Batch 6: JOIN Advanced Edge Cases ── public class QueryDeepDiveV9_JoinAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","tags":["x","y"],"scores":[10,20]}""", - """{"id":"2","pk":"a","name":"Bob","tags":["z"],"scores":[30]}""", - """{"id":"3","pk":"a","name":"Charlie","tags":[],"scores":[40,50,60]}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_WithWhereOnJoinedField_FiltersCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE t = 'x'"); - results.Should().ContainSingle(); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[0]["tag"]!.ToString().Should().Be("x"); - } - - [Fact] - public async Task Join_MultipleJoins_CrossProduct() - { - await Seed(); - // Alice: tags[2] x scores[2] = 4 rows - // Bob: tags[1] x scores[1] = 1 row - // Charlie: tags[0] x scores[3] = 0 rows (empty tags) - var results = await RunQuery( - "SELECT c.name, t AS tag, s AS score FROM c JOIN t IN c.tags JOIN s IN c.scores"); - results.Should().HaveCount(5); // 4 + 1 + 0 - } - - [Fact] - public async Task Join_WithAggregate_CountsExpandedRows() - { - await Seed(); - // JOIN expands: Alice=2, Bob=1, Charlie=0 → 3 total rows - var results = await RunQuery( - "SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); - results.Should().ContainSingle().Which.Should().Be(3); - } - - [Fact] - public async Task Join_WithGroupBy_GroupsOnJoinedField() - { - await Seed(); - // After JOIN on tags: (Alice,x), (Alice,y), (Bob,z) - // GROUP BY tag: x=1, y=1, z=1 - var results = await RunQuery( - "SELECT t AS tag, COUNT(1) as cnt FROM c JOIN t IN c.tags GROUP BY t"); - results.Should().HaveCount(3); - results.Select(r => r["tag"]!.ToString()).Should().BeEquivalentTo("x", "y", "z"); - results.Should().AllSatisfy(r => r["cnt"]!.Value().Should().Be(1)); - } - - [Fact] - public async Task Join_SingleElementArray_NoExpansion() - { - await Seed(); - // Bob has tags:["z"] — single element → 1 row - var results = await RunQuery( - "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.id = '2'"); - results.Should().ContainSingle(); - results[0]["tag"]!.ToString().Should().Be("z"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","tags":["x","y"],"scores":[10,20]}""", + """{"id":"2","pk":"a","name":"Bob","tags":["z"],"scores":[30]}""", + """{"id":"3","pk":"a","name":"Charlie","tags":[],"scores":[40,50,60]}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_WithWhereOnJoinedField_FiltersCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE t = 'x'"); + results.Should().ContainSingle(); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[0]["tag"]!.ToString().Should().Be("x"); + } + + [Fact] + public async Task Join_MultipleJoins_CrossProduct() + { + await Seed(); + // Alice: tags[2] x scores[2] = 4 rows + // Bob: tags[1] x scores[1] = 1 row + // Charlie: tags[0] x scores[3] = 0 rows (empty tags) + var results = await RunQuery( + "SELECT c.name, t AS tag, s AS score FROM c JOIN t IN c.tags JOIN s IN c.scores"); + results.Should().HaveCount(5); // 4 + 1 + 0 + } + + [Fact] + public async Task Join_WithAggregate_CountsExpandedRows() + { + await Seed(); + // JOIN expands: Alice=2, Bob=1, Charlie=0 → 3 total rows + var results = await RunQuery( + "SELECT VALUE COUNT(1) FROM c JOIN t IN c.tags"); + results.Should().ContainSingle().Which.Should().Be(3); + } + + [Fact] + public async Task Join_WithGroupBy_GroupsOnJoinedField() + { + await Seed(); + // After JOIN on tags: (Alice,x), (Alice,y), (Bob,z) + // GROUP BY tag: x=1, y=1, z=1 + var results = await RunQuery( + "SELECT t AS tag, COUNT(1) as cnt FROM c JOIN t IN c.tags GROUP BY t"); + results.Should().HaveCount(3); + results.Select(r => r["tag"]!.ToString()).Should().BeEquivalentTo("x", "y", "z"); + results.Should().AllSatisfy(r => r["cnt"]!.Value().Should().Be(1)); + } + + [Fact] + public async Task Join_SingleElementArray_NoExpansion() + { + await Seed(); + // Bob has tags:["z"] — single element → 1 row + var results = await RunQuery( + "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE c.id = '2'"); + results.Should().ContainSingle(); + results[0]["tag"]!.ToString().Should().Be("z"); + } } // ── Batch 7: Subquery & EXISTS Edge Cases ── public class QueryDeepDiveV9_SubqueryExistsTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","tags":["admin","user"],"scores":[90,85]}""", - """{"id":"2","pk":"a","name":"Bob","tags":[],"scores":[70]}""", - """{"id":"3","pk":"a","name":"Charlie","scores":[60,55]}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Exists_EmptyArrayField_ReturnsFalse() - { - await Seed(); - // Bob has empty tags array — EXISTS should return false since no elements match - var results = await RunQuery( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'admin')"); - results.Should().ContainSingle() - .Which["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task ArraySubquery_InSelect_ReturnsArray() - { - await Seed(); - // ARRAY(SELECT ...) creates an inline array in the result - var results = await RunQuery( - "SELECT c.name, ARRAY(SELECT VALUE s FROM s IN c.scores WHERE s > 70) AS highScores FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - var highScores = results[0]["highScores"] as JArray; - highScores.Should().NotBeNull(); - highScores!.Count.Should().Be(2); // 90 and 85 - } - - [Fact] - public async Task NotExists_WithCondition_FiltersCorrectly() - { - await Seed(); - // Items where no tag is 'admin' → Bob and Charlie - var results = await RunQuery( - "SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'admin') ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Bob"); - results[1]["name"]!.ToString().Should().Be("Charlie"); - } - - [Fact] - public async Task Exists_OnMissingField_ReturnsFalse() - { - await Seed(); - // Charlie has no 'tags' field — EXISTS on non-existent array - var results = await RunQuery( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); - // Only Alice has non-empty tags; Bob has empty array (EXISTS returns false for empty) - results.Should().ContainSingle() - .Which["name"]!.ToString().Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","tags":["admin","user"],"scores":[90,85]}""", + """{"id":"2","pk":"a","name":"Bob","tags":[],"scores":[70]}""", + """{"id":"3","pk":"a","name":"Charlie","scores":[60,55]}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Exists_EmptyArrayField_ReturnsFalse() + { + await Seed(); + // Bob has empty tags array — EXISTS should return false since no elements match + var results = await RunQuery( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'admin')"); + results.Should().ContainSingle() + .Which["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task ArraySubquery_InSelect_ReturnsArray() + { + await Seed(); + // ARRAY(SELECT ...) creates an inline array in the result + var results = await RunQuery( + "SELECT c.name, ARRAY(SELECT VALUE s FROM s IN c.scores WHERE s > 70) AS highScores FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + var highScores = results[0]["highScores"] as JArray; + highScores.Should().NotBeNull(); + highScores!.Count.Should().Be(2); // 90 and 85 + } + + [Fact] + public async Task NotExists_WithCondition_FiltersCorrectly() + { + await Seed(); + // Items where no tag is 'admin' → Bob and Charlie + var results = await RunQuery( + "SELECT * FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'admin') ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Bob"); + results[1]["name"]!.ToString().Should().Be("Charlie"); + } + + [Fact] + public async Task Exists_OnMissingField_ReturnsFalse() + { + await Seed(); + // Charlie has no 'tags' field — EXISTS on non-existent array + var results = await RunQuery( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); + // Only Alice has non-empty tags; Bob has empty array (EXISTS returns false for empty) + results.Should().ContainSingle() + .Which["name"]!.ToString().Should().Be("Alice"); + } } // ── Batch 8: ORDER BY Edge Cases ── public class QueryDeepDiveV9_OrderByTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","val":10,"sortField":"b"}""", - """{"id":"2","pk":"a","name":"Bob","val":20,"sortField":"a"}""", - """{"id":"3","pk":"a","val":30,"sortField":"c"}""", - """{"id":"4","pk":"a","name":null,"val":5,"sortField":"d"}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_NullsAndUndefined_CosmosOrdering() - { - await Seed(); - // Cosmos type ordering (ASC): undefined < null < booleans < numbers < strings - // id3 has undefined name, id4 has null name, id1 has "Alice", id2 has "Bob" - var results = await RunQuery( - "SELECT c.id, c.name FROM c ORDER BY c.name ASC"); - results.Should().HaveCount(4); - // undefined first (id=3), then null (id=4), then "Alice" (id=1), then "Bob" (id=2) - results[0]["id"]!.ToString().Should().Be("3"); - results[1]["id"]!.ToString().Should().Be("4"); - results[2]["id"]!.ToString().Should().Be("1"); - results[3]["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task OrderBy_MultipleDifferentDirections() - { - await Seed(); - // ORDER BY val ASC then sortField DESC - var container2 = new InMemoryContainer("order-multi", "/pk"); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1,"sf":"b"}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":1,"sf":"a"}""")), - new PartitionKey("a")); - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":2,"sf":"c"}""")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.val ASC, c.sf DESC"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results[0]["id"]!.ToString().Should().Be("1"); // val=1, sf=b (desc: b before a) - results[1]["id"]!.ToString().Should().Be("2"); // val=1, sf=a - results[2]["id"]!.ToString().Should().Be("3"); // val=2, sf=c - } - - [Fact] - public async Task OrderBy_ComputedExpression() - { - await Seed(); - var results = await RunQuery( - "SELECT c.id, c.val * 2 AS doubled FROM c ORDER BY c.val * 2 DESC"); - results.Should().HaveCount(4); - results[0]["id"]!.ToString().Should().Be("3"); // 30*2=60 - results[1]["id"]!.ToString().Should().Be("2"); // 20*2=40 - results[2]["id"]!.ToString().Should().Be("1"); // 10*2=20 - results[3]["id"]!.ToString().Should().Be("4"); // 5*2=10 - } - - [Fact] - public async Task OrderBy_WithDistinct_CorrectOrder() - { - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.val FROM c ORDER BY c.val ASC"); - results.Should().Equal(5, 10, 20, 30); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","val":10,"sortField":"b"}""", + """{"id":"2","pk":"a","name":"Bob","val":20,"sortField":"a"}""", + """{"id":"3","pk":"a","val":30,"sortField":"c"}""", + """{"id":"4","pk":"a","name":null,"val":5,"sortField":"d"}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_NullsAndUndefined_CosmosOrdering() + { + await Seed(); + // Cosmos type ordering (ASC): undefined < null < booleans < numbers < strings + // id3 has undefined name, id4 has null name, id1 has "Alice", id2 has "Bob" + var results = await RunQuery( + "SELECT c.id, c.name FROM c ORDER BY c.name ASC"); + results.Should().HaveCount(4); + // undefined first (id=3), then null (id=4), then "Alice" (id=1), then "Bob" (id=2) + results[0]["id"]!.ToString().Should().Be("3"); + results[1]["id"]!.ToString().Should().Be("4"); + results[2]["id"]!.ToString().Should().Be("1"); + results[3]["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task OrderBy_MultipleDifferentDirections() + { + await Seed(); + // ORDER BY val ASC then sortField DESC + var container2 = new InMemoryContainer("order-multi", "/pk"); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1,"sf":"b"}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":1,"sf":"a"}""")), + new PartitionKey("a")); + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":2,"sf":"c"}""")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.val ASC, c.sf DESC"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results[0]["id"]!.ToString().Should().Be("1"); // val=1, sf=b (desc: b before a) + results[1]["id"]!.ToString().Should().Be("2"); // val=1, sf=a + results[2]["id"]!.ToString().Should().Be("3"); // val=2, sf=c + } + + [Fact] + public async Task OrderBy_ComputedExpression() + { + await Seed(); + var results = await RunQuery( + "SELECT c.id, c.val * 2 AS doubled FROM c ORDER BY c.val * 2 DESC"); + results.Should().HaveCount(4); + results[0]["id"]!.ToString().Should().Be("3"); // 30*2=60 + results[1]["id"]!.ToString().Should().Be("2"); // 20*2=40 + results[2]["id"]!.ToString().Should().Be("1"); // 10*2=20 + results[3]["id"]!.ToString().Should().Be("4"); // 5*2=10 + } + + [Fact] + public async Task OrderBy_WithDistinct_CorrectOrder() + { + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.val FROM c ORDER BY c.val ASC"); + results.Should().Equal(5, 10, 20, 30); + } } // ── Batch 9: OFFSET/LIMIT & TOP Edge Cases ── public class QueryDeepDiveV9_PaginationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - for (int i = 1; i <= 5; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $@"{{""id"":""{i}"",""pk"":""a"",""val"":{i * 10}}}")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OffsetLimit_MiddlePage() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.val OFFSET 2 LIMIT 2"); - results.Should().HaveCount(2); - results[0]["val"]!.Value().Should().Be(30); - results[1]["val"]!.Value().Should().Be(40); - } - - [Fact] - public async Task OffsetLimit_WithOrderBy_PaginatesCorrectly() - { - await Seed(); - // Get second page of 2 items in descending order - var results = await RunQuery( - "SELECT * FROM c ORDER BY c.val DESC OFFSET 2 LIMIT 2"); - results.Should().HaveCount(2); - results[0]["val"]!.Value().Should().Be(30); - results[1]["val"]!.Value().Should().Be(20); - } - - [Fact] - public async Task OffsetLimit_WithGroupBy_PaginatesGroups() - { - // Seed items with categories - var container2 = new InMemoryContainer("group-page", "/pk"); - foreach (var cat in new[] { "A", "A", "B", "B", "C" }) - await container2.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $@"{{""id"":""{Guid.NewGuid()}"",""pk"":""a"",""cat"":""{cat}""}}")), - new PartitionKey("a")); - - var iterator = container2.GetItemQueryIterator( - "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY c.cat OFFSET 1 LIMIT 1"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - // Groups: A(2), B(2), C(1). Skip 1 = B, Take 1 = B only - results.Should().ContainSingle(); - results[0]["cat"]!.ToString().Should().Be("B"); - } - - [Fact] - public async Task Top_WithDistinct_AppliesAfterDistinct() - { - await Seed(); - // All vals are already distinct (10,20,30,40,50) - // Add a duplicate val - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"6","pk":"a","val":10}""")), - new PartitionKey("a")); - - var results = await RunQuery( - "SELECT DISTINCT TOP 3 VALUE c.val FROM c ORDER BY c.val"); - results.Should().HaveCount(3); - results.Should().Equal(10, 20, 30); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + for (int i = 1; i <= 5; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $@"{{""id"":""{i}"",""pk"":""a"",""val"":{i * 10}}}")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OffsetLimit_MiddlePage() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.val OFFSET 2 LIMIT 2"); + results.Should().HaveCount(2); + results[0]["val"]!.Value().Should().Be(30); + results[1]["val"]!.Value().Should().Be(40); + } + + [Fact] + public async Task OffsetLimit_WithOrderBy_PaginatesCorrectly() + { + await Seed(); + // Get second page of 2 items in descending order + var results = await RunQuery( + "SELECT * FROM c ORDER BY c.val DESC OFFSET 2 LIMIT 2"); + results.Should().HaveCount(2); + results[0]["val"]!.Value().Should().Be(30); + results[1]["val"]!.Value().Should().Be(20); + } + + [Fact] + public async Task OffsetLimit_WithGroupBy_PaginatesGroups() + { + // Seed items with categories + var container2 = new InMemoryContainer("group-page", "/pk"); + foreach (var cat in new[] { "A", "A", "B", "B", "C" }) + await container2.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $@"{{""id"":""{Guid.NewGuid()}"",""pk"":""a"",""cat"":""{cat}""}}")), + new PartitionKey("a")); + + var iterator = container2.GetItemQueryIterator( + "SELECT c.cat, COUNT(1) as cnt FROM c GROUP BY c.cat ORDER BY c.cat OFFSET 1 LIMIT 1"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + // Groups: A(2), B(2), C(1). Skip 1 = B, Take 1 = B only + results.Should().ContainSingle(); + results[0]["cat"]!.ToString().Should().Be("B"); + } + + [Fact] + public async Task Top_WithDistinct_AppliesAfterDistinct() + { + await Seed(); + // All vals are already distinct (10,20,30,40,50) + // Add a duplicate val + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"6","pk":"a","val":10}""")), + new PartitionKey("a")); + + var results = await RunQuery( + "SELECT DISTINCT TOP 3 VALUE c.val FROM c ORDER BY c.val"); + results.Should().HaveCount(3); + results.Should().Equal(10, 20, 30); + } } // ── Batch 10: Complex Cross-Feature Interactions ── public class QueryDeepDiveV9_CrossFeatureTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","val":15,"nested":{"score":90,"desc":"good"}}""", - """{"id":"2","pk":"a","name":"Bob","val":25,"nested":{"score":70,"desc":"ok"}}""", - """{"id":"3","pk":"a","name":"Charlie","val":5,"nested":{"score":85,"desc":"great"}}""", - """{"id":"4","pk":"a","name":"Di","val":35}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_ArithmeticInWhere_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.val + 10 > 20"); - // val+10 > 20 → val > 10: Bob(25), Charlie?5+10=15>20? No. Alice(15+10=25>20 yes), Bob(25+10=35 yes), Di(35+10=45 yes) - results.Should().HaveCount(3); - results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Bob", "Di"); - } - - [Fact] - public async Task Select_FunctionInWhere_Works() - { - await Seed(); - // LENGTH("Alice")=5, LENGTH("Bob")=3, LENGTH("Charlie")=7, LENGTH("Di")=2 - var results = await RunQuery( - "SELECT * FROM c WHERE LENGTH(c.name) > 3 ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Alice"); - results[1]["name"]!.ToString().Should().Be("Charlie"); - } - - [Fact] - public async Task Select_NestedPropertyAccess_Deep() - { - await Seed(); - // 4-level deep access: only 3 items have nested.score - var results = await RunQuery( - "SELECT c.name, c.nested.score AS s FROM c WHERE IS_DEFINED(c.nested) ORDER BY c.nested.score DESC"); - results.Should().HaveCount(3); - results[0]["s"]!.Value().Should().Be(90); - results[1]["s"]!.Value().Should().Be(85); - results[2]["s"]!.Value().Should().Be(70); - } - - [Fact] - public async Task Select_BracketAccessOnProperty() - { - await Seed(); - // c["name"] is equivalent to c.name - var results = await RunQuery( - """SELECT c["name"] AS n FROM c WHERE c.id = '1'"""); - results.Should().ContainSingle() - .Which["n"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Where_MultipleOrConditions() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.val = 15 OR c.val = 25 OR c.val = 5 ORDER BY c.val"); - results.Should().HaveCount(3); - results[0]["val"]!.Value().Should().Be(5); - results[1]["val"]!.Value().Should().Be(15); - results[2]["val"]!.Value().Should().Be(25); - } - - [Fact] - public async Task Where_ComplexAndOrPrecedence() - { - await Seed(); - // (val > 20 AND name != 'Di') OR (val < 10) - // val>20 AND name!='Di': Bob(25) ✓ - // val<10: Charlie(5) ✓ - var results = await RunQuery( - "SELECT * FROM c WHERE (c.val > 20 AND c.name != 'Di') OR c.val < 10 ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.ToString().Should().Be("Bob"); - results[1]["name"]!.ToString().Should().Be("Charlie"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","val":15,"nested":{"score":90,"desc":"good"}}""", + """{"id":"2","pk":"a","name":"Bob","val":25,"nested":{"score":70,"desc":"ok"}}""", + """{"id":"3","pk":"a","name":"Charlie","val":5,"nested":{"score":85,"desc":"great"}}""", + """{"id":"4","pk":"a","name":"Di","val":35}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_ArithmeticInWhere_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.val + 10 > 20"); + // val+10 > 20 → val > 10: Bob(25), Charlie?5+10=15>20? No. Alice(15+10=25>20 yes), Bob(25+10=35 yes), Di(35+10=45 yes) + results.Should().HaveCount(3); + results.Select(r => r["name"]!.ToString()).Should().BeEquivalentTo("Alice", "Bob", "Di"); + } + + [Fact] + public async Task Select_FunctionInWhere_Works() + { + await Seed(); + // LENGTH("Alice")=5, LENGTH("Bob")=3, LENGTH("Charlie")=7, LENGTH("Di")=2 + var results = await RunQuery( + "SELECT * FROM c WHERE LENGTH(c.name) > 3 ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Alice"); + results[1]["name"]!.ToString().Should().Be("Charlie"); + } + + [Fact] + public async Task Select_NestedPropertyAccess_Deep() + { + await Seed(); + // 4-level deep access: only 3 items have nested.score + var results = await RunQuery( + "SELECT c.name, c.nested.score AS s FROM c WHERE IS_DEFINED(c.nested) ORDER BY c.nested.score DESC"); + results.Should().HaveCount(3); + results[0]["s"]!.Value().Should().Be(90); + results[1]["s"]!.Value().Should().Be(85); + results[2]["s"]!.Value().Should().Be(70); + } + + [Fact] + public async Task Select_BracketAccessOnProperty() + { + await Seed(); + // c["name"] is equivalent to c.name + var results = await RunQuery( + """SELECT c["name"] AS n FROM c WHERE c.id = '1'"""); + results.Should().ContainSingle() + .Which["n"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Where_MultipleOrConditions() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.val = 15 OR c.val = 25 OR c.val = 5 ORDER BY c.val"); + results.Should().HaveCount(3); + results[0]["val"]!.Value().Should().Be(5); + results[1]["val"]!.Value().Should().Be(15); + results[2]["val"]!.Value().Should().Be(25); + } + + [Fact] + public async Task Where_ComplexAndOrPrecedence() + { + await Seed(); + // (val > 20 AND name != 'Di') OR (val < 10) + // val>20 AND name!='Di': Bob(25) ✓ + // val<10: Charlie(5) ✓ + var results = await RunQuery( + "SELECT * FROM c WHERE (c.val > 20 AND c.name != 'Di') OR c.val < 10 ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.ToString().Should().Be("Bob"); + results[1]["name"]!.ToString().Should().Be("Charlie"); + } } // ── Batch 11: Type Checking Function Edge Cases ── public class QueryDeepDiveV9_TypeCheckTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","intVal":42,"floatVal":3.14,"boolVal":true,"nullVal":null,"strVal":"hello","arrVal":[1,2],"objVal":{"x":1}}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsInteger_Float_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_INTEGER(c.floatVal) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsInteger_WholeNumber_ReturnsTrue() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_INTEGER(c.intVal) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task IsBool_Null_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_BOOL(c.nullVal) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsObject_Array_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_OBJECT(c.arrVal) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsArray_Object_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_ARRAY(c.objVal) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","intVal":42,"floatVal":3.14,"boolVal":true,"nullVal":null,"strVal":"hello","arrVal":[1,2],"objVal":{"x":1}}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsInteger_Float_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_INTEGER(c.floatVal) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsInteger_WholeNumber_ReturnsTrue() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_INTEGER(c.intVal) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task IsBool_Null_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_BOOL(c.nullVal) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsObject_Array_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_OBJECT(c.arrVal) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsArray_Object_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_ARRAY(c.objVal) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } } // ── Batch 12: Date/Time Function Edge Cases ── public class QueryDeepDiveV9_DateTimeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","dt":"2024-03-15T10:30:00.000Z","dt2":"2024-03-15T10:30:00.000Z"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DateTimeAdd_NegativeMonths_CrossesYearBoundary() - { - await Seed(); - // March 2024 - 6 months = September 2023 - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('mm', -6, c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().StartWith("2023-09-15"); - } - - [Fact] - public async Task DateTimeDiff_SameDateTime_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('hh', c.dt, c.dt2) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task DateTimePart_AllParts_Work() - { - await Seed(); - // Test year, month, day, hour, minute, second - var year = await RunQuery("SELECT VALUE DateTimePart('yyyy', c.dt) FROM c"); - year.Should().ContainSingle().Which.Should().Be(2024); - - var month = await RunQuery("SELECT VALUE DateTimePart('mm', c.dt) FROM c"); - month.Should().ContainSingle().Which.Should().Be(3); - - var day = await RunQuery("SELECT VALUE DateTimePart('dd', c.dt) FROM c"); - day.Should().ContainSingle().Which.Should().Be(15); - - var hour = await RunQuery("SELECT VALUE DateTimePart('hh', c.dt) FROM c"); - hour.Should().ContainSingle().Which.Should().Be(10); - - var minute = await RunQuery("SELECT VALUE DateTimePart('mi', c.dt) FROM c"); - minute.Should().ContainSingle().Which.Should().Be(30); - } - - [Fact] - public async Task GetCurrentDateTime_ReturnsTodaysDate() - { - var before = DateTime.UtcNow; - await Seed(); - var results = await RunQuery( - "SELECT VALUE GetCurrentDateTime() FROM c"); - var after = DateTime.UtcNow; - results.Should().ContainSingle(); - // Should be a valid ISO 8601 datetime string with UTC kind - DateTime.TryParse(results[0], null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) - .Should().BeTrue(); - dt.Date.Should().BeOnOrAfter(before.Date); - dt.Date.Should().BeOnOrBefore(after.Date); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","dt":"2024-03-15T10:30:00.000Z","dt2":"2024-03-15T10:30:00.000Z"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DateTimeAdd_NegativeMonths_CrossesYearBoundary() + { + await Seed(); + // March 2024 - 6 months = September 2023 + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('mm', -6, c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().StartWith("2023-09-15"); + } + + [Fact] + public async Task DateTimeDiff_SameDateTime_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('hh', c.dt, c.dt2) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task DateTimePart_AllParts_Work() + { + await Seed(); + // Test year, month, day, hour, minute, second + var year = await RunQuery("SELECT VALUE DateTimePart('yyyy', c.dt) FROM c"); + year.Should().ContainSingle().Which.Should().Be(2024); + + var month = await RunQuery("SELECT VALUE DateTimePart('mm', c.dt) FROM c"); + month.Should().ContainSingle().Which.Should().Be(3); + + var day = await RunQuery("SELECT VALUE DateTimePart('dd', c.dt) FROM c"); + day.Should().ContainSingle().Which.Should().Be(15); + + var hour = await RunQuery("SELECT VALUE DateTimePart('hh', c.dt) FROM c"); + hour.Should().ContainSingle().Which.Should().Be(10); + + var minute = await RunQuery("SELECT VALUE DateTimePart('mi', c.dt) FROM c"); + minute.Should().ContainSingle().Which.Should().Be(30); + } + + [Fact] + public async Task GetCurrentDateTime_ReturnsTodaysDate() + { + var before = DateTime.UtcNow; + await Seed(); + var results = await RunQuery( + "SELECT VALUE GetCurrentDateTime() FROM c"); + var after = DateTime.UtcNow; + results.Should().ContainSingle(); + // Should be a valid ISO 8601 datetime string with UTC kind + DateTime.TryParse(results[0], null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) + .Should().BeTrue(); + dt.Date.Should().BeOnOrAfter(before.Date); + dt.Date.Should().BeOnOrBefore(after.Date); + } } // ── Batch 13: Array Function Edge Cases ── public class QueryDeepDiveV9_ArrayFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","arr1":[1,2,3,4,5],"arr2":[3,4,5,6,7],"arr3":[10,20]}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArraySlice_NegativeStart_CountsFromEnd() - { - await Seed(); - // ARRAY_SLICE([1,2,3,4,5], -2) → [4,5] - var results = await RunQuery( - "SELECT VALUE ARRAY_SLICE(c.arr1, -2) FROM c"); - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Should().Equal(4, 5); - } - - [Fact] - public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ARRAY_SLICE(c.arr1, 100) FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeEmpty(); - } - - [Fact] - public async Task ArrayConcat_ThreeArrays() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ARRAY_CONCAT(c.arr1, c.arr2, c.arr3) FROM c"); - results.Should().ContainSingle(); - results[0].Count.Should().Be(12); // 5 + 5 + 2 - } - - [Fact] - public async Task SetIntersect_NoOverlap_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SetIntersect([1,2], [3,4]) FROM c"); - results.Should().ContainSingle(); - results[0].Should().BeEmpty(); - } - - [Fact] - public async Task SetUnion_WithDuplicates_Deduplicates() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SetUnion([1,2,2], [2,3]) FROM c"); - results.Should().ContainSingle(); - results[0].Select(t => t.Value()).Order().Should().Equal(1, 2, 3); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","arr1":[1,2,3,4,5],"arr2":[3,4,5,6,7],"arr3":[10,20]}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArraySlice_NegativeStart_CountsFromEnd() + { + await Seed(); + // ARRAY_SLICE([1,2,3,4,5], -2) → [4,5] + var results = await RunQuery( + "SELECT VALUE ARRAY_SLICE(c.arr1, -2) FROM c"); + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Should().Equal(4, 5); + } + + [Fact] + public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ARRAY_SLICE(c.arr1, 100) FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeEmpty(); + } + + [Fact] + public async Task ArrayConcat_ThreeArrays() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ARRAY_CONCAT(c.arr1, c.arr2, c.arr3) FROM c"); + results.Should().ContainSingle(); + results[0].Count.Should().Be(12); // 5 + 5 + 2 + } + + [Fact] + public async Task SetIntersect_NoOverlap_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SetIntersect([1,2], [3,4]) FROM c"); + results.Should().ContainSingle(); + results[0].Should().BeEmpty(); + } + + [Fact] + public async Task SetUnion_WithDuplicates_Deduplicates() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SetUnion([1,2,2], [2,3]) FROM c"); + results.Should().ContainSingle(); + results[0].Select(t => t.Value()).Order().Should().Equal(1, 2, 3); + } } // ── Batch 14: Parameterized Query Edge Cases ── public class QueryDeepDiveV9_ParameterTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","val":10,"tags":["x","y"]}""", - """{"id":"2","pk":"a","name":"Bob","val":20,"tags":["y","z"]}""", - """{"id":"3","pk":"a","name":null,"val":30,"tags":["x"]}""", - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Parameter_InSelect_ProjectsValue() - { - await Seed(); - var query = new QueryDefinition("SELECT @val as constant, c.name FROM c WHERE c.id = '1'") - .WithParameter("@val", 99); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["constant"]!.Value().Should().Be(99); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Parameter_Array_InArrayContains() - { - await Seed(); - // Use parameter for the value to check - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, @tag)") - .WithParameter("@tag", "x"); - var results = await RunQuery(query); - // id1=Alice(tags:x,y) and id3=null-name(tags:x) both match - results.Should().HaveCount(2); - results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "3"); - } - - [Fact] - public async Task Parameter_NullValue_InWhere() - { - await Seed(); - // WHERE c.name = @param with null — in Cosmos DB, null = null evaluates to true - // (unlike SQL standard where NULL = NULL is UNKNOWN) - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @param") - .WithParameter("@param", null); - var results = await RunQuery(query); - // id=3 has name:null, and null=null is true in Cosmos DB - results.Should().ContainSingle() - .Which["id"]!.ToString().Should().Be("3"); - } - - [Fact] - public async Task Parameter_NullValue_WithIsNull() - { - await Seed(); - // Use IS NULL to properly match null values - var query = new QueryDefinition("SELECT * FROM c WHERE IS_NULL(c.name)"); - var results = await RunQuery(query); - results.Should().ContainSingle() - .Which["id"]!.ToString().Should().Be("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","val":10,"tags":["x","y"]}""", + """{"id":"2","pk":"a","name":"Bob","val":20,"tags":["y","z"]}""", + """{"id":"3","pk":"a","name":null,"val":30,"tags":["x"]}""", + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Parameter_InSelect_ProjectsValue() + { + await Seed(); + var query = new QueryDefinition("SELECT @val as constant, c.name FROM c WHERE c.id = '1'") + .WithParameter("@val", 99); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["constant"]!.Value().Should().Be(99); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Parameter_Array_InArrayContains() + { + await Seed(); + // Use parameter for the value to check + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, @tag)") + .WithParameter("@tag", "x"); + var results = await RunQuery(query); + // id1=Alice(tags:x,y) and id3=null-name(tags:x) both match + results.Should().HaveCount(2); + results.Select(r => r["id"]!.ToString()).Should().BeEquivalentTo("1", "3"); + } + + [Fact] + public async Task Parameter_NullValue_InWhere() + { + await Seed(); + // WHERE c.name = @param with null — in Cosmos DB, null = null evaluates to true + // (unlike SQL standard where NULL = NULL is UNKNOWN) + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @param") + .WithParameter("@param", null); + var results = await RunQuery(query); + // id=3 has name:null, and null=null is true in Cosmos DB + results.Should().ContainSingle() + .Which["id"]!.ToString().Should().Be("3"); + } + + [Fact] + public async Task Parameter_NullValue_WithIsNull() + { + await Seed(); + // Use IS NULL to properly match null values + var query = new QueryDefinition("SELECT * FROM c WHERE IS_NULL(c.name)"); + var results = await RunQuery(query); + results.Should().ContainSingle() + .Which["id"]!.ToString().Should().Be("3"); + } } // ── Batch 15: Math Function Edge Cases ── public class QueryDeepDiveV9_MathEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":2.5,"neg":-2.3,"zero":0}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Power_ZeroToZero_ReturnsOne() - { - await Seed(); - var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(1.0); - } - - [Fact] - public async Task Round_HalfValues_BankersRounding() - { - await Seed(); - // Cosmos DB uses "round half away from zero" (Math.Round MidpointRounding.AwayFromZero) - var results = await RunQuery("SELECT VALUE ROUND(c.val) FROM c"); - // 2.5 → 3 (away from zero) - results.Should().ContainSingle().Which.Should().Be(3.0); - } - - [Fact] - public async Task Abs_NegativeZero_ReturnsZero() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ABS(-0.0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0.0); - } - - [Fact] - public async Task Floor_NegativeDecimal_RoundsDown() - { - await Seed(); - // FLOOR(-2.3) = -3 - var results = await RunQuery("SELECT VALUE FLOOR(c.neg) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-3.0); - } - - [Fact] - public async Task Ceiling_NegativeDecimal_RoundsUp() - { - await Seed(); - // CEILING(-2.3) = -2 - var results = await RunQuery("SELECT VALUE CEILING(c.neg) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-2.0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":2.5,"neg":-2.3,"zero":0}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Power_ZeroToZero_ReturnsOne() + { + await Seed(); + var results = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(1.0); + } + + [Fact] + public async Task Round_HalfValues_BankersRounding() + { + await Seed(); + // Cosmos DB uses "round half away from zero" (Math.Round MidpointRounding.AwayFromZero) + var results = await RunQuery("SELECT VALUE ROUND(c.val) FROM c"); + // 2.5 → 3 (away from zero) + results.Should().ContainSingle().Which.Should().Be(3.0); + } + + [Fact] + public async Task Abs_NegativeZero_ReturnsZero() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ABS(-0.0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0.0); + } + + [Fact] + public async Task Floor_NegativeDecimal_RoundsDown() + { + await Seed(); + // FLOOR(-2.3) = -3 + var results = await RunQuery("SELECT VALUE FLOOR(c.neg) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-3.0); + } + + [Fact] + public async Task Ceiling_NegativeDecimal_RoundsUp() + { + await Seed(); + // CEILING(-2.3) = -2 + var results = await RunQuery("SELECT VALUE CEILING(c.neg) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-2.0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -15766,904 +15776,904 @@ public async Task Ceiling_NegativeDecimal_RoundsUp() // ── Phase 1: IS_NAN Function (Bug Fix) ── public class QueryDeepDiveV10_IsNanTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":42,"name":"Alice","neg":-1}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsNan_WithNumber_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_NAN(c.val) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsNan_WithNull_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_NAN(null) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsNan_WithString_ReturnsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IS_NAN(c.name) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task IsNan_WithUndefined_ReturnsFalse() - { - await Seed(); - // IS_NAN on a missing field should return false, not crash - var results = await RunQuery("SELECT VALUE IS_NAN(c.nonExistent) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":42,"name":"Alice","neg":-1}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsNan_WithNumber_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_NAN(c.val) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsNan_WithNull_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_NAN(null) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsNan_WithString_ReturnsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IS_NAN(c.name) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task IsNan_WithUndefined_ReturnsFalse() + { + await Seed(); + // IS_NAN on a missing field should return false, not crash + var results = await RunQuery("SELECT VALUE IS_NAN(c.nonExistent) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } } // ── Phase 2: TYPE() Function ── public class QueryDeepDiveV10_TypeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","str":"hello","num":42,"boolVal":true,"nullVal":null,"arr":[1,2],"obj":{"x":1}}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Type_String_ReturnsString() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.str) FROM c"); - results.Should().ContainSingle().Which.Should().Be("string"); - } - - [Fact] - public async Task Type_Number_ReturnsNumber() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.num) FROM c"); - results.Should().ContainSingle().Which.Should().Be("number"); - } - - [Fact] - public async Task Type_Boolean_ReturnsBool() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.boolVal) FROM c"); - results.Should().ContainSingle().Which.Should().Be("boolean"); - } - - [Fact] - public async Task Type_Null_ReturnsNull() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.nullVal) FROM c"); - results.Should().ContainSingle().Which.Should().Be("null"); - } - - [Fact] - public async Task Type_Array_ReturnsArray() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.arr) FROM c"); - results.Should().ContainSingle().Which.Should().Be("array"); - } - - [Fact] - public async Task Type_Object_ReturnsObject() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TYPE(c.obj) FROM c"); - results.Should().ContainSingle().Which.Should().Be("object"); - } - - [Fact] - public async Task Type_Undefined_ReturnsNothing() - { - await Seed(); - // TYPE on a missing field — undefined propagates, no result emitted - var results = await RunQuery("SELECT VALUE TYPE(c.missing) FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","str":"hello","num":42,"boolVal":true,"nullVal":null,"arr":[1,2],"obj":{"x":1}}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Type_String_ReturnsString() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.str) FROM c"); + results.Should().ContainSingle().Which.Should().Be("string"); + } + + [Fact] + public async Task Type_Number_ReturnsNumber() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.num) FROM c"); + results.Should().ContainSingle().Which.Should().Be("number"); + } + + [Fact] + public async Task Type_Boolean_ReturnsBool() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.boolVal) FROM c"); + results.Should().ContainSingle().Which.Should().Be("boolean"); + } + + [Fact] + public async Task Type_Null_ReturnsNull() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.nullVal) FROM c"); + results.Should().ContainSingle().Which.Should().Be("null"); + } + + [Fact] + public async Task Type_Array_ReturnsArray() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.arr) FROM c"); + results.Should().ContainSingle().Which.Should().Be("array"); + } + + [Fact] + public async Task Type_Object_ReturnsObject() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TYPE(c.obj) FROM c"); + results.Should().ContainSingle().Which.Should().Be("object"); + } + + [Fact] + public async Task Type_Undefined_ReturnsNothing() + { + await Seed(); + // TYPE on a missing field — undefined propagates, no result emitted + var results = await RunQuery("SELECT VALUE TYPE(c.missing) FROM c"); + results.Should().BeEmpty(); + } } // ── Phase 3: STARTSWITH/ENDSWITH/CONTAINS null arg behaviour ── public class QueryDeepDiveV10_StringFunctionNullTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - // One doc with null name, one with a normal name - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StartsWith_NullInput_InSelectValue_ReturnsUndefined() - { - await Seed(); - // In Cosmos DB, STARTSWITH(null, 'A') returns undefined (row excluded from SELECT VALUE) - var results = await RunQuery("SELECT VALUE STARTSWITH(c.name, 'A') FROM c"); - // Only "Alice" matches; null name produces undefined → excluded - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task EndsWith_NullInput_InSelectValue_ReturnsUndefined() - { - await Seed(); - // ENDSWITH(null, 'e') returns undefined (row excluded) - var results = await RunQuery("SELECT VALUE ENDSWITH(c.name, 'e') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task Contains_NullInput_InSelectValue_ReturnsUndefined() - { - await Seed(); - // CONTAINS(null, 'li') returns undefined (row excluded) - var results = await RunQuery("SELECT VALUE CONTAINS(c.name, 'li') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task Length_NullInput_ReturnsUndefined() - { - await Seed(); - // LENGTH(null) → undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE LENGTH(c.name) FROM c"); - // Only Alice's length (5) should be returned; null name → undefined → excluded - results.Should().ContainSingle().Which.Should().Be(5); - } - - [Fact] - public async Task Upper_NullInput_ReturnsUndefined() - { - await Seed(); - // UPPER(null) → undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE UPPER(c.name) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ALICE"); - } - - [Fact] - public async Task Lower_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LOWER(c.name) FROM c"); - results.Should().ContainSingle().Which.Should().Be("alice"); - } - - [Fact] - public async Task Reverse_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REVERSE(c.name) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ecilA"); - } - - [Fact] - public async Task Trim_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE TRIM(c.name) FROM c"); - results.Should().ContainSingle().Which.Should().Be("Alice"); - } - - [Fact] - public async Task Replace_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REPLACE(c.name, 'A', 'Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be("Zlice"); - } - - [Fact] - public async Task Left_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LEFT(c.name, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be("Ali"); - } - - [Fact] - public async Task Right_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE RIGHT(c.name, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be("ice"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + // One doc with null name, one with a normal name + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StartsWith_NullInput_InSelectValue_ReturnsUndefined() + { + await Seed(); + // In Cosmos DB, STARTSWITH(null, 'A') returns undefined (row excluded from SELECT VALUE) + var results = await RunQuery("SELECT VALUE STARTSWITH(c.name, 'A') FROM c"); + // Only "Alice" matches; null name produces undefined → excluded + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task EndsWith_NullInput_InSelectValue_ReturnsUndefined() + { + await Seed(); + // ENDSWITH(null, 'e') returns undefined (row excluded) + var results = await RunQuery("SELECT VALUE ENDSWITH(c.name, 'e') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task Contains_NullInput_InSelectValue_ReturnsUndefined() + { + await Seed(); + // CONTAINS(null, 'li') returns undefined (row excluded) + var results = await RunQuery("SELECT VALUE CONTAINS(c.name, 'li') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task Length_NullInput_ReturnsUndefined() + { + await Seed(); + // LENGTH(null) → undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE LENGTH(c.name) FROM c"); + // Only Alice's length (5) should be returned; null name → undefined → excluded + results.Should().ContainSingle().Which.Should().Be(5); + } + + [Fact] + public async Task Upper_NullInput_ReturnsUndefined() + { + await Seed(); + // UPPER(null) → undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE UPPER(c.name) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ALICE"); + } + + [Fact] + public async Task Lower_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LOWER(c.name) FROM c"); + results.Should().ContainSingle().Which.Should().Be("alice"); + } + + [Fact] + public async Task Reverse_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REVERSE(c.name) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ecilA"); + } + + [Fact] + public async Task Trim_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE TRIM(c.name) FROM c"); + results.Should().ContainSingle().Which.Should().Be("Alice"); + } + + [Fact] + public async Task Replace_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REPLACE(c.name, 'A', 'Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be("Zlice"); + } + + [Fact] + public async Task Left_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LEFT(c.name, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be("Ali"); + } + + [Fact] + public async Task Right_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE RIGHT(c.name, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be("ice"); + } } // ── Phase 4: Math Edge Cases ── public class QueryDeepDiveV10_MathEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":10,"zero":0}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Cot_Zero_ReturnsUndefined() - { - await Seed(); - // cot(0) = 1/tan(0) = infinity → undefined - var results = await RunQuery("SELECT VALUE COT(c.zero) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Log_BaseOne_ReturnsUndefined() - { - await Seed(); - // LOG(10, 1) = infinity → undefined - var results = await RunQuery("SELECT VALUE LOG(c.val, 1) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Sqrt_Zero_ReturnsZero() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SQRT(c.zero) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0.0); - } - - [Fact] - public async Task Sign_Zero_ReturnsZero() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SIGN(c.zero) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task Round_NegativeHalf_RoundsAwayFromZero() - { - await Seed(); - // ROUND(-2.5) = -3 (away from zero) - var results = await RunQuery("SELECT VALUE ROUND(-2.5) FROM c"); - results.Should().ContainSingle().Which.Should().Be(-3.0); - } - - [Fact] - public async Task Atn2_BothZero_ReturnsZero() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ATN2(0, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0.0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":10,"zero":0}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Cot_Zero_ReturnsUndefined() + { + await Seed(); + // cot(0) = 1/tan(0) = infinity → undefined + var results = await RunQuery("SELECT VALUE COT(c.zero) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Log_BaseOne_ReturnsUndefined() + { + await Seed(); + // LOG(10, 1) = infinity → undefined + var results = await RunQuery("SELECT VALUE LOG(c.val, 1) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Sqrt_Zero_ReturnsZero() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SQRT(c.zero) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0.0); + } + + [Fact] + public async Task Sign_Zero_ReturnsZero() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SIGN(c.zero) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task Round_NegativeHalf_RoundsAwayFromZero() + { + await Seed(); + // ROUND(-2.5) = -3 (away from zero) + var results = await RunQuery("SELECT VALUE ROUND(-2.5) FROM c"); + results.Should().ContainSingle().Which.Should().Be(-3.0); + } + + [Fact] + public async Task Atn2_BothZero_ReturnsZero() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ATN2(0, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0.0); + } } // ── Phase 5: String Function Edge Cases ── public class QueryDeepDiveV10_StringEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Hello","empty":"","num":42}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Replicate_ZeroCount_ReturnsEmptyString() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REPLICATE(c.name, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task IndexOf_NotFound_ReturnsMinusOne() - { - await Seed(); - var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'xyz') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-1); - } - - [Fact] - public async Task Reverse_EmptyString_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE REVERSE(c.empty) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Left_ZeroCount_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE LEFT(c.name, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Right_ZeroCount_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE RIGHT(c.name, 0) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Substring_StartBeyondLength_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 100, 3) FROM c"); - results.Should().ContainSingle().Which.Should().Be(""); - } - - [Fact] - public async Task Length_OnNumber_ReturnsUndefined() - { - await Seed(); - // LENGTH() is for strings; passing a number → undefined (omitted from VALUE results) - var results = await RunQuery("SELECT VALUE LENGTH(c.num) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task IndexOf_WithStartPosition_Works() - { - await Seed(); - // INDEX_OF("Hello", "l", 3) — search for 'l' starting from index 3 - var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'l', 3) FROM c"); - // "Hello" has 'l' at index 2 and 3. Starting from 3, finds at index 3. - results.Should().ContainSingle().Which.Should().Be(3); - } - - [Fact] - public async Task Replace_EmptyFind_ReturnsOriginal() - { - await Seed(); - // REPLACE("Hello", "", "x") — empty search string - var results = await RunQuery("SELECT VALUE REPLACE(c.name, '', 'x') FROM c"); - // .NET String.Replace("", "x") inserts between every char: "xHxexlxlxox" - // Cosmos DB behaviour may differ — test current emulator behaviour - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Hello","empty":"","num":42}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Replicate_ZeroCount_ReturnsEmptyString() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REPLICATE(c.name, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task IndexOf_NotFound_ReturnsMinusOne() + { + await Seed(); + var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'xyz') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-1); + } + + [Fact] + public async Task Reverse_EmptyString_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE REVERSE(c.empty) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Left_ZeroCount_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE LEFT(c.name, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Right_ZeroCount_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE RIGHT(c.name, 0) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Substring_StartBeyondLength_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SUBSTRING(c.name, 100, 3) FROM c"); + results.Should().ContainSingle().Which.Should().Be(""); + } + + [Fact] + public async Task Length_OnNumber_ReturnsUndefined() + { + await Seed(); + // LENGTH() is for strings; passing a number → undefined (omitted from VALUE results) + var results = await RunQuery("SELECT VALUE LENGTH(c.num) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task IndexOf_WithStartPosition_Works() + { + await Seed(); + // INDEX_OF("Hello", "l", 3) — search for 'l' starting from index 3 + var results = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'l', 3) FROM c"); + // "Hello" has 'l' at index 2 and 3. Starting from 3, finds at index 3. + results.Should().ContainSingle().Which.Should().Be(3); + } + + [Fact] + public async Task Replace_EmptyFind_ReturnsOriginal() + { + await Seed(); + // REPLACE("Hello", "", "x") — empty search string + var results = await RunQuery("SELECT VALUE REPLACE(c.name, '', 'x') FROM c"); + // .NET String.Replace("", "x") inserts between every char: "xHxexlxlxox" + // Cosmos DB behaviour may differ — test current emulator behaviour + results.Should().ContainSingle(); + } } // ── Phase 6: Array Function Edge Cases ── public class QueryDeepDiveV10_ArrayEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","tags":["a","b","c"],"empty":[],"nums":[1,2,3]}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArraySlice_NegativeLength_ReturnsEmpty() - { - await Seed(); - // ARRAY_SLICE(arr, 0, -1) — negative length - var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.tags, 0, -1) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_EmptyArrays_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SetIntersect(c.empty, c.empty) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task SetUnion_Deduplication_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SetUnion(c.tags, c.tags) FROM c"); - // Union of ["a","b","c"] with itself = ["a","b","c"] - results.Should().ContainSingle().Which.Should().HaveCount(3); - } - - [Fact] - public async Task SetDifference_IdenticalArrays_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SetDifference(c.tags, c.tags) FROM c"); - results.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayContainsAll_EmptySearchArray_ReturnsTrue() - { - await Seed(); - // All of nothing = trivially true - var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, []) FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task ArrayContainsAny_EmptySearchArray_ReturnsFalse() - { - await Seed(); - // Any of nothing = false - var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, []) FROM c"); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","tags":["a","b","c"],"empty":[],"nums":[1,2,3]}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArraySlice_NegativeLength_ReturnsEmpty() + { + await Seed(); + // ARRAY_SLICE(arr, 0, -1) — negative length + var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.tags, 0, -1) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_EmptyArrays_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SetIntersect(c.empty, c.empty) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task SetUnion_Deduplication_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SetUnion(c.tags, c.tags) FROM c"); + // Union of ["a","b","c"] with itself = ["a","b","c"] + results.Should().ContainSingle().Which.Should().HaveCount(3); + } + + [Fact] + public async Task SetDifference_IdenticalArrays_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SetDifference(c.tags, c.tags) FROM c"); + results.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayContainsAll_EmptySearchArray_ReturnsTrue() + { + await Seed(); + // All of nothing = trivially true + var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, []) FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task ArrayContainsAny_EmptySearchArray_ReturnsFalse() + { + await Seed(); + // Any of nothing = false + var results = await RunQuery("SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, []) FROM c"); + results.Should().ContainSingle().Which.Should().BeFalse(); + } } // ── Phase 7: Aggregate Edge Cases ── public class QueryDeepDiveV10_AggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - // Three docs where "score" is null in all of them - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","score":null,"name":"Alice"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","score":null,"name":"Bob"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","name":"Charlie"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Count_Star_WithAllNullField_CountsAll() - { - await Seed(); - var results = await RunQuery("SELECT COUNT(1) AS cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(3); - } - - [Fact] - public async Task Count_NullField_ExcludesNulls() - { - await Seed(); - // COUNT(c.score) excludes nulls and undefined — should be 0 - var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); - results.Should().ContainSingle(); - results[0]["cnt"]!.Value().Should().Be(0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + // Three docs where "score" is null in all of them + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","score":null,"name":"Alice"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","score":null,"name":"Bob"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","name":"Charlie"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Count_Star_WithAllNullField_CountsAll() + { + await Seed(); + var results = await RunQuery("SELECT COUNT(1) AS cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(3); + } + + [Fact] + public async Task Count_NullField_ExcludesNulls() + { + await Seed(); + // COUNT(c.score) excludes nulls and undefined — should be 0 + var results = await RunQuery("SELECT COUNT(c.score) AS cnt FROM c"); + results.Should().ContainSingle(); + results[0]["cnt"]!.Value().Should().Be(0); + } } // ── Phase 8: Date/Time Edge Cases ── public class QueryDeepDiveV10_DateTimeEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","dt":"2024-01-15T10:30:00.0000000Z"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DateTimeDiff_SameDate_ReturnsZero() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimeDiff('day', c.dt, c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task DateTimeFromParts_Feb30_ReturnsUndefined() - { - await Seed(); - // Feb 30 is invalid — should return undefined - var results = await RunQuery("SELECT VALUE DateTimeFromParts(2024, 2, 30) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task DateTimeToTicks_InvalidFormat_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimeToTicks('not-a-date') FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","dt":"2024-01-15T10:30:00.0000000Z"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DateTimeDiff_SameDate_ReturnsZero() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimeDiff('day', c.dt, c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task DateTimeFromParts_Feb30_ReturnsUndefined() + { + await Seed(); + // Feb 30 is invalid — should return undefined + var results = await RunQuery("SELECT VALUE DateTimeFromParts(2024, 2, 30) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task DateTimeToTicks_InvalidFormat_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimeToTicks('not-a-date') FROM c"); + results.Should().BeEmpty(); + } } // ── Phase 9: WHERE/Conditional Edge Cases ── public class QueryDeepDiveV10_WhereConditionalTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","active":true,"val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","active":false,"val":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Iif_NumericCondition_ReturnsFalseArm() - { - await Seed(); - // IIF(1, 'yes', 'no') → 'no' because IIF requires strict bool, 1 is not bool - var results = await RunQuery("SELECT VALUE IIF(1, 'yes', 'no') FROM c"); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - [Fact] - public async Task Iif_NullCondition_ReturnsFalseArm() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c"); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - [Fact] - public async Task Where_BoolFieldAlone_FiltersCorrectly() - { - await Seed(); - // WHERE c.active (without = true) should filter to active docs - var results = await RunQuery("SELECT * FROM c WHERE c.active"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Where_DoubleNot_IsIdentity() - { - await Seed(); - // NOT NOT c.active should be equivalent to c.active - var results = await RunQuery("SELECT * FROM c WHERE NOT NOT c.active"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Coalesce_ChainedNulls_ReturnsFirstValue() - { - await Seed(); - // COALESCE with null and undefined args - var results = await RunQuery("SELECT VALUE (c.missing ?? null ?? c.val) FROM c"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Select_LiteralInProjection_Works() - { - await Seed(); - var results = await RunQuery("SELECT 42 AS constVal, c.id FROM c"); - results.Should().HaveCount(2); - results[0]["constVal"]!.Value().Should().Be(42); - } - - [Fact] - public async Task Select_ArithmeticInProjection_Works() - { - await Seed(); - var results = await RunQuery("SELECT c.val * 2 AS doubled FROM c"); - results.Should().HaveCount(2); - results.Should().Contain(r => r["doubled"]!.Value() == 20); - results.Should().Contain(r => r["doubled"]!.Value() == 40); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","active":true,"val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","active":false,"val":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Iif_NumericCondition_ReturnsFalseArm() + { + await Seed(); + // IIF(1, 'yes', 'no') → 'no' because IIF requires strict bool, 1 is not bool + var results = await RunQuery("SELECT VALUE IIF(1, 'yes', 'no') FROM c"); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + [Fact] + public async Task Iif_NullCondition_ReturnsFalseArm() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c"); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + [Fact] + public async Task Where_BoolFieldAlone_FiltersCorrectly() + { + await Seed(); + // WHERE c.active (without = true) should filter to active docs + var results = await RunQuery("SELECT * FROM c WHERE c.active"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Where_DoubleNot_IsIdentity() + { + await Seed(); + // NOT NOT c.active should be equivalent to c.active + var results = await RunQuery("SELECT * FROM c WHERE NOT NOT c.active"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Coalesce_ChainedNulls_ReturnsFirstValue() + { + await Seed(); + // COALESCE with null and undefined args + var results = await RunQuery("SELECT VALUE (c.missing ?? null ?? c.val) FROM c"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Select_LiteralInProjection_Works() + { + await Seed(); + var results = await RunQuery("SELECT 42 AS constVal, c.id FROM c"); + results.Should().HaveCount(2); + results[0]["constVal"]!.Value().Should().Be(42); + } + + [Fact] + public async Task Select_ArithmeticInProjection_Works() + { + await Seed(); + var results = await RunQuery("SELECT c.val * 2 AS doubled FROM c"); + results.Should().HaveCount(2); + results.Should().Contain(r => r["doubled"]!.Value() == 20); + results.Should().Contain(r => r["doubled"]!.Value() == 40); + } } // ── Phase 10: ORDER BY / GROUP BY / LIKE / BETWEEN / IN Edge Cases ── public class QueryDeepDiveV10_ClauseEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":10,"cat":"A","name":"A_B"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","val":20,"cat":"B","name":"ACB"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","val":30,"cat":"A","name":"AXB"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_UnderscoreMatchesSingleChar() - { - await Seed(); - // 'A_B' pattern: A, any single char, B - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A_B'"); - // "A_B" matches, "ACB" matches, "AXB" matches — all have A + 1 char + B - results.Should().HaveCount(3); - } - - [Fact] - public async Task Like_EmptyPattern_MatchesEmptyString() - { - await Seed(); - // Empty pattern should only match empty string - var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Between_ReversedBounds_ReturnsEmpty() - { - await Seed(); - // BETWEEN 30 AND 10 — reversed, nothing qualifies - var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 30 AND 10"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task In_MixedTypes_MatchesCorrectType() - { - await Seed(); - // IN with mixed types: number 10 should match, string '20' should NOT match number 20 - var results = await RunQuery("SELECT * FROM c WHERE c.val IN (10, '20')"); - // Only val=10 matches (strict type comparison) - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task NotBetween_Works() - { - await Seed(); - var results = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 10 AND 20"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("3"); - } - - [Fact] - public async Task NotIn_Works() - { - await Seed(); - var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 20)"); - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("3"); - } - - [Fact] - public async Task GroupBy_WithOrderBy_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC"); - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("A"); - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cat"]!.ToString().Should().Be("B"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":10,"cat":"A","name":"A_B"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","val":20,"cat":"B","name":"ACB"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","val":30,"cat":"A","name":"AXB"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_UnderscoreMatchesSingleChar() + { + await Seed(); + // 'A_B' pattern: A, any single char, B + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE 'A_B'"); + // "A_B" matches, "ACB" matches, "AXB" matches — all have A + 1 char + B + results.Should().HaveCount(3); + } + + [Fact] + public async Task Like_EmptyPattern_MatchesEmptyString() + { + await Seed(); + // Empty pattern should only match empty string + var results = await RunQuery("SELECT * FROM c WHERE c.name LIKE ''"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Between_ReversedBounds_ReturnsEmpty() + { + await Seed(); + // BETWEEN 30 AND 10 — reversed, nothing qualifies + var results = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 30 AND 10"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task In_MixedTypes_MatchesCorrectType() + { + await Seed(); + // IN with mixed types: number 10 should match, string '20' should NOT match number 20 + var results = await RunQuery("SELECT * FROM c WHERE c.val IN (10, '20')"); + // Only val=10 matches (strict type comparison) + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task NotBetween_Works() + { + await Seed(); + var results = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 10 AND 20"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("3"); + } + + [Fact] + public async Task NotIn_Works() + { + await Seed(); + var results = await RunQuery("SELECT * FROM c WHERE c.val NOT IN (10, 20)"); + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("3"); + } + + [Fact] + public async Task GroupBy_WithOrderBy_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY c.cat ASC"); + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("A"); + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cat"]!.ToString().Should().Be("B"); + } } // ── Phase 11: StringToNull function ── public class QueryDeepDiveV10_StringToNullTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":"null","other":"hello"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StringToNull_NullString_ReturnsNull() - { - await Seed(); - // StringToNull("null") → null - var results = await RunQuery("SELECT StringToNull(c.val) AS result FROM c"); - results.Should().ContainSingle(); - results[0]["result"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task StringToNull_OtherString_ReturnsUndefined() - { - await Seed(); - // StringToNull("hello") → undefined (excluded from SELECT VALUE) - var results = await RunQuery("SELECT VALUE StringToNull(c.other) FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":"null","other":"hello"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StringToNull_NullString_ReturnsNull() + { + await Seed(); + // StringToNull("null") → null + var results = await RunQuery("SELECT StringToNull(c.val) AS result FROM c"); + results.Should().ContainSingle(); + results[0]["result"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task StringToNull_OtherString_ReturnsUndefined() + { + await Seed(); + // StringToNull("hello") → undefined (excluded from SELECT VALUE) + var results = await RunQuery("SELECT VALUE StringToNull(c.other) FROM c"); + results.Should().BeEmpty(); + } } // ── Phase 12: Cross-Feature Interactions ── public class QueryDeepDiveV10_CrossFeatureTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","cat":"X","val":10,"tags":["a","b"]}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","cat":"Y","val":20,"tags":["c"]}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","cat":"X","val":30,"tags":["a"]}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_WithGroupBy_AndHaving() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE COUNT(1) FROM c GROUP BY c.cat HAVING COUNT(1) > 1"); - results.Should().ContainSingle().Which.Should().Be(2); - } - - [Fact] - public async Task DistinctWithOrderBy_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat ASC"); - results.Should().HaveCount(2); - results[0].Should().Be("X"); - results[1].Should().Be("Y"); - } - - [Fact] - public async Task TopWithGroupBy_LimitsGroups() - { - await Seed(); - var results = await RunQuery( - "SELECT TOP 1 c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Join_WithGroupBy_AggregatesCorrectly() - { - await Seed(); - // Join expands tags, then group by tag and count - var results = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); - results.Should().HaveCount(3); // "a" (2 times), "b" (1 time), "c" (1 time) - var tagA = results.FirstOrDefault(r => r["tag"]!.ToString() == "a"); - tagA.Should().NotBeNull(); - tagA!["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","cat":"X","val":10,"tags":["a","b"]}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","cat":"Y","val":20,"tags":["c"]}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","cat":"X","val":30,"tags":["a"]}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_WithGroupBy_AndHaving() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE COUNT(1) FROM c GROUP BY c.cat HAVING COUNT(1) > 1"); + results.Should().ContainSingle().Which.Should().Be(2); + } + + [Fact] + public async Task DistinctWithOrderBy_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat ASC"); + results.Should().HaveCount(2); + results[0].Should().Be("X"); + results[1].Should().Be("Y"); + } + + [Fact] + public async Task TopWithGroupBy_LimitsGroups() + { + await Seed(); + var results = await RunQuery( + "SELECT TOP 1 c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Join_WithGroupBy_AggregatesCorrectly() + { + await Seed(); + // Join expands tags, then group by tag and count + var results = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); + results.Should().HaveCount(3); // "a" (2 times), "b" (1 time), "c" (1 time) + var tagA = results.FirstOrDefault(r => r["tag"]!.ToString() == "a"); + tagA.Should().NotBeNull(); + tagA!["cnt"]!.Value().Should().Be(2); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -16673,593 +16683,593 @@ public async Task Join_WithGroupBy_AggregatesCorrectly() // ── Category A: Function/Operator Edge Cases ── public class QueryDeepDiveV11_FunctionEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"tags":["a","b"],"nullField":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"tags":["c"],"nullField":null}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── A1: CONCAT with non-string args ── - [Fact] - public async Task Concat_WithNonStringArgs_ReturnsUndefined() - { - await Seed(); - // Cosmos DB CONCAT requires all args to be strings; non-string args return undefined - var results = await RunQuery("SELECT VALUE CONCAT('val:', c.value) FROM c ORDER BY c.value ASC"); - results.Should().BeEmpty(); - } - - // ── A2: CONCAT with null arg ── - [Fact] - public async Task Concat_WithNullArg_ReturnsUndefined() - { - await Seed(); - // In Cosmos DB, CONCAT returns undefined if any argument is null or undefined - var results = await RunQuery("SELECT VALUE CONCAT('hi', null, 'there') FROM c"); - // If CONCAT propagates undefined for null args, SELECT VALUE excludes them - // If it returns "hinullthere", we get 2 results - // Real Cosmos DB: CONCAT with null → undefined - results.Should().BeEmpty(); - } - - // ── A3: REPLICATE negative count ── - [Fact] - public async Task Replicate_NegativeCount_ReturnsUndefined() - { - await Seed(); - // REPLICATE('x', -1) → undefined in Cosmos DB - var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); - results.Should().BeEmpty(); - } - - // ── A4: REPLICATE large count (>10000) ── - [Fact] - public async Task Replicate_LargeCount_ReturnsUndefined() - { - await Seed(); - // Cosmos DB caps REPLICATE at 10000 repetitions, returning undefined above that - var results = await RunQuery("SELECT VALUE REPLICATE('a', 10001) FROM c"); - results.Should().BeEmpty(); - } - - // ── A5: ROUND with negative precision ── - [Fact] - public async Task Round_NegativePrecision_RoundsToNearestPowerOfTen() - { - await Seed(); - // ROUND(1234.5, -2) should round to nearest 100 → 1200 - var results = await RunQuery("SELECT VALUE ROUND(1234.5, -2) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(1200); - } - - // ── A6: NumberBin with zero binSize ── - [Fact] - public async Task NumberBin_ZeroBinSize_ReturnsUndefined() - { - await Seed(); - // NumberBin(42, 0) → division by zero → undefined - var results = await RunQuery("SELECT VALUE NumberBin(42, 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── A7: NumberBin with negative binSize ── - [Fact] - public async Task NumberBin_NegativeBinSize_Works() - { - await Seed(); - // NumberBin(42, -5) - Cosmos DB behavior with negative bin sizes - // This should either work (returning a valid bin) or return undefined - var results = await RunQuery("SELECT VALUE NumberBin(42, -5) FROM c"); - // Real Cosmos: NumberBin with negative bin returns undefined - results.Should().BeEmpty(); - } - - // ── A8: ARRAY_SLICE with zero length ── - [Fact] - public async Task ArraySlice_ZeroLength_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.tags, 0, 0) FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeEmpty(); - } - - // ── A9: SET_INTERSECT with empty array ── - [Fact] - public async Task SetIntersect_EmptyWithNonEmpty_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE SetIntersect([], c.tags) FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeEmpty(); - } - - // ── A10: SET_INTERSECT both non-empty ── - [Fact] - public async Task SetIntersect_BothNonEmpty_ReturnsCommon() - { - await Seed(); - // First doc has tags ["a","b"], intersect with ["b","c"] → ["b"] - var results = await RunQuery( - "SELECT VALUE SetIntersect(c.tags, ['b', 'c']) FROM c ORDER BY c.id ASC"); - results.Should().HaveCount(2); - results[0].Should().ContainSingle().Which.Value().Should().Be("b"); // ["a","b"] ∩ ["b","c"] = ["b"] - results[1].Should().ContainSingle().Which.Value().Should().Be("c"); // ["c"] ∩ ["b","c"] = ["c"] - } - - // ── A11: SET_UNION with duplicates ── - [Fact] - public async Task SetUnion_WithDuplicates_Deduplicates() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SetUnion(c.tags, ['b', 'z']) FROM c ORDER BY c.id ASC"); - results.Should().HaveCount(2); - // First doc ["a","b"] ∪ ["b","z"] → ["a","b","z"] - results[0].Count.Should().Be(3); - results[0].Select(t => t.Value()).Should().Contain("a").And.Contain("b").And.Contain("z"); - } - - // ── A12: SET_DIFFERENCE ── - [Fact] - public async Task SetDifference_RemovesMatched() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SetDifference(c.tags, ['b']) FROM c ORDER BY c.id ASC"); - results.Should().HaveCount(2); - // First doc ["a","b"] - ["b"] → ["a"] - results[0].Should().ContainSingle().Which.Value().Should().Be("a"); - // Second doc ["c"] - ["b"] → ["c"] - results[1].Should().ContainSingle().Which.Value().Should().Be("c"); - } - - // ── A13: StringSplit with empty delimiter ── - [Fact] - public async Task StringSplit_EmptyDelimiter_ReturnsSingleElement() - { - await Seed(); - // StringSplit('abc', '') - when delimiter is empty, Cosmos returns undefined - var results = await RunQuery("SELECT VALUE StringSplit('abc', '') FROM c"); - // Real Cosmos: empty delimiter → undefined - results.Should().BeEmpty(); - } - - // ── A14: StringSplit empty string ── - [Fact] - public async Task StringSplit_EmptyString_ReturnsArrayWithEmpty() - { - await Seed(); - var results = await RunQuery("SELECT VALUE StringSplit('', ',') FROM c"); - results.Should().HaveCount(2); - results[0].Should().ContainSingle().Which.Value().Should().Be(""); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"tags":["a","b"],"nullField":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"tags":["c"],"nullField":null}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── A1: CONCAT with non-string args ── + [Fact] + public async Task Concat_WithNonStringArgs_ReturnsUndefined() + { + await Seed(); + // Cosmos DB CONCAT requires all args to be strings; non-string args return undefined + var results = await RunQuery("SELECT VALUE CONCAT('val:', c.value) FROM c ORDER BY c.value ASC"); + results.Should().BeEmpty(); + } + + // ── A2: CONCAT with null arg ── + [Fact] + public async Task Concat_WithNullArg_ReturnsUndefined() + { + await Seed(); + // In Cosmos DB, CONCAT returns undefined if any argument is null or undefined + var results = await RunQuery("SELECT VALUE CONCAT('hi', null, 'there') FROM c"); + // If CONCAT propagates undefined for null args, SELECT VALUE excludes them + // If it returns "hinullthere", we get 2 results + // Real Cosmos DB: CONCAT with null → undefined + results.Should().BeEmpty(); + } + + // ── A3: REPLICATE negative count ── + [Fact] + public async Task Replicate_NegativeCount_ReturnsUndefined() + { + await Seed(); + // REPLICATE('x', -1) → undefined in Cosmos DB + var results = await RunQuery("SELECT VALUE REPLICATE('x', -1) FROM c"); + results.Should().BeEmpty(); + } + + // ── A4: REPLICATE large count (>10000) ── + [Fact] + public async Task Replicate_LargeCount_ReturnsUndefined() + { + await Seed(); + // Cosmos DB caps REPLICATE at 10000 repetitions, returning undefined above that + var results = await RunQuery("SELECT VALUE REPLICATE('a', 10001) FROM c"); + results.Should().BeEmpty(); + } + + // ── A5: ROUND with negative precision ── + [Fact] + public async Task Round_NegativePrecision_RoundsToNearestPowerOfTen() + { + await Seed(); + // ROUND(1234.5, -2) should round to nearest 100 → 1200 + var results = await RunQuery("SELECT VALUE ROUND(1234.5, -2) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(1200); + } + + // ── A6: NumberBin with zero binSize ── + [Fact] + public async Task NumberBin_ZeroBinSize_ReturnsUndefined() + { + await Seed(); + // NumberBin(42, 0) → division by zero → undefined + var results = await RunQuery("SELECT VALUE NumberBin(42, 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── A7: NumberBin with negative binSize ── + [Fact] + public async Task NumberBin_NegativeBinSize_Works() + { + await Seed(); + // NumberBin(42, -5) - Cosmos DB behavior with negative bin sizes + // This should either work (returning a valid bin) or return undefined + var results = await RunQuery("SELECT VALUE NumberBin(42, -5) FROM c"); + // Real Cosmos: NumberBin with negative bin returns undefined + results.Should().BeEmpty(); + } + + // ── A8: ARRAY_SLICE with zero length ── + [Fact] + public async Task ArraySlice_ZeroLength_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE ARRAY_SLICE(c.tags, 0, 0) FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeEmpty(); + } + + // ── A9: SET_INTERSECT with empty array ── + [Fact] + public async Task SetIntersect_EmptyWithNonEmpty_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE SetIntersect([], c.tags) FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeEmpty(); + } + + // ── A10: SET_INTERSECT both non-empty ── + [Fact] + public async Task SetIntersect_BothNonEmpty_ReturnsCommon() + { + await Seed(); + // First doc has tags ["a","b"], intersect with ["b","c"] → ["b"] + var results = await RunQuery( + "SELECT VALUE SetIntersect(c.tags, ['b', 'c']) FROM c ORDER BY c.id ASC"); + results.Should().HaveCount(2); + results[0].Should().ContainSingle().Which.Value().Should().Be("b"); // ["a","b"] ∩ ["b","c"] = ["b"] + results[1].Should().ContainSingle().Which.Value().Should().Be("c"); // ["c"] ∩ ["b","c"] = ["c"] + } + + // ── A11: SET_UNION with duplicates ── + [Fact] + public async Task SetUnion_WithDuplicates_Deduplicates() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SetUnion(c.tags, ['b', 'z']) FROM c ORDER BY c.id ASC"); + results.Should().HaveCount(2); + // First doc ["a","b"] ∪ ["b","z"] → ["a","b","z"] + results[0].Count.Should().Be(3); + results[0].Select(t => t.Value()).Should().Contain("a").And.Contain("b").And.Contain("z"); + } + + // ── A12: SET_DIFFERENCE ── + [Fact] + public async Task SetDifference_RemovesMatched() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SetDifference(c.tags, ['b']) FROM c ORDER BY c.id ASC"); + results.Should().HaveCount(2); + // First doc ["a","b"] - ["b"] → ["a"] + results[0].Should().ContainSingle().Which.Value().Should().Be("a"); + // Second doc ["c"] - ["b"] → ["c"] + results[1].Should().ContainSingle().Which.Value().Should().Be("c"); + } + + // ── A13: StringSplit with empty delimiter ── + [Fact] + public async Task StringSplit_EmptyDelimiter_ReturnsSingleElement() + { + await Seed(); + // StringSplit('abc', '') - when delimiter is empty, Cosmos returns undefined + var results = await RunQuery("SELECT VALUE StringSplit('abc', '') FROM c"); + // Real Cosmos: empty delimiter → undefined + results.Should().BeEmpty(); + } + + // ── A14: StringSplit empty string ── + [Fact] + public async Task StringSplit_EmptyString_ReturnsArrayWithEmpty() + { + await Seed(); + var results = await RunQuery("SELECT VALUE StringSplit('', ',') FROM c"); + results.Should().HaveCount(2); + results[0].Should().ContainSingle().Which.Value().Should().Be(""); + } } // ── Category B: Clause/Operator Edge Cases ── public class QueryDeepDiveV11_ClauseEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"date":"2024-06-15"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"date":"2024-03-01"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","name":"Charlie","value":30,"isActive":true,"date":"2025-01-20"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── B1–B4: Arithmetic on null ── - [Fact] - public async Task ArithmeticOnNull_Addition_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE null + 1 FROM c"); - results.Should().BeEmpty(); // undefined excluded by SELECT VALUE - } - - [Fact] - public async Task ArithmeticOnNull_Multiplication_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE null * 2 FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArithmeticOnNull_Subtraction_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE null - 3 FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task ArithmeticOnNull_Modulo_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE null % 2 FROM c"); - results.Should().BeEmpty(); - } - - // ── B5–B6: Mixed type comparisons ── - [Fact] - public async Task MixedTypeComparison_NumberVsString_NoMatch() - { - await Seed(); - // value=10 (number) should NOT match '10' (string) - var results = await RunQuery("SELECT * FROM c WHERE c.value = '10'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task MixedTypeComparison_BoolVsString_NoMatch() - { - await Seed(); - // isActive=true (bool) should NOT match 'true' (string) - var results = await RunQuery("SELECT * FROM c WHERE c.isActive = 'true'"); - results.Should().BeEmpty(); - } - - // ── B7: DISTINCT multiple fields ── - [Fact] - public async Task DistinctMultipleFields_ReturnsUniqueCombinations() - { - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT c.pk, c.isActive FROM c"); - // pk="a" + isActive=true (2 docs), pk="a" + isActive=false (1 doc) → 2 combos - results.Should().HaveCount(2); - } - - // ── B8: BETWEEN with date strings ── - [Fact] - public async Task Between_WithDateStrings_LexicographicRange() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.date BETWEEN '2024-01-01' AND '2024-12-31'"); - // "2024-06-15" and "2024-03-01" are in range, "2025-01-20" is not - results.Should().HaveCount(2); - } - - // ── B9–B10: IIF non-boolean conditions ── - [Fact] - public async Task IIF_NumberCondition_TreatedAsFalse() - { - await Seed(); - // IIF(1, 'yes', 'no') → 'no' because only boolean true is truthy - var results = await RunQuery("SELECT VALUE IIF(1, 'yes', 'no') FROM c"); - results.Should().HaveCount(3); - results.Should().AllBeEquivalentTo("no"); - } - - [Fact] - public async Task IIF_StringCondition_TreatedAsFalse() - { - await Seed(); - var results = await RunQuery("SELECT VALUE IIF('true', 'yes', 'no') FROM c"); - results.Should().HaveCount(3); - results.Should().AllBeEquivalentTo("no"); - } - - // ── B11: LIKE with OR ── - [Fact] - public async Task LikeMultiplePatterns_WithOr_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'A%' OR c.name LIKE 'B%' ORDER BY c.name"); - results.Should().HaveCount(2); - results[0]["name"]!.Value().Should().Be("Alice"); - results[1]["name"]!.Value().Should().Be("Bob"); - } - - // ── B12: NOT on null ── - [Fact] - public async Task NotOperator_OnNull_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT VALUE NOT null FROM c"); - // NOT null → undefined (3-value logic), excluded by SELECT VALUE - results.Should().BeEmpty(); - } - - // ── B13: Coalesce three args ── - [Fact] - public async Task Coalesce_ThreeArgs_ChainsCorrectly() - { - await Seed(); - // Test chaining ?? with multiple nullish values - // c.nonExistent is undefined, c.alsoMissing undefined → 'fallback' - var results = await RunQuery( - "SELECT VALUE c.nonExistent ?? c.alsoMissing ?? 'fallback' FROM c"); - results.Should().HaveCount(3); - results.Should().AllBeEquivalentTo("fallback"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Alice","value":10,"isActive":true,"date":"2024-06-15"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","name":"Bob","value":20,"isActive":false,"date":"2024-03-01"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","name":"Charlie","value":30,"isActive":true,"date":"2025-01-20"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── B1–B4: Arithmetic on null ── + [Fact] + public async Task ArithmeticOnNull_Addition_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE null + 1 FROM c"); + results.Should().BeEmpty(); // undefined excluded by SELECT VALUE + } + + [Fact] + public async Task ArithmeticOnNull_Multiplication_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE null * 2 FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArithmeticOnNull_Subtraction_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE null - 3 FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task ArithmeticOnNull_Modulo_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE null % 2 FROM c"); + results.Should().BeEmpty(); + } + + // ── B5–B6: Mixed type comparisons ── + [Fact] + public async Task MixedTypeComparison_NumberVsString_NoMatch() + { + await Seed(); + // value=10 (number) should NOT match '10' (string) + var results = await RunQuery("SELECT * FROM c WHERE c.value = '10'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task MixedTypeComparison_BoolVsString_NoMatch() + { + await Seed(); + // isActive=true (bool) should NOT match 'true' (string) + var results = await RunQuery("SELECT * FROM c WHERE c.isActive = 'true'"); + results.Should().BeEmpty(); + } + + // ── B7: DISTINCT multiple fields ── + [Fact] + public async Task DistinctMultipleFields_ReturnsUniqueCombinations() + { + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT c.pk, c.isActive FROM c"); + // pk="a" + isActive=true (2 docs), pk="a" + isActive=false (1 doc) → 2 combos + results.Should().HaveCount(2); + } + + // ── B8: BETWEEN with date strings ── + [Fact] + public async Task Between_WithDateStrings_LexicographicRange() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.date BETWEEN '2024-01-01' AND '2024-12-31'"); + // "2024-06-15" and "2024-03-01" are in range, "2025-01-20" is not + results.Should().HaveCount(2); + } + + // ── B9–B10: IIF non-boolean conditions ── + [Fact] + public async Task IIF_NumberCondition_TreatedAsFalse() + { + await Seed(); + // IIF(1, 'yes', 'no') → 'no' because only boolean true is truthy + var results = await RunQuery("SELECT VALUE IIF(1, 'yes', 'no') FROM c"); + results.Should().HaveCount(3); + results.Should().AllBeEquivalentTo("no"); + } + + [Fact] + public async Task IIF_StringCondition_TreatedAsFalse() + { + await Seed(); + var results = await RunQuery("SELECT VALUE IIF('true', 'yes', 'no') FROM c"); + results.Should().HaveCount(3); + results.Should().AllBeEquivalentTo("no"); + } + + // ── B11: LIKE with OR ── + [Fact] + public async Task LikeMultiplePatterns_WithOr_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'A%' OR c.name LIKE 'B%' ORDER BY c.name"); + results.Should().HaveCount(2); + results[0]["name"]!.Value().Should().Be("Alice"); + results[1]["name"]!.Value().Should().Be("Bob"); + } + + // ── B12: NOT on null ── + [Fact] + public async Task NotOperator_OnNull_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT VALUE NOT null FROM c"); + // NOT null → undefined (3-value logic), excluded by SELECT VALUE + results.Should().BeEmpty(); + } + + // ── B13: Coalesce three args ── + [Fact] + public async Task Coalesce_ThreeArgs_ChainsCorrectly() + { + await Seed(); + // Test chaining ?? with multiple nullish values + // c.nonExistent is undefined, c.alsoMissing undefined → 'fallback' + var results = await RunQuery( + "SELECT VALUE c.nonExistent ?? c.alsoMissing ?? 'fallback' FROM c"); + results.Should().HaveCount(3); + results.Should().AllBeEquivalentTo("fallback"); + } } // ── Category C: Cross-Feature Interactions ── public class QueryDeepDiveV11_CrossFeatureTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Alice","value":10,"tags":["x","y"],"nested":{"deep":{"value":42}}}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"b","name":"Bob","value":20,"tags":["z"],"nested":{"deep":{}}}""")), - new PartitionKey("b")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","name":"Charlie","value":30,"tags":[],"nested":{}}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── C1: JOIN on non-array (string) field ── - [Fact] - public async Task JoinOnNonArrayField_ProducesNoRows() - { - await Seed(); - // c.name is a string, not an array – JOIN should produce 0 expansion rows - var results = await RunQuery( - "SELECT c.id, t AS letter FROM c JOIN t IN c.name"); - results.Should().BeEmpty(); - } - - // ── C2: JOIN on null field ── - [Fact] - public async Task JoinOnNullField_ProducesNoRows() - { - var container = new InMemoryContainer("join-null-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","items":null}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, t AS item FROM c JOIN t IN c.items"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - // ── C3: JOIN on undefined (missing) field ── - [Fact] - public async Task JoinOnUndefinedField_ProducesNoRows() - { - var container = new InMemoryContainer("join-undef-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, t AS item FROM c JOIN t IN c.nonExistent"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - // ── C4: IS_DEFINED on deeply nested path ── - [Fact] - public async Task IsDefined_DeeplyNested_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE IS_DEFINED(c.nested.deep.value)"); - // Only doc 1 has nested.deep.value=42 - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── C5: DISTINCT with computed ORDER BY ── - [Fact] - public async Task DistinctWithComputedOrderBy_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.pk FROM c ORDER BY c.pk ASC"); - results.Should().HaveCount(2); - results[0].Should().Be("a"); - results[1].Should().Be("b"); - } - - // ── C6: Cross-partition pagination with continuation tokens ── - [Fact] - public async Task CrossPartition_ContinuationToken_PaginatesAllResults() - { - await Seed(); - var allResults = new List(); - string? continuationToken = null; - - do - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.id ASC", - continuationToken: continuationToken, - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - - var response = await iterator.ReadNextAsync(); - allResults.AddRange(response); - continuationToken = response.ContinuationToken; - } while (continuationToken != null); - - allResults.Should().HaveCount(3); - allResults[0]["id"]!.Value().Should().Be("1"); - allResults[1]["id"]!.Value().Should().Be("2"); - allResults[2]["id"]!.Value().Should().Be("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Alice","value":10,"tags":["x","y"],"nested":{"deep":{"value":42}}}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"b","name":"Bob","value":20,"tags":["z"],"nested":{"deep":{}}}""")), + new PartitionKey("b")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","name":"Charlie","value":30,"tags":[],"nested":{}}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── C1: JOIN on non-array (string) field ── + [Fact] + public async Task JoinOnNonArrayField_ProducesNoRows() + { + await Seed(); + // c.name is a string, not an array – JOIN should produce 0 expansion rows + var results = await RunQuery( + "SELECT c.id, t AS letter FROM c JOIN t IN c.name"); + results.Should().BeEmpty(); + } + + // ── C2: JOIN on null field ── + [Fact] + public async Task JoinOnNullField_ProducesNoRows() + { + var container = new InMemoryContainer("join-null-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","items":null}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, t AS item FROM c JOIN t IN c.items"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + // ── C3: JOIN on undefined (missing) field ── + [Fact] + public async Task JoinOnUndefinedField_ProducesNoRows() + { + var container = new InMemoryContainer("join-undef-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, t AS item FROM c JOIN t IN c.nonExistent"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + // ── C4: IS_DEFINED on deeply nested path ── + [Fact] + public async Task IsDefined_DeeplyNested_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE IS_DEFINED(c.nested.deep.value)"); + // Only doc 1 has nested.deep.value=42 + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── C5: DISTINCT with computed ORDER BY ── + [Fact] + public async Task DistinctWithComputedOrderBy_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.pk FROM c ORDER BY c.pk ASC"); + results.Should().HaveCount(2); + results[0].Should().Be("a"); + results[1].Should().Be("b"); + } + + // ── C6: Cross-partition pagination with continuation tokens ── + [Fact] + public async Task CrossPartition_ContinuationToken_PaginatesAllResults() + { + await Seed(); + var allResults = new List(); + string? continuationToken = null; + + do + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.id ASC", + continuationToken: continuationToken, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + + var response = await iterator.ReadNextAsync(); + allResults.AddRange(response); + continuationToken = response.ContinuationToken; + } while (continuationToken != null); + + allResults.Should().HaveCount(3); + allResults[0]["id"]!.Value().Should().Be("1"); + allResults[1]["id"]!.Value().Should().Be("2"); + allResults[2]["id"]!.Value().Should().Be("3"); + } } // ── Category D: DateTime Function Gap Coverage ── public class QueryDeepDiveV11_DateTimeFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","dt":"2024-06-15T14:30:45.1234567Z"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── D1: DateTimeDiff same datetime → 0 ── - [Fact] - public async Task DateTimeDiff_SameDateTime_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('hh', c.dt, c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0); - } - - // ── D2: DateTimeDiff reversed args → negative ── - [Fact] - public async Task DateTimeDiff_ReversedArgs_ReturnsNegative() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', '2024-06-20T00:00:00Z', '2024-06-15T00:00:00Z') FROM c"); - results.Should().ContainSingle().Which.Should().Be(-5); - } - - // ── D3: DateTimeBin truncation ── - [Fact] - public async Task DateTimeBin_ToHour_TruncatesMinutesAndSeconds() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin(c.dt, 'hh', 1) FROM c"); - results.Should().ContainSingle(); - // 14:30:45 binned to hour → 14:00:00 - results[0].Should().Contain("14:00:00"); - } - - // ── D4: GetCurrentDateTime consistency in same query ── - [Fact] - public async Task GetCurrentDateTime_TwoCallsSameQuery_ReturnSameValue() - { - await Seed(); - var results = await RunQuery( - "SELECT GetCurrentDateTime() AS dt1, GetCurrentDateTime() AS dt2 FROM c"); - results.Should().ContainSingle(); - results[0]["dt1"]!.Value().Should().Be(results[0]["dt2"]!.Value()); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","dt":"2024-06-15T14:30:45.1234567Z"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── D1: DateTimeDiff same datetime → 0 ── + [Fact] + public async Task DateTimeDiff_SameDateTime_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('hh', c.dt, c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0); + } + + // ── D2: DateTimeDiff reversed args → negative ── + [Fact] + public async Task DateTimeDiff_ReversedArgs_ReturnsNegative() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', '2024-06-20T00:00:00Z', '2024-06-15T00:00:00Z') FROM c"); + results.Should().ContainSingle().Which.Should().Be(-5); + } + + // ── D3: DateTimeBin truncation ── + [Fact] + public async Task DateTimeBin_ToHour_TruncatesMinutesAndSeconds() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin(c.dt, 'hh', 1) FROM c"); + results.Should().ContainSingle(); + // 14:30:45 binned to hour → 14:00:00 + results[0].Should().Contain("14:00:00"); + } + + // ── D4: GetCurrentDateTime consistency in same query ── + [Fact] + public async Task GetCurrentDateTime_TwoCallsSameQuery_ReturnSameValue() + { + await Seed(); + var results = await RunQuery( + "SELECT GetCurrentDateTime() AS dt1, GetCurrentDateTime() AS dt2 FROM c"); + results.Should().ContainSingle(); + results[0]["dt1"]!.Value().Should().Be(results[0]["dt2"]!.Value()); + } } // ── Category E: Parameter Edge Cases ── public class QueryDeepDiveV11_ParameterTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Alice","nested":{"x":1}}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","name":"Bob","nested":{"x":2}}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── E1: Array parameter in ARRAY_CONTAINS ── - [Fact] - public async Task Parameter_ArrayValue_InArrayContains() - { - await Seed(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(@names, c.name)") - .WithParameter("@names", new[] { "Alice", "Charlie" }); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Alice"); - } - - // ── E2: Object parameter in equality ── - [Fact] - public async Task Parameter_ObjectValue_InEquality() - { - await Seed(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.nested = @obj") - .WithParameter("@obj", new { x = 1 }); - var results = await RunQuery(query); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Alice","nested":{"x":1}}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","name":"Bob","nested":{"x":2}}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── E1: Array parameter in ARRAY_CONTAINS ── + [Fact] + public async Task Parameter_ArrayValue_InArrayContains() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(@names, c.name)") + .WithParameter("@names", new[] { "Alice", "Charlie" }); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Alice"); + } + + // ── E2: Object parameter in equality ── + [Fact] + public async Task Parameter_ObjectValue_InEquality() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.nested = @obj") + .WithParameter("@obj", new { x = 1 }); + var results = await RunQuery(query); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -17268,791 +17278,791 @@ public async Task Parameter_ObjectValue_InEquality() // ══════════════════════════════════════════════════════════════════════════════ public class QueryDeepDiveV12_EdgeCaseTests { - private readonly InMemoryContainer _container = new("edge-case-test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","value":10,"name":"Alice","tags":["x","y"]}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","value":20,"name":"Bob","tags":["y","z"]}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── A1: Arithmetic integer overflow (long.MaxValue + 1) ── - // Real Cosmos DB returns a double when integer addition overflows. - // Our ArithmeticOp uses doubles internally, so long.MaxValue + 1 should - // return 9223372036854775808.0 (double), not wrap to long.MinValue. - [Fact] - public async Task IntegerOverflow_Addition_ReturnsDouble() - { - await Seed(); - var results = await RunQuery( - $"SELECT VALUE {long.MaxValue} + 1 FROM c"); - results.Should().HaveCount(2); - // long.MaxValue = 9223372036854775807; + 1 = 9223372036854775808 - // In doubles this loses precision but should not wrap to negative - results[0].Value().Should().BeGreaterThan(0); - } - - // ── A2: Arithmetic integer underflow (long.MinValue - 1) ── - [Fact] - public async Task IntegerUnderflow_Subtraction_ReturnsDouble() - { - await Seed(); - var results = await RunQuery( - $"SELECT VALUE {long.MinValue} - 1 FROM c"); - results.Should().HaveCount(2); - results[0].Value().Should().BeLessThan(0); - } - - // ── A3: Integer multiply overflow ── - [Fact] - public async Task IntegerOverflow_Multiplication_ReturnsDouble() - { - await Seed(); - var results = await RunQuery( - $"SELECT VALUE {long.MaxValue} * 2 FROM c"); - results.Should().HaveCount(2); - results[0].Value().Should().BeGreaterThan(0); - } - - // ── A4: INTBITLEFTSHIFT with shift >= 64 ── - // Shifting by >= 64 bits on a 64-bit integer is invalid → returns undefined. - [Fact] - public async Task IntBitLeftShift_LargeShift_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE INTBITLEFTSHIFT(1, 64) FROM c"); - // Undefined values are omitted by SELECT VALUE - results.Should().BeEmpty(); - } - - // ── A5: INTBITRIGHTSHIFT with shift >= 64 ── - [Fact] - public async Task IntBitRightShift_LargeShift_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE INTBITRIGHTSHIFT(1, 64) FROM c"); - results.Should().BeEmpty(); - } - - // ── A6: INTDIV by zero ── - // INTDIV by zero returns undefined (excluded from SELECT VALUE results). - [Fact] - public async Task IntDiv_ByZero_ReturnsUndefined() - { - await Seed(); - // INTDIV returns undefined for division by zero (not null, not an exception) - var results = await RunQuery( - "SELECT VALUE INTDIV(10, 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── A7: INTMOD by zero ── - [Fact] - public async Task IntMod_ByZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE INTMOD(10, 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── A8: ORDER BY with mixed null/undefined/number/string ── - // Cosmos DB type rank: undefined < null < boolean < number < string < array < object - [Fact] - public async Task OrderBy_MixedTypes_FollowsTypeRank() - { - var container = new InMemoryContainer("orderby-mixed-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","sortKey":42}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","sortKey":null}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","sortKey":"hello"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"4","pk":"a"}""")), - new PartitionKey("a")); // sortKey is undefined - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, c.sortKey FROM c ORDER BY c.sortKey ASC"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - // Undefined < null < number < string - results.Should().HaveCount(4); - results[0]["id"]!.Value().Should().Be("4"); // undefined first - results[1]["id"]!.Value().Should().Be("2"); // null - results[2]["id"]!.Value().Should().Be("1"); // number 42 - results[3]["id"]!.Value().Should().Be("3"); // string "hello" - } - - // ── A10: DATETIMEADD overflow ── - // Adding a very large number of years should not throw an unhandled exception. - [Fact] - public async Task DateTimeAdd_OverflowYear_ReturnsUndefined() - { - await Seed(); - // Adding 100000 years to year 2000 should overflow DateTime - var results = await RunQuery( - "SELECT VALUE DATETIMEADD('yyyy', 100000, '2000-01-01T00:00:00Z') FROM c"); - // Should return undefined (excluded by SELECT VALUE) rather than throw - results.Should().BeEmpty(); - } - - // ── A11: REPLICATE boundary 10000 ── - [Fact] - public async Task Replicate_ExactBoundary10000_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REPLICATE('a', 10000) FROM c"); - results.Should().HaveCount(2); - results[0].Value().Should().HaveLength(10000); - } - - // ── A12: REPLICATE over 10000 ── - [Fact] - public async Task Replicate_Over10000_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REPLICATE('a', 10001) FROM c"); - // Undefined excluded by SELECT VALUE - results.Should().BeEmpty(); - } - - // ── A13: INDEX_OF with empty search string ── - [Fact] - public async Task IndexOf_EmptySearchString_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE INDEX_OF('hello', '') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0); - } - - // ── A14: CONTAINS with empty search string ── - [Fact] - public async Task Contains_EmptySearchString_ReturnsTrue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CONTAINS('hello', '') FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeTrue(); - } - - // ── A15: STARTSWITH with empty prefix ── - [Fact] - public async Task StartsWith_EmptyPrefix_ReturnsTrue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE STARTSWITH('hello', '') FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeTrue(); - } - - // ── A16: ENDSWITH with empty suffix ── - [Fact] - public async Task EndsWith_EmptySuffix_ReturnsTrue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ENDSWITH('hello', '') FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeTrue(); - } - - // ── A17: POWER(0, 0) ── - [Fact] - public async Task Power_ZeroToZero_ReturnsOne() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE POWER(0, 0) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(1.0); - } - - // ── A18: POWER(0, -1) → Infinity → undefined ── - [Fact] - public async Task Power_ZeroToNegative_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE POWER(0, -1) FROM c"); - // POWER(0, -1) = 1/0 = Infinity → treated as undefined - results.Should().BeEmpty(); - } - - // ── A19: LOG(1) = 0 ── - [Fact] - public async Task Log_OfOne_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LOG(1) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0.0); - } - - // ── A20: LOG(0) = -Infinity → undefined ── - [Fact] - public async Task Log_OfZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LOG(0) FROM c"); - results.Should().BeEmpty(); - } - - // ── A21: SQRT(0) = 0 ── - [Fact] - public async Task Sqrt_OfZero_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SQRT(0) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0.0); - } - - // ── A22: TRUNC with very large double ── - [Fact] - public async Task Trunc_VeryLargeDouble_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TRUNC(1.0E+15) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(1.0E+15); - } - - // ── A23: NUMBERBIN with zero bin size ── - [Fact] - public async Task NumberBin_ZeroBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NUMBERBIN(42, 0) FROM c"); - // Zero bin size → division by zero → undefined - results.Should().BeEmpty(); - } - - // ── A24: ARRAY_SLICE with negative start beyond length ── - [Fact] - public async Task ArraySlice_NegativeStartBeyondLength_ClampsToStart() - { - await Seed(); - // tags has 2 elements; -10 should clamp to index 0 - var results = await RunQuery( - "SELECT VALUE ARRAY_SLICE(c.tags, -10) FROM c"); - results.Should().HaveCount(2); - results[0].Should().HaveCount(2); // returns full array - } - - // ── A25: STRINGTOARRAY with invalid JSON ── - [Fact] - public async Task StringToArray_InvalidJson_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE STRINGTOARRAY('not valid json') FROM c"); - results.Should().BeEmpty(); - } - - // ── A26: STRINGTOOBJECT with invalid JSON ── - [Fact] - public async Task StringToObject_InvalidJson_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE STRINGTOOBJECT('not valid json') FROM c"); - results.Should().BeEmpty(); - } - - // ── A27: SELECT VALUE with aggregate in subquery ── - [Fact] - public async Task SelectValue_AggregateSubquery_ReturnsScalar() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE COUNT(1) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(2); - } - - // ── A28: Nested COALESCE with mixed null/undefined ── - [Fact] - public async Task NestedCoalesce_MixedNullUndefined_ReturnsFirstNonUndefined() - { - await Seed(); - // c.missing is undefined → skip, null is defined → return null - // COALESCE only skips undefined, not null - var results = await RunQuery( - "SELECT VALUE COALESCE(c.missing, null, 'fallback') FROM c"); - results.Should().HaveCount(2); - results[0].Type.Should().Be(JTokenType.Null); - } - - // ── B3: INTBITLEFTSHIFT with negative shift ── - [Fact] - public async Task IntBitLeftShift_NegativeShift_ReturnsUndefined() - { - await Seed(); - // Negative shift amounts are invalid → undefined - var results = await RunQuery( - "SELECT VALUE INTBITLEFTSHIFT(1, -1) FROM c"); - results.Should().BeEmpty(); - } - - // ── B5: DATETIMEADD negative overflow ── - [Fact] - public async Task DateTimeAdd_NegativeOverflow_ReturnsUndefined() - { - await Seed(); - // Subtracting 100000 years from year 2000 should overflow DateTime.MinValue - var results = await RunQuery( - "SELECT VALUE DATETIMEADD('yyyy', -100000, '2000-01-01T00:00:00Z') FROM c"); - results.Should().BeEmpty(); - } - - // ── B6: DATETIMEPART nanosecond on various dates ── - [Fact] - public async Task DateTimePart_Nanosecond_ReturnsCorrectValue() - { - await Seed(); - // 2024-01-15T10:30:45.1234567Z → nanoseconds of the fractional second - // .1234567 = 1234567 ticks; nanoseconds = ticks % TicksPerSecond * 100 - // 1234567 ticks * 100 = 123456700 ns - var results = await RunQuery( - "SELECT VALUE DATETIMEPART('ns', '2024-01-15T10:30:45.1234567Z') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(123456700L); - } - - // ── Extra: NUMBERBIN negative value ── - [Fact] - public async Task NumberBin_NegativeValue_BinsCorrectly() - { - await Seed(); - // NUMBERBIN(-7, 5) → floor(-7/5)*5 = floor(-1.4)*5 = -2*5 = -10 - var results = await RunQuery( - "SELECT VALUE NUMBERBIN(-7, 5) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(-10.0); - } - - // ── Extra: NUMBERBIN negative bin size ── - [Fact] - public async Task NumberBin_NegativeBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NUMBERBIN(42, -5) FROM c"); - // Negative bin size → undefined - results.Should().BeEmpty(); - } - - // ── Extra: String concat operator with null ── - // In Cosmos DB, 'hello' || null → undefined (omitted by SELECT VALUE). - [Fact] - public async Task StringConcat_OperatorWithNull_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE 'hello' || null FROM c"); - results.Should().BeEmpty(); - } - - // ── Extra: Division by zero results in undefined ── - [Fact] - public async Task Division_ByZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE 1 / 0 FROM c"); - // Division by zero → NaN → undefined - results.Should().BeEmpty(); - } - - // ── Extra: Modulo by zero results in undefined ── - [Fact] - public async Task Modulo_ByZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE 10 % 0 FROM c"); - results.Should().BeEmpty(); - } - - // ── Extra: SIGN of zero ── - [Fact] - public async Task Sign_OfZero_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SIGN(0) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0.0); - } - - // ── Extra: SIGN of negative ── - [Fact] - public async Task Sign_OfNegative_ReturnsMinusOne() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SIGN(-42) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(-1.0); - } - - // ── Extra: CEILING of already-integer ── - [Fact] - public async Task Ceiling_OfWholeNumber_ReturnsSameValue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CEILING(5.0) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(5.0); - } - - // ── Extra: FLOOR of negative fraction ── - [Fact] - public async Task Floor_OfNegativeFraction_RoundsDown() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE FLOOR(-2.3) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(-3.0); - } - - // ── Extra: ABS of long.MinValue (edge case - could overflow) ── - [Fact] - public async Task Abs_OfNegativeNumber_ReturnsPositive() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ABS(-999) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(999.0); - } - - // ── Extra: REVERSE of empty string ── - [Fact] - public async Task Reverse_EmptyString_ReturnsEmpty() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REVERSE('') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(""); - } - - // ── Extra: LENGTH of empty string ── - [Fact] - public async Task Length_EmptyString_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LENGTH('') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0); - } - - // ── Extra: TRIM of already-trimmed string ── - [Fact] - public async Task Trim_AlreadyTrimmed_ReturnsSame() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TRIM('hello') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("hello"); - } - - // ── Extra: ARRAY_LENGTH of empty array ── - [Fact] - public async Task ArrayLength_EmptyArray_ReturnsZero() - { - var container = new InMemoryContainer("arr-empty-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","arr":[]}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE ARRAY_LENGTH(c.arr) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().Be(0); - } - - // ── Extra: ARRAY_CONCAT with empty arrays ── - [Fact] - public async Task ArrayConcat_WithEmptyArrays_ReturnsOtherArray() - { - var container = new InMemoryContainer("arr-concat-empty-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","a1":[1,2],"a2":[]}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE ARRAY_CONCAT(c.a1, c.a2) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().HaveCount(2); - } - - // ── Extra: IS_DEFINED on null vs missing ── - [Fact] - public async Task IsDefined_NullField_ReturnsTrue() - { - var container = new InMemoryContainer("def-null-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","field":null}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE IS_DEFINED(c.field) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().BeTrue(); // null IS defined; undefined is not - } - - // ── Extra: IS_DEFINED on missing field ── - [Fact] - public async Task IsDefined_MissingField_ReturnsFalse() - { - var container = new InMemoryContainer("def-missing-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE IS_DEFINED(c.missing) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().BeFalse(); - } - - // ── Extra: BETWEEN with equal bounds ── - [Fact] - public async Task Between_EqualBounds_IncludesBoundaryValue() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.value BETWEEN 10 AND 10"); - results.Should().ContainSingle(); - results[0]["value"]!.Value().Should().Be(10); - } - - // ── Extra: IN with single value ── - [Fact] - public async Task In_SingleValue_Works() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name IN ('Alice')"); - results.Should().ContainSingle(); - } - - // ── Extra: IN with mixed types ── - [Fact] - public async Task In_MixedTypes_MatchesCorrectType() - { - await Seed(); - // value=10 (number) should match 10 (number), not '10' (string) - var results = await RunQuery( - "SELECT * FROM c WHERE c.value IN ('10', 10)"); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Alice"); - } - - // ── Extra: NOT IN ── - [Fact] - public async Task NotIn_ExcludesMatchingValues() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name NOT IN ('Alice')"); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Bob"); - } - - // ── Extra: LIKE with underscore wildcard ── - [Fact] - public async Task Like_UnderscoreWildcard_MatchesSingleChar() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'Bo_'"); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Bob"); - } - - // ── Extra: LIKE with percent wildcard in middle ── - [Fact] - public async Task Like_PercentInMiddle_MatchesMultipleChars() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'A%e'"); - results.Should().ContainSingle(); - results[0]["name"]!.Value().Should().Be("Alice"); - } - - // ── Extra: TYPE function returns correct types ── - [Fact] - public async Task Type_ReturnsCorrectTypeName() - { - var container = new InMemoryContainer("type-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","num":42,"str":"hello","bool":true,"arr":[1],"obj":{"x":1},"nullVal":null}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE TYPE(c.num) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results[0].Should().Be("number"); - } - - // ── Extra: IS_NULL distinguishes null from undefined ── - [Fact] - public async Task IsNull_OnNullField_ReturnsTrue() - { - var container = new InMemoryContainer("isnull-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","field":null}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE IS_NULL(c.field) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results[0].Should().BeTrue(); - } - - // ── Extra: IS_NULL on undefined (missing) returns false ── - // In Cosmos DB, IS_NULL(undefined) returns false (not undefined). - // The field doesn't exist, so it's not null; it's undefined. - [Fact] - public async Task IsNull_OnMissingField_ReturnsFalse() - { - var container = new InMemoryContainer("isnull-missing-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE IS_NULL(c.missing) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - results[0].Should().BeFalse(); - } - - // ── Extra: CHOOSE with 1-based index ── - [Fact] - public async Task Choose_ValidIndex_ReturnsCorrectValue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CHOOSE(2, 'a', 'b', 'c') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("b"); - } - - // ── Extra: CHOOSE with zero index ── - [Fact] - public async Task Choose_ZeroIndex_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE CHOOSE(0, 'a', 'b', 'c') FROM c"); - // CHOOSE is 1-based; 0 is out of bounds → undefined - results.Should().BeEmpty(); - } - - // ── Extra: SETINTERSECT with empty result ── - [Fact] - public async Task SetIntersect_NoOverlap_ReturnsEmptyArray() - { - var container = new InMemoryContainer("setint-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","a1":[1,2,3],"a2":[4,5,6]}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE SETINTERSECT(c.a1, c.a2) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().BeEmpty(); - } - - // ── Extra: SETUNION deduplicates ── - [Fact] - public async Task SetUnion_WithOverlap_Deduplicates() - { - var container = new InMemoryContainer("setunion-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","a1":[1,2,3],"a2":[2,3,4]}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT VALUE SETUNION(c.a1, c.a2) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0].Should().HaveCount(4); // {1,2,3,4} - } + private readonly InMemoryContainer _container = new("edge-case-test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","value":10,"name":"Alice","tags":["x","y"]}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","value":20,"name":"Bob","tags":["y","z"]}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── A1: Arithmetic integer overflow (long.MaxValue + 1) ── + // Real Cosmos DB returns a double when integer addition overflows. + // Our ArithmeticOp uses doubles internally, so long.MaxValue + 1 should + // return 9223372036854775808.0 (double), not wrap to long.MinValue. + [Fact] + public async Task IntegerOverflow_Addition_ReturnsDouble() + { + await Seed(); + var results = await RunQuery( + $"SELECT VALUE {long.MaxValue} + 1 FROM c"); + results.Should().HaveCount(2); + // long.MaxValue = 9223372036854775807; + 1 = 9223372036854775808 + // In doubles this loses precision but should not wrap to negative + results[0].Value().Should().BeGreaterThan(0); + } + + // ── A2: Arithmetic integer underflow (long.MinValue - 1) ── + [Fact] + public async Task IntegerUnderflow_Subtraction_ReturnsDouble() + { + await Seed(); + var results = await RunQuery( + $"SELECT VALUE {long.MinValue} - 1 FROM c"); + results.Should().HaveCount(2); + results[0].Value().Should().BeLessThan(0); + } + + // ── A3: Integer multiply overflow ── + [Fact] + public async Task IntegerOverflow_Multiplication_ReturnsDouble() + { + await Seed(); + var results = await RunQuery( + $"SELECT VALUE {long.MaxValue} * 2 FROM c"); + results.Should().HaveCount(2); + results[0].Value().Should().BeGreaterThan(0); + } + + // ── A4: INTBITLEFTSHIFT with shift >= 64 ── + // Shifting by >= 64 bits on a 64-bit integer is invalid → returns undefined. + [Fact] + public async Task IntBitLeftShift_LargeShift_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE INTBITLEFTSHIFT(1, 64) FROM c"); + // Undefined values are omitted by SELECT VALUE + results.Should().BeEmpty(); + } + + // ── A5: INTBITRIGHTSHIFT with shift >= 64 ── + [Fact] + public async Task IntBitRightShift_LargeShift_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE INTBITRIGHTSHIFT(1, 64) FROM c"); + results.Should().BeEmpty(); + } + + // ── A6: INTDIV by zero ── + // INTDIV by zero returns undefined (excluded from SELECT VALUE results). + [Fact] + public async Task IntDiv_ByZero_ReturnsUndefined() + { + await Seed(); + // INTDIV returns undefined for division by zero (not null, not an exception) + var results = await RunQuery( + "SELECT VALUE INTDIV(10, 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── A7: INTMOD by zero ── + [Fact] + public async Task IntMod_ByZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE INTMOD(10, 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── A8: ORDER BY with mixed null/undefined/number/string ── + // Cosmos DB type rank: undefined < null < boolean < number < string < array < object + [Fact] + public async Task OrderBy_MixedTypes_FollowsTypeRank() + { + var container = new InMemoryContainer("orderby-mixed-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","sortKey":42}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","sortKey":null}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","sortKey":"hello"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"4","pk":"a"}""")), + new PartitionKey("a")); // sortKey is undefined + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, c.sortKey FROM c ORDER BY c.sortKey ASC"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + // Undefined < null < number < string + results.Should().HaveCount(4); + results[0]["id"]!.Value().Should().Be("4"); // undefined first + results[1]["id"]!.Value().Should().Be("2"); // null + results[2]["id"]!.Value().Should().Be("1"); // number 42 + results[3]["id"]!.Value().Should().Be("3"); // string "hello" + } + + // ── A10: DATETIMEADD overflow ── + // Adding a very large number of years should not throw an unhandled exception. + [Fact] + public async Task DateTimeAdd_OverflowYear_ReturnsUndefined() + { + await Seed(); + // Adding 100000 years to year 2000 should overflow DateTime + var results = await RunQuery( + "SELECT VALUE DATETIMEADD('yyyy', 100000, '2000-01-01T00:00:00Z') FROM c"); + // Should return undefined (excluded by SELECT VALUE) rather than throw + results.Should().BeEmpty(); + } + + // ── A11: REPLICATE boundary 10000 ── + [Fact] + public async Task Replicate_ExactBoundary10000_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REPLICATE('a', 10000) FROM c"); + results.Should().HaveCount(2); + results[0].Value().Should().HaveLength(10000); + } + + // ── A12: REPLICATE over 10000 ── + [Fact] + public async Task Replicate_Over10000_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REPLICATE('a', 10001) FROM c"); + // Undefined excluded by SELECT VALUE + results.Should().BeEmpty(); + } + + // ── A13: INDEX_OF with empty search string ── + [Fact] + public async Task IndexOf_EmptySearchString_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE INDEX_OF('hello', '') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0); + } + + // ── A14: CONTAINS with empty search string ── + [Fact] + public async Task Contains_EmptySearchString_ReturnsTrue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CONTAINS('hello', '') FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeTrue(); + } + + // ── A15: STARTSWITH with empty prefix ── + [Fact] + public async Task StartsWith_EmptyPrefix_ReturnsTrue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE STARTSWITH('hello', '') FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeTrue(); + } + + // ── A16: ENDSWITH with empty suffix ── + [Fact] + public async Task EndsWith_EmptySuffix_ReturnsTrue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ENDSWITH('hello', '') FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeTrue(); + } + + // ── A17: POWER(0, 0) ── + [Fact] + public async Task Power_ZeroToZero_ReturnsOne() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE POWER(0, 0) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(1.0); + } + + // ── A18: POWER(0, -1) → Infinity → undefined ── + [Fact] + public async Task Power_ZeroToNegative_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE POWER(0, -1) FROM c"); + // POWER(0, -1) = 1/0 = Infinity → treated as undefined + results.Should().BeEmpty(); + } + + // ── A19: LOG(1) = 0 ── + [Fact] + public async Task Log_OfOne_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LOG(1) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0.0); + } + + // ── A20: LOG(0) = -Infinity → undefined ── + [Fact] + public async Task Log_OfZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LOG(0) FROM c"); + results.Should().BeEmpty(); + } + + // ── A21: SQRT(0) = 0 ── + [Fact] + public async Task Sqrt_OfZero_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SQRT(0) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0.0); + } + + // ── A22: TRUNC with very large double ── + [Fact] + public async Task Trunc_VeryLargeDouble_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TRUNC(1.0E+15) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(1.0E+15); + } + + // ── A23: NUMBERBIN with zero bin size ── + [Fact] + public async Task NumberBin_ZeroBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NUMBERBIN(42, 0) FROM c"); + // Zero bin size → division by zero → undefined + results.Should().BeEmpty(); + } + + // ── A24: ARRAY_SLICE with negative start beyond length ── + [Fact] + public async Task ArraySlice_NegativeStartBeyondLength_ClampsToStart() + { + await Seed(); + // tags has 2 elements; -10 should clamp to index 0 + var results = await RunQuery( + "SELECT VALUE ARRAY_SLICE(c.tags, -10) FROM c"); + results.Should().HaveCount(2); + results[0].Should().HaveCount(2); // returns full array + } + + // ── A25: STRINGTOARRAY with invalid JSON ── + [Fact] + public async Task StringToArray_InvalidJson_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE STRINGTOARRAY('not valid json') FROM c"); + results.Should().BeEmpty(); + } + + // ── A26: STRINGTOOBJECT with invalid JSON ── + [Fact] + public async Task StringToObject_InvalidJson_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE STRINGTOOBJECT('not valid json') FROM c"); + results.Should().BeEmpty(); + } + + // ── A27: SELECT VALUE with aggregate in subquery ── + [Fact] + public async Task SelectValue_AggregateSubquery_ReturnsScalar() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE COUNT(1) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(2); + } + + // ── A28: Nested COALESCE with mixed null/undefined ── + [Fact] + public async Task NestedCoalesce_MixedNullUndefined_ReturnsFirstNonUndefined() + { + await Seed(); + // c.missing is undefined → skip, null is defined → return null + // COALESCE only skips undefined, not null + var results = await RunQuery( + "SELECT VALUE COALESCE(c.missing, null, 'fallback') FROM c"); + results.Should().HaveCount(2); + results[0].Type.Should().Be(JTokenType.Null); + } + + // ── B3: INTBITLEFTSHIFT with negative shift ── + [Fact] + public async Task IntBitLeftShift_NegativeShift_ReturnsUndefined() + { + await Seed(); + // Negative shift amounts are invalid → undefined + var results = await RunQuery( + "SELECT VALUE INTBITLEFTSHIFT(1, -1) FROM c"); + results.Should().BeEmpty(); + } + + // ── B5: DATETIMEADD negative overflow ── + [Fact] + public async Task DateTimeAdd_NegativeOverflow_ReturnsUndefined() + { + await Seed(); + // Subtracting 100000 years from year 2000 should overflow DateTime.MinValue + var results = await RunQuery( + "SELECT VALUE DATETIMEADD('yyyy', -100000, '2000-01-01T00:00:00Z') FROM c"); + results.Should().BeEmpty(); + } + + // ── B6: DATETIMEPART nanosecond on various dates ── + [Fact] + public async Task DateTimePart_Nanosecond_ReturnsCorrectValue() + { + await Seed(); + // 2024-01-15T10:30:45.1234567Z → nanoseconds of the fractional second + // .1234567 = 1234567 ticks; nanoseconds = ticks % TicksPerSecond * 100 + // 1234567 ticks * 100 = 123456700 ns + var results = await RunQuery( + "SELECT VALUE DATETIMEPART('ns', '2024-01-15T10:30:45.1234567Z') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(123456700L); + } + + // ── Extra: NUMBERBIN negative value ── + [Fact] + public async Task NumberBin_NegativeValue_BinsCorrectly() + { + await Seed(); + // NUMBERBIN(-7, 5) → floor(-7/5)*5 = floor(-1.4)*5 = -2*5 = -10 + var results = await RunQuery( + "SELECT VALUE NUMBERBIN(-7, 5) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(-10.0); + } + + // ── Extra: NUMBERBIN negative bin size ── + [Fact] + public async Task NumberBin_NegativeBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NUMBERBIN(42, -5) FROM c"); + // Negative bin size → undefined + results.Should().BeEmpty(); + } + + // ── Extra: String concat operator with null ── + // In Cosmos DB, 'hello' || null → undefined (omitted by SELECT VALUE). + [Fact] + public async Task StringConcat_OperatorWithNull_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE 'hello' || null FROM c"); + results.Should().BeEmpty(); + } + + // ── Extra: Division by zero results in undefined ── + [Fact] + public async Task Division_ByZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE 1 / 0 FROM c"); + // Division by zero → NaN → undefined + results.Should().BeEmpty(); + } + + // ── Extra: Modulo by zero results in undefined ── + [Fact] + public async Task Modulo_ByZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE 10 % 0 FROM c"); + results.Should().BeEmpty(); + } + + // ── Extra: SIGN of zero ── + [Fact] + public async Task Sign_OfZero_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SIGN(0) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0.0); + } + + // ── Extra: SIGN of negative ── + [Fact] + public async Task Sign_OfNegative_ReturnsMinusOne() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SIGN(-42) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(-1.0); + } + + // ── Extra: CEILING of already-integer ── + [Fact] + public async Task Ceiling_OfWholeNumber_ReturnsSameValue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CEILING(5.0) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(5.0); + } + + // ── Extra: FLOOR of negative fraction ── + [Fact] + public async Task Floor_OfNegativeFraction_RoundsDown() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE FLOOR(-2.3) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(-3.0); + } + + // ── Extra: ABS of long.MinValue (edge case - could overflow) ── + [Fact] + public async Task Abs_OfNegativeNumber_ReturnsPositive() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ABS(-999) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(999.0); + } + + // ── Extra: REVERSE of empty string ── + [Fact] + public async Task Reverse_EmptyString_ReturnsEmpty() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REVERSE('') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(""); + } + + // ── Extra: LENGTH of empty string ── + [Fact] + public async Task Length_EmptyString_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LENGTH('') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0); + } + + // ── Extra: TRIM of already-trimmed string ── + [Fact] + public async Task Trim_AlreadyTrimmed_ReturnsSame() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TRIM('hello') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("hello"); + } + + // ── Extra: ARRAY_LENGTH of empty array ── + [Fact] + public async Task ArrayLength_EmptyArray_ReturnsZero() + { + var container = new InMemoryContainer("arr-empty-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","arr":[]}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE ARRAY_LENGTH(c.arr) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().Be(0); + } + + // ── Extra: ARRAY_CONCAT with empty arrays ── + [Fact] + public async Task ArrayConcat_WithEmptyArrays_ReturnsOtherArray() + { + var container = new InMemoryContainer("arr-concat-empty-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","a1":[1,2],"a2":[]}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE ARRAY_CONCAT(c.a1, c.a2) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().HaveCount(2); + } + + // ── Extra: IS_DEFINED on null vs missing ── + [Fact] + public async Task IsDefined_NullField_ReturnsTrue() + { + var container = new InMemoryContainer("def-null-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","field":null}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE IS_DEFINED(c.field) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().BeTrue(); // null IS defined; undefined is not + } + + // ── Extra: IS_DEFINED on missing field ── + [Fact] + public async Task IsDefined_MissingField_ReturnsFalse() + { + var container = new InMemoryContainer("def-missing-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE IS_DEFINED(c.missing) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().BeFalse(); + } + + // ── Extra: BETWEEN with equal bounds ── + [Fact] + public async Task Between_EqualBounds_IncludesBoundaryValue() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.value BETWEEN 10 AND 10"); + results.Should().ContainSingle(); + results[0]["value"]!.Value().Should().Be(10); + } + + // ── Extra: IN with single value ── + [Fact] + public async Task In_SingleValue_Works() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name IN ('Alice')"); + results.Should().ContainSingle(); + } + + // ── Extra: IN with mixed types ── + [Fact] + public async Task In_MixedTypes_MatchesCorrectType() + { + await Seed(); + // value=10 (number) should match 10 (number), not '10' (string) + var results = await RunQuery( + "SELECT * FROM c WHERE c.value IN ('10', 10)"); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Alice"); + } + + // ── Extra: NOT IN ── + [Fact] + public async Task NotIn_ExcludesMatchingValues() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name NOT IN ('Alice')"); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Bob"); + } + + // ── Extra: LIKE with underscore wildcard ── + [Fact] + public async Task Like_UnderscoreWildcard_MatchesSingleChar() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'Bo_'"); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Bob"); + } + + // ── Extra: LIKE with percent wildcard in middle ── + [Fact] + public async Task Like_PercentInMiddle_MatchesMultipleChars() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'A%e'"); + results.Should().ContainSingle(); + results[0]["name"]!.Value().Should().Be("Alice"); + } + + // ── Extra: TYPE function returns correct types ── + [Fact] + public async Task Type_ReturnsCorrectTypeName() + { + var container = new InMemoryContainer("type-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","num":42,"str":"hello","bool":true,"arr":[1],"obj":{"x":1},"nullVal":null}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE TYPE(c.num) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results[0].Should().Be("number"); + } + + // ── Extra: IS_NULL distinguishes null from undefined ── + [Fact] + public async Task IsNull_OnNullField_ReturnsTrue() + { + var container = new InMemoryContainer("isnull-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","field":null}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE IS_NULL(c.field) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results[0].Should().BeTrue(); + } + + // ── Extra: IS_NULL on undefined (missing) returns false ── + // In Cosmos DB, IS_NULL(undefined) returns false (not undefined). + // The field doesn't exist, so it's not null; it's undefined. + [Fact] + public async Task IsNull_OnMissingField_ReturnsFalse() + { + var container = new InMemoryContainer("isnull-missing-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE IS_NULL(c.missing) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle(); + results[0].Should().BeFalse(); + } + + // ── Extra: CHOOSE with 1-based index ── + [Fact] + public async Task Choose_ValidIndex_ReturnsCorrectValue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CHOOSE(2, 'a', 'b', 'c') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("b"); + } + + // ── Extra: CHOOSE with zero index ── + [Fact] + public async Task Choose_ZeroIndex_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE CHOOSE(0, 'a', 'b', 'c') FROM c"); + // CHOOSE is 1-based; 0 is out of bounds → undefined + results.Should().BeEmpty(); + } + + // ── Extra: SETINTERSECT with empty result ── + [Fact] + public async Task SetIntersect_NoOverlap_ReturnsEmptyArray() + { + var container = new InMemoryContainer("setint-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","a1":[1,2,3],"a2":[4,5,6]}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE SETINTERSECT(c.a1, c.a2) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().BeEmpty(); + } + + // ── Extra: SETUNION deduplicates ── + [Fact] + public async Task SetUnion_WithOverlap_Deduplicates() + { + var container = new InMemoryContainer("setunion-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","a1":[1,2,3],"a2":[2,3,4]}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT VALUE SETUNION(c.a1, c.a2) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0].Should().HaveCount(4); // {1,2,3,4} + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -18063,1072 +18073,1072 @@ await container.CreateItemStreamAsync( public class QueryDeepDiveV12_DateTimeFunctionEdgeCases { - private readonly InMemoryContainer _container = new("dt-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","dt":"2024-06-15T10:30:00.0000000Z","nullField":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","dt":"2024-01-01T00:00:00.0000000Z","nullField":null}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 1.1: DATETIMEFROMPARTS 3-arg ── - [Fact] - public async Task DateTimeFromParts_3Args_YearMonthDay_ReturnsDateOnly() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2024, 3, 15) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("2024-03-15T00:00:00.0000000Z"); - } - - // ── 1.2: DATETIMEFROMPARTS invalid month ── - [Fact] - public async Task DateTimeFromParts_InvalidMonth13_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2024, 13, 1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.3: DATETIMEFROMPARTS invalid day ── - [Fact] - public async Task DateTimeFromParts_InvalidDay32_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2024, 1, 32) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.4: DATETIMEFROMPARTS leap year ── - [Fact] - public async Task DateTimeFromParts_LeapYear_Feb29_ReturnsDate() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2024, 2, 29) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("2024-02-29T00:00:00.0000000Z"); - } - - // ── 1.5: DATETIMEFROMPARTS non-leap year Feb 29 ── - [Fact] - public async Task DateTimeFromParts_NonLeapYear_Feb29_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(2023, 2, 29) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.6: DATETIMEFROMPARTS null args ── - [Fact] - public async Task DateTimeFromParts_NullArgs_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeFromParts(null, 1, 1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.7: DATETIMEBIN negative bin size ── - [Fact] - public async Task DateTimeBin_NegativeBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin(c.dt, 'hh', -1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.8: DATETIMEBIN zero bin size ── - [Fact] - public async Task DateTimeBin_ZeroBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin(c.dt, 'hh', 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.9: DATETIMEBIN null datetime ── - [Fact] - public async Task DateTimeBin_NullDatetime_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeBin(null, 'hh', 1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.10: DATETIMEBIN minute granularity ── - [Fact] - public async Task DateTimeBin_MinuteGranularity_BinsCorrectly() - { - await Seed(); - // 10:30 binned to 15-minute intervals → 10:30 - var results = await RunQuery( - "SELECT VALUE DateTimeBin('2024-06-15T10:37:00.0000000Z', 'mi', 15) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("2024-06-15T10:30:00.0000000Z"); - } - - // ── 1.11: DATETIMETOTICKS / TICKSTODATETIME round trip ── - [Fact] - public async Task DateTimeTicks_RoundTrip_PreservesPrecision() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TicksToDateTime(DateTimeToTicks('2024-06-15T10:30:00.1234567Z')) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("2024-06-15T10:30:00.1234567Z"); - } - - // ── 1.12: DATETIMETOTICKS null input ── - [Fact] - public async Task DateTimeToTicks_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeToTicks(null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.13: TICKSTODATETIME null input ── - [Fact] - public async Task TicksToDateTime_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TicksToDateTime(null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.14: DATETIMETOTIMESTAMP epoch ── - [Fact] - public async Task DateTimeToTimestamp_Epoch_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeToTimestamp('1970-01-01T00:00:00.0000000Z') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(0L); - } - - // ── 1.15: DATETIMETOTIMESTAMP pre-epoch ── - [Fact] - public async Task DateTimeToTimestamp_PreEpoch_ReturnsNegative() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeToTimestamp('1969-12-31T00:00:00.0000000Z') FROM c"); - results.Should().HaveCount(2); - results[0].Should().BeNegative(); - } - - // ── 1.16: TIMESTAMPTODATETIME null input ── - [Fact] - public async Task TimestampToDateTime_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TimestampToDateTime(null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.17: DATETIMETOTIMESTAMP / TIMESTAMPTODATETIME round trip ── - [Fact] - public async Task DateTimeTimestamp_RoundTrip_PreservesPrecision() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-06-15T10:30:00.0000000Z')) FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be("2024-06-15T10:30:00.0000000Z"); - } - - // ── 1.18: DATETIMEADD null date ── - [Fact] - public async Task DateTimeAdd_NullDate_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('dd', 1, null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.19: DATETIMEADD null number ── - [Fact] - public async Task DateTimeAdd_NullNumber_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeAdd('dd', null, c.dt) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.20: DATETIMEPART weekday returns 1-7 ── - [Fact] - public async Task DateTimePart_Weekday_Returns1Through7() - { - await Seed(); - // 2024-06-15 is a Saturday (DayOfWeek=6, +1 = 7) - var results = await RunQuery( - "SELECT VALUE DateTimePart('dw', '2024-06-15T10:30:00.0000000Z') FROM c"); - results.Should().HaveCount(2); - results[0].Should().Be(7L); // Saturday = 7 - } - - // ── 1.21: DATETIMEPART null date ── - [Fact] - public async Task DateTimePart_NullDate_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimePart('dd', null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 1.22: DATETIMEDIFF same datetime ── - [Fact] - public async Task DateTimeDiff_SameDateTime_ReturnsZero() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', c.dt, c.dt) FROM c"); - results.Should().HaveCount(2); - results.Should().AllSatisfy(r => r.Should().Be(0L)); - } - - // ── 1.23: DATETIMEDIFF null args ── - [Fact] - public async Task DateTimeDiff_NullArgs_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE DateTimeDiff('dd', null, c.dt) FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("dt-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","dt":"2024-06-15T10:30:00.0000000Z","nullField":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","dt":"2024-01-01T00:00:00.0000000Z","nullField":null}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 1.1: DATETIMEFROMPARTS 3-arg ── + [Fact] + public async Task DateTimeFromParts_3Args_YearMonthDay_ReturnsDateOnly() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2024, 3, 15) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("2024-03-15T00:00:00.0000000Z"); + } + + // ── 1.2: DATETIMEFROMPARTS invalid month ── + [Fact] + public async Task DateTimeFromParts_InvalidMonth13_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2024, 13, 1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.3: DATETIMEFROMPARTS invalid day ── + [Fact] + public async Task DateTimeFromParts_InvalidDay32_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2024, 1, 32) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.4: DATETIMEFROMPARTS leap year ── + [Fact] + public async Task DateTimeFromParts_LeapYear_Feb29_ReturnsDate() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2024, 2, 29) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("2024-02-29T00:00:00.0000000Z"); + } + + // ── 1.5: DATETIMEFROMPARTS non-leap year Feb 29 ── + [Fact] + public async Task DateTimeFromParts_NonLeapYear_Feb29_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(2023, 2, 29) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.6: DATETIMEFROMPARTS null args ── + [Fact] + public async Task DateTimeFromParts_NullArgs_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeFromParts(null, 1, 1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.7: DATETIMEBIN negative bin size ── + [Fact] + public async Task DateTimeBin_NegativeBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin(c.dt, 'hh', -1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.8: DATETIMEBIN zero bin size ── + [Fact] + public async Task DateTimeBin_ZeroBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin(c.dt, 'hh', 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.9: DATETIMEBIN null datetime ── + [Fact] + public async Task DateTimeBin_NullDatetime_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeBin(null, 'hh', 1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.10: DATETIMEBIN minute granularity ── + [Fact] + public async Task DateTimeBin_MinuteGranularity_BinsCorrectly() + { + await Seed(); + // 10:30 binned to 15-minute intervals → 10:30 + var results = await RunQuery( + "SELECT VALUE DateTimeBin('2024-06-15T10:37:00.0000000Z', 'mi', 15) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("2024-06-15T10:30:00.0000000Z"); + } + + // ── 1.11: DATETIMETOTICKS / TICKSTODATETIME round trip ── + [Fact] + public async Task DateTimeTicks_RoundTrip_PreservesPrecision() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TicksToDateTime(DateTimeToTicks('2024-06-15T10:30:00.1234567Z')) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("2024-06-15T10:30:00.1234567Z"); + } + + // ── 1.12: DATETIMETOTICKS null input ── + [Fact] + public async Task DateTimeToTicks_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeToTicks(null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.13: TICKSTODATETIME null input ── + [Fact] + public async Task TicksToDateTime_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TicksToDateTime(null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.14: DATETIMETOTIMESTAMP epoch ── + [Fact] + public async Task DateTimeToTimestamp_Epoch_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeToTimestamp('1970-01-01T00:00:00.0000000Z') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(0L); + } + + // ── 1.15: DATETIMETOTIMESTAMP pre-epoch ── + [Fact] + public async Task DateTimeToTimestamp_PreEpoch_ReturnsNegative() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeToTimestamp('1969-12-31T00:00:00.0000000Z') FROM c"); + results.Should().HaveCount(2); + results[0].Should().BeNegative(); + } + + // ── 1.16: TIMESTAMPTODATETIME null input ── + [Fact] + public async Task TimestampToDateTime_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TimestampToDateTime(null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.17: DATETIMETOTIMESTAMP / TIMESTAMPTODATETIME round trip ── + [Fact] + public async Task DateTimeTimestamp_RoundTrip_PreservesPrecision() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-06-15T10:30:00.0000000Z')) FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be("2024-06-15T10:30:00.0000000Z"); + } + + // ── 1.18: DATETIMEADD null date ── + [Fact] + public async Task DateTimeAdd_NullDate_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('dd', 1, null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.19: DATETIMEADD null number ── + [Fact] + public async Task DateTimeAdd_NullNumber_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeAdd('dd', null, c.dt) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.20: DATETIMEPART weekday returns 1-7 ── + [Fact] + public async Task DateTimePart_Weekday_Returns1Through7() + { + await Seed(); + // 2024-06-15 is a Saturday (DayOfWeek=6, +1 = 7) + var results = await RunQuery( + "SELECT VALUE DateTimePart('dw', '2024-06-15T10:30:00.0000000Z') FROM c"); + results.Should().HaveCount(2); + results[0].Should().Be(7L); // Saturday = 7 + } + + // ── 1.21: DATETIMEPART null date ── + [Fact] + public async Task DateTimePart_NullDate_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimePart('dd', null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 1.22: DATETIMEDIFF same datetime ── + [Fact] + public async Task DateTimeDiff_SameDateTime_ReturnsZero() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', c.dt, c.dt) FROM c"); + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().Be(0L)); + } + + // ── 1.23: DATETIMEDIFF null args ── + [Fact] + public async Task DateTimeDiff_NullArgs_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE DateTimeDiff('dd', null, c.dt) FROM c"); + results.Should().BeEmpty(); + } } public class QueryDeepDiveV12_NumberBinEdgeCases { - private readonly InMemoryContainer _container = new("nb-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 2.1: NUMBERBIN negative value ── - [Fact] - public async Task NumberBin_NegativeValue_BinsCorrectly() - { - await Seed(); - // Math.Floor(-7 / 3) * 3 = Math.Floor(-2.333) * 3 = -3 * 3 = -9 - var results = await RunQuery( - "SELECT VALUE NumberBin(-7, 3) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(-9.0); - } - - // ── 2.2: NUMBERBIN zero bin size ── - [Fact] - public async Task NumberBin_ZeroBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NumberBin(42, 0) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.3: NUMBERBIN negative bin size ── - [Fact] - public async Task NumberBin_NegativeBinSize_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NumberBin(42, -5) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.4: NUMBERBIN null value ── - [Fact] - public async Task NumberBin_NullValue_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NumberBin(null, 3) FROM c"); - results.Should().BeEmpty(); - } - - // ── 2.5: NUMBERBIN fractional bin size ── - [Fact] - public async Task NumberBin_FractionalBinSize_BinsCorrectly() - { - await Seed(); - // Math.Floor(7.5 / 2.5) * 2.5 = Math.Floor(3.0) * 2.5 = 3 * 2.5 = 7.5 - var results = await RunQuery( - "SELECT VALUE NumberBin(7.5, 2.5) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(7.5); - } - - // ── 2.6: NUMBERBIN exact multiple ── - [Fact] - public async Task NumberBin_ExactMultiple_ReturnsSameValue() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE NumberBin(9, 3) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(9.0); - } + private readonly InMemoryContainer _container = new("nb-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 2.1: NUMBERBIN negative value ── + [Fact] + public async Task NumberBin_NegativeValue_BinsCorrectly() + { + await Seed(); + // Math.Floor(-7 / 3) * 3 = Math.Floor(-2.333) * 3 = -3 * 3 = -9 + var results = await RunQuery( + "SELECT VALUE NumberBin(-7, 3) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(-9.0); + } + + // ── 2.2: NUMBERBIN zero bin size ── + [Fact] + public async Task NumberBin_ZeroBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NumberBin(42, 0) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.3: NUMBERBIN negative bin size ── + [Fact] + public async Task NumberBin_NegativeBinSize_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NumberBin(42, -5) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.4: NUMBERBIN null value ── + [Fact] + public async Task NumberBin_NullValue_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NumberBin(null, 3) FROM c"); + results.Should().BeEmpty(); + } + + // ── 2.5: NUMBERBIN fractional bin size ── + [Fact] + public async Task NumberBin_FractionalBinSize_BinsCorrectly() + { + await Seed(); + // Math.Floor(7.5 / 2.5) * 2.5 = Math.Floor(3.0) * 2.5 = 3 * 2.5 = 7.5 + var results = await RunQuery( + "SELECT VALUE NumberBin(7.5, 2.5) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(7.5); + } + + // ── 2.6: NUMBERBIN exact multiple ── + [Fact] + public async Task NumberBin_ExactMultiple_ReturnsSameValue() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE NumberBin(9, 3) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(9.0); + } } public class QueryDeepDiveV12_LikeEscapeEdgeCases { - private readonly InMemoryContainer _container = new("like-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test!value"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"test_value"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"test%value"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 3.1: LIKE double-escape matches literal escape char ── - [Fact] - public async Task Like_DoubleEscape_MatchesLiteralEscapeChar() - { - await Seed(); - // !! should match literal ! character - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'test!!value' ESCAPE '!'"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── 3.2: LIKE escape underscore ── - [Fact] - public async Task Like_EscapeUnderscore_MatchesLiteralUnderscore() - { - await Seed(); - // !_ should match literal _ character - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'test!_value' ESCAPE '!'"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } - - // ── 3.3: LIKE escape at end of pattern ── - [Fact] - public async Task Like_EscapeAtEndOfPattern_NoMatch() - { - await Seed(); - // Escape char at end of pattern without following char - should not match anything - var results = await RunQuery( - "SELECT * FROM c WHERE c.name LIKE 'test!' ESCAPE '!'"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("like-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test!value"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"test_value"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"test%value"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 3.1: LIKE double-escape matches literal escape char ── + [Fact] + public async Task Like_DoubleEscape_MatchesLiteralEscapeChar() + { + await Seed(); + // !! should match literal ! character + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'test!!value' ESCAPE '!'"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── 3.2: LIKE escape underscore ── + [Fact] + public async Task Like_EscapeUnderscore_MatchesLiteralUnderscore() + { + await Seed(); + // !_ should match literal _ character + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'test!_value' ESCAPE '!'"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } + + // ── 3.3: LIKE escape at end of pattern ── + [Fact] + public async Task Like_EscapeAtEndOfPattern_NoMatch() + { + await Seed(); + // Escape char at end of pattern without following char - should not match anything + var results = await RunQuery( + "SELECT * FROM c WHERE c.name LIKE 'test!' ESCAPE '!'"); + results.Should().BeEmpty(); + } } public class QueryDeepDiveV12_StringFunctionEdgeCases { - private readonly InMemoryContainer _container = new("str-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Hello World","nullField":null}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 4.1: REPLACE null search string ── - [Fact] - public async Task Replace_NullSearchString_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REPLACE('hello', null, 'x') FROM c"); - results.Should().BeEmpty(); - } - - // ── 4.2: REPLACE empty search string ── - [Fact] - public async Task Replace_EmptySearchString_ReturnsOriginal() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REPLACE('hello', '', 'x') FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be("hello"); - } - - // ── 4.3: SUBSTRING length exceeds remaining ── - [Fact] - public async Task Substring_LengthExceedsRemaining_ReturnsToEnd() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SUBSTRING('hello', 3, 100) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be("lo"); - } - - // ── 4.4: INDEX_OF unicode chars ── - [Fact] - public async Task IndexOf_UnicodeChars_ReturnsCorrectPosition() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE INDEX_OF('café', 'é') FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(3L); - } - - // ── 4.5: LEFT null input ── - [Fact] - public async Task Left_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LEFT(null, 3) FROM c"); - results.Should().BeEmpty(); - } - - // ── 4.6: RIGHT null input ── - [Fact] - public async Task Right_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE RIGHT(null, 3) FROM c"); - results.Should().BeEmpty(); - } - - // ── 4.7: LENGTH null input ── - [Fact] - public async Task Length_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LENGTH(null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 4.8: REVERSE unicode string ── - [Fact] - public async Task Reverse_UnicodeString_ReversesCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE REVERSE('abc') FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be("cba"); - } - - // ── 4.9: LOWER null input ── - [Fact] - public async Task Lower_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LOWER(null) FROM c"); - results.Should().BeEmpty(); - } - - // ── 4.10: UPPER null input ── - [Fact] - public async Task Upper_NullInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE UPPER(null) FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("str-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Hello World","nullField":null}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 4.1: REPLACE null search string ── + [Fact] + public async Task Replace_NullSearchString_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REPLACE('hello', null, 'x') FROM c"); + results.Should().BeEmpty(); + } + + // ── 4.2: REPLACE empty search string ── + [Fact] + public async Task Replace_EmptySearchString_ReturnsOriginal() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REPLACE('hello', '', 'x') FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be("hello"); + } + + // ── 4.3: SUBSTRING length exceeds remaining ── + [Fact] + public async Task Substring_LengthExceedsRemaining_ReturnsToEnd() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SUBSTRING('hello', 3, 100) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be("lo"); + } + + // ── 4.4: INDEX_OF unicode chars ── + [Fact] + public async Task IndexOf_UnicodeChars_ReturnsCorrectPosition() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE INDEX_OF('café', 'é') FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(3L); + } + + // ── 4.5: LEFT null input ── + [Fact] + public async Task Left_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LEFT(null, 3) FROM c"); + results.Should().BeEmpty(); + } + + // ── 4.6: RIGHT null input ── + [Fact] + public async Task Right_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE RIGHT(null, 3) FROM c"); + results.Should().BeEmpty(); + } + + // ── 4.7: LENGTH null input ── + [Fact] + public async Task Length_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LENGTH(null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 4.8: REVERSE unicode string ── + [Fact] + public async Task Reverse_UnicodeString_ReversesCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE REVERSE('abc') FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be("cba"); + } + + // ── 4.9: LOWER null input ── + [Fact] + public async Task Lower_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LOWER(null) FROM c"); + results.Should().BeEmpty(); + } + + // ── 4.10: UPPER null input ── + [Fact] + public async Task Upper_NullInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE UPPER(null) FROM c"); + results.Should().BeEmpty(); + } } public class QueryDeepDiveV12_MathFunctionEdgeCases { - private readonly InMemoryContainer _container = new("math-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 5.1: SQRT negative ── - [Fact] - public async Task Sqrt_NegativeNumber_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SQRT(-1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 5.2: LOG negative ── - [Fact] - public async Task Log_NegativeNumber_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LOG(-1) FROM c"); - results.Should().BeEmpty(); - } - - // ── 5.3: LOG zero ── - [Fact] - public async Task Log_Zero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE LOG(0) FROM c"); - results.Should().BeEmpty(); - } - - // ── 5.4: POWER large exponent ── - [Fact] - public async Task Power_LargeExponent_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE POWER(10, 1000) FROM c"); - // 10^1000 → Infinity → undefined - results.Should().BeEmpty(); - } - - // ── 5.5: ASIN out of range ── - [Fact] - public async Task Asin_OutOfRange_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ASIN(2) FROM c"); - results.Should().BeEmpty(); - } - - // ── 5.6: ACOS out of range ── - [Fact] - public async Task Acos_OutOfRange_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE ACOS(2) FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("math-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 5.1: SQRT negative ── + [Fact] + public async Task Sqrt_NegativeNumber_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SQRT(-1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 5.2: LOG negative ── + [Fact] + public async Task Log_NegativeNumber_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LOG(-1) FROM c"); + results.Should().BeEmpty(); + } + + // ── 5.3: LOG zero ── + [Fact] + public async Task Log_Zero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE LOG(0) FROM c"); + results.Should().BeEmpty(); + } + + // ── 5.4: POWER large exponent ── + [Fact] + public async Task Power_LargeExponent_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE POWER(10, 1000) FROM c"); + // 10^1000 → Infinity → undefined + results.Should().BeEmpty(); + } + + // ── 5.5: ASIN out of range ── + [Fact] + public async Task Asin_OutOfRange_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ASIN(2) FROM c"); + results.Should().BeEmpty(); + } + + // ── 5.6: ACOS out of range ── + [Fact] + public async Task Acos_OutOfRange_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE ACOS(2) FROM c"); + results.Should().BeEmpty(); + } } public class QueryDeepDiveV12_AggregateEdgeCases { - private readonly InMemoryContainer _container = new("agg-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","value":10,"nullValue":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","value":20,"nullValue":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"a","value":30}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 6.1: SUM with nulls ── - [Fact] - public async Task Sum_WithNulls_IgnoresNulls() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE SUM(c.value) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(60.0); - } - - // ── 6.2: AVG with nulls ── - [Fact] - public async Task Avg_WithNulls_IgnoresNulls() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE AVG(c.value) FROM c"); - results.Should().ContainSingle(); - results[0].Should().Be(20.0); - } - - // ── 6.3: MIN empty set ── - [Fact] - public async Task Min_EmptySet_ReturnsNoResults() - { - var container = new InMemoryContainer("empty-min", "/pk"); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE MIN(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - // Empty set → no results or a single undefined/null - results.Should().HaveCountLessThanOrEqualTo(1); - } - - // ── 6.4: MAX empty set ── - [Fact] - public async Task Max_EmptySet_ReturnsNoResults() - { - var container = new InMemoryContainer("empty-max", "/pk"); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE MAX(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCountLessThanOrEqualTo(1); - } - - // ── 6.5: SUM empty set ── - [Fact] - public async Task Sum_EmptySet_ReturnsNoResults() - { - var container = new InMemoryContainer("empty-sum", "/pk"); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE SUM(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCountLessThanOrEqualTo(1); - } + private readonly InMemoryContainer _container = new("agg-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","value":10,"nullValue":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","value":20,"nullValue":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"a","value":30}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 6.1: SUM with nulls ── + [Fact] + public async Task Sum_WithNulls_IgnoresNulls() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE SUM(c.value) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(60.0); + } + + // ── 6.2: AVG with nulls ── + [Fact] + public async Task Avg_WithNulls_IgnoresNulls() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE AVG(c.value) FROM c"); + results.Should().ContainSingle(); + results[0].Should().Be(20.0); + } + + // ── 6.3: MIN empty set ── + [Fact] + public async Task Min_EmptySet_ReturnsNoResults() + { + var container = new InMemoryContainer("empty-min", "/pk"); + var iterator = container.GetItemQueryIterator( + "SELECT VALUE MIN(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + // Empty set → no results or a single undefined/null + results.Should().HaveCountLessThanOrEqualTo(1); + } + + // ── 6.4: MAX empty set ── + [Fact] + public async Task Max_EmptySet_ReturnsNoResults() + { + var container = new InMemoryContainer("empty-max", "/pk"); + var iterator = container.GetItemQueryIterator( + "SELECT VALUE MAX(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCountLessThanOrEqualTo(1); + } + + // ── 6.5: SUM empty set ── + [Fact] + public async Task Sum_EmptySet_ReturnsNoResults() + { + var container = new InMemoryContainer("empty-sum", "/pk"); + var iterator = container.GetItemQueryIterator( + "SELECT VALUE SUM(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCountLessThanOrEqualTo(1); + } } public class QueryDeepDiveV12_OperatorEdgeCases { - private readonly InMemoryContainer _container = new("op-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","value":10,"nullField":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","value":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 7.1: Ternary undefined condition ── - [Fact] - public async Task Ternary_UndefinedCondition_ReturnsFalseBranch() - { - await Seed(); - // c.missing is undefined → false branch - var results = await RunQuery( - "SELECT VALUE (c.missing ? 'yes' : 'no') FROM c"); - results.Should().HaveCount(2); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - // ── 7.2: Ternary null condition ── - [Fact] - public async Task Ternary_NullCondition_ReturnsFalseBranch() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE (null ? 'yes' : 'no') FROM c"); - results.Should().HaveCount(2); - results.Should().AllSatisfy(r => r.Should().Be("no")); - } - - // ── 7.3: Null-coalesce chain ── - [Fact] - public async Task NullCoalesce_Chain_ReturnsFirstNonNull() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE (null ?? null ?? 'found') FROM c"); - results.Should().HaveCount(2); - results.Should().AllSatisfy(r => r.Should().Be("found")); - } - - // ── 7.4: IN with null ── - [Fact] - public async Task In_WithNull_HandlesCorrectly() - { - await Seed(); - // 10 IN (null, 10, 20) should match because 10 is in the list - var results = await RunQuery( - "SELECT * FROM c WHERE c.value IN (null, 10, 20)"); - results.Should().HaveCount(2); - } - - // ── 7.5: BETWEEN with null ── - [Fact] - public async Task Between_WithNull_ReturnsFalse() - { - await Seed(); - // null BETWEEN 1 AND 10 → should not match - var results = await RunQuery( - "SELECT * FROM c WHERE null BETWEEN 1 AND 10"); - results.Should().BeEmpty(); - } - - // ── 7.6: Division by zero ── - [Fact] - public async Task Arithmetic_DivisionByZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE 1 / 0 FROM c"); - results.Should().BeEmpty(); - } - - // ── 7.7: Modulo by zero ── - [Fact] - public async Task Arithmetic_ModuloByZero_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery( - "SELECT VALUE 10 % 0 FROM c"); - results.Should().BeEmpty(); - } - - // ── 7.8: String concat with null ── - [Fact] - public async Task StringConcat_Operator_WithNull_ReturnsUndefined() - { - await Seed(); - // In Cosmos DB, 'hello' || null → undefined (omitted by SELECT VALUE) - var results = await RunQuery( - "SELECT VALUE 'hello' || null FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("op-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","value":10,"nullField":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","value":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 7.1: Ternary undefined condition ── + [Fact] + public async Task Ternary_UndefinedCondition_ReturnsFalseBranch() + { + await Seed(); + // c.missing is undefined → false branch + var results = await RunQuery( + "SELECT VALUE (c.missing ? 'yes' : 'no') FROM c"); + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + // ── 7.2: Ternary null condition ── + [Fact] + public async Task Ternary_NullCondition_ReturnsFalseBranch() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE (null ? 'yes' : 'no') FROM c"); + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().Be("no")); + } + + // ── 7.3: Null-coalesce chain ── + [Fact] + public async Task NullCoalesce_Chain_ReturnsFirstNonNull() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE (null ?? null ?? 'found') FROM c"); + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().Be("found")); + } + + // ── 7.4: IN with null ── + [Fact] + public async Task In_WithNull_HandlesCorrectly() + { + await Seed(); + // 10 IN (null, 10, 20) should match because 10 is in the list + var results = await RunQuery( + "SELECT * FROM c WHERE c.value IN (null, 10, 20)"); + results.Should().HaveCount(2); + } + + // ── 7.5: BETWEEN with null ── + [Fact] + public async Task Between_WithNull_ReturnsFalse() + { + await Seed(); + // null BETWEEN 1 AND 10 → should not match + var results = await RunQuery( + "SELECT * FROM c WHERE null BETWEEN 1 AND 10"); + results.Should().BeEmpty(); + } + + // ── 7.6: Division by zero ── + [Fact] + public async Task Arithmetic_DivisionByZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE 1 / 0 FROM c"); + results.Should().BeEmpty(); + } + + // ── 7.7: Modulo by zero ── + [Fact] + public async Task Arithmetic_ModuloByZero_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery( + "SELECT VALUE 10 % 0 FROM c"); + results.Should().BeEmpty(); + } + + // ── 7.8: String concat with null ── + [Fact] + public async Task StringConcat_Operator_WithNull_ReturnsUndefined() + { + await Seed(); + // In Cosmos DB, 'hello' || null → undefined (omitted by SELECT VALUE) + var results = await RunQuery( + "SELECT VALUE 'hello' || null FROM c"); + results.Should().BeEmpty(); + } } public class QueryDeepDiveV12_SelectJoinEdgeCases { - private readonly InMemoryContainer _container = new("sj-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"Alice","tags":["a","b"],"nested":{"items":[1,2]}}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","name":"Bob","tags":[],"nullField":null}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 8.1: SELECT non-existent field ── - [Fact] - public async Task Select_NonExistentField_OmitsField() - { - await Seed(); - var results = await RunQuery( - "SELECT c.missing FROM c"); - results.Should().HaveCount(2); - // Non-existent field should be omitted from the result object - results[0].ContainsKey("missing").Should().BeFalse(); - } - - // ── 8.2: JOIN with nested property ── - [Fact] - public async Task Join_WithNestedProperty_ExpandsCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT c.name, t AS item FROM c JOIN t IN c.nested.items"); - // Alice has [1,2] → 2 rows; Bob has no nested.items → 0 rows - results.Should().HaveCount(2); - } - - // ── 8.3: GROUP BY with null values ── - [Fact] - public async Task GroupBy_WithNullValues_GroupsCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT c.nullField, COUNT(1) AS cnt FROM c GROUP BY c.nullField"); - // Both docs have nullField=null (or missing) → grouped together - results.Should().HaveCountGreaterThanOrEqualTo(1); - } - - // ── 8.4: ORDER BY undefined field ── - [Fact] - public async Task OrderBy_UndefinedField_SortsCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT c.id, c.nullField FROM c ORDER BY c.nullField"); - results.Should().HaveCount(2); - } - - // ── 8.5: DISTINCT with null values ── - [Fact] - public async Task Distinct_NullValues_DeduplicatesCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.nullField FROM c"); - // Multiple nulls should deduplicate to one null - results.Should().HaveCountLessThanOrEqualTo(2); - } + private readonly InMemoryContainer _container = new("sj-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"Alice","tags":["a","b"],"nested":{"items":[1,2]}}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","name":"Bob","tags":[],"nullField":null}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 8.1: SELECT non-existent field ── + [Fact] + public async Task Select_NonExistentField_OmitsField() + { + await Seed(); + var results = await RunQuery( + "SELECT c.missing FROM c"); + results.Should().HaveCount(2); + // Non-existent field should be omitted from the result object + results[0].ContainsKey("missing").Should().BeFalse(); + } + + // ── 8.2: JOIN with nested property ── + [Fact] + public async Task Join_WithNestedProperty_ExpandsCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT c.name, t AS item FROM c JOIN t IN c.nested.items"); + // Alice has [1,2] → 2 rows; Bob has no nested.items → 0 rows + results.Should().HaveCount(2); + } + + // ── 8.3: GROUP BY with null values ── + [Fact] + public async Task GroupBy_WithNullValues_GroupsCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT c.nullField, COUNT(1) AS cnt FROM c GROUP BY c.nullField"); + // Both docs have nullField=null (or missing) → grouped together + results.Should().HaveCountGreaterThanOrEqualTo(1); + } + + // ── 8.4: ORDER BY undefined field ── + [Fact] + public async Task OrderBy_UndefinedField_SortsCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT c.id, c.nullField FROM c ORDER BY c.nullField"); + results.Should().HaveCount(2); + } + + // ── 8.5: DISTINCT with null values ── + [Fact] + public async Task Distinct_NullValues_DeduplicatesCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.nullField FROM c"); + // Multiple nulls should deduplicate to one null + results.Should().HaveCountLessThanOrEqualTo(2); + } } public class QueryDeepDiveV12_SubqueryEdgeCases { - private readonly InMemoryContainer _container = new("sq-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","tags":["x","y","z"],"value":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","tags":[],"value":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 9.1: EXISTS empty subquery ── - [Fact] - public async Task Exists_EmptySubquery_ReturnsFalse() - { - await Seed(); - // EXISTS on empty array → false - var results = await RunQuery( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'nonexistent')"); - results.Should().BeEmpty(); - } - - // ── 9.2: EXISTS non-empty subquery ── - [Fact] - public async Task Exists_NonEmptySubquery_ReturnsTrue() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } - - // ── 9.3: ARRAY subquery in WHERE ── - [Fact] - public async Task ArraySubquery_InWhere_FiltersCorrectly() - { - await Seed(); - var results = await RunQuery( - "SELECT * FROM c WHERE ARRAY_LENGTH(ARRAY(SELECT VALUE t FROM t IN c.tags)) > 0"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("sq-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","tags":["x","y","z"],"value":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","tags":[],"value":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 9.1: EXISTS empty subquery ── + [Fact] + public async Task Exists_EmptySubquery_ReturnsFalse() + { + await Seed(); + // EXISTS on empty array → false + var results = await RunQuery( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'nonexistent')"); + results.Should().BeEmpty(); + } + + // ── 9.2: EXISTS non-empty subquery ── + [Fact] + public async Task Exists_NonEmptySubquery_ReturnsTrue() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } + + // ── 9.3: ARRAY subquery in WHERE ── + [Fact] + public async Task ArraySubquery_InWhere_FiltersCorrectly() + { + await Seed(); + var results = await RunQuery( + "SELECT * FROM c WHERE ARRAY_LENGTH(ARRAY(SELECT VALUE t FROM t IN c.tags)) > 0"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } public class QueryDeepDiveV12_TypeCoercionEdgeCases { - private readonly InMemoryContainer _container = new("tc-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","numValue":1,"strValue":"1","boolValue":true}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ── 10.1: Number vs string comparison ── - [Fact] - public async Task Comparison_NumberVsString_ReturnsFalse() - { - await Seed(); - // Cosmos DB: 1 = "1" → false (strict type comparison) - var results = await RunQuery( - "SELECT * FROM c WHERE c.numValue = c.strValue"); - results.Should().BeEmpty(); - } - - // ── 10.2: Bool vs number comparison ── - [Fact] - public async Task Comparison_BoolVsNumber_ReturnsFalse() - { - await Seed(); - // Cosmos DB: true = 1 → false (strict type comparison) - var results = await RunQuery( - "SELECT * FROM c WHERE c.boolValue = c.numValue"); - results.Should().BeEmpty(); - } - - // ── 10.3: ORDER BY mixed types ── - [Fact] - public async Task OrderBy_MixedTypes_CosmosOrdering() - { - var container = new InMemoryContainer("mixed-order", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"hello"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":42}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":true}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","val":null}""")), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT c.id, c.val FROM c ORDER BY c.val"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - // Cosmos ordering: null < bool < number < string - results.Should().HaveCount(4); - results[0]["val"]!.Type.Should().Be(JTokenType.Null); - results[1]["val"]!.Type.Should().Be(JTokenType.Boolean); - results[2]["val"]!.Type.Should().Be(JTokenType.Integer); - results[3]["val"]!.Type.Should().Be(JTokenType.String); - } + private readonly InMemoryContainer _container = new("tc-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","numValue":1,"strValue":"1","boolValue":true}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ── 10.1: Number vs string comparison ── + [Fact] + public async Task Comparison_NumberVsString_ReturnsFalse() + { + await Seed(); + // Cosmos DB: 1 = "1" → false (strict type comparison) + var results = await RunQuery( + "SELECT * FROM c WHERE c.numValue = c.strValue"); + results.Should().BeEmpty(); + } + + // ── 10.2: Bool vs number comparison ── + [Fact] + public async Task Comparison_BoolVsNumber_ReturnsFalse() + { + await Seed(); + // Cosmos DB: true = 1 → false (strict type comparison) + var results = await RunQuery( + "SELECT * FROM c WHERE c.boolValue = c.numValue"); + results.Should().BeEmpty(); + } + + // ── 10.3: ORDER BY mixed types ── + [Fact] + public async Task OrderBy_MixedTypes_CosmosOrdering() + { + var container = new InMemoryContainer("mixed-order", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"hello"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":42}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":true}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"4","pk":"a","val":null}""")), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT c.id, c.val FROM c ORDER BY c.val"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + // Cosmos ordering: null < bool < number < string + results.Should().HaveCount(4); + results[0]["val"]!.Type.Should().Be(JTokenType.Null); + results[1]["val"]!.Type.Should().Be(JTokenType.Boolean); + results[2]["val"]!.Type.Should().Be(JTokenType.Integer); + results[3]["val"]!.Type.Should().Be(JTokenType.String); + } } public class QueryDeepDiveV12_ErrorHandling { - private readonly InMemoryContainer _container = new("err-edge", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), - new PartitionKey("a")); - } - - // ── 11.1: Invalid SQL ── - [Fact] - public async Task InvalidSql_ThrowsBadRequest() - { - await Seed(); - // Exception is thrown during iterator creation (parse time), not during ReadNextAsync - var act = () => _container.GetItemQueryIterator( - "THIS IS NOT VALID SQL"); - act.Should().Throw() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); - } - - // ── 11.2: Unknown function ── - [Fact] - public async Task UnknownFunction_ThrowsBadRequest() - { - await Seed(); - // Unknown functions may throw at parse time or execution time - Func act = async () => - { - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE TOTALLY_UNKNOWN_FUNC(c.id) FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("err-edge", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","value":10}""")), + new PartitionKey("a")); + } + + // ── 11.1: Invalid SQL ── + [Fact] + public async Task InvalidSql_ThrowsBadRequest() + { + await Seed(); + // Exception is thrown during iterator creation (parse time), not during ReadNextAsync + var act = () => _container.GetItemQueryIterator( + "THIS IS NOT VALID SQL"); + act.Should().Throw() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); + } + + // ── 11.2: Unknown function ── + [Fact] + public async Task UnknownFunction_ThrowsBadRequest() + { + await Seed(); + // Unknown functions may throw at parse time or execution time + Func act = async () => + { + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE TOTALLY_UNKNOWN_FUNC(c.id) FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -19137,799 +19147,799 @@ await act.Should().ThrowAsync() public class QueryDeepDiveV13_StringEdgeCases { - private readonly InMemoryContainer _container = new("v13-str", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","name":"hello","empty":"","spaces":" "}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StartsWith_EmptyPrefix_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE STARTSWITH(c.name, '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task EndsWith_EmptySuffix_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ENDSWITH(c.name, '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task IndexOf_EmptySearchString_ReturnsZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task Replace_EmptyFind_ReturnsOriginal() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REPLACE('hello', '', 'x') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("hello"); - } - - [Fact] - public async Task Replace_EmptyReplacement_RemovesFind() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REPLACE('hello', 'l', '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("heo"); - } - - [Fact] - public async Task Contains_EmptySubstring_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CONTAINS('hello', '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task RegexMatch_InvalidPattern_ReturnsUndefined() - { - await Seed(); - // Real Cosmos DB returns undefined for invalid regex - var r = await RunQuery("SELECT VALUE REGEXMATCH('test', '[invalid') FROM c"); - // Invalid regex — undefined → omitted by SELECT VALUE - r.Should().BeEmpty(); - } - - [Fact] - public async Task Trim_WhitespaceOnlyString_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TRIM(c.spaces) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task StringSplit_ConsecutiveDelimiters_IncludesEmpties() - { - await Seed(); - var r = await RunQuery("SELECT VALUE STRINGSPLIT('a,,b', ',') FROM c"); - r.Should().ContainSingle(); - var arr = r[0]; - arr.Should().HaveCount(3); - arr[0]!.Value().Should().Be("a"); - arr[1]!.Value().Should().Be(""); - arr[2]!.Value().Should().Be("b"); - } - - [Fact] - public async Task Concat_ManyArgs_WorksCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CONCAT('a','b','c','d','e') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("abcde"); - } + private readonly InMemoryContainer _container = new("v13-str", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","name":"hello","empty":"","spaces":" "}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StartsWith_EmptyPrefix_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE STARTSWITH(c.name, '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task EndsWith_EmptySuffix_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ENDSWITH(c.name, '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task IndexOf_EmptySearchString_ReturnsZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task Replace_EmptyFind_ReturnsOriginal() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REPLACE('hello', '', 'x') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("hello"); + } + + [Fact] + public async Task Replace_EmptyReplacement_RemovesFind() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REPLACE('hello', 'l', '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("heo"); + } + + [Fact] + public async Task Contains_EmptySubstring_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CONTAINS('hello', '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task RegexMatch_InvalidPattern_ReturnsUndefined() + { + await Seed(); + // Real Cosmos DB returns undefined for invalid regex + var r = await RunQuery("SELECT VALUE REGEXMATCH('test', '[invalid') FROM c"); + // Invalid regex — undefined → omitted by SELECT VALUE + r.Should().BeEmpty(); + } + + [Fact] + public async Task Trim_WhitespaceOnlyString_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TRIM(c.spaces) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task StringSplit_ConsecutiveDelimiters_IncludesEmpties() + { + await Seed(); + var r = await RunQuery("SELECT VALUE STRINGSPLIT('a,,b', ',') FROM c"); + r.Should().ContainSingle(); + var arr = r[0]; + arr.Should().HaveCount(3); + arr[0]!.Value().Should().Be("a"); + arr[1]!.Value().Should().Be(""); + arr[2]!.Value().Should().Be("b"); + } + + [Fact] + public async Task Concat_ManyArgs_WorksCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CONCAT('a','b','c','d','e') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("abcde"); + } } public class QueryDeepDiveV13_MathEdgeCases { - private readonly InMemoryContainer _container = new("v13-math", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Power_ZeroToZero_ReturnsOne() - { - await Seed(); - var r = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(1.0); - } - - [Fact] - public async Task Power_NegativeBaseFractionalExp_ReturnsUndefined() - { - await Seed(); - // POWER(-1, 0.5) → NaN → undefined - var r = await RunQuery("SELECT VALUE POWER(-1, 0.5) FROM c"); - r.Should().BeEmpty(); // undefined → omitted by SELECT VALUE - } - - [Fact] - public async Task Log_BaseOne_ReturnsUndefined() - { - await Seed(); - // LOG(10, 1) → division by zero in change of base → Infinity → undefined - var r = await RunQuery("SELECT VALUE LOG(10, 1) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Log_OneAnyBase_ReturnsZero() - { - await Seed(); - // LOG(1) = ln(1) = 0 - var r = await RunQuery("SELECT VALUE LOG(1) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0.0); - } - - [Fact] - public async Task Trunc_NegativeNumber_TruncatesTowardZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TRUNC(-3.7) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-3.0); - } - - [Fact] - public async Task Sign_NegativeZero_ReturnsZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SIGN(-0.0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0.0); - } - - [Fact] - public async Task Asin_ExactBoundary_ReturnsHalfPi() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ASIN(1.0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI / 2, 1e-10); - } - - [Fact] - public async Task Acos_ExactNegBoundary_ReturnsPi() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ACOS(-1.0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 1e-10); - } + private readonly InMemoryContainer _container = new("v13-math", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Power_ZeroToZero_ReturnsOne() + { + await Seed(); + var r = await RunQuery("SELECT VALUE POWER(0, 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(1.0); + } + + [Fact] + public async Task Power_NegativeBaseFractionalExp_ReturnsUndefined() + { + await Seed(); + // POWER(-1, 0.5) → NaN → undefined + var r = await RunQuery("SELECT VALUE POWER(-1, 0.5) FROM c"); + r.Should().BeEmpty(); // undefined → omitted by SELECT VALUE + } + + [Fact] + public async Task Log_BaseOne_ReturnsUndefined() + { + await Seed(); + // LOG(10, 1) → division by zero in change of base → Infinity → undefined + var r = await RunQuery("SELECT VALUE LOG(10, 1) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Log_OneAnyBase_ReturnsZero() + { + await Seed(); + // LOG(1) = ln(1) = 0 + var r = await RunQuery("SELECT VALUE LOG(1) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0.0); + } + + [Fact] + public async Task Trunc_NegativeNumber_TruncatesTowardZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TRUNC(-3.7) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-3.0); + } + + [Fact] + public async Task Sign_NegativeZero_ReturnsZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SIGN(-0.0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0.0); + } + + [Fact] + public async Task Asin_ExactBoundary_ReturnsHalfPi() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ASIN(1.0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI / 2, 1e-10); + } + + [Fact] + public async Task Acos_ExactNegBoundary_ReturnsPi() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ACOS(-1.0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 1e-10); + } } public class QueryDeepDiveV13_ArrayEdgeCases { - private readonly InMemoryContainer _container = new("v13-arr", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","arr":[1,2,3],"empty":[],"obj":{"x":1,"y":2}}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArraySlice_NegativeLength_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY_SLICE([1,2,3], 0, -1) FROM c"); - r.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayConcat_WithNull_ReturnsUndefined() - { - await Seed(); - // ARRAY_CONCAT with null arg → undefined - var r = await RunQuery("SELECT VALUE ARRAY_CONCAT([1], null) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task SetIntersect_WithDuplicates_Deduplicates() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SetIntersect([1,1,2], [1,2,2]) FROM c"); - r.Should().ContainSingle(); - var arr = r[0]; - arr.Select(t => t.Value()).Order().Should().BeEquivalentTo([1, 2]); - } - - [Fact] - public async Task SetUnion_WithDuplicates_Deduplicates() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SetUnion([1,1], [2,2]) FROM c"); - r.Should().ContainSingle(); - var arr = r[0]; - arr.Select(t => t.Value()).Order().Should().BeEquivalentTo([1, 2]); - } - - [Fact] - public async Task SetDifference_EmptyFirst_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SetDifference([], [1]) FROM c"); - r.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task ObjectToArray_EmptyObject_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ObjectToArray({}) FROM c"); - r.Should().ContainSingle().Which.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayToObject_EmptyArray_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ArrayToObject([]) FROM c"); - r.Should().ContainSingle().Which.Properties().Should().BeEmpty(); - } - - [Fact] - public async Task Choose_FractionalIndex_TruncatesToInt() - { - await Seed(); - // CHOOSE(1.9, ...) → truncated to 1 → returns first element - var r = await RunQuery("SELECT VALUE CHOOSE(1.9, 'a', 'b', 'c') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("a"); - } + private readonly InMemoryContainer _container = new("v13-arr", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","arr":[1,2,3],"empty":[],"obj":{"x":1,"y":2}}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArraySlice_NegativeLength_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY_SLICE([1,2,3], 0, -1) FROM c"); + r.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayConcat_WithNull_ReturnsUndefined() + { + await Seed(); + // ARRAY_CONCAT with null arg → undefined + var r = await RunQuery("SELECT VALUE ARRAY_CONCAT([1], null) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task SetIntersect_WithDuplicates_Deduplicates() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SetIntersect([1,1,2], [1,2,2]) FROM c"); + r.Should().ContainSingle(); + var arr = r[0]; + arr.Select(t => t.Value()).Order().Should().BeEquivalentTo([1, 2]); + } + + [Fact] + public async Task SetUnion_WithDuplicates_Deduplicates() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SetUnion([1,1], [2,2]) FROM c"); + r.Should().ContainSingle(); + var arr = r[0]; + arr.Select(t => t.Value()).Order().Should().BeEquivalentTo([1, 2]); + } + + [Fact] + public async Task SetDifference_EmptyFirst_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SetDifference([], [1]) FROM c"); + r.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task ObjectToArray_EmptyObject_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ObjectToArray({}) FROM c"); + r.Should().ContainSingle().Which.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayToObject_EmptyArray_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ArrayToObject([]) FROM c"); + r.Should().ContainSingle().Which.Properties().Should().BeEmpty(); + } + + [Fact] + public async Task Choose_FractionalIndex_TruncatesToInt() + { + await Seed(); + // CHOOSE(1.9, ...) → truncated to 1 → returns first element + var r = await RunQuery("SELECT VALUE CHOOSE(1.9, 'a', 'b', 'c') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("a"); + } } public class QueryDeepDiveV13_DateTimeEdgeCases { - private readonly InMemoryContainer _container = new("v13-dt", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DateTimeAdd_NegativeMagnitude_Subtracts() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEADD('dd', -1, '2024-01-01T00:00:00Z') FROM c"); - r.Should().ContainSingle().Which.Value().Should().StartWith("2023-12-31"); - } - - [Fact] - public async Task DateTimeDiff_EndBeforeStart_ReturnsNegative() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEDIFF('dd', '2024-01-02T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-1); - } - - [Fact] - public async Task DateTimeDiff_EqualDates_ReturnsZero() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEDIFF('dd', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task DateTimeFromParts_InvalidMonth_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEFROMPARTS(2024, 13, 1, 0, 0, 0, 0) FROM c"); - r.Should().BeEmpty(); // undefined → omitted - } - - [Fact] - public async Task DateTimePart_Millisecond_ReturnsCorrect() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEPART('ms', '2024-06-15T12:30:45.123Z') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(123); - } - - [Fact] - public async Task DateTimePart_Nanosecond_ReturnsCorrect() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE DATETIMEPART('ns', '2024-06-15T12:30:45.1234567Z') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(123456700); - } + private readonly InMemoryContainer _container = new("v13-dt", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DateTimeAdd_NegativeMagnitude_Subtracts() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEADD('dd', -1, '2024-01-01T00:00:00Z') FROM c"); + r.Should().ContainSingle().Which.Value().Should().StartWith("2023-12-31"); + } + + [Fact] + public async Task DateTimeDiff_EndBeforeStart_ReturnsNegative() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEDIFF('dd', '2024-01-02T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-1); + } + + [Fact] + public async Task DateTimeDiff_EqualDates_ReturnsZero() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEDIFF('dd', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task DateTimeFromParts_InvalidMonth_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEFROMPARTS(2024, 13, 1, 0, 0, 0, 0) FROM c"); + r.Should().BeEmpty(); // undefined → omitted + } + + [Fact] + public async Task DateTimePart_Millisecond_ReturnsCorrect() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEPART('ms', '2024-06-15T12:30:45.123Z') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(123); + } + + [Fact] + public async Task DateTimePart_Nanosecond_ReturnsCorrect() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE DATETIMEPART('ns', '2024-06-15T12:30:45.1234567Z') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(123456700); + } } public class QueryDeepDiveV13_OperatorEdgeCases { - private readonly InMemoryContainer _container = new("v13-ops", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":5,"name":"banana","nullVal":null}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Between_WithStrings_ComparesLexically() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE ('banana' BETWEEN 'apple' AND 'cherry') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task Between_NotBetween_ExcludesCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE NOT (5 BETWEEN 1 AND 10) FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task In_WithNull_MatchesNull() - { - await Seed(); - var r = await RunQuery( - "SELECT * FROM c WHERE c.nullVal IN (null, 1, 'a')"); - r.Should().ContainSingle(); - } - - [Fact] - public async Task In_MixedTypes_StrictTypeMatch() - { - await Seed(); - // c.val = 5 (number), should match 5 but not "5" or true - var r = await RunQuery( - "SELECT * FROM c WHERE c.val IN (5, '5', true)"); - r.Should().ContainSingle(); - } - - [Fact] - public async Task NestedTernary_EvaluatesCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE (true ? (false ? 'a' : 'b') : 'c') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("b"); - } - - [Fact] - public async Task Ternary_NonBoolCondition_ReturnsFalseBranch() - { - await Seed(); - // 1 is not boolean → false branch - var r = await RunQuery( - "SELECT VALUE (1 ? 'yes' : 'no') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task Like_MultipleWildcards_MatchesCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE ('abcabc' LIKE 'a%c%c') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task Like_EscapedUnderscore_MatchesLiteral() - { - await Seed(); - var r = await RunQuery( - @"SELECT VALUE ('a_b' LIKE 'a\_b' ESCAPE '\') FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task NullCoalesce_NestedChain_FirstNonNull() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE (null ?? null ?? 'found') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("found"); - } - - [Fact] - public async Task StringConcat_Operator_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE ('hello' || ' ' || 'world') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("hello world"); - } + private readonly InMemoryContainer _container = new("v13-ops", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":5,"name":"banana","nullVal":null}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Between_WithStrings_ComparesLexically() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE ('banana' BETWEEN 'apple' AND 'cherry') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task Between_NotBetween_ExcludesCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE NOT (5 BETWEEN 1 AND 10) FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task In_WithNull_MatchesNull() + { + await Seed(); + var r = await RunQuery( + "SELECT * FROM c WHERE c.nullVal IN (null, 1, 'a')"); + r.Should().ContainSingle(); + } + + [Fact] + public async Task In_MixedTypes_StrictTypeMatch() + { + await Seed(); + // c.val = 5 (number), should match 5 but not "5" or true + var r = await RunQuery( + "SELECT * FROM c WHERE c.val IN (5, '5', true)"); + r.Should().ContainSingle(); + } + + [Fact] + public async Task NestedTernary_EvaluatesCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE (true ? (false ? 'a' : 'b') : 'c') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("b"); + } + + [Fact] + public async Task Ternary_NonBoolCondition_ReturnsFalseBranch() + { + await Seed(); + // 1 is not boolean → false branch + var r = await RunQuery( + "SELECT VALUE (1 ? 'yes' : 'no') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task Like_MultipleWildcards_MatchesCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE ('abcabc' LIKE 'a%c%c') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task Like_EscapedUnderscore_MatchesLiteral() + { + await Seed(); + var r = await RunQuery( + @"SELECT VALUE ('a_b' LIKE 'a\_b' ESCAPE '\') FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task NullCoalesce_NestedChain_FirstNonNull() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE (null ?? null ?? 'found') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("found"); + } + + [Fact] + public async Task StringConcat_Operator_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE ('hello' || ' ' || 'world') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("hello world"); + } } public class QueryDeepDiveV13_CrossFeature { - private readonly InMemoryContainer _container = new("v13-cross", "/pk"); - - private async Task Seed() - { - foreach (var (id, pk, val, cat) in new[] - { - ("1", "a", 10, "X"), ("2", "a", 20, "X"), ("3", "a", 30, "Y"), - ("4", "b", 40, "Y"), ("5", "b", 50, "Z"), - }) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$"""{"id":"{{id}}","pk":"{{pk}}","val":{{val}},"cat":"{{cat}}","tags":["t1","t2"]}""")), - new PartitionKey(pk)); - } - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Distinct_Top_OrderBy_ExecutionOrder() - { - await Seed(); - var r = await RunQuery( - "SELECT DISTINCT TOP 2 c.cat FROM c ORDER BY c.cat"); - r.Should().HaveCount(2); - r[0]["cat"]!.Value().Should().Be("X"); - r[1]["cat"]!.Value().Should().Be("Y"); - } - - [Fact] - public async Task Offset_BeyondResultCount_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery( - "SELECT * FROM c ORDER BY c.val OFFSET 100 LIMIT 10"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Limit_Zero_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery( - "SELECT * FROM c ORDER BY c.val OFFSET 0 LIMIT 0"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task GroupBy_OrderBy_Top_Combined() - { - await Seed(); - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); - r.Should().HaveCount(3); - // X=2, Y=2, Z=1 — X and Y tied at 2, Z is last with 1 - r[2]["cnt"]!.Value().Should().Be(1); - r[2]["cat"]!.Value().Should().Be("Z"); - r[0]["cnt"]!.Value().Should().Be(2); - r[1]["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Join_WithDistinct_Deduplicates() - { - await Seed(); - // All 5 docs have tags ["t1","t2"], so JOIN gives 10 rows; DISTINCT → 2 - var r = await RunQuery( - "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); - r.Should().HaveCount(2); - r.Select(t => t.Value()).Order().Should().BeEquivalentTo(["t1", "t2"]); - } - - [Fact] - public async Task GroupBy_WithMultipleAggregates() - { - await Seed(); - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY c.cat"); - r.Should().HaveCount(3); - r[0]["cat"]!.Value().Should().Be("X"); - r[0]["cnt"]!.Value().Should().Be(2); - r[0]["total"]!.Value().Should().Be(30); // 10+20 - } - - [Fact] - public async Task Exists_WithComplex_Where() - { - await Seed(); - var r = await RunQuery( - "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 't1') AND c.val > 20"); - r.Should().HaveCount(3); // docs 3,4,5 have val>20 and all have t1 - } - - [Fact] - public async Task Between_WithParameters_Works() - { - await Seed(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") - .WithParameter("@lo", 15) - .WithParameter("@hi", 35); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); // val=20, val=30 - } - - [Fact] - public async Task MultipleAggregates_SingleQuery() - { - await Seed(); - var r = await RunQuery( - "SELECT COUNT(1) AS cnt, SUM(c.val) AS total, AVG(c.val) AS avg FROM c"); - r.Should().ContainSingle(); - r[0]["cnt"]!.Value().Should().Be(5); - r[0]["total"]!.Value().Should().Be(150); - r[0]["avg"]!.Value().Should().Be(30.0); - } - - [Fact] - public async Task Join_GroupBy_Aggregate() - { - await Seed(); - var r = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t ORDER BY t"); - r.Should().HaveCount(2); - r[0]["tag"]!.Value().Should().Be("t1"); - r[0]["cnt"]!.Value().Should().Be(5); // every doc has t1 - } + private readonly InMemoryContainer _container = new("v13-cross", "/pk"); + + private async Task Seed() + { + foreach (var (id, pk, val, cat) in new[] + { + ("1", "a", 10, "X"), ("2", "a", 20, "X"), ("3", "a", 30, "Y"), + ("4", "b", 40, "Y"), ("5", "b", 50, "Z"), + }) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$"""{"id":"{{id}}","pk":"{{pk}}","val":{{val}},"cat":"{{cat}}","tags":["t1","t2"]}""")), + new PartitionKey(pk)); + } + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Distinct_Top_OrderBy_ExecutionOrder() + { + await Seed(); + var r = await RunQuery( + "SELECT DISTINCT TOP 2 c.cat FROM c ORDER BY c.cat"); + r.Should().HaveCount(2); + r[0]["cat"]!.Value().Should().Be("X"); + r[1]["cat"]!.Value().Should().Be("Y"); + } + + [Fact] + public async Task Offset_BeyondResultCount_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery( + "SELECT * FROM c ORDER BY c.val OFFSET 100 LIMIT 10"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Limit_Zero_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery( + "SELECT * FROM c ORDER BY c.val OFFSET 0 LIMIT 0"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task GroupBy_OrderBy_Top_Combined() + { + await Seed(); + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY cnt DESC"); + r.Should().HaveCount(3); + // X=2, Y=2, Z=1 — X and Y tied at 2, Z is last with 1 + r[2]["cnt"]!.Value().Should().Be(1); + r[2]["cat"]!.Value().Should().Be("Z"); + r[0]["cnt"]!.Value().Should().Be(2); + r[1]["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Join_WithDistinct_Deduplicates() + { + await Seed(); + // All 5 docs have tags ["t1","t2"], so JOIN gives 10 rows; DISTINCT → 2 + var r = await RunQuery( + "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags"); + r.Should().HaveCount(2); + r.Select(t => t.Value()).Order().Should().BeEquivalentTo(["t1", "t2"]); + } + + [Fact] + public async Task GroupBy_WithMultipleAggregates() + { + await Seed(); + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY c.cat"); + r.Should().HaveCount(3); + r[0]["cat"]!.Value().Should().Be("X"); + r[0]["cnt"]!.Value().Should().Be(2); + r[0]["total"]!.Value().Should().Be(30); // 10+20 + } + + [Fact] + public async Task Exists_WithComplex_Where() + { + await Seed(); + var r = await RunQuery( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 't1') AND c.val > 20"); + r.Should().HaveCount(3); // docs 3,4,5 have val>20 and all have t1 + } + + [Fact] + public async Task Between_WithParameters_Works() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") + .WithParameter("@lo", 15) + .WithParameter("@hi", 35); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); // val=20, val=30 + } + + [Fact] + public async Task MultipleAggregates_SingleQuery() + { + await Seed(); + var r = await RunQuery( + "SELECT COUNT(1) AS cnt, SUM(c.val) AS total, AVG(c.val) AS avg FROM c"); + r.Should().ContainSingle(); + r[0]["cnt"]!.Value().Should().Be(5); + r[0]["total"]!.Value().Should().Be(150); + r[0]["avg"]!.Value().Should().Be(30.0); + } + + [Fact] + public async Task Join_GroupBy_Aggregate() + { + await Seed(); + var r = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t ORDER BY t"); + r.Should().HaveCount(2); + r[0]["tag"]!.Value().Should().Be("t1"); + r[0]["cnt"]!.Value().Should().Be(5); // every doc has t1 + } } public class QueryDeepDiveV13_ParameterEdgeCases { - private readonly InMemoryContainer _container = new("v13-param", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":10,"nullVal":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","val":20}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task ParameterReuse_SameParamMultipleTimes() - { - await Seed(); - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.val > @x AND c.val < @x * 3 + @x") - .WithParameter("@x", 5); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - // val > 5 AND val < 20 → only val=10 - results.Should().ContainSingle(); - results[0]["val"]!.Value().Should().Be(10); - } - - [Fact] - public async Task ParameterEmptyArray_InQuery() - { - await Seed(); - var query = new QueryDefinition( - "SELECT VALUE ARRAY_LENGTH(@arr) FROM c") - .WithParameter("@arr", new JArray()); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); // one per doc - results[0].Value().Should().Be(0); - } - - [Fact] - public async Task ParameterEmptyObject_InQuery() - { - await Seed(); - var query = new QueryDefinition( - "SELECT VALUE IS_OBJECT(@obj) FROM c") - .WithParameter("@obj", new JObject()); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results[0].Value().Should().BeTrue(); - } - - [Fact] - public async Task ParameterNull_IsNull_ReturnsTrue() - { - await Seed(); - var query = new QueryDefinition( - "SELECT VALUE IS_NULL(@val) FROM c") - .WithParameter("@val", null); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results[0].Value().Should().BeTrue(); - } - - [Fact] - public async Task DuplicateParameter_LastWins() - { - await Seed(); - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.val = @x") - .WithParameter("@x", 999) - .WithParameter("@x", 10); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["val"]!.Value().Should().Be(10); - } - - [Fact] - public async Task ParameterInBetween_Works() - { - await Seed(); - var query = new QueryDefinition( - "SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") - .WithParameter("@lo", 5) - .WithParameter("@hi", 15); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["val"]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("v13-param", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":10,"nullVal":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","val":20}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task ParameterReuse_SameParamMultipleTimes() + { + await Seed(); + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.val > @x AND c.val < @x * 3 + @x") + .WithParameter("@x", 5); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + // val > 5 AND val < 20 → only val=10 + results.Should().ContainSingle(); + results[0]["val"]!.Value().Should().Be(10); + } + + [Fact] + public async Task ParameterEmptyArray_InQuery() + { + await Seed(); + var query = new QueryDefinition( + "SELECT VALUE ARRAY_LENGTH(@arr) FROM c") + .WithParameter("@arr", new JArray()); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); // one per doc + results[0].Value().Should().Be(0); + } + + [Fact] + public async Task ParameterEmptyObject_InQuery() + { + await Seed(); + var query = new QueryDefinition( + "SELECT VALUE IS_OBJECT(@obj) FROM c") + .WithParameter("@obj", new JObject()); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results[0].Value().Should().BeTrue(); + } + + [Fact] + public async Task ParameterNull_IsNull_ReturnsTrue() + { + await Seed(); + var query = new QueryDefinition( + "SELECT VALUE IS_NULL(@val) FROM c") + .WithParameter("@val", null); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results[0].Value().Should().BeTrue(); + } + + [Fact] + public async Task DuplicateParameter_LastWins() + { + await Seed(); + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.val = @x") + .WithParameter("@x", 999) + .WithParameter("@x", 10); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle(); + results[0]["val"]!.Value().Should().Be(10); + } + + [Fact] + public async Task ParameterInBetween_Works() + { + await Seed(); + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.val BETWEEN @lo AND @hi") + .WithParameter("@lo", 5) + .WithParameter("@hi", 15); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle(); + results[0]["val"]!.Value().Should().Be(10); + } } public class QueryDeepDiveV13_ProjectionEdgeCases { - private readonly InMemoryContainer _container = new("v13-proj", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","val":10,"nested":{"deep":{"deeper":{"leaf":"found"}}}}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"a","val":20}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_Null_ReturnsNullRow() - { - await Seed(); - var r = await RunQuery("SELECT VALUE null FROM c"); - r.Should().HaveCount(2); - r[0].Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task SelectValue_UndefinedPath_OmitsRow() - { - await Seed(); - // doc 2 has no "nested" → undefined → omitted by SELECT VALUE - var r = await RunQuery("SELECT VALUE c.nested.deep.deeper.leaf FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("found"); - } - - [Fact] - public async Task DeeplyNestedPath_FourLevels() - { - await Seed(); - var r = await RunQuery( - "SELECT c.nested.deep.deeper.leaf AS leaf FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["leaf"]!.Value().Should().Be("found"); - } - - [Fact] - public async Task Select_SystemProperties_HasCorrectTypes() - { - await Seed(); - var r = await RunQuery( - "SELECT c._ts, c._etag, c.id FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["_ts"]!.Type.Should().Be(JTokenType.Integer); - r[0]["_etag"]!.Type.Should().Be(JTokenType.String); - } - - [Fact] - public async Task Select_SameFieldMultipleAliases() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id AS id1, c.id AS id2 FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["id1"]!.Value().Should().Be("1"); - r[0]["id2"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task SelectValue_WithAggregate_DirectlyWorks() - { - await Seed(); - var r = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("v13-proj", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","val":10,"nested":{"deep":{"deeper":{"leaf":"found"}}}}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"a","val":20}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_Null_ReturnsNullRow() + { + await Seed(); + var r = await RunQuery("SELECT VALUE null FROM c"); + r.Should().HaveCount(2); + r[0].Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task SelectValue_UndefinedPath_OmitsRow() + { + await Seed(); + // doc 2 has no "nested" → undefined → omitted by SELECT VALUE + var r = await RunQuery("SELECT VALUE c.nested.deep.deeper.leaf FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("found"); + } + + [Fact] + public async Task DeeplyNestedPath_FourLevels() + { + await Seed(); + var r = await RunQuery( + "SELECT c.nested.deep.deeper.leaf AS leaf FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["leaf"]!.Value().Should().Be("found"); + } + + [Fact] + public async Task Select_SystemProperties_HasCorrectTypes() + { + await Seed(); + var r = await RunQuery( + "SELECT c._ts, c._etag, c.id FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["_ts"]!.Type.Should().Be(JTokenType.Integer); + r[0]["_etag"]!.Type.Should().Be(JTokenType.String); + } + + [Fact] + public async Task Select_SameFieldMultipleAliases() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id AS id1, c.id AS id2 FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["id1"]!.Value().Should().Be("1"); + r[0]["id2"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task SelectValue_WithAggregate_DirectlyWorks() + { + await Seed(); + var r = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -19938,141 +19948,141 @@ public async Task SelectValue_WithAggregate_DirectlyWorks() public class QueryDeepDiveV14_GroupByAdvanced { - private readonly InMemoryContainer _container = new("v14-grp", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","val":10,"nullable":null,"active":true,"price":5,"qty":2}""", - """{"id":"2","pk":"a","val":20,"nullable":5,"active":true,"price":3,"qty":4}""", - """{"id":"3","pk":"b","val":30,"nullable":null,"active":false,"price":7,"qty":1}""", - """{"id":"4","pk":"b","val":40,"nullable":10,"active":false,"price":2,"qty":3}""", - """{"id":"5","pk":"c","val":50,"active":true,"price":9,"qty":5}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey(JObject.Parse(json)["pk"]!.Value())); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_SelectValueObjectWithAggregate() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"pk": c.pk, "cnt": COUNT(1)} FROM c GROUP BY c.pk"""); - r.Should().HaveCount(3); - var byPk = r.ToDictionary(x => x["pk"]!.Value()!); - byPk["a"]["cnt"]!.Value().Should().Be(2); - byPk["b"]["cnt"]!.Value().Should().Be(2); - byPk["c"]["cnt"]!.Value().Should().Be(1); - } - - [Fact] - public async Task GroupBy_SelectValueObjectWithMultipleAggs() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"pk": c.pk, "cnt": COUNT(1), "total": SUM(c.val)} FROM c GROUP BY c.pk"""); - r.Should().HaveCount(3); - var byPk = r.ToDictionary(x => x["pk"]!.Value()!); - byPk["a"]["cnt"]!.Value().Should().Be(2); - byPk["a"]["total"]!.Value().Should().Be(30); - byPk["b"]["total"]!.Value().Should().Be(70); - } - - [Fact] - public async Task GroupBy_HavingCountField_ExcludesNull() - { - await Seed(); - // nullable is null for id 1,3 and missing for id 5 → COUNT(c.nullable) should only count non-null defined values - var r = await RunQuery( - "SELECT c.pk, COUNT(c.nullable) AS cnt FROM c GROUP BY c.pk HAVING COUNT(c.nullable) > 0"); - r.Should().HaveCount(2); // 'a' has 1 non-null, 'b' has 1 non-null - foreach (var item in r) - item["cnt"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task GroupBy_MultipleGroupKeys_WithAggregate() - { - await Seed(); - var r = await RunQuery( - "SELECT c.pk, c.active, COUNT(1) AS cnt FROM c GROUP BY c.pk, c.active"); - r.Should().HaveCount(3); // (a,true), (b,false), (c,true) - } - - [Fact] - public async Task GroupBy_WithCompoundAggregate_InOrderBy() - { - await Seed(); - var r = await RunQuery( - "SELECT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk ORDER BY COUNT(1) DESC"); - r.Should().HaveCount(3); - r[0]["cnt"]!.Value().Should().Be(2); - r[2]["cnt"]!.Value().Should().Be(1); - } - - [Fact] - public async Task GroupBy_EmptyGroups_Excluded() - { - await Seed(); - var r = await RunQuery( - "SELECT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk HAVING COUNT(1) > 100"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task GroupBy_WithValueSelect_AndAggregate() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE COUNT(1) FROM c GROUP BY c.pk"); - r.Should().HaveCount(3); - r.Select(t => t.Value()).OrderBy(x => x).Should().Equal(1, 2, 2); - } - - [Fact] - public async Task GroupBy_SumOnExpression_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.pk, SUM(c.price * c.qty) AS revenue FROM c GROUP BY c.pk"); - var byPk = r.ToDictionary(x => x["pk"]!.Value()!); - byPk["a"]["revenue"]!.Value().Should().Be(22); // 5*2 + 3*4 - byPk["b"]["revenue"]!.Value().Should().Be(13); // 7*1 + 2*3 - byPk["c"]["revenue"]!.Value().Should().Be(45); // 9*5 - } - - [Fact] - public async Task GroupBy_WithDistinct_CombinesCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT DISTINCT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk"); - r.Should().HaveCount(3); // already distinct per group - } - - [Fact] - public async Task GroupBy_BooleanKey_GroupsCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); - r.Should().HaveCount(2); - var byActive = r.ToDictionary(x => x["active"]!.Value()!); - byActive[true]["cnt"]!.Value().Should().Be(3); // ids 1,2,5 - byActive[false]["cnt"]!.Value().Should().Be(2); // ids 3,4 - } + private readonly InMemoryContainer _container = new("v14-grp", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","val":10,"nullable":null,"active":true,"price":5,"qty":2}""", + """{"id":"2","pk":"a","val":20,"nullable":5,"active":true,"price":3,"qty":4}""", + """{"id":"3","pk":"b","val":30,"nullable":null,"active":false,"price":7,"qty":1}""", + """{"id":"4","pk":"b","val":40,"nullable":10,"active":false,"price":2,"qty":3}""", + """{"id":"5","pk":"c","val":50,"active":true,"price":9,"qty":5}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey(JObject.Parse(json)["pk"]!.Value())); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_SelectValueObjectWithAggregate() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"pk": c.pk, "cnt": COUNT(1)} FROM c GROUP BY c.pk"""); + r.Should().HaveCount(3); + var byPk = r.ToDictionary(x => x["pk"]!.Value()!); + byPk["a"]["cnt"]!.Value().Should().Be(2); + byPk["b"]["cnt"]!.Value().Should().Be(2); + byPk["c"]["cnt"]!.Value().Should().Be(1); + } + + [Fact] + public async Task GroupBy_SelectValueObjectWithMultipleAggs() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"pk": c.pk, "cnt": COUNT(1), "total": SUM(c.val)} FROM c GROUP BY c.pk"""); + r.Should().HaveCount(3); + var byPk = r.ToDictionary(x => x["pk"]!.Value()!); + byPk["a"]["cnt"]!.Value().Should().Be(2); + byPk["a"]["total"]!.Value().Should().Be(30); + byPk["b"]["total"]!.Value().Should().Be(70); + } + + [Fact] + public async Task GroupBy_HavingCountField_ExcludesNull() + { + await Seed(); + // nullable is null for id 1,3 and missing for id 5 → COUNT(c.nullable) should only count non-null defined values + var r = await RunQuery( + "SELECT c.pk, COUNT(c.nullable) AS cnt FROM c GROUP BY c.pk HAVING COUNT(c.nullable) > 0"); + r.Should().HaveCount(2); // 'a' has 1 non-null, 'b' has 1 non-null + foreach (var item in r) + item["cnt"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task GroupBy_MultipleGroupKeys_WithAggregate() + { + await Seed(); + var r = await RunQuery( + "SELECT c.pk, c.active, COUNT(1) AS cnt FROM c GROUP BY c.pk, c.active"); + r.Should().HaveCount(3); // (a,true), (b,false), (c,true) + } + + [Fact] + public async Task GroupBy_WithCompoundAggregate_InOrderBy() + { + await Seed(); + var r = await RunQuery( + "SELECT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk ORDER BY COUNT(1) DESC"); + r.Should().HaveCount(3); + r[0]["cnt"]!.Value().Should().Be(2); + r[2]["cnt"]!.Value().Should().Be(1); + } + + [Fact] + public async Task GroupBy_EmptyGroups_Excluded() + { + await Seed(); + var r = await RunQuery( + "SELECT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk HAVING COUNT(1) > 100"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task GroupBy_WithValueSelect_AndAggregate() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE COUNT(1) FROM c GROUP BY c.pk"); + r.Should().HaveCount(3); + r.Select(t => t.Value()).OrderBy(x => x).Should().Equal(1, 2, 2); + } + + [Fact] + public async Task GroupBy_SumOnExpression_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.pk, SUM(c.price * c.qty) AS revenue FROM c GROUP BY c.pk"); + var byPk = r.ToDictionary(x => x["pk"]!.Value()!); + byPk["a"]["revenue"]!.Value().Should().Be(22); // 5*2 + 3*4 + byPk["b"]["revenue"]!.Value().Should().Be(13); // 7*1 + 2*3 + byPk["c"]["revenue"]!.Value().Should().Be(45); // 9*5 + } + + [Fact] + public async Task GroupBy_WithDistinct_CombinesCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT DISTINCT c.pk, COUNT(1) AS cnt FROM c GROUP BY c.pk"); + r.Should().HaveCount(3); // already distinct per group + } + + [Fact] + public async Task GroupBy_BooleanKey_GroupsCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); + r.Should().HaveCount(2); + var byActive = r.ToDictionary(x => x["active"]!.Value()!); + byActive[true]["cnt"]!.Value().Should().Be(3); // ids 1,2,5 + byActive[false]["cnt"]!.Value().Should().Be(2); // ids 3,4 + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20081,140 +20091,140 @@ public async Task GroupBy_BooleanKey_GroupsCorrectly() public class QueryDeepDiveV14_UndefinedNullPropagation { - private readonly InMemoryContainer _container = new("v14-undef", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","val":10,"nullable":null}""", - """{"id":"2","pk":"a","name":"Bob","val":20}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task MathFunction_NullInput_ReturnsUndefined() - { - await Seed(); - // ABS(null) should return undefined; SELECT VALUE should omit undefined rows - var r = await RunQuery("SELECT VALUE ABS(null) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task StringEquals_NullArg_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE STRING_EQUALS(null, 'x') FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Bitwise_NonInteger_ReturnsUndefined() - { - await Seed(); - // IntBitAnd with a float arg should return undefined - var r = await RunQuery("SELECT VALUE IntBitAnd(1.5, 3) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task IntAdd_DoubleArgs_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTADD(1.5, 2.5) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Coalesce_AllUndefined_ReturnsUndefined() - { - await Seed(); - // COALESCE with all undefined args returns undefined (items are omitted from SELECT VALUE) - var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); - r.Should().BeEmpty("COALESCE of all undefined fields should return undefined (omitted)"); - } - - [Fact] - public async Task IIF_UndefinedCondition_ReturnsFalseBranch() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IIF(c.missing, 'yes', 'no') FROM c"); - r.Should().AllSatisfy(t => t.Value().Should().Be("no")); - } - - [Fact] - public async Task SelectValue_FunctionOnUndefined_OmitsRow() - { - await Seed(); - var r = await RunQuery("SELECT VALUE UPPER(c.missing) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Where_ArithmeticOnUndefined_NoMatch() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.missing + 1 > 0"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Select_UndefinedField_OmittedFromResult() - { - await Seed(); - var r = await RunQuery("SELECT c.name, c.missing FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0].ContainsKey("name").Should().BeTrue(); - r[0].ContainsKey("missing").Should().BeFalse(); - } - - [Fact] - public async Task StringConcat_NullOperand_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE null || 'text' FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Comparison_NullVsUndefined_BothExcluded() - { - await Seed(); - // WHERE c.nullable = c.missing — null vs undefined → no match - var r = await RunQuery("SELECT * FROM c WHERE c.nullable = c.missing"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Aggregate_AllNullValues_ReturnsUndefined() - { - // Seed items where the aggregated field is always null - var container = new InMemoryContainer("v14-undef-agg", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":null}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":null}""")), - new PartitionKey("a")); - var iterator = container.GetItemQueryIterator("SELECT VALUE SUM(c.val) FROM c"); - var r = new List(); - while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); - // SUM of all nulls → undefined → empty result for SELECT VALUE - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("v14-undef", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","val":10,"nullable":null}""", + """{"id":"2","pk":"a","name":"Bob","val":20}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task MathFunction_NullInput_ReturnsUndefined() + { + await Seed(); + // ABS(null) should return undefined; SELECT VALUE should omit undefined rows + var r = await RunQuery("SELECT VALUE ABS(null) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task StringEquals_NullArg_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE STRING_EQUALS(null, 'x') FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Bitwise_NonInteger_ReturnsUndefined() + { + await Seed(); + // IntBitAnd with a float arg should return undefined + var r = await RunQuery("SELECT VALUE IntBitAnd(1.5, 3) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task IntAdd_DoubleArgs_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTADD(1.5, 2.5) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Coalesce_AllUndefined_ReturnsUndefined() + { + await Seed(); + // COALESCE with all undefined args returns undefined (items are omitted from SELECT VALUE) + var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); + r.Should().BeEmpty("COALESCE of all undefined fields should return undefined (omitted)"); + } + + [Fact] + public async Task IIF_UndefinedCondition_ReturnsFalseBranch() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IIF(c.missing, 'yes', 'no') FROM c"); + r.Should().AllSatisfy(t => t.Value().Should().Be("no")); + } + + [Fact] + public async Task SelectValue_FunctionOnUndefined_OmitsRow() + { + await Seed(); + var r = await RunQuery("SELECT VALUE UPPER(c.missing) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Where_ArithmeticOnUndefined_NoMatch() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.missing + 1 > 0"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Select_UndefinedField_OmittedFromResult() + { + await Seed(); + var r = await RunQuery("SELECT c.name, c.missing FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0].ContainsKey("name").Should().BeTrue(); + r[0].ContainsKey("missing").Should().BeFalse(); + } + + [Fact] + public async Task StringConcat_NullOperand_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE null || 'text' FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Comparison_NullVsUndefined_BothExcluded() + { + await Seed(); + // WHERE c.nullable = c.missing — null vs undefined → no match + var r = await RunQuery("SELECT * FROM c WHERE c.nullable = c.missing"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Aggregate_AllNullValues_ReturnsUndefined() + { + // Seed items where the aggregated field is always null + var container = new InMemoryContainer("v14-undef-agg", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":null}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":null}""")), + new PartitionKey("a")); + var iterator = container.GetItemQueryIterator("SELECT VALUE SUM(c.val) FROM c"); + var r = new List(); + while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); + // SUM of all nulls → undefined → empty result for SELECT VALUE + r.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20223,86 +20233,86 @@ await container.CreateItemStreamAsync( public class QueryDeepDiveV14_BitwiseIntEdgeCases { - private readonly InMemoryContainer _container = new("v14-bit", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":42}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IntBitAnd_WithZero_ReturnsZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntBitAnd(255, 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task IntBitOr_WithZero_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntBitOr(42, 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(42); - } - - [Fact] - public async Task IntBitNot_NegativeInput_Inverts() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntBitNot(-1) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task IntBitLeftShift_ByZero_NoChange() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntBitLeftShift(5, 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(5); - } - - [Fact] - public async Task IntMul_LargeNumbers_ReturnsCorrect() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTMUL(1000000, 1000000) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(1000000000000L); - } - - [Fact] - public async Task IntDiv_NegativeByPositive_NegativeResult() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTDIV(-10, 3) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-3); - } - - [Fact] - public async Task IntMod_NegativeDividend_PreservesSign() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTMOD(-10, 3) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-1); - } - - [Fact] - public async Task IntBitXor_SameValues_ReturnsZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntBitXor(42, 42) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } + private readonly InMemoryContainer _container = new("v14-bit", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":42}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IntBitAnd_WithZero_ReturnsZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntBitAnd(255, 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task IntBitOr_WithZero_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntBitOr(42, 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(42); + } + + [Fact] + public async Task IntBitNot_NegativeInput_Inverts() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntBitNot(-1) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task IntBitLeftShift_ByZero_NoChange() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntBitLeftShift(5, 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(5); + } + + [Fact] + public async Task IntMul_LargeNumbers_ReturnsCorrect() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTMUL(1000000, 1000000) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(1000000000000L); + } + + [Fact] + public async Task IntDiv_NegativeByPositive_NegativeResult() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTDIV(-10, 3) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-3); + } + + [Fact] + public async Task IntMod_NegativeDividend_PreservesSign() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTMOD(-10, 3) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-1); + } + + [Fact] + public async Task IntBitXor_SameValues_ReturnsZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntBitXor(42, 42) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20311,86 +20321,86 @@ public async Task IntBitXor_SameValues_ReturnsZero() public class QueryDeepDiveV14_StringNullEdgeCases { - private readonly InMemoryContainer _container = new("v14-strnull", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Upper_NullInput_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE UPPER(null) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Lower_NullInput_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LOWER(null) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Length_NullInput_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LENGTH(null) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Reverse_EmptyString_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REVERSE('') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task IndexOf_BothEmpty_ReturnsZero() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INDEX_OF('', '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task Left_ZeroCount_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task Right_ZeroCount_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RIGHT('hello', 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task Substring_ZeroLength_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, 0) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } + private readonly InMemoryContainer _container = new("v14-strnull", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"hello"}""")), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Upper_NullInput_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE UPPER(null) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Lower_NullInput_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LOWER(null) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Length_NullInput_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LENGTH(null) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Reverse_EmptyString_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REVERSE('') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task IndexOf_BothEmpty_ReturnsZero() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INDEX_OF('', '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task Left_ZeroCount_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task Right_ZeroCount_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RIGHT('hello', 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task Substring_ZeroLength_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SUBSTRING('hello', 0, 0) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20399,106 +20409,106 @@ public async Task Substring_ZeroLength_ReturnsEmpty() public class QueryDeepDiveV14_SubqueryAdvanced { - private readonly InMemoryContainer _container = new("v14-sub", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","tags":["red","green","blue"],"scores":[10,20,30]}""", - """{"id":"2","pk":"a","tags":["green","yellow"],"scores":[5,15]}""", - """{"id":"3","pk":"a","tags":[],"scores":[]}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArraySubquery_WithWhere_FiltersCorrectly() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'green') AS filtered FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - var filtered = r[0]["filtered"]!.ToObject>(); - filtered.Should().BeEquivalentTo("red", "blue"); - } - - [Fact] - public async Task ArraySubquery_EmptySourceArray_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, ARRAY(SELECT VALUE t FROM t IN c.tags) AS all_tags FROM c WHERE c.id = '3'"); - r.Should().ContainSingle(); - r[0]["all_tags"]!.ToObject>().Should().BeEmpty(); - } - - [Fact] - public async Task Exists_WithAlwaysTrueSubquery_ReturnsAll() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); - r.Should().HaveCount(2); // id 1 and 2 have tags, id 3 has empty array - } - - [Fact] - public async Task ScalarSubquery_InWhere_Compares() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id FROM c WHERE ARRAY_LENGTH(c.tags) > 2"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task ArraySubquery_WithTop_LimitsResults() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, ARRAY(SELECT TOP 2 VALUE t FROM t IN c.tags) AS limited FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["limited"]!.ToObject>().Should().HaveCount(2); - } - - [Fact] - public async Task NotExists_EmptyArray_ReturnsAll() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'purple')"); - r.Should().HaveCount(3); // no item has 'purple' - } - - [Fact] - public async Task SubqueryWithDistinctValue() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, ARRAY(SELECT DISTINCT VALUE t FROM t IN c.tags) AS unique_tags FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["unique_tags"]!.ToObject>().Should().HaveCount(3); - } - - [Fact] - public async Task Exists_WithJoinAndWhere_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE STARTSWITH(t, 'g'))"); - r.Should().HaveCount(2); // ids 1 and 2 have "green" - } + private readonly InMemoryContainer _container = new("v14-sub", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","tags":["red","green","blue"],"scores":[10,20,30]}""", + """{"id":"2","pk":"a","tags":["green","yellow"],"scores":[5,15]}""", + """{"id":"3","pk":"a","tags":[],"scores":[]}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArraySubquery_WithWhere_FiltersCorrectly() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'green') AS filtered FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + var filtered = r[0]["filtered"]!.ToObject>(); + filtered.Should().BeEquivalentTo("red", "blue"); + } + + [Fact] + public async Task ArraySubquery_EmptySourceArray_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, ARRAY(SELECT VALUE t FROM t IN c.tags) AS all_tags FROM c WHERE c.id = '3'"); + r.Should().ContainSingle(); + r[0]["all_tags"]!.ToObject>().Should().BeEmpty(); + } + + [Fact] + public async Task Exists_WithAlwaysTrueSubquery_ReturnsAll() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags)"); + r.Should().HaveCount(2); // id 1 and 2 have tags, id 3 has empty array + } + + [Fact] + public async Task ScalarSubquery_InWhere_Compares() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id FROM c WHERE ARRAY_LENGTH(c.tags) > 2"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task ArraySubquery_WithTop_LimitsResults() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, ARRAY(SELECT TOP 2 VALUE t FROM t IN c.tags) AS limited FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["limited"]!.ToObject>().Should().HaveCount(2); + } + + [Fact] + public async Task NotExists_EmptyArray_ReturnsAll() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'purple')"); + r.Should().HaveCount(3); // no item has 'purple' + } + + [Fact] + public async Task SubqueryWithDistinctValue() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, ARRAY(SELECT DISTINCT VALUE t FROM t IN c.tags) AS unique_tags FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["unique_tags"]!.ToObject>().Should().HaveCount(3); + } + + [Fact] + public async Task Exists_WithJoinAndWhere_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE STARTSWITH(t, 'g'))"); + r.Should().HaveCount(2); // ids 1 and 2 have "green" + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20507,147 +20517,147 @@ public async Task Exists_WithJoinAndWhere_Works() public class QueryDeepDiveV14_OrderByComplex { - private readonly InMemoryContainer _container = new("v14-ord", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Charlie","price":10,"qty":3,"active":true,"x":null}""", - """{"id":"2","pk":"a","name":"Alice","price":20,"qty":1,"active":false,"x":5}""", - """{"id":"3","pk":"a","name":"Bob","price":5,"qty":10,"active":true}""", - """{"id":"4","pk":"a","name":"Diana","price":15,"qty":2,"active":false,"x":3}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_NestedFunctionCall_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.name FROM c ORDER BY LOWER(c.name)"); - r.Select(x => x["name"]!.Value()).Should().Equal("Alice", "Bob", "Charlie", "Diana"); - } - - [Fact] - public async Task OrderBy_ArithmeticExpression_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.name, c.price * c.qty AS revenue FROM c ORDER BY c.price * c.qty DESC"); - r[0]["name"]!.Value().Should().Be("Bob"); // 5*10=50 - r[3]["name"]!.Value().Should().Be("Alice"); // 20*1=20 - // Middle two (Charlie=30, Diana=30) may sort either way — just verify revenue - r[1]["revenue"]!.Value().Should().Be(30); - r[2]["revenue"]!.Value().Should().Be(30); - } - - [Fact] - public async Task OrderBy_Coalesce_FallsBackCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT c.id, c.x ?? 0 AS xval FROM c ORDER BY c.x ?? 0"); - // x values: null→0, 5, undefined→0, 3 → sorted: 0, 0, 3, 5 - r[0]["xval"]!.Value().Should().Be(0); - r[3]["xval"]!.Value().Should().Be(5); - } - - [Fact] - public async Task OrderBy_ThreeFields_MixedDirections() - { - await Seed(); - var r = await RunQuery( - "SELECT c.name, c.active, c.price FROM c ORDER BY c.active DESC, c.price ASC"); - // active true first (DESC), then by price ASC - r[0]["name"]!.Value().Should().Be("Bob"); // true, price=5 - r[1]["name"]!.Value().Should().Be("Charlie"); // true, price=10 - } - - [Fact] - public async Task OrderBy_TypeRankRespected() - { - // Items with mixed types in same field - var container = new InMemoryContainer("v14-ord-type", "/pk"); - var items = new[] - { - """{"id":"1","pk":"a","x":null}""", - """{"id":"2","pk":"a","x":false}""", - """{"id":"3","pk":"a","x":42}""", - """{"id":"4","pk":"a","x":"hello"}""", - """{"id":"5","pk":"a"}""" - }; - foreach (var json in items) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - var iterator = container.GetItemQueryIterator( - "SELECT c.id, c.x FROM c ORDER BY c.x"); - var r = new List(); - while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); - // undefined < null < bool < number < string - r[0]["id"]!.Value().Should().Be("5"); // undefined - r[1]["id"]!.Value().Should().Be("1"); // null - r[2]["id"]!.Value().Should().Be("2"); // false (bool) - r[3]["id"]!.Value().Should().Be("3"); // 42 (number) - r[4]["id"]!.Value().Should().Be("4"); // "hello" (string) - } - - [Fact] - public async Task OrderBy_TernaryExpression_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.name FROM c ORDER BY (c.active ? 0 : 1), c.name"); - // active=true gets 0 (first), then sorted by name - r[0]["name"]!.Value().Should().Be("Bob"); // active, B - r[1]["name"]!.Value().Should().Be("Charlie"); // active, C - } - - [Fact] - public async Task OrderBy_LengthFunction_SortsCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT c.name FROM c ORDER BY LENGTH(c.name)"); - r[0]["name"]!.Value().Should().Be("Bob"); // 3 - // Alice(5) and Diana(5) are next in non-deterministic order - r[3]["name"]!.Value().Should().Be("Charlie"); // 7 - } - - [Fact] - public async Task OrderBy_WithGroupBy_CountDesc() - { - var container = new InMemoryContainer("v14-ord-grp", "/pk"); - var items = new[] - { - """{"id":"1","pk":"a","cat":"x"}""", - """{"id":"2","pk":"a","cat":"x"}""", - """{"id":"3","pk":"a","cat":"x"}""", - """{"id":"4","pk":"a","cat":"y"}""", - """{"id":"5","pk":"a","cat":"z"}""", - """{"id":"6","pk":"a","cat":"z"}""" - }; - foreach (var json in items) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - var iterator = container.GetItemQueryIterator( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - var r = new List(); - while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); - r[0]["cat"]!.Value().Should().Be("x"); // 3 - r[1]["cat"]!.Value().Should().Be("z"); // 2 - r[2]["cat"]!.Value().Should().Be("y"); // 1 - } + private readonly InMemoryContainer _container = new("v14-ord", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Charlie","price":10,"qty":3,"active":true,"x":null}""", + """{"id":"2","pk":"a","name":"Alice","price":20,"qty":1,"active":false,"x":5}""", + """{"id":"3","pk":"a","name":"Bob","price":5,"qty":10,"active":true}""", + """{"id":"4","pk":"a","name":"Diana","price":15,"qty":2,"active":false,"x":3}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_NestedFunctionCall_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.name FROM c ORDER BY LOWER(c.name)"); + r.Select(x => x["name"]!.Value()).Should().Equal("Alice", "Bob", "Charlie", "Diana"); + } + + [Fact] + public async Task OrderBy_ArithmeticExpression_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.name, c.price * c.qty AS revenue FROM c ORDER BY c.price * c.qty DESC"); + r[0]["name"]!.Value().Should().Be("Bob"); // 5*10=50 + r[3]["name"]!.Value().Should().Be("Alice"); // 20*1=20 + // Middle two (Charlie=30, Diana=30) may sort either way — just verify revenue + r[1]["revenue"]!.Value().Should().Be(30); + r[2]["revenue"]!.Value().Should().Be(30); + } + + [Fact] + public async Task OrderBy_Coalesce_FallsBackCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT c.id, c.x ?? 0 AS xval FROM c ORDER BY c.x ?? 0"); + // x values: null→0, 5, undefined→0, 3 → sorted: 0, 0, 3, 5 + r[0]["xval"]!.Value().Should().Be(0); + r[3]["xval"]!.Value().Should().Be(5); + } + + [Fact] + public async Task OrderBy_ThreeFields_MixedDirections() + { + await Seed(); + var r = await RunQuery( + "SELECT c.name, c.active, c.price FROM c ORDER BY c.active DESC, c.price ASC"); + // active true first (DESC), then by price ASC + r[0]["name"]!.Value().Should().Be("Bob"); // true, price=5 + r[1]["name"]!.Value().Should().Be("Charlie"); // true, price=10 + } + + [Fact] + public async Task OrderBy_TypeRankRespected() + { + // Items with mixed types in same field + var container = new InMemoryContainer("v14-ord-type", "/pk"); + var items = new[] + { + """{"id":"1","pk":"a","x":null}""", + """{"id":"2","pk":"a","x":false}""", + """{"id":"3","pk":"a","x":42}""", + """{"id":"4","pk":"a","x":"hello"}""", + """{"id":"5","pk":"a"}""" + }; + foreach (var json in items) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + var iterator = container.GetItemQueryIterator( + "SELECT c.id, c.x FROM c ORDER BY c.x"); + var r = new List(); + while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); + // undefined < null < bool < number < string + r[0]["id"]!.Value().Should().Be("5"); // undefined + r[1]["id"]!.Value().Should().Be("1"); // null + r[2]["id"]!.Value().Should().Be("2"); // false (bool) + r[3]["id"]!.Value().Should().Be("3"); // 42 (number) + r[4]["id"]!.Value().Should().Be("4"); // "hello" (string) + } + + [Fact] + public async Task OrderBy_TernaryExpression_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.name FROM c ORDER BY (c.active ? 0 : 1), c.name"); + // active=true gets 0 (first), then sorted by name + r[0]["name"]!.Value().Should().Be("Bob"); // active, B + r[1]["name"]!.Value().Should().Be("Charlie"); // active, C + } + + [Fact] + public async Task OrderBy_LengthFunction_SortsCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT c.name FROM c ORDER BY LENGTH(c.name)"); + r[0]["name"]!.Value().Should().Be("Bob"); // 3 + // Alice(5) and Diana(5) are next in non-deterministic order + r[3]["name"]!.Value().Should().Be("Charlie"); // 7 + } + + [Fact] + public async Task OrderBy_WithGroupBy_CountDesc() + { + var container = new InMemoryContainer("v14-ord-grp", "/pk"); + var items = new[] + { + """{"id":"1","pk":"a","cat":"x"}""", + """{"id":"2","pk":"a","cat":"x"}""", + """{"id":"3","pk":"a","cat":"x"}""", + """{"id":"4","pk":"a","cat":"y"}""", + """{"id":"5","pk":"a","cat":"z"}""", + """{"id":"6","pk":"a","cat":"z"}""" + }; + foreach (var json in items) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + var iterator = container.GetItemQueryIterator( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + var r = new List(); + while (iterator.HasMoreResults) r.AddRange(await iterator.ReadNextAsync()); + r[0]["cat"]!.Value().Should().Be("x"); // 3 + r[1]["cat"]!.Value().Should().Be("z"); // 2 + r[2]["cat"]!.Value().Should().Be("y"); // 1 + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20656,115 +20666,115 @@ await container.CreateItemStreamAsync( public class QueryDeepDiveV14_ProjectionComplex { - private readonly InMemoryContainer _container = new("v14-proj", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":" Alice ","price":10,"qty":3,"active":true,"tags":["a","b","c"]}""", - """{"id":"2","pk":"a","name":"Bob","price":20,"qty":1,"active":false,"tags":["d"]}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_ObjectLiteral_WithFields() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"name": c.name, "len": LENGTH(c.name)} FROM c WHERE c.id = '2'"""); - r.Should().ContainSingle(); - r[0]["name"]!.Value().Should().Be("Bob"); - r[0]["len"]!.Value().Should().Be(3); - } - - [Fact] - public async Task Select_ArrayLiteral_WithExpressions() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE [c.name, UPPER(c.name)] FROM c WHERE c.id = '2'"""); - r.Should().ContainSingle(); - var arr = r[0].ToObject>(); - arr.Should().Equal("Bob", "BOB"); - } - - [Fact] - public async Task Select_NestedFunctions_InProjection() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE UPPER(TRIM(c.name)) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("ALICE"); - } - - [Fact] - public async Task SelectValue_ArithmeticExpression() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.price * c.qty FROM c ORDER BY c.id"); - r.Should().HaveCount(2); - r[0].Value().Should().Be(30); // 10*3 - r[1].Value().Should().Be(20); // 20*1 - } - - [Fact] - public async Task Select_ConditionalExpression_InProjection() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, c.x ?? 'default' AS label FROM c"); - r.Should().HaveCount(2); - foreach (var item in r) - item["label"]!.Value().Should().Be("default"); - } - - [Fact] - public async Task Select_IIF_InProjection() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, IIF(c.active, 'Y', 'N') AS flag FROM c ORDER BY c.id"); - r[0]["flag"]!.Value().Should().Be("Y"); - r[1]["flag"]!.Value().Should().Be("N"); - } - - [Fact] - public async Task SelectValue_ObjectWithNestedArrayLength() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"id": c.id, "tagCount": ARRAY_LENGTH(c.tags)} FROM c ORDER BY c.id"""); - r.Should().HaveCount(2); - r[0]["tagCount"]!.Value().Should().Be(3); - r[1]["tagCount"]!.Value().Should().Be(1); - } - - [Fact] - public async Task Select_AllAggregatesInSingleQuery() - { - await Seed(); - var r = await RunQuery( - "SELECT COUNT(1) AS cnt, SUM(c.price) AS total, AVG(c.price) AS avg, MIN(c.price) AS lo, MAX(c.price) AS hi FROM c"); - r.Should().ContainSingle(); - r[0]["cnt"]!.Value().Should().Be(2); - r[0]["total"]!.Value().Should().Be(30); - r[0]["avg"]!.Value().Should().Be(15); - r[0]["lo"]!.Value().Should().Be(10); - r[0]["hi"]!.Value().Should().Be(20); - } + private readonly InMemoryContainer _container = new("v14-proj", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":" Alice ","price":10,"qty":3,"active":true,"tags":["a","b","c"]}""", + """{"id":"2","pk":"a","name":"Bob","price":20,"qty":1,"active":false,"tags":["d"]}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_ObjectLiteral_WithFields() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"name": c.name, "len": LENGTH(c.name)} FROM c WHERE c.id = '2'"""); + r.Should().ContainSingle(); + r[0]["name"]!.Value().Should().Be("Bob"); + r[0]["len"]!.Value().Should().Be(3); + } + + [Fact] + public async Task Select_ArrayLiteral_WithExpressions() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE [c.name, UPPER(c.name)] FROM c WHERE c.id = '2'"""); + r.Should().ContainSingle(); + var arr = r[0].ToObject>(); + arr.Should().Equal("Bob", "BOB"); + } + + [Fact] + public async Task Select_NestedFunctions_InProjection() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE UPPER(TRIM(c.name)) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("ALICE"); + } + + [Fact] + public async Task SelectValue_ArithmeticExpression() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.price * c.qty FROM c ORDER BY c.id"); + r.Should().HaveCount(2); + r[0].Value().Should().Be(30); // 10*3 + r[1].Value().Should().Be(20); // 20*1 + } + + [Fact] + public async Task Select_ConditionalExpression_InProjection() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, c.x ?? 'default' AS label FROM c"); + r.Should().HaveCount(2); + foreach (var item in r) + item["label"]!.Value().Should().Be("default"); + } + + [Fact] + public async Task Select_IIF_InProjection() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, IIF(c.active, 'Y', 'N') AS flag FROM c ORDER BY c.id"); + r[0]["flag"]!.Value().Should().Be("Y"); + r[1]["flag"]!.Value().Should().Be("N"); + } + + [Fact] + public async Task SelectValue_ObjectWithNestedArrayLength() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"id": c.id, "tagCount": ARRAY_LENGTH(c.tags)} FROM c ORDER BY c.id"""); + r.Should().HaveCount(2); + r[0]["tagCount"]!.Value().Should().Be(3); + r[1]["tagCount"]!.Value().Should().Be(1); + } + + [Fact] + public async Task Select_AllAggregatesInSingleQuery() + { + await Seed(); + var r = await RunQuery( + "SELECT COUNT(1) AS cnt, SUM(c.price) AS total, AVG(c.price) AS avg, MIN(c.price) AS lo, MAX(c.price) AS hi FROM c"); + r.Should().ContainSingle(); + r[0]["cnt"]!.Value().Should().Be(2); + r[0]["total"]!.Value().Should().Be(30); + r[0]["avg"]!.Value().Should().Be(15); + r[0]["lo"]!.Value().Should().Be(10); + r[0]["hi"]!.Value().Should().Be(20); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20773,83 +20783,83 @@ public async Task Select_AllAggregatesInSingleQuery() public class QueryDeepDiveV14_CrossPlatform { - private readonly InMemoryContainer _container = new("v14-xplat", "/pk"); - - private async Task Seed() - { - var items = new[] - { - """{"id":"1","pk":"a","name":"Alice","val":2.675}""", - """{"id":"2","pk":"a","name":"bob","val":1000000}""", - """{"id":"3","pk":"a","name":"Charlie","val":0.1}""" - }; - foreach (var json in items) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKey("a")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_CaseInsensitiveStringComparison_OrdinalSort() - { - await Seed(); - var r = await RunQuery("SELECT c.name FROM c ORDER BY c.name"); - // Ordinal sort: uppercase letters come before lowercase - var names = r.Select(x => x["name"]!.Value()).ToList(); - names.Should().Equal("Alice", "Charlie", "bob"); // 'A' < 'C' < 'b' in ordinal - } - - [Fact] - public async Task FloatingPoint_Precision_ConsistentAcrossPlatforms() - { - await Seed(); - // ROUND(2.675, 2) with MidpointRounding.AwayFromZero should be 2.68 - var r = await RunQuery("SELECT VALUE ROUND(2.675, 2) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(2.68); - } - - [Fact] - public async Task DateTime_UtcFormat_NoTimezoneShift() - { - await Seed(); - var r = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().EndWith("Z"); // UTC marker - } - - [Fact] - public async Task LargeInteger_ToString_NoCultureFormatting() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ToString(1000000) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("1000000"); - } - - [Fact] - public async Task RegexMatch_DotNotation_Consistent() - { - await Seed(); - // Without 's' modifier, dot should NOT match newline - var r = await RunQuery("""SELECT VALUE REGEXMATCH('a\nb', 'a.b') FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task NumberParsing_InvariantCulture() - { - await Seed(); - // Ensure decimal point not affected by locale - var r = await RunQuery("SELECT VALUE 3.14 FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(3.14, 0.001); - } + private readonly InMemoryContainer _container = new("v14-xplat", "/pk"); + + private async Task Seed() + { + var items = new[] + { + """{"id":"1","pk":"a","name":"Alice","val":2.675}""", + """{"id":"2","pk":"a","name":"bob","val":1000000}""", + """{"id":"3","pk":"a","name":"Charlie","val":0.1}""" + }; + foreach (var json in items) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKey("a")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_CaseInsensitiveStringComparison_OrdinalSort() + { + await Seed(); + var r = await RunQuery("SELECT c.name FROM c ORDER BY c.name"); + // Ordinal sort: uppercase letters come before lowercase + var names = r.Select(x => x["name"]!.Value()).ToList(); + names.Should().Equal("Alice", "Charlie", "bob"); // 'A' < 'C' < 'b' in ordinal + } + + [Fact] + public async Task FloatingPoint_Precision_ConsistentAcrossPlatforms() + { + await Seed(); + // ROUND(2.675, 2) with MidpointRounding.AwayFromZero should be 2.68 + var r = await RunQuery("SELECT VALUE ROUND(2.675, 2) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(2.68); + } + + [Fact] + public async Task DateTime_UtcFormat_NoTimezoneShift() + { + await Seed(); + var r = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().EndWith("Z"); // UTC marker + } + + [Fact] + public async Task LargeInteger_ToString_NoCultureFormatting() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ToString(1000000) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("1000000"); + } + + [Fact] + public async Task RegexMatch_DotNotation_Consistent() + { + await Seed(); + // Without 's' modifier, dot should NOT match newline + var r = await RunQuery("""SELECT VALUE REGEXMATCH('a\nb', 'a.b') FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task NumberParsing_InvariantCulture() + { + await Seed(); + // Ensure decimal point not affected by locale + var r = await RunQuery("SELECT VALUE 3.14 FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(3.14, 0.001); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -20858,929 +20868,929 @@ public async Task NumberParsing_InvariantCulture() public class QueryMathFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"value\":10.0}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ABS_ReturnsAbsoluteValue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ABS(-5) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(5); - var r2 = await RunQuery("SELECT VALUE ABS(5) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(5); - } - - [Fact] - public async Task ABS_NonNumeric_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ABS('text') FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task CEILING_RoundsUp() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CEILING(4.2) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(5); - var r2 = await RunQuery("SELECT VALUE CEILING(-4.2) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(-4); - } - - [Fact] - public async Task FLOOR_RoundsDown() - { - await Seed(); - var r = await RunQuery("SELECT VALUE FLOOR(4.8) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(4); - var r2 = await RunQuery("SELECT VALUE FLOOR(-4.8) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(-5); - } - - [Fact] - public async Task ROUND_RoundsToNearest() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ROUND(4.5) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(5); - var r2 = await RunQuery("SELECT VALUE ROUND(4.4) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(4); - } - - [Fact] - public async Task ROUND_WithPrecision() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ROUND(4.567, 2) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(4.57); - } - - [Fact] - public async Task SQRT_ReturnsSquareRoot() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SQRT(9) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(3); - var r2 = await RunQuery("SELECT VALUE SQRT(0) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task SQRT_Negative_ReturnsUndefined() - { - await Seed(); - // SQRT(-1) produces NaN which should be returned as undefined - var r = await RunQuery("SELECT VALUE SQRT(-1) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task SQUARE_ReturnsSquare() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SQUARE(3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(9); - } - - [Fact] - public async Task LOG_NaturalLog() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LOG(EXP(1)) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(1.0, 0.0001); - } - - [Fact] - public async Task LOG_WithBase() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LOG(100, 10) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(2.0, 0.0001); - } - - [Fact] - public async Task LOG10_ReturnsLog10() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LOG10(100) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(2.0, 0.0001); - } - - [Fact] - public async Task EXP_ReturnsExponential() - { - await Seed(); - var r = await RunQuery("SELECT VALUE EXP(1) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.E, 0.0001); - } - - [Fact] - public async Task SIN_COS_TAN_BasicValues() - { - await Seed(); - var sin0 = await RunQuery("SELECT VALUE SIN(0) FROM c WHERE c.id = '1'"); - sin0.Should().ContainSingle().Which.Value().Should().Be(0); - - var cos0 = await RunQuery("SELECT VALUE COS(0) FROM c WHERE c.id = '1'"); - cos0.Should().ContainSingle().Which.Value().Should().Be(1); - - var tan0 = await RunQuery("SELECT VALUE TAN(0) FROM c WHERE c.id = '1'"); - tan0.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task ASIN_ACOS_ATAN_BasicValues() - { - await Seed(); - var asin0 = await RunQuery("SELECT VALUE ASIN(0) FROM c WHERE c.id = '1'"); - asin0.Should().ContainSingle().Which.Value().Should().Be(0); - - var acos1 = await RunQuery("SELECT VALUE ACOS(1) FROM c WHERE c.id = '1'"); - acos1.Should().ContainSingle().Which.Value().Should().Be(0); - - var atan0 = await RunQuery("SELECT VALUE ATAN(0) FROM c WHERE c.id = '1'"); - atan0.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task ATN2_ReturnsTwoArgArctan() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ATN2(1, 1) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI / 4, 0.0001); - } - - [Fact] - public async Task DEGREES_ConvertsToDegrees() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DEGREES(PI()) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(180.0, 0.0001); - } - - [Fact] - public async Task RADIANS_ConvertsToRadians() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RADIANS(180) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 0.0001); - } - - [Fact] - public async Task PI_ReturnsPI() - { - await Seed(); - var r = await RunQuery("SELECT VALUE PI() FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 0.0001); - } - - [Fact] - public async Task SIGN_ReturnsSign() - { - await Seed(); - var neg = await RunQuery("SELECT VALUE SIGN(-5) FROM c WHERE c.id = '1'"); - neg.Should().ContainSingle().Which.Value().Should().Be(-1); - - var zero = await RunQuery("SELECT VALUE SIGN(0) FROM c WHERE c.id = '1'"); - zero.Should().ContainSingle().Which.Value().Should().Be(0); - - var pos = await RunQuery("SELECT VALUE SIGN(5) FROM c WHERE c.id = '1'"); - pos.Should().ContainSingle().Which.Value().Should().Be(1); - } - - [Fact] - public async Task TRUNC_TruncatesDecimal() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TRUNC(4.9) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(4); - - var r2 = await RunQuery("SELECT VALUE TRUNC(-4.9) FROM c WHERE c.id = '1'"); - r2.Should().ContainSingle().Which.Value().Should().Be(-4); - } - - [Fact] - public async Task RAND_ReturnsBetween0And1() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RAND() FROM c WHERE c.id = '1'"); - var val = r.Should().ContainSingle().Which.Value(); - val.Should().BeGreaterThanOrEqualTo(0.0); - val.Should().BeLessThan(1.0); - } - - [Fact] - public async Task NUMBERBIN_RoundsToNearestMultiple() - { - await Seed(); - // NumberBin(10, 3) = 9 (floor to nearest multiple of 3) - var r = await RunQuery("SELECT VALUE NumberBin(10, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(9); - } - - [Fact] - public async Task POWER_Zero_Zero_ReturnsOne() - { - await Seed(); - var r = await RunQuery("SELECT VALUE POWER(0, 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(1); - } - - [Fact] - public async Task POWER_BasicComputation() - { - await Seed(); - var r = await RunQuery("SELECT VALUE POWER(2, 10) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(1024); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"value\":10.0}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ABS_ReturnsAbsoluteValue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ABS(-5) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(5); + var r2 = await RunQuery("SELECT VALUE ABS(5) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(5); + } + + [Fact] + public async Task ABS_NonNumeric_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ABS('text') FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task CEILING_RoundsUp() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CEILING(4.2) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(5); + var r2 = await RunQuery("SELECT VALUE CEILING(-4.2) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(-4); + } + + [Fact] + public async Task FLOOR_RoundsDown() + { + await Seed(); + var r = await RunQuery("SELECT VALUE FLOOR(4.8) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(4); + var r2 = await RunQuery("SELECT VALUE FLOOR(-4.8) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(-5); + } + + [Fact] + public async Task ROUND_RoundsToNearest() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ROUND(4.5) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(5); + var r2 = await RunQuery("SELECT VALUE ROUND(4.4) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(4); + } + + [Fact] + public async Task ROUND_WithPrecision() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ROUND(4.567, 2) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(4.57); + } + + [Fact] + public async Task SQRT_ReturnsSquareRoot() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SQRT(9) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(3); + var r2 = await RunQuery("SELECT VALUE SQRT(0) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task SQRT_Negative_ReturnsUndefined() + { + await Seed(); + // SQRT(-1) produces NaN which should be returned as undefined + var r = await RunQuery("SELECT VALUE SQRT(-1) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task SQUARE_ReturnsSquare() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SQUARE(3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(9); + } + + [Fact] + public async Task LOG_NaturalLog() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LOG(EXP(1)) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(1.0, 0.0001); + } + + [Fact] + public async Task LOG_WithBase() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LOG(100, 10) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(2.0, 0.0001); + } + + [Fact] + public async Task LOG10_ReturnsLog10() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LOG10(100) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(2.0, 0.0001); + } + + [Fact] + public async Task EXP_ReturnsExponential() + { + await Seed(); + var r = await RunQuery("SELECT VALUE EXP(1) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.E, 0.0001); + } + + [Fact] + public async Task SIN_COS_TAN_BasicValues() + { + await Seed(); + var sin0 = await RunQuery("SELECT VALUE SIN(0) FROM c WHERE c.id = '1'"); + sin0.Should().ContainSingle().Which.Value().Should().Be(0); + + var cos0 = await RunQuery("SELECT VALUE COS(0) FROM c WHERE c.id = '1'"); + cos0.Should().ContainSingle().Which.Value().Should().Be(1); + + var tan0 = await RunQuery("SELECT VALUE TAN(0) FROM c WHERE c.id = '1'"); + tan0.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task ASIN_ACOS_ATAN_BasicValues() + { + await Seed(); + var asin0 = await RunQuery("SELECT VALUE ASIN(0) FROM c WHERE c.id = '1'"); + asin0.Should().ContainSingle().Which.Value().Should().Be(0); + + var acos1 = await RunQuery("SELECT VALUE ACOS(1) FROM c WHERE c.id = '1'"); + acos1.Should().ContainSingle().Which.Value().Should().Be(0); + + var atan0 = await RunQuery("SELECT VALUE ATAN(0) FROM c WHERE c.id = '1'"); + atan0.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task ATN2_ReturnsTwoArgArctan() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ATN2(1, 1) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI / 4, 0.0001); + } + + [Fact] + public async Task DEGREES_ConvertsToDegrees() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DEGREES(PI()) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(180.0, 0.0001); + } + + [Fact] + public async Task RADIANS_ConvertsToRadians() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RADIANS(180) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 0.0001); + } + + [Fact] + public async Task PI_ReturnsPI() + { + await Seed(); + var r = await RunQuery("SELECT VALUE PI() FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(Math.PI, 0.0001); + } + + [Fact] + public async Task SIGN_ReturnsSign() + { + await Seed(); + var neg = await RunQuery("SELECT VALUE SIGN(-5) FROM c WHERE c.id = '1'"); + neg.Should().ContainSingle().Which.Value().Should().Be(-1); + + var zero = await RunQuery("SELECT VALUE SIGN(0) FROM c WHERE c.id = '1'"); + zero.Should().ContainSingle().Which.Value().Should().Be(0); + + var pos = await RunQuery("SELECT VALUE SIGN(5) FROM c WHERE c.id = '1'"); + pos.Should().ContainSingle().Which.Value().Should().Be(1); + } + + [Fact] + public async Task TRUNC_TruncatesDecimal() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TRUNC(4.9) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(4); + + var r2 = await RunQuery("SELECT VALUE TRUNC(-4.9) FROM c WHERE c.id = '1'"); + r2.Should().ContainSingle().Which.Value().Should().Be(-4); + } + + [Fact] + public async Task RAND_ReturnsBetween0And1() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RAND() FROM c WHERE c.id = '1'"); + var val = r.Should().ContainSingle().Which.Value(); + val.Should().BeGreaterThanOrEqualTo(0.0); + val.Should().BeLessThan(1.0); + } + + [Fact] + public async Task NUMBERBIN_RoundsToNearestMultiple() + { + await Seed(); + // NumberBin(10, 3) = 9 (floor to nearest multiple of 3) + var r = await RunQuery("SELECT VALUE NumberBin(10, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(9); + } + + [Fact] + public async Task POWER_Zero_Zero_ReturnsOne() + { + await Seed(); + var r = await RunQuery("SELECT VALUE POWER(0, 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(1); + } + + [Fact] + public async Task POWER_BasicComputation() + { + await Seed(); + var r = await RunQuery("SELECT VALUE POWER(2, 10) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(1024); + } } public class QueryStringFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"name\":\"hello\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task LTRIM_TrimsLeadingWhitespace() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LTRIM(' hi') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hi"); - } - - [Fact] - public async Task RTRIM_TrimsTrailingWhitespace() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RTRIM('hi ') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hi"); - } - - [Fact] - public async Task TRIM_TrimsBothSides() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TRIM(' hi ') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hi"); - } - - [Fact] - public async Task REVERSE_ReversesString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REVERSE('abc') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("cba"); - } - - [Fact] - public async Task LEFT_ReturnsLeftChars() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LEFT('hello', 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hel"); - } - - [Fact] - public async Task RIGHT_ReturnsRightChars() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RIGHT('hello', 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("llo"); - } - - [Fact] - public async Task INDEX_OF_ReturnsPosition() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INDEX_OF('hello', 'll') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(2); - } - - [Fact] - public async Task INDEX_OF_NotFound_ReturnsMinusOne() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INDEX_OF('hello', 'xyz') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(-1); - } - - [Fact] - public async Task REPLICATE_RepeatsString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("ababab"); - } - - [Fact] - public async Task REPLICATE_Zero_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE REPLICATE('ab', 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task TOSTRING_Number_ReturnsString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ToString(42) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("42"); - } - - [Fact] - public async Task TONUMBER_ValidString_ReturnsNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ToNumber('42') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(42); - } - - [Fact] - public async Task TOBOOLEAN_ValidString_ReturnsBool() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ToBoolean('true') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task UPPER_Null_ReturnsUndefined() - { - await Seed(); - // When applied to a non-string, UPPER should return undefined (excluded from results) - var r = await RunQuery("SELECT VALUE UPPER(null) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task LOWER_Null_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LOWER(null) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task LENGTH_Null_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LENGTH(null) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task CONCAT_WithNull_ReturnsUndefined() - { - await Seed(); - // In Cosmos DB, CONCAT with a null argument returns undefined - var r = await RunQuery("SELECT VALUE CONCAT(null, 'a') FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task STARTSWITH_Null_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE STARTSWITH(null, 'a') FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task SUBSTRING_StartBeyondLength_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE SUBSTRING('abc', 10, 1) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task INDEX_OF_EmptySearchString() - { - await Seed(); - // In .NET, "hello".IndexOf("") = 0 - var r = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task REPLACE_EmptyOldString() - { - await Seed(); - // REPLACE("hello", "", "x") - behavior depends on platform - var r = await RunQuery("SELECT VALUE REPLACE('hello', '', 'x') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); // Just verify it doesn't crash - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"name\":\"hello\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task LTRIM_TrimsLeadingWhitespace() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LTRIM(' hi') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hi"); + } + + [Fact] + public async Task RTRIM_TrimsTrailingWhitespace() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RTRIM('hi ') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hi"); + } + + [Fact] + public async Task TRIM_TrimsBothSides() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TRIM(' hi ') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hi"); + } + + [Fact] + public async Task REVERSE_ReversesString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REVERSE('abc') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("cba"); + } + + [Fact] + public async Task LEFT_ReturnsLeftChars() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LEFT('hello', 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hel"); + } + + [Fact] + public async Task RIGHT_ReturnsRightChars() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RIGHT('hello', 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("llo"); + } + + [Fact] + public async Task INDEX_OF_ReturnsPosition() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INDEX_OF('hello', 'll') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(2); + } + + [Fact] + public async Task INDEX_OF_NotFound_ReturnsMinusOne() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INDEX_OF('hello', 'xyz') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(-1); + } + + [Fact] + public async Task REPLICATE_RepeatsString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REPLICATE('ab', 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("ababab"); + } + + [Fact] + public async Task REPLICATE_Zero_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE REPLICATE('ab', 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task TOSTRING_Number_ReturnsString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ToString(42) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("42"); + } + + [Fact] + public async Task TONUMBER_ValidString_ReturnsNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ToNumber('42') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(42); + } + + [Fact] + public async Task TOBOOLEAN_ValidString_ReturnsBool() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ToBoolean('true') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task UPPER_Null_ReturnsUndefined() + { + await Seed(); + // When applied to a non-string, UPPER should return undefined (excluded from results) + var r = await RunQuery("SELECT VALUE UPPER(null) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task LOWER_Null_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LOWER(null) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task LENGTH_Null_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LENGTH(null) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task CONCAT_WithNull_ReturnsUndefined() + { + await Seed(); + // In Cosmos DB, CONCAT with a null argument returns undefined + var r = await RunQuery("SELECT VALUE CONCAT(null, 'a') FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task STARTSWITH_Null_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE STARTSWITH(null, 'a') FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task SUBSTRING_StartBeyondLength_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE SUBSTRING('abc', 10, 1) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task INDEX_OF_EmptySearchString() + { + await Seed(); + // In .NET, "hello".IndexOf("") = 0 + var r = await RunQuery("SELECT VALUE INDEX_OF('hello', '') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task REPLACE_EmptyOldString() + { + await Seed(); + // REPLACE("hello", "", "x") - behavior depends on platform + var r = await RunQuery("SELECT VALUE REPLACE('hello', '', 'x') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); // Just verify it doesn't crash + } } public class QueryIntegerBitwiseFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IntAdd_AddsIntegers() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntAdd(5, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(8); - } - - [Fact] - public async Task IntSub_SubtractsIntegers() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntSub(5, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(2); - } - - [Fact] - public async Task IntMul_MultipliesIntegers() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntMul(5, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(15); - } - - [Fact] - public async Task IntDiv_DividesIntegers() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntDiv(10, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(3); - } - - [Fact] - public async Task IntMod_ModuloIntegers() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(1); - } - - [Fact] - public async Task IntBitOr_BitwiseOr() - { - await Seed(); - // 5 = 101, 3 = 011 → 111 = 7 - var r = await RunQuery("SELECT VALUE IntBitOr(5, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(7); - } - - [Fact] - public async Task IntBitXor_BitwiseXor() - { - await Seed(); - // 5 = 101, 3 = 011 → 110 = 6 - var r = await RunQuery("SELECT VALUE IntBitXor(5, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(6); - } - - [Fact] - public async Task IntBitNot_BitwiseNot() - { - await Seed(); - // ~0 = -1 in two's complement - var r = await RunQuery("SELECT VALUE IntBitNot(0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(-1); - } - - [Fact] - public async Task IntBitLeftShift_ShiftsLeft() - { - await Seed(); - // 1 << 3 = 8 - var r = await RunQuery("SELECT VALUE IntBitLeftShift(1, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(8); - } - - [Fact] - public async Task IntBitRightShift_ShiftsRight() - { - await Seed(); - // 8 >> 3 = 1 - var r = await RunQuery("SELECT VALUE IntBitRightShift(8, 3) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(1); - } - - [Fact] - public async Task IntDiv_ByZero_ReturnsUndefined() - { - await Seed(); - // Division by zero should return undefined - var r = await RunQuery("SELECT VALUE IntDiv(1, 0) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task IntMod_ByZero_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IntMod(1, 0) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IntAdd_AddsIntegers() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntAdd(5, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(8); + } + + [Fact] + public async Task IntSub_SubtractsIntegers() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntSub(5, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(2); + } + + [Fact] + public async Task IntMul_MultipliesIntegers() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntMul(5, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(15); + } + + [Fact] + public async Task IntDiv_DividesIntegers() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntDiv(10, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(3); + } + + [Fact] + public async Task IntMod_ModuloIntegers() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntMod(10, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(1); + } + + [Fact] + public async Task IntBitOr_BitwiseOr() + { + await Seed(); + // 5 = 101, 3 = 011 → 111 = 7 + var r = await RunQuery("SELECT VALUE IntBitOr(5, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(7); + } + + [Fact] + public async Task IntBitXor_BitwiseXor() + { + await Seed(); + // 5 = 101, 3 = 011 → 110 = 6 + var r = await RunQuery("SELECT VALUE IntBitXor(5, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(6); + } + + [Fact] + public async Task IntBitNot_BitwiseNot() + { + await Seed(); + // ~0 = -1 in two's complement + var r = await RunQuery("SELECT VALUE IntBitNot(0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(-1); + } + + [Fact] + public async Task IntBitLeftShift_ShiftsLeft() + { + await Seed(); + // 1 << 3 = 8 + var r = await RunQuery("SELECT VALUE IntBitLeftShift(1, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(8); + } + + [Fact] + public async Task IntBitRightShift_ShiftsRight() + { + await Seed(); + // 8 >> 3 = 1 + var r = await RunQuery("SELECT VALUE IntBitRightShift(8, 3) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(1); + } + + [Fact] + public async Task IntDiv_ByZero_ReturnsUndefined() + { + await Seed(); + // Division by zero should return undefined + var r = await RunQuery("SELECT VALUE IntDiv(1, 0) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task IntMod_ByZero_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IntMod(1, 0) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } } public class QueryDateTimeFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GetCurrentDateTime_ReturnsUtcString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().EndWith("Z"); - DateTime.TryParse(dtStr, out _).Should().BeTrue(); - } - - [Fact] - public async Task GetCurrentTimestamp_ReturnsPositiveNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task DateTimeAdd_Hours() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeAdd('hh', 1, '2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-01-01T01:00:00"); - } - - [Fact] - public async Task DateTimeAdd_Days() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeAdd('dd', 5, '2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-01-06"); - } - - [Fact] - public async Task DateTimePart_Year() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimePart('yyyy', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(2024); - } - - [Fact] - public async Task DateTimePart_Month() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimePart('mm', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(6); - } - - [Fact] - public async Task DateTimePart_Day() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimePart('dd', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(15); - } - - [Fact] - public async Task DateTimePart_Hour() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimePart('hh', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(10); - } - - [Fact] - public async Task DateTimeDiff_Days() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeDiff('day', '2024-01-01T00:00:00.0000000Z', '2024-01-11T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(10); - } - - [Fact] - public async Task DateTimeFromParts_CreatesIsoString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeFromParts(2024, 6, 15) FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-06-15"); - } - - [Fact] - public async Task DateTimeToTicks_ReturnsTickCount() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeToTicks('2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task TicksToDateTime_RoundTrip() - { - await Seed(); - // Convert datetime to ticks, then back - should round-trip - var r = await RunQuery("SELECT VALUE TicksToDateTime(DateTimeToTicks('2024-01-01T00:00:00.0000000Z')) FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-01-01"); - } - - [Fact] - public async Task DateTimeToTimestamp_ReturnsUnixMs() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task TimestampToDateTime_RoundTrip() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z')) FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-01-01"); - } - - [Fact] - public async Task GetCurrentTicks_ReturnsPositiveNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task DateTimeBin_BinsToHour() - { - await Seed(); - // DateTimeBin(dateTime, datePart, [binSize], [origin]) - datetime first, part second - var r = await RunQuery("SELECT VALUE DateTimeBin('2024-06-15T10:37:00.0000000Z', 'hh') FROM c WHERE c.id = '1'"); - var dtStr = r.Should().ContainSingle().Which.Value(); - dtStr.Should().Contain("2024-06-15T10:00:00"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GetCurrentDateTime_ReturnsUtcString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().EndWith("Z"); + DateTime.TryParse(dtStr, out _).Should().BeTrue(); + } + + [Fact] + public async Task GetCurrentTimestamp_ReturnsPositiveNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task DateTimeAdd_Hours() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeAdd('hh', 1, '2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-01-01T01:00:00"); + } + + [Fact] + public async Task DateTimeAdd_Days() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeAdd('dd', 5, '2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-01-06"); + } + + [Fact] + public async Task DateTimePart_Year() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimePart('yyyy', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(2024); + } + + [Fact] + public async Task DateTimePart_Month() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimePart('mm', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(6); + } + + [Fact] + public async Task DateTimePart_Day() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimePart('dd', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(15); + } + + [Fact] + public async Task DateTimePart_Hour() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimePart('hh', '2024-06-15T10:30:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(10); + } + + [Fact] + public async Task DateTimeDiff_Days() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeDiff('day', '2024-01-01T00:00:00.0000000Z', '2024-01-11T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(10); + } + + [Fact] + public async Task DateTimeFromParts_CreatesIsoString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeFromParts(2024, 6, 15) FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-06-15"); + } + + [Fact] + public async Task DateTimeToTicks_ReturnsTickCount() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeToTicks('2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task TicksToDateTime_RoundTrip() + { + await Seed(); + // Convert datetime to ticks, then back - should round-trip + var r = await RunQuery("SELECT VALUE TicksToDateTime(DateTimeToTicks('2024-01-01T00:00:00.0000000Z')) FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-01-01"); + } + + [Fact] + public async Task DateTimeToTimestamp_ReturnsUnixMs() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task TimestampToDateTime_RoundTrip() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TimestampToDateTime(DateTimeToTimestamp('2024-01-01T00:00:00.0000000Z')) FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-01-01"); + } + + [Fact] + public async Task GetCurrentTicks_ReturnsPositiveNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task DateTimeBin_BinsToHour() + { + await Seed(); + // DateTimeBin(dateTime, datePart, [binSize], [origin]) - datetime first, part second + var r = await RunQuery("SELECT VALUE DateTimeBin('2024-06-15T10:37:00.0000000Z', 'hh') FROM c WHERE c.id = '1'"); + var dtStr = r.Should().ContainSingle().Which.Value(); + dtStr.Should().Contain("2024-06-15T10:00:00"); + } } public class QueryTypeCheckFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"val\":42,\"fval\":42.5,\"str\":\"hi\",\"arr\":[1,2],\"obj\":{\"x\":1}}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IS_FINITE_NUMBER_TrueForRegular() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IS_FINITE_NUMBER(42) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task IS_INTEGER_TrueForWholeNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IS_INTEGER(c.val) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task IS_INTEGER_FalseForFloat() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IS_INTEGER(c.fval) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task TYPE_ReturnsCorrectTypeNames() - { - await Seed(); - var strType = await RunQuery("SELECT VALUE TYPE('hello') FROM c WHERE c.id = '1'"); - strType.Should().ContainSingle().Which.Value().Should().Be("string"); - - var numType = await RunQuery("SELECT VALUE TYPE(42) FROM c WHERE c.id = '1'"); - numType.Should().ContainSingle().Which.Value().Should().Be("number"); - - var boolType = await RunQuery("SELECT VALUE TYPE(true) FROM c WHERE c.id = '1'"); - boolType.Should().ContainSingle().Which.Value().Should().Be("boolean"); - - var nullType = await RunQuery("SELECT VALUE TYPE(null) FROM c WHERE c.id = '1'"); - nullType.Should().ContainSingle().Which.Value().Should().Be("null"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"val\":42,\"fval\":42.5,\"str\":\"hi\",\"arr\":[1,2],\"obj\":{\"x\":1}}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IS_FINITE_NUMBER_TrueForRegular() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IS_FINITE_NUMBER(42) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task IS_INTEGER_TrueForWholeNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IS_INTEGER(c.val) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task IS_INTEGER_FalseForFloat() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IS_INTEGER(c.fval) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task TYPE_ReturnsCorrectTypeNames() + { + await Seed(); + var strType = await RunQuery("SELECT VALUE TYPE('hello') FROM c WHERE c.id = '1'"); + strType.Should().ContainSingle().Which.Value().Should().Be("string"); + + var numType = await RunQuery("SELECT VALUE TYPE(42) FROM c WHERE c.id = '1'"); + numType.Should().ContainSingle().Which.Value().Should().Be("number"); + + var boolType = await RunQuery("SELECT VALUE TYPE(true) FROM c WHERE c.id = '1'"); + boolType.Should().ContainSingle().Which.Value().Should().Be("boolean"); + + var nullType = await RunQuery("SELECT VALUE TYPE(null) FROM c WHERE c.id = '1'"); + nullType.Should().ContainSingle().Which.Value().Should().Be("null"); + } } public class QueryArrayFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"arr\":[1,2,3],\"tags\":[\"a\",\"b\",\"c\"]}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ARRAY_LENGTH_ReturnsLength() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.arr) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(3); - } - - [Fact] - public async Task ARRAY_LENGTH_EmptyArray() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"arr\":[]}"), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.arr) FROM c WHERE c.id = '2'"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task ARRAY_LENGTH_NonArray_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY_LENGTH('not_array') FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task ARRAY_CONCAT_TwoArrays() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY_CONCAT(c.arr, c.tags) FROM c WHERE c.id = '1'"); - var arr = r.Should().ContainSingle().Which; - arr.Should().HaveCount(6); - } - - [Fact] - public async Task ARRAY_CONTAINS_ANY_MultipleElements() - { - await Seed(); - // ARRAY_CONTAINS_ANY checks if array contains any of the given elements - var r = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ["a", "x"]) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task ARRAY_CONTAINS_ALL_AllMustMatch() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ["a", "b"]) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - - var r2 = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ["a", "x"]) FROM c WHERE c.id = '1'"""); - r2.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task SetIntersect_CommonElements() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE SetIntersect(["a", "b", "c"], ["b", "c", "d"]) FROM c WHERE c.id = '1'"""); - var arr = r.Should().ContainSingle().Which.ToObject(); - arr.Should().BeEquivalentTo(new[] { "b", "c" }); - } - - [Fact] - public async Task SetUnion_CombinedElements() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE SetUnion(["a", "b"], ["b", "c"]) FROM c WHERE c.id = '1'"""); - var arr = r.Should().ContainSingle().Which.ToObject(); - arr.Should().BeEquivalentTo(new[] { "a", "b", "c" }); - } - - [Fact] - public async Task SetDifference_UniqueToFirst() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE SetDifference(["a", "b", "c"], ["b", "c"]) FROM c WHERE c.id = '1'"""); - var arr = r.Should().ContainSingle().Which.ToObject(); - arr.Should().BeEquivalentTo(new[] { "a" }); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"arr\":[1,2,3],\"tags\":[\"a\",\"b\",\"c\"]}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ARRAY_LENGTH_ReturnsLength() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.arr) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(3); + } + + [Fact] + public async Task ARRAY_LENGTH_EmptyArray() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"arr\":[]}"), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE ARRAY_LENGTH(c.arr) FROM c WHERE c.id = '2'"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task ARRAY_LENGTH_NonArray_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY_LENGTH('not_array') FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task ARRAY_CONCAT_TwoArrays() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY_CONCAT(c.arr, c.tags) FROM c WHERE c.id = '1'"); + var arr = r.Should().ContainSingle().Which; + arr.Should().HaveCount(6); + } + + [Fact] + public async Task ARRAY_CONTAINS_ANY_MultipleElements() + { + await Seed(); + // ARRAY_CONTAINS_ANY checks if array contains any of the given elements + var r = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ANY(c.tags, ["a", "x"]) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task ARRAY_CONTAINS_ALL_AllMustMatch() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ["a", "b"]) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + + var r2 = await RunQuery("""SELECT VALUE ARRAY_CONTAINS_ALL(c.tags, ["a", "x"]) FROM c WHERE c.id = '1'"""); + r2.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task SetIntersect_CommonElements() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE SetIntersect(["a", "b", "c"], ["b", "c", "d"]) FROM c WHERE c.id = '1'"""); + var arr = r.Should().ContainSingle().Which.ToObject(); + arr.Should().BeEquivalentTo(new[] { "b", "c" }); + } + + [Fact] + public async Task SetUnion_CombinedElements() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE SetUnion(["a", "b"], ["b", "c"]) FROM c WHERE c.id = '1'"""); + var arr = r.Should().ContainSingle().Which.ToObject(); + arr.Should().BeEquivalentTo(new[] { "a", "b", "c" }); + } + + [Fact] + public async Task SetDifference_UniqueToFirst() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE SetDifference(["a", "b", "c"], ["b", "c"]) FROM c WHERE c.id = '1'"""); + var arr = r.Should().ContainSingle().Which.ToObject(); + arr.Should().BeEquivalentTo(new[] { "a" }); + } } public class QueryConditionalFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IIF_TrueCondition_ReturnsSecond() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("yes"); - } - - [Fact] - public async Task IIF_FalseCondition_ReturnsThird() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IIF(false, 'yes', 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task IIF_NullCondition_ReturnsThird() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IIF_TrueCondition_ReturnsSecond() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("yes"); + } + + [Fact] + public async Task IIF_FalseCondition_ReturnsThird() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IIF(false, 'yes', 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task IIF_NullCondition_ReturnsThird() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IIF(null, 'yes', 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } } public class QueryGeoSpatialFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - private async Task Seed() - { - var doc = JObject.Parse(""" + private async Task Seed() + { + var doc = JObject.Parse(""" { "id": "1", "pk": "p1", "location": { "type": "Point", "coordinates": [-122.12, 47.67] }, @@ -21790,291 +21800,291 @@ private async Task Seed() } } """); - await _container.CreateItemAsync(doc, new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ST_DISTANCE_ReturnsMetersBetweenPoints() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE ST_DISTANCE(c.location, {"type": "Point", "coordinates": [-122.12, 47.68]}) FROM c WHERE c.id = '1'"""); - var dist = r.Should().ContainSingle().Which.Value(); - dist.Should().BeGreaterThan(0); - dist.Should().BeLessThan(2000); // ~1.1km apart - } - - [Fact] - public async Task ST_WITHIN_PointInsidePolygon_True() - { - await Seed(); - // Point at -121.5, 47.5 is inside polygon spanning [-122, -121] x [47, 48] - var r = await RunQuery("""SELECT VALUE ST_WITHIN({"type": "Point", "coordinates": [-121.5, 47.5]}, c.area) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task ST_WITHIN_PointOutsidePolygon_False() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE ST_WITHIN({"type": "Point", "coordinates": [-100, 40]}, c.area) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task ST_ISVALID_ValidPoint_True() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE ST_ISVALID(c.location) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task ST_ISVALID_InvalidGeometry_False() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE ST_ISVALID({"type": "Invalid", "coordinates": []}) FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } + await _container.CreateItemAsync(doc, new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ST_DISTANCE_ReturnsMetersBetweenPoints() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE ST_DISTANCE(c.location, {"type": "Point", "coordinates": [-122.12, 47.68]}) FROM c WHERE c.id = '1'"""); + var dist = r.Should().ContainSingle().Which.Value(); + dist.Should().BeGreaterThan(0); + dist.Should().BeLessThan(2000); // ~1.1km apart + } + + [Fact] + public async Task ST_WITHIN_PointInsidePolygon_True() + { + await Seed(); + // Point at -121.5, 47.5 is inside polygon spanning [-122, -121] x [47, 48] + var r = await RunQuery("""SELECT VALUE ST_WITHIN({"type": "Point", "coordinates": [-121.5, 47.5]}, c.area) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task ST_WITHIN_PointOutsidePolygon_False() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE ST_WITHIN({"type": "Point", "coordinates": [-100, 40]}, c.area) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task ST_ISVALID_ValidPoint_True() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE ST_ISVALID(c.location) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task ST_ISVALID_InvalidGeometry_False() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE ST_ISVALID({"type": "Invalid", "coordinates": []}) FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } } public class QueryFullTextSearchFunctionTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"text\":\"The quick brown fox jumps over the lazy dog\"}"), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"text\":\"Hello world, this is a test\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task FullTextContains_Match_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE FullTextContains(c.text, 'quick')"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task FullTextContains_NoMatch_ReturnsFalse() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE FullTextContains(c.text, 'xyz_nonexistent')"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAll_AllPresent_True() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'fox')"); - r.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextContainsAll_SomeMissing_False() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'unicorn')"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task FullTextContainsAny_OneMatch_True() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAny(c.text, 'unicorn', 'fox')"); - r.Should().ContainSingle(); - } - - [Fact] - public async Task FullTextScore_ReturnsPositiveNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE FullTextScore(c.text, ['quick', 'fox']) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"text\":\"The quick brown fox jumps over the lazy dog\"}"), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"text\":\"Hello world, this is a test\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task FullTextContains_Match_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE FullTextContains(c.text, 'quick')"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task FullTextContains_NoMatch_ReturnsFalse() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE FullTextContains(c.text, 'xyz_nonexistent')"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAll_AllPresent_True() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'fox')"); + r.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextContainsAll_SomeMissing_False() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAll(c.text, 'quick', 'unicorn')"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task FullTextContainsAny_OneMatch_True() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE FullTextContainsAny(c.text, 'unicorn', 'fox')"); + r.Should().ContainSingle(); + } + + [Fact] + public async Task FullTextScore_ReturnsPositiveNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE FullTextScore(c.text, ['quick', 'fox']) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeGreaterThan(0); + } } public class QueryArithmeticEdgeCaseTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DivisionByZero_Float_ReturnsUndefined() - { - await Seed(); - // 1.0 / 0 → infinity → should be treated as undefined - var r = await RunQuery("SELECT VALUE 1.0 / 0 FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task ModuloByZero_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE 10 % 0 FROM c WHERE c.id = '1'"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\"}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DivisionByZero_Float_ReturnsUndefined() + { + await Seed(); + // 1.0 / 0 → infinity → should be treated as undefined + var r = await RunQuery("SELECT VALUE 1.0 / 0 FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task ModuloByZero_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE 10 % 0 FROM c WHERE c.id = '1'"); + r.Should().BeEmpty(); + } } public class QueryOrderByEdgeCaseTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","arr":[1,2]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","arr":[1,3]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","arr":[0,5]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_FunctionInOrderBy_SortsCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c ORDER BY ARRAY_LENGTH(c.arr)"); - r.Should().HaveCount(3); - // All arrays have length 2, so order should be stable - r.Should().AllSatisfy(x => x["arr"]!.Count().Should().Be(2)); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","arr":[1,2]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","arr":[1,3]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","arr":[0,5]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_FunctionInOrderBy_SortsCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c ORDER BY ARRAY_LENGTH(c.arr)"); + r.Should().HaveCount(3); + // All arrays have length 2, so order should be stable + r.Should().AllSatisfy(x => x["arr"]!.Count().Should().Be(2)); + } } public class QueryGroupByEdgeCaseTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"cat\":\"A\",\"val\":10}"), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"cat\":\"B\",\"val\":20}"), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"3\",\"pk\":\"p1\",\"cat\":\"A\",\"val\":30}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_EmptyResult_NoGroups() - { - await Seed(); - var r = await RunQuery("SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.id = 'nonexistent' GROUP BY c.cat"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task GroupBy_AllNullKeys_SingleGroup() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":null,"val":1}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","cat":null,"val":2}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.cat = null GROUP BY c.cat"); - r.Should().ContainSingle(); - r[0]["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"pk\":\"p1\",\"cat\":\"A\",\"val\":10}"), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"2\",\"pk\":\"p1\",\"cat\":\"B\",\"val\":20}"), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"3\",\"pk\":\"p1\",\"cat\":\"A\",\"val\":30}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_EmptyResult_NoGroups() + { + await Seed(); + var r = await RunQuery("SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.id = 'nonexistent' GROUP BY c.cat"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task GroupBy_AllNullKeys_SingleGroup() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":null,"val":1}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","cat":null,"val":2}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT c.cat, COUNT(1) as cnt FROM c WHERE c.cat = null GROUP BY c.cat"); + r.Should().ContainSingle(); + r[0]["cnt"]!.Value().Should().Be(2); + } } public class QueryJoinEdgeCaseTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_NestedArrayPath_Works() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","nested":{"items":["a","b","c"]}}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.nested.items"); - r.Should().HaveCount(3); - } - - [Fact] - public async Task Join_WithGroupBy_AggregatesExpandedRows() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["b","c"]}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); - r.Should().HaveCount(3); // a, b, c - where b appears in both docs - var bGroup = r.FirstOrDefault(x => x["tag"]?.Value() == "b"); - bGroup.Should().NotBeNull(); - bGroup!["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_NestedArrayPath_Works() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","nested":{"items":["a","b","c"]}}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.nested.items"); + r.Should().HaveCount(3); + } + + [Fact] + public async Task Join_WithGroupBy_AggregatesExpandedRows() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["b","c"]}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); + r.Should().HaveCount(3); // a, b, c - where b appears in both docs + var bGroup = r.FirstOrDefault(x => x["tag"]?.Value() == "b"); + bGroup.Should().NotBeNull(); + bGroup!["cnt"]!.Value().Should().Be(2); + } } public class QuerySubqueryEdgeCaseTests_V15 { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","items":[{"name":"a","val":1},{"name":"b","val":2},{"name":"c","val":3}]}"""), new PartitionKey("p1")); - } + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","items":[{"name":"a","val":1},{"name":"b","val":2},{"name":"c","val":3}]}"""), new PartitionKey("p1")); + } - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } - [Fact] - public async Task ArraySubquery_WithAggregate_ReturnsSingleElement() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY(SELECT VALUE COUNT(1) FROM i IN c.items) FROM c WHERE c.id = '1'"); - var arr = r.Should().ContainSingle().Which; - arr.Should().HaveCount(1); - arr[0]!.Value().Should().Be(3); - } + [Fact] + public async Task ArraySubquery_WithAggregate_ReturnsSingleElement() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY(SELECT VALUE COUNT(1) FROM i IN c.items) FROM c WHERE c.id = '1'"); + var arr = r.Should().ContainSingle().Which; + arr.Should().HaveCount(1); + arr[0]!.Value().Should().Be(3); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -22084,925 +22094,925 @@ public async Task ArraySubquery_WithAggregate_ReturnsSingleElement() // ── V16 Phase 1: TOBOOLEAN bug fix tests ── public class QueryDeepDiveV16_ToBooleanTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":"true"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToBoolean_InvalidString_ReturnsUndefined() - { - await Seed(); - // TOBOOLEAN('hello') should return undefined, which is omitted by SELECT VALUE - var r = await RunQuery("SELECT VALUE TOBOOLEAN('hello') FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("TOBOOLEAN with unparseable string should return undefined"); - } - - [Fact] - public async Task ToBoolean_Number_ReturnsUndefined() - { - await Seed(); - // TOBOOLEAN(42) — numbers are not booleans; Cosmos DB returns undefined - var r = await RunQuery("SELECT VALUE TOBOOLEAN(42) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("TOBOOLEAN with number should return undefined"); - } - - [Fact] - public async Task ToBoolean_Null_ReturnsUndefined() - { - await Seed(); - // TOBOOLEAN(null) should return undefined - var r = await RunQuery("SELECT VALUE TOBOOLEAN(null) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("TOBOOLEAN(null) should return undefined"); - } - - [Fact] - public async Task ToBoolean_AlreadyBool_ReturnsSelf() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOBOOLEAN(true) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task ToBoolean_FalseString_ReturnsFalse() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOBOOLEAN('false') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeFalse(); - } - - [Fact] - public async Task ToBoolean_TrueString_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOBOOLEAN('true') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":"true"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToBoolean_InvalidString_ReturnsUndefined() + { + await Seed(); + // TOBOOLEAN('hello') should return undefined, which is omitted by SELECT VALUE + var r = await RunQuery("SELECT VALUE TOBOOLEAN('hello') FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("TOBOOLEAN with unparseable string should return undefined"); + } + + [Fact] + public async Task ToBoolean_Number_ReturnsUndefined() + { + await Seed(); + // TOBOOLEAN(42) — numbers are not booleans; Cosmos DB returns undefined + var r = await RunQuery("SELECT VALUE TOBOOLEAN(42) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("TOBOOLEAN with number should return undefined"); + } + + [Fact] + public async Task ToBoolean_Null_ReturnsUndefined() + { + await Seed(); + // TOBOOLEAN(null) should return undefined + var r = await RunQuery("SELECT VALUE TOBOOLEAN(null) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("TOBOOLEAN(null) should return undefined"); + } + + [Fact] + public async Task ToBoolean_AlreadyBool_ReturnsSelf() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOBOOLEAN(true) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task ToBoolean_FalseString_ReturnsFalse() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOBOOLEAN('false') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeFalse(); + } + + [Fact] + public async Task ToBoolean_TrueString_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOBOOLEAN('true') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } } // ── V16 Phase 2: LEFT/RIGHT negative count bug fix tests ── public class QueryDeepDiveV16_LeftRightEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Left_NegativeCount_ReturnsUndefined() - { - await Seed(); - // LEFT('hello', -1) → undefined (omitted by SELECT VALUE) - // Cosmos DB returns undefined for LEFT with negative count - var r = await RunQuery("SELECT VALUE LEFT('hello', -1) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("LEFT with negative count should return undefined"); - } - - [Fact] - public async Task Right_NegativeCount_ReturnsUndefined() - { - await Seed(); - // RIGHT('hello', -1) → undefined - var r = await RunQuery("SELECT VALUE RIGHT('hello', -1) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("RIGHT with negative count should return undefined"); - } - - [Fact] - public async Task Left_Zero_ReturnsEmptyString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeEmpty(); - } - - [Fact] - public async Task Right_Zero_ReturnsEmptyString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RIGHT('hello', 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeEmpty(); - } - - [Fact] - public async Task Left_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LEFT('hi', 100) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hi"); - } - - [Fact] - public async Task Right_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE RIGHT('hi', 100) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("hi"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Left_NegativeCount_ReturnsUndefined() + { + await Seed(); + // LEFT('hello', -1) → undefined (omitted by SELECT VALUE) + // Cosmos DB returns undefined for LEFT with negative count + var r = await RunQuery("SELECT VALUE LEFT('hello', -1) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("LEFT with negative count should return undefined"); + } + + [Fact] + public async Task Right_NegativeCount_ReturnsUndefined() + { + await Seed(); + // RIGHT('hello', -1) → undefined + var r = await RunQuery("SELECT VALUE RIGHT('hello', -1) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("RIGHT with negative count should return undefined"); + } + + [Fact] + public async Task Left_Zero_ReturnsEmptyString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LEFT('hello', 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeEmpty(); + } + + [Fact] + public async Task Right_Zero_ReturnsEmptyString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RIGHT('hello', 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeEmpty(); + } + + [Fact] + public async Task Left_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LEFT('hi', 100) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hi"); + } + + [Fact] + public async Task Right_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE RIGHT('hi', 100) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("hi"); + } } // ── V16 Phase 3: INTBITLEFTSHIFT/INTBITRIGHTSHIFT edge cases ── public class QueryDeepDiveV16_IntBitShiftEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IntBitLeftShift_ShiftBy64_ReturnsUndefined() - { - await Seed(); - // Shifting by >= 64 bits is invalid for 64-bit integers → undefined - var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 64) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("INTBITLEFTSHIFT with shift >= 64 should return undefined"); - } - - [Fact] - public async Task IntBitRightShift_ShiftBy64_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(1, 64) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("INTBITRIGHTSHIFT with shift >= 64 should return undefined"); - } - - [Fact] - public async Task IntBitLeftShift_NegativeShift_ReturnsUndefined() - { - await Seed(); - // Negative shift amounts are invalid → undefined - var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, -1) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("INTBITLEFTSHIFT with negative shift should return undefined"); - } - - [Fact] - public async Task IntBitRightShift_NegativeShift_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(1, -1) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("INTBITRIGHTSHIFT with negative shift should return undefined"); - } - - [Fact] - public async Task IntBitLeftShift_ShiftByZero_ReturnsSameValue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(42, 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(42); - } - - [Fact] - public async Task IntBitRightShift_ShiftByZero_ReturnsSameValue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(42, 0) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(42); - } - - [Fact] - public async Task IntBitLeftShift_ShiftBy63_Works() - { - await Seed(); - // Max valid shift for 64-bit integer - var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 63) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(long.MinValue); // 1 << 63 = -9223372036854775808 - } - - [Fact] - public async Task IntBitLeftShift_LargeShift128_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 128) FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("INTBITLEFTSHIFT with shift >= 64 should return undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IntBitLeftShift_ShiftBy64_ReturnsUndefined() + { + await Seed(); + // Shifting by >= 64 bits is invalid for 64-bit integers → undefined + var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 64) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("INTBITLEFTSHIFT with shift >= 64 should return undefined"); + } + + [Fact] + public async Task IntBitRightShift_ShiftBy64_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(1, 64) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("INTBITRIGHTSHIFT with shift >= 64 should return undefined"); + } + + [Fact] + public async Task IntBitLeftShift_NegativeShift_ReturnsUndefined() + { + await Seed(); + // Negative shift amounts are invalid → undefined + var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, -1) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("INTBITLEFTSHIFT with negative shift should return undefined"); + } + + [Fact] + public async Task IntBitRightShift_NegativeShift_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(1, -1) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("INTBITRIGHTSHIFT with negative shift should return undefined"); + } + + [Fact] + public async Task IntBitLeftShift_ShiftByZero_ReturnsSameValue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(42, 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(42); + } + + [Fact] + public async Task IntBitRightShift_ShiftByZero_ReturnsSameValue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTBITRIGHTSHIFT(42, 0) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(42); + } + + [Fact] + public async Task IntBitLeftShift_ShiftBy63_Works() + { + await Seed(); + // Max valid shift for 64-bit integer + var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 63) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(long.MinValue); // 1 << 63 = -9223372036854775808 + } + + [Fact] + public async Task IntBitLeftShift_LargeShift128_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INTBITLEFTSHIFT(1, 128) FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("INTBITLEFTSHIFT with shift >= 64 should return undefined"); + } } // ── V16 Phase 4: WHERE three-value logic ── public class QueryDeepDiveV16_WhereThreeValueLogicTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), new PartitionKey("p1")); - // id=3 has no "val" property at all (undefined) - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Where_NotUndefined_ExcludesRow() - { - await Seed(); - // NOT (c.missing = 5): c.missing is undefined → undefined comparison → NOT undefined = undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE NOT (c.missing = 5)"); - // Only items where c.missing is actually defined (none have it) should match - // All items lack c.missing, so NOT (undefined = 5) = NOT undefined = undefined → all excluded - r.Should().BeEmpty(); - } - - [Fact] - public async Task Where_UndefinedOrTrue_ReturnsRow() - { - await Seed(); - // c.missing = 5 OR true → undefined OR true = true → included - var r = await RunQuery("SELECT * FROM c WHERE c.missing = 5 OR true"); - r.Should().HaveCount(3, "true OR undefined = true, so all rows match"); - } - - [Fact] - public async Task Where_UndefinedAndFalse_ExcludesRow() - { - await Seed(); - // c.missing = 5 AND false → undefined AND false = false → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.missing = 5 AND false"); - r.Should().BeEmpty("false AND undefined = false"); - } - - [Fact] - public async Task Where_DoubleNot_Undefined_ExcludesRow() - { - await Seed(); - // NOT NOT (c.missing = 5) = NOT (NOT undefined) = NOT undefined = undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE NOT NOT (c.missing = 5)"); - r.Should().BeEmpty("double NOT on undefined is still undefined → excluded"); - } - - [Fact] - public async Task Where_IsNull_MatchesOnlyExplicitNull() - { - await Seed(); - // c.val = null should match id=2 (explicit null) but not id=3 (undefined/missing) - var r = await RunQuery("SELECT * FROM c WHERE c.val = null"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task Where_IsNotDefined_MatchesMissing() - { - await Seed(); - // NOT IS_DEFINED(c.val) matches only id=3 where val is missing - var r = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.val)"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), new PartitionKey("p1")); + // id=3 has no "val" property at all (undefined) + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Where_NotUndefined_ExcludesRow() + { + await Seed(); + // NOT (c.missing = 5): c.missing is undefined → undefined comparison → NOT undefined = undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE NOT (c.missing = 5)"); + // Only items where c.missing is actually defined (none have it) should match + // All items lack c.missing, so NOT (undefined = 5) = NOT undefined = undefined → all excluded + r.Should().BeEmpty(); + } + + [Fact] + public async Task Where_UndefinedOrTrue_ReturnsRow() + { + await Seed(); + // c.missing = 5 OR true → undefined OR true = true → included + var r = await RunQuery("SELECT * FROM c WHERE c.missing = 5 OR true"); + r.Should().HaveCount(3, "true OR undefined = true, so all rows match"); + } + + [Fact] + public async Task Where_UndefinedAndFalse_ExcludesRow() + { + await Seed(); + // c.missing = 5 AND false → undefined AND false = false → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.missing = 5 AND false"); + r.Should().BeEmpty("false AND undefined = false"); + } + + [Fact] + public async Task Where_DoubleNot_Undefined_ExcludesRow() + { + await Seed(); + // NOT NOT (c.missing = 5) = NOT (NOT undefined) = NOT undefined = undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE NOT NOT (c.missing = 5)"); + r.Should().BeEmpty("double NOT on undefined is still undefined → excluded"); + } + + [Fact] + public async Task Where_IsNull_MatchesOnlyExplicitNull() + { + await Seed(); + // c.val = null should match id=2 (explicit null) but not id=3 (undefined/missing) + var r = await RunQuery("SELECT * FROM c WHERE c.val = null"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task Where_IsNotDefined_MatchesMissing() + { + await Seed(); + // NOT IS_DEFINED(c.val) matches only id=3 where val is missing + var r = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.val)"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("3"); + } } // ── V16 Phase 5: Mixed-type ORDER BY ── public class QueryDeepDiveV16_OrderByMixedTypeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - // Documents with different types for "val": null, bool, number, string - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":42}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":true}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":false}"""), new PartitionKey("p1")); - // id=6 has no val property (undefined) - await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_MixedTypes_CorrectTypeRank() - { - await Seed(); - // Cosmos DB type order: undefined < null < bool < number < string - var r = await RunQuery("SELECT c.id, c.val FROM c ORDER BY c.val ASC"); - r.Should().HaveCount(6); - var ids = r.Select(x => x["id"]!.Value()).ToList(); - // undefined (id=6) < null (id=4) < false (id=5) < true (id=3) < 42 (id=2) < "hello" (id=1) - ids.Should().Equal("6", "4", "5", "3", "2", "1"); - } - - [Fact] - public async Task OrderBy_Desc_MixedTypes_ReversesOrder() - { - await Seed(); - var r = await RunQuery("SELECT c.id, c.val FROM c ORDER BY c.val DESC"); - r.Should().HaveCount(6); - var ids = r.Select(x => x["id"]!.Value()).ToList(); - // Reversed: "hello" (id=1) > 42 (id=2) > true (id=3) > false (id=5) > null (id=4) > undefined (id=6) - ids.Should().Equal("1", "2", "3", "5", "4", "6"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + // Documents with different types for "val": null, bool, number, string + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":42}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":true}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":false}"""), new PartitionKey("p1")); + // id=6 has no val property (undefined) + await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_MixedTypes_CorrectTypeRank() + { + await Seed(); + // Cosmos DB type order: undefined < null < bool < number < string + var r = await RunQuery("SELECT c.id, c.val FROM c ORDER BY c.val ASC"); + r.Should().HaveCount(6); + var ids = r.Select(x => x["id"]!.Value()).ToList(); + // undefined (id=6) < null (id=4) < false (id=5) < true (id=3) < 42 (id=2) < "hello" (id=1) + ids.Should().Equal("6", "4", "5", "3", "2", "1"); + } + + [Fact] + public async Task OrderBy_Desc_MixedTypes_ReversesOrder() + { + await Seed(); + var r = await RunQuery("SELECT c.id, c.val FROM c ORDER BY c.val DESC"); + r.Should().HaveCount(6); + var ids = r.Select(x => x["id"]!.Value()).ToList(); + // Reversed: "hello" (id=1) > 42 (id=2) > true (id=3) > false (id=5) > null (id=4) > undefined (id=6) + ids.Should().Equal("1", "2", "3", "5", "4", "6"); + } } // ── V16 Phase 6: GROUP BY edge cases ── public class QueryDeepDiveV16_GroupByEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"Alpha","val":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"ALPHA","val":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"Beta","val":30}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":null,"val":40}"""), new PartitionKey("p1")); - // id=5 has no cat property (undefined) - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":50}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_FunctionExpression_Works() - { - await Seed(); - // GROUP BY LOWER(c.cat) should merge "Alpha" and "ALPHA" into one group - var r = await RunQuery("SELECT LOWER(c.cat) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.cat)"); - // Expected: "alpha" (cnt=2), "beta" (cnt=1), null group (cnt=1), undefined group (cnt=1) - var alphaGroup = r.FirstOrDefault(x => x["cat"]?.ToString() == "alpha"); - alphaGroup.Should().NotBeNull(); - alphaGroup!["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_UndefinedVsNull_SeparateGroups() - { - await Seed(); - // null and undefined should be in separate groups - var r = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - // Groups: "Alpha" (1), "ALPHA" (1), "Beta" (1), null (1), undefined (1) - r.Should().HaveCount(5); - } - - [Fact] - public async Task GroupBy_Having_FiltersGroups() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","cat":"Alpha","val":60}"""), new PartitionKey("p1")); - await Seed(); - var r = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 1"); - // Only "Alpha" group has count > 1 (ids 1, 6) - r.Should().ContainSingle().Which["cat"]!.Value().Should().Be("Alpha"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"Alpha","val":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"ALPHA","val":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"Beta","val":30}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":null,"val":40}"""), new PartitionKey("p1")); + // id=5 has no cat property (undefined) + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":50}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_FunctionExpression_Works() + { + await Seed(); + // GROUP BY LOWER(c.cat) should merge "Alpha" and "ALPHA" into one group + var r = await RunQuery("SELECT LOWER(c.cat) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.cat)"); + // Expected: "alpha" (cnt=2), "beta" (cnt=1), null group (cnt=1), undefined group (cnt=1) + var alphaGroup = r.FirstOrDefault(x => x["cat"]?.ToString() == "alpha"); + alphaGroup.Should().NotBeNull(); + alphaGroup!["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_UndefinedVsNull_SeparateGroups() + { + await Seed(); + // null and undefined should be in separate groups + var r = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + // Groups: "Alpha" (1), "ALPHA" (1), "Beta" (1), null (1), undefined (1) + r.Should().HaveCount(5); + } + + [Fact] + public async Task GroupBy_Having_FiltersGroups() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","cat":"Alpha","val":60}"""), new PartitionKey("p1")); + await Seed(); + var r = await RunQuery("SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 1"); + // Only "Alpha" group has count > 1 (ids 1, 6) + r.Should().ContainSingle().Which["cat"]!.Value().Should().Be("Alpha"); + } } // ── V16 Phase 7: SELECT VALUE aggregate + empty set ── public class QueryDeepDiveV16_AggregateEmptySetTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_CountEmpty_ReturnsZero() - { - await Seed(); - // COUNT of empty set returns 0 - var r = await RunQuery("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task SelectValue_SumEmpty_ReturnsUndefined() - { - await Seed(); - // SUM of empty set returns undefined (omitted by SELECT VALUE) - var r = await RunQuery("SELECT VALUE SUM(c.val) FROM c WHERE c.id = 'nonexistent'"); - r.Should().BeEmpty("SUM of empty set should return undefined"); - } - - [Fact] - public async Task SelectValue_AvgEmpty_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE AVG(c.val) FROM c WHERE c.id = 'nonexistent'"); - r.Should().BeEmpty("AVG of empty set should return undefined"); - } - - [Fact] - public async Task SelectValue_MinEmpty_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE MIN(c.val) FROM c WHERE c.id = 'nonexistent'"); - r.Should().BeEmpty("MIN of empty set should return undefined"); - } - - [Fact] - public async Task SelectValue_MaxEmpty_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE MAX(c.val) FROM c WHERE c.id = 'nonexistent'"); - r.Should().BeEmpty("MAX of empty set should return undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_CountEmpty_ReturnsZero() + { + await Seed(); + // COUNT of empty set returns 0 + var r = await RunQuery("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task SelectValue_SumEmpty_ReturnsUndefined() + { + await Seed(); + // SUM of empty set returns undefined (omitted by SELECT VALUE) + var r = await RunQuery("SELECT VALUE SUM(c.val) FROM c WHERE c.id = 'nonexistent'"); + r.Should().BeEmpty("SUM of empty set should return undefined"); + } + + [Fact] + public async Task SelectValue_AvgEmpty_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE AVG(c.val) FROM c WHERE c.id = 'nonexistent'"); + r.Should().BeEmpty("AVG of empty set should return undefined"); + } + + [Fact] + public async Task SelectValue_MinEmpty_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE MIN(c.val) FROM c WHERE c.id = 'nonexistent'"); + r.Should().BeEmpty("MIN of empty set should return undefined"); + } + + [Fact] + public async Task SelectValue_MaxEmpty_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE MAX(c.val) FROM c WHERE c.id = 'nonexistent'"); + r.Should().BeEmpty("MAX of empty set should return undefined"); + } } // ── V16 Phase 8: DISTINCT + TOP combined ── public class QueryDeepDiveV16_DistinctTopCombinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"B"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":"C"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","cat":"B"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DistinctTop_AppliesTopAfterDistinct() - { - await Seed(); - // 5 docs, 3 distinct cats (A, B, C). TOP 2 DISTINCT should give 2 unique cats. - var r = await RunQuery("SELECT DISTINCT TOP 2 VALUE c.cat FROM c"); - r.Should().HaveCount(2); - r.Select(x => x.Value()).Distinct().Should().HaveCount(2, "all results should be distinct"); - } - - [Fact] - public async Task Distinct_WithOrderBy_OrderPreserved() - { - await Seed(); - var r = await RunQuery("SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat ASC"); - r.Should().HaveCount(3); - r.Select(x => x.Value()).Should().Equal("A", "B", "C"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"B"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":"C"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","cat":"B"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DistinctTop_AppliesTopAfterDistinct() + { + await Seed(); + // 5 docs, 3 distinct cats (A, B, C). TOP 2 DISTINCT should give 2 unique cats. + var r = await RunQuery("SELECT DISTINCT TOP 2 VALUE c.cat FROM c"); + r.Should().HaveCount(2); + r.Select(x => x.Value()).Distinct().Should().HaveCount(2, "all results should be distinct"); + } + + [Fact] + public async Task Distinct_WithOrderBy_OrderPreserved() + { + await Seed(); + var r = await RunQuery("SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat ASC"); + r.Should().HaveCount(3); + r.Select(x => x.Value()).Should().Equal("A", "B", "C"); + } } // ── V16 Phase 9: JOIN with empty/null/missing arrays ── public class QueryDeepDiveV16_JoinWithEmptyArrayTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_EmptyArray_ProducesNoResults() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":[]}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Join_NullArray_ProducesNoResults() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":null}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Join_MissingArray_ProducesNoResults() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Join_MixedDocuments_OnlyExpandsArrays() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":[]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); - var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); - r.Should().HaveCount(2); // only from id=1 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_EmptyArray_ProducesNoResults() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":[]}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Join_NullArray_ProducesNoResults() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":null}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Join_MissingArray_ProducesNoResults() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Join_MixedDocuments_OnlyExpandsArrays() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":[]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); + var r = await RunQuery("SELECT VALUE t FROM c JOIN t IN c.tags"); + r.Should().HaveCount(2); // only from id=1 + } } // ── V16 Phase 10: Subquery edge cases ── public class QueryDeepDiveV16_SubqueryEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","items":[1,2,3,4,5]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Subquery_EmptyArray_ReturnsEmptyArray() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ARRAY(SELECT VALUE x FROM x IN c.items WHERE x > 999) FROM c WHERE c.id = '1'"); - var arr = r.Should().ContainSingle().Which; - arr.Should().BeEmpty(); - } - - [Fact] - public async Task Exists_EmptySubquery_ReturnsFalse() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE EXISTS(SELECT VALUE 1 FROM x IN c.items WHERE x > 999)"); - r.Should().BeEmpty("no items match x > 999, so EXISTS returns false"); - } - - [Fact] - public async Task Exists_NonEmptySubquery_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE EXISTS(SELECT VALUE 1 FROM x IN c.items WHERE x > 3)"); - r.Should().ContainSingle("items 4 and 5 match x > 3, so EXISTS returns true"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","items":[1,2,3,4,5]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Subquery_EmptyArray_ReturnsEmptyArray() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ARRAY(SELECT VALUE x FROM x IN c.items WHERE x > 999) FROM c WHERE c.id = '1'"); + var arr = r.Should().ContainSingle().Which; + arr.Should().BeEmpty(); + } + + [Fact] + public async Task Exists_EmptySubquery_ReturnsFalse() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE EXISTS(SELECT VALUE 1 FROM x IN c.items WHERE x > 999)"); + r.Should().BeEmpty("no items match x > 999, so EXISTS returns false"); + } + + [Fact] + public async Task Exists_NonEmptySubquery_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE EXISTS(SELECT VALUE 1 FROM x IN c.items WHERE x > 3)"); + r.Should().ContainSingle("items 4 and 5 match x > 3, so EXISTS returns true"); + } } // ── V16 Phase 11: COALESCE edge cases ── public class QueryDeepDiveV16_CoalesceEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), new PartitionKey("p1")); - // id=3 has no val (undefined) - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Coalesce_FirstDefined_Returned() - { - await Seed(); - // c.val ?? 'fallback': for id=1, val=10, so 10 is returned - var r = await RunQuery("SELECT VALUE c.val ?? 'fallback' FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(10); - } - - [Fact] - public async Task Coalesce_UndefinedFallsThrough() - { - await Seed(); - // c.missing ?? 'fallback': c.missing is undefined → 'fallback' - var r = await RunQuery("SELECT VALUE c.missing ?? 'fallback' FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("fallback"); - } - - [Fact] - public async Task Coalesce_NullDoesNotFallThrough() - { - await Seed(); - // null ?? 'fallback': null is NOT undefined in Cosmos — null is a defined value - // But ?? in Cosmos SQL treats null as "falls through" (same as JavaScript) - var r = await RunQuery("SELECT VALUE c.val ?? 'fallback' FROM c WHERE c.id = '2'"); - // In Cosmos DB, null ?? 'fallback' = 'fallback' (null is falsy for ??) - r.Should().ContainSingle().Which.Value().Should().Be("fallback"); - } - - [Fact] - public async Task Coalesce_BothUndefined_ReturnsUndefined() - { - await Seed(); - // c.missing ?? c.also_missing → both undefined → undefined - var r = await RunQuery("SELECT VALUE c.missing ?? c.also_missing FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("both sides undefined → result is undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), new PartitionKey("p1")); + // id=3 has no val (undefined) + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Coalesce_FirstDefined_Returned() + { + await Seed(); + // c.val ?? 'fallback': for id=1, val=10, so 10 is returned + var r = await RunQuery("SELECT VALUE c.val ?? 'fallback' FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(10); + } + + [Fact] + public async Task Coalesce_UndefinedFallsThrough() + { + await Seed(); + // c.missing ?? 'fallback': c.missing is undefined → 'fallback' + var r = await RunQuery("SELECT VALUE c.missing ?? 'fallback' FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("fallback"); + } + + [Fact] + public async Task Coalesce_NullDoesNotFallThrough() + { + await Seed(); + // null ?? 'fallback': null is NOT undefined in Cosmos — null is a defined value + // But ?? in Cosmos SQL treats null as "falls through" (same as JavaScript) + var r = await RunQuery("SELECT VALUE c.val ?? 'fallback' FROM c WHERE c.id = '2'"); + // In Cosmos DB, null ?? 'fallback' = 'fallback' (null is falsy for ??) + r.Should().ContainSingle().Which.Value().Should().Be("fallback"); + } + + [Fact] + public async Task Coalesce_BothUndefined_ReturnsUndefined() + { + await Seed(); + // c.missing ?? c.also_missing → both undefined → undefined + var r = await RunQuery("SELECT VALUE c.missing ?? c.also_missing FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("both sides undefined → result is undefined"); + } } // ── V16 Phase 12: Ternary (IIF/?) expression edge cases ── public class QueryDeepDiveV16_TernaryEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Ternary_UndefinedCondition_ReturnsIfFalse() - { - await Seed(); - // c.missing ? 'yes' : 'no' → condition is undefined → falsy → 'no' - var r = await RunQuery("SELECT VALUE (c.missing ? 'yes' : 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task Ternary_NullCondition_ReturnsIfFalse() - { - await Seed(); - // null ? 'yes' : 'no' → null is falsy → 'no' - var r = await RunQuery("SELECT VALUE (null ? 'yes' : 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task Ternary_TrueCondition_ReturnsIfTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE (true ? 'yes' : 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("yes"); - } - - [Fact] - public async Task IIF_SameAsternary() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("yes"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Ternary_UndefinedCondition_ReturnsIfFalse() + { + await Seed(); + // c.missing ? 'yes' : 'no' → condition is undefined → falsy → 'no' + var r = await RunQuery("SELECT VALUE (c.missing ? 'yes' : 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task Ternary_NullCondition_ReturnsIfFalse() + { + await Seed(); + // null ? 'yes' : 'no' → null is falsy → 'no' + var r = await RunQuery("SELECT VALUE (null ? 'yes' : 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task Ternary_TrueCondition_ReturnsIfTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE (true ? 'yes' : 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("yes"); + } + + [Fact] + public async Task IIF_SameAsternary() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IIF(true, 'yes', 'no') FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("yes"); + } } // ── V16 Phase 13: LIKE / BETWEEN / IN edge cases ── public class QueryDeepDiveV16_LikeBetweenInEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":5}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","val":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"A","val":1}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"50%OFF","val":null}"""), new PartitionKey("p1")); - // id=5 has no name or val - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_OnlyPercent_MatchesAll() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%'"); - r.Should().HaveCount(4, "id=5 has no name, so it's excluded; the other 4 match"); - } - - [Fact] - public async Task Like_OnlyUnderscore_MatchesSingleChar() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '_'"); - r.Should().ContainSingle().Which["name"]!.Value().Should().Be("A"); - } - - [Fact] - public async Task Like_EscapedPercent_MatchesLiteral() - { - await Seed(); - // Match names containing literal '%' using ESCAPE - var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%!%%' ESCAPE '!'"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("4"); - } - - [Fact] - public async Task Between_BoundaryInclusive() - { - await Seed(); - // BETWEEN is inclusive on both ends - var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 5"); - r.Should().HaveCount(2); // val=5 (id=1) and val=1 (id=3) - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3"); - } - - [Fact] - public async Task Between_UndefinedOperand_ExcludesRow() - { - await Seed(); - // c.val BETWEEN 1 AND 10 for id=5 (no val property) → undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 10"); - r.Select(x => x["id"]!.Value()).Should().NotContain("5"); - } - - [Fact] - public async Task In_UndefinedField_ExcludesRow() - { - await Seed(); - // c.val IN (1, 5, 10) for id=5 (no val) → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.val IN (1, 5, 10)"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2", "3"); - } - - [Fact] - public async Task In_NullInList_CanMatch() - { - await Seed(); - // c.val IN (null, 1) — id=4 has val=null, id=3 has val=1 - var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("3", "4"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":5}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","val":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"A","val":1}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"50%OFF","val":null}"""), new PartitionKey("p1")); + // id=5 has no name or val + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_OnlyPercent_MatchesAll() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%'"); + r.Should().HaveCount(4, "id=5 has no name, so it's excluded; the other 4 match"); + } + + [Fact] + public async Task Like_OnlyUnderscore_MatchesSingleChar() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '_'"); + r.Should().ContainSingle().Which["name"]!.Value().Should().Be("A"); + } + + [Fact] + public async Task Like_EscapedPercent_MatchesLiteral() + { + await Seed(); + // Match names containing literal '%' using ESCAPE + var r = await RunQuery("SELECT * FROM c WHERE c.name LIKE '%!%%' ESCAPE '!'"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("4"); + } + + [Fact] + public async Task Between_BoundaryInclusive() + { + await Seed(); + // BETWEEN is inclusive on both ends + var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 5"); + r.Should().HaveCount(2); // val=5 (id=1) and val=1 (id=3) + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3"); + } + + [Fact] + public async Task Between_UndefinedOperand_ExcludesRow() + { + await Seed(); + // c.val BETWEEN 1 AND 10 for id=5 (no val property) → undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 10"); + r.Select(x => x["id"]!.Value()).Should().NotContain("5"); + } + + [Fact] + public async Task In_UndefinedField_ExcludesRow() + { + await Seed(); + // c.val IN (1, 5, 10) for id=5 (no val) → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.val IN (1, 5, 10)"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2", "3"); + } + + [Fact] + public async Task In_NullInList_CanMatch() + { + await Seed(); + // c.val IN (null, 1) — id=4 has val=null, id=3 has val=1 + var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("3", "4"); + } } // ── V16 Phase 14: String concatenation operator ── public class QueryDeepDiveV16_StringConcatOperatorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","first":"John","last":"Doe"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StringConcat_Operator_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.first || ' ' || c.last FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be("John Doe"); - } - - [Fact] - public async Task StringConcat_WithUndefined_ReturnsUndefined() - { - await Seed(); - // c.missing || 'suffix' → undefined || 'suffix' → undefined - var r = await RunQuery("SELECT VALUE c.missing || 'suffix' FROM c WHERE c.id = '1'"); - r.Should().BeEmpty("string concat with undefined returns undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","first":"John","last":"Doe"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StringConcat_Operator_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.first || ' ' || c.last FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be("John Doe"); + } + + [Fact] + public async Task StringConcat_WithUndefined_ReturnsUndefined() + { + await Seed(); + // c.missing || 'suffix' → undefined || 'suffix' → undefined + var r = await RunQuery("SELECT VALUE c.missing || 'suffix' FROM c WHERE c.id = '1'"); + r.Should().BeEmpty("string concat with undefined returns undefined"); + } } // ── V16 Phase 15: Object/Array literal in SELECT ── public class QueryDeepDiveV16_ObjectArrayLiteralTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":10}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectObjectLiteral_Works() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE {"n": c.name, "v": c.val} FROM c WHERE c.id = '1'"""); - var obj = r.Should().ContainSingle().Which; - obj["n"]!.Value().Should().Be("Alice"); - obj["v"]!.Value().Should().Be(10); - } - - [Fact] - public async Task SelectArrayLiteral_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE [c.name, c.val] FROM c WHERE c.id = '1'"); - var arr = r.Should().ContainSingle().Which; - arr[0]!.Value().Should().Be("Alice"); - arr[1]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":10}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectObjectLiteral_Works() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE {"n": c.name, "v": c.val} FROM c WHERE c.id = '1'"""); + var obj = r.Should().ContainSingle().Which; + obj["n"]!.Value().Should().Be("Alice"); + obj["v"]!.Value().Should().Be(10); + } + + [Fact] + public async Task SelectArrayLiteral_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE [c.name, c.val] FROM c WHERE c.id = '1'"); + var arr = r.Should().ContainSingle().Which; + arr[0]!.Value().Should().Be("Alice"); + arr[1]!.Value().Should().Be(10); + } } // ── V16 Phase 16: OFFSET/LIMIT without ORDER BY ── public class QueryDeepDiveV16_OffsetLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - for (int i = 1; i <= 10; i++) - await _container.CreateItemAsync(JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i}}}"), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OffsetLimit_WithoutOrderBy_StillWorks() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c OFFSET 3 LIMIT 2"); - r.Should().HaveCount(2); - } - - [Fact] - public async Task OffsetLimit_WithOrderBy_CorrectSlice() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 2 LIMIT 3"); - r.Should().HaveCount(3); - r.Select(x => x["val"]!.Value()).Should().Equal(3, 4, 5); - } - - [Fact] - public async Task OffsetBeyondData_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 100 LIMIT 5"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + for (int i = 1; i <= 10; i++) + await _container.CreateItemAsync(JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i}}}"), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OffsetLimit_WithoutOrderBy_StillWorks() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c OFFSET 3 LIMIT 2"); + r.Should().HaveCount(2); + } + + [Fact] + public async Task OffsetLimit_WithOrderBy_CorrectSlice() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 2 LIMIT 3"); + r.Should().HaveCount(3); + r.Select(x => x["val"]!.Value()).Should().Equal(3, 4, 5); + } + + [Fact] + public async Task OffsetBeyondData_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 100 LIMIT 5"); + r.Should().BeEmpty(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -23012,851 +23022,851 @@ public async Task OffsetBeyondData_ReturnsEmpty() // ── V17 Phase 1: Negate null/undefined/non-numeric → undefined (Bug #121) ── public class QueryDeepDiveV17_NegateUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":42,"name":"Alice","flag":true}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Negate_Null_ReturnsUndefined() - { - await Seed(); - // -null should return undefined (omitted from SELECT VALUE) - var r = await RunQuery("SELECT VALUE -null FROM c"); - r.Should().BeEmpty("negating null should produce undefined"); - } - - [Fact] - public async Task Negate_UndefinedField_ReturnsUndefined() - { - await Seed(); - // -c.missing where c.missing is undefined → undefined - var r = await RunQuery("SELECT VALUE -c.missing FROM c"); - r.Should().BeEmpty("negating undefined should produce undefined"); - } - - [Fact] - public async Task Negate_String_ReturnsUndefined() - { - await Seed(); - // -'hello' → strings can't be negated → undefined - var r = await RunQuery("SELECT VALUE -'hello' FROM c"); - r.Should().BeEmpty("negating a string should produce undefined"); - } - - [Fact] - public async Task Negate_Boolean_ReturnsUndefined() - { - await Seed(); - // -true → booleans can't be negated → undefined - var r = await RunQuery("SELECT VALUE -true FROM c"); - r.Should().BeEmpty("negating a boolean should produce undefined"); - } - - [Fact] - public async Task Negate_Number_Works() - { - await Seed(); - // -42 → -42 (sanity check for correct path) - var r = await RunQuery("SELECT VALUE -c.val FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-42); - } - - [Fact] - public async Task Negate_Double_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE -(3.14) FROM c"); - r.Should().ContainSingle().Which.Value().Should().BeApproximately(-3.14, 0.001); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":42,"name":"Alice","flag":true}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Negate_Null_ReturnsUndefined() + { + await Seed(); + // -null should return undefined (omitted from SELECT VALUE) + var r = await RunQuery("SELECT VALUE -null FROM c"); + r.Should().BeEmpty("negating null should produce undefined"); + } + + [Fact] + public async Task Negate_UndefinedField_ReturnsUndefined() + { + await Seed(); + // -c.missing where c.missing is undefined → undefined + var r = await RunQuery("SELECT VALUE -c.missing FROM c"); + r.Should().BeEmpty("negating undefined should produce undefined"); + } + + [Fact] + public async Task Negate_String_ReturnsUndefined() + { + await Seed(); + // -'hello' → strings can't be negated → undefined + var r = await RunQuery("SELECT VALUE -'hello' FROM c"); + r.Should().BeEmpty("negating a string should produce undefined"); + } + + [Fact] + public async Task Negate_Boolean_ReturnsUndefined() + { + await Seed(); + // -true → booleans can't be negated → undefined + var r = await RunQuery("SELECT VALUE -true FROM c"); + r.Should().BeEmpty("negating a boolean should produce undefined"); + } + + [Fact] + public async Task Negate_Number_Works() + { + await Seed(); + // -42 → -42 (sanity check for correct path) + var r = await RunQuery("SELECT VALUE -c.val FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-42); + } + + [Fact] + public async Task Negate_Double_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE -(3.14) FROM c"); + r.Should().ContainSingle().Which.Value().Should().BeApproximately(-3.14, 0.001); + } } // ── V17 Phase 2: BitwiseNot null/undefined/non-long → undefined (Bug #122) ── public class QueryDeepDiveV17_BitwiseNotUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":42,"name":"Alice","flag":true}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task BitwiseNot_Null_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ~null FROM c"); - r.Should().BeEmpty("bitwise NOT of null should produce undefined"); - } - - [Fact] - public async Task BitwiseNot_UndefinedField_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ~c.missing FROM c"); - r.Should().BeEmpty("bitwise NOT of undefined should produce undefined"); - } - - [Fact] - public async Task BitwiseNot_String_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ~'hello' FROM c"); - r.Should().BeEmpty("bitwise NOT of string should produce undefined"); - } - - [Fact] - public async Task BitwiseNot_Boolean_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE ~true FROM c"); - r.Should().BeEmpty("bitwise NOT of boolean should produce undefined"); - } - - [Fact] - public async Task BitwiseNot_Integer_Works() - { - await Seed(); - // ~42 = -43 (sanity check) - var r = await RunQuery("SELECT VALUE ~c.val FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(-43); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":42,"name":"Alice","flag":true}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task BitwiseNot_Null_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ~null FROM c"); + r.Should().BeEmpty("bitwise NOT of null should produce undefined"); + } + + [Fact] + public async Task BitwiseNot_UndefinedField_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ~c.missing FROM c"); + r.Should().BeEmpty("bitwise NOT of undefined should produce undefined"); + } + + [Fact] + public async Task BitwiseNot_String_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ~'hello' FROM c"); + r.Should().BeEmpty("bitwise NOT of string should produce undefined"); + } + + [Fact] + public async Task BitwiseNot_Boolean_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE ~true FROM c"); + r.Should().BeEmpty("bitwise NOT of boolean should produce undefined"); + } + + [Fact] + public async Task BitwiseNot_Integer_Works() + { + await Seed(); + // ~42 = -43 (sanity check) + var r = await RunQuery("SELECT VALUE ~c.val FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(-43); + } } // ── V17 Phase 3: LIKE with null → undefined (Bug #123) ── public class QueryDeepDiveV17_LikeNullTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_NullValue_ReturnsUndefined() - { - await Seed(); - // null LIKE '%' → undefined (null is not a string; LIKE follows 3VL) - var r = await RunQuery("SELECT VALUE null LIKE '%' FROM c"); - r.Should().BeEmpty("null LIKE pattern should return undefined"); - } - - [Fact] - public async Task Like_NullPattern_ReturnsUndefined() - { - await Seed(); - // 'abc' LIKE null → undefined - var r = await RunQuery("SELECT VALUE 'abc' LIKE null FROM c"); - r.Should().BeEmpty("value LIKE null should return undefined"); - } - - [Fact] - public async Task Like_NullField_Where_ExcludesRow() - { - await Seed(); - // WHERE c.nullName LIKE '%' → null field → undefined → row excluded - var r = await RunQuery("SELECT * FROM c WHERE c.nullName LIKE '%'"); - r.Should().BeEmpty("null field in LIKE should not match"); - } - - [Fact] - public async Task Like_NotOfNullLike_ReturnsUndefined() - { - await Seed(); - // NOT (null LIKE '%') → NOT undefined → undefined (not true!) - var r = await RunQuery("SELECT VALUE NOT (null LIKE '%') FROM c"); - r.Should().BeEmpty("NOT of undefined LIKE should propagate undefined"); - } - - [Fact] - public async Task Like_NullField_NotLike_ExcludesRow() - { - await Seed(); - // WHERE c.nullName NOT LIKE 'A%' → NOT (null LIKE 'A%') → NOT undefined → undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.nullName NOT LIKE 'A%'"); - r.Should().BeEmpty("NOT LIKE on null field should exclude row"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_NullValue_ReturnsUndefined() + { + await Seed(); + // null LIKE '%' → undefined (null is not a string; LIKE follows 3VL) + var r = await RunQuery("SELECT VALUE null LIKE '%' FROM c"); + r.Should().BeEmpty("null LIKE pattern should return undefined"); + } + + [Fact] + public async Task Like_NullPattern_ReturnsUndefined() + { + await Seed(); + // 'abc' LIKE null → undefined + var r = await RunQuery("SELECT VALUE 'abc' LIKE null FROM c"); + r.Should().BeEmpty("value LIKE null should return undefined"); + } + + [Fact] + public async Task Like_NullField_Where_ExcludesRow() + { + await Seed(); + // WHERE c.nullName LIKE '%' → null field → undefined → row excluded + var r = await RunQuery("SELECT * FROM c WHERE c.nullName LIKE '%'"); + r.Should().BeEmpty("null field in LIKE should not match"); + } + + [Fact] + public async Task Like_NotOfNullLike_ReturnsUndefined() + { + await Seed(); + // NOT (null LIKE '%') → NOT undefined → undefined (not true!) + var r = await RunQuery("SELECT VALUE NOT (null LIKE '%') FROM c"); + r.Should().BeEmpty("NOT of undefined LIKE should propagate undefined"); + } + + [Fact] + public async Task Like_NullField_NotLike_ExcludesRow() + { + await Seed(); + // WHERE c.nullName NOT LIKE 'A%' → NOT (null LIKE 'A%') → NOT undefined → undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.nullName NOT LIKE 'A%'"); + r.Should().BeEmpty("NOT LIKE on null field should exclude row"); + } } // ── V17 Phase 4: COALESCE function all-undefined → undefined (Bug #124) ── public class QueryDeepDiveV17_CoalesceFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":10}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task CoalesceFunc_AllUndefined_ReturnsUndefined() - { - await Seed(); - // COALESCE(undefined, undefined) → undefined (not null) - var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); - r.Should().BeEmpty("COALESCE of all undefined fields should return undefined (omitted)"); - } - - [Fact] - public async Task CoalesceFunc_UndefinedThenValue_ReturnsValue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE COALESCE(c.missing, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("fallback"); - } - - [Fact] - public async Task CoalesceFunc_NullThenValue_ReturnsNull() - { - await Seed(); - // COALESCE(null, 'fallback') → null (null is NOT undefined, so it's returned) - // This differs from standard SQL COALESCE. Use ?? operator to skip null. - var r = await RunQuery("SELECT VALUE COALESCE(null, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task CoalesceFunc_FirstDefined_ReturnsFirst() - { - await Seed(); - var r = await RunQuery("SELECT VALUE COALESCE(c.name, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","val":10}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task CoalesceFunc_AllUndefined_ReturnsUndefined() + { + await Seed(); + // COALESCE(undefined, undefined) → undefined (not null) + var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); + r.Should().BeEmpty("COALESCE of all undefined fields should return undefined (omitted)"); + } + + [Fact] + public async Task CoalesceFunc_UndefinedThenValue_ReturnsValue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE COALESCE(c.missing, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("fallback"); + } + + [Fact] + public async Task CoalesceFunc_NullThenValue_ReturnsNull() + { + await Seed(); + // COALESCE(null, 'fallback') → null (null is NOT undefined, so it's returned) + // This differs from standard SQL COALESCE. Use ?? operator to skip null. + var r = await RunQuery("SELECT VALUE COALESCE(null, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task CoalesceFunc_FirstDefined_ReturnsFirst() + { + await Seed(); + var r = await RunQuery("SELECT VALUE COALESCE(c.name, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("Alice"); + } } // ── V17 Phase 5: Nested subqueries ── public class QueryDeepDiveV17_NestedSubqueryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b","c"],"val":10}"""), - new PartitionKey("p1")); - await _container.CreateItemAsync( - JObject.Parse("""{"id":"2","pk":"p1","tags":["x"],"val":20}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Subquery_CountInSelect_Works() - { - await Seed(); - // Subquery that counts tags per document - var r = await RunQuery( - "SELECT VALUE (SELECT VALUE COUNT(1) FROM t IN c.tags) FROM c ORDER BY c.id ASC"); - r.Should().HaveCount(2); - r[0].Value().Should().Be(3); - r[1].Value().Should().Be(1); - } - - [Fact] - public async Task Subquery_ArrayLength_InWhere() - { - await Seed(); - // Filter by array length (not a subquery per se, but baseline) - var r = await RunQuery("SELECT * FROM c WHERE ARRAY_LENGTH(c.tags) > 2"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b","c"],"val":10}"""), + new PartitionKey("p1")); + await _container.CreateItemAsync( + JObject.Parse("""{"id":"2","pk":"p1","tags":["x"],"val":20}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Subquery_CountInSelect_Works() + { + await Seed(); + // Subquery that counts tags per document + var r = await RunQuery( + "SELECT VALUE (SELECT VALUE COUNT(1) FROM t IN c.tags) FROM c ORDER BY c.id ASC"); + r.Should().HaveCount(2); + r[0].Value().Should().Be(3); + r[1].Value().Should().Be(1); + } + + [Fact] + public async Task Subquery_ArrayLength_InWhere() + { + await Seed(); + // Filter by array length (not a subquery per se, but baseline) + var r = await RunQuery("SELECT * FROM c WHERE ARRAY_LENGTH(c.tags) > 2"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } } // ── V17 Phase 6: Aggregate on empty container ── public class QueryDeepDiveV17_EmptyContainerAggregateTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Count_EmptyContainer_ReturnsZero() - { - // No seed — container is empty - var r = await RunQuery("SELECT VALUE COUNT(1) FROM c"); - // COUNT on empty container should return 0 (single result) - r.Should().ContainSingle().Which.Value().Should().Be(0); - } - - [Fact] - public async Task CountStar_EmptyContainer_ReturnsZero() - { - var r = await RunQuery("SELECT COUNT(1) AS cnt FROM c"); - r.Should().ContainSingle(); - r[0]["cnt"]!.Value().Should().Be(0); - } - - [Fact] - public async Task Sum_EmptyContainer() - { - // SUM on empty set — real Cosmos returns undefined/empty - var r = await RunQuery("SELECT SUM(c.val) AS total FROM c"); - // Should return a row with the field potentially missing or null - r.Should().ContainSingle(); - } - - [Fact] - public async Task SelectStar_EmptyContainer_ReturnsEmpty() - { - var r = await RunQuery("SELECT * FROM c"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Count_EmptyContainer_ReturnsZero() + { + // No seed — container is empty + var r = await RunQuery("SELECT VALUE COUNT(1) FROM c"); + // COUNT on empty container should return 0 (single result) + r.Should().ContainSingle().Which.Value().Should().Be(0); + } + + [Fact] + public async Task CountStar_EmptyContainer_ReturnsZero() + { + var r = await RunQuery("SELECT COUNT(1) AS cnt FROM c"); + r.Should().ContainSingle(); + r[0]["cnt"]!.Value().Should().Be(0); + } + + [Fact] + public async Task Sum_EmptyContainer() + { + // SUM on empty set — real Cosmos returns undefined/empty + var r = await RunQuery("SELECT SUM(c.val) AS total FROM c"); + // Should return a row with the field potentially missing or null + r.Should().ContainSingle(); + } + + [Fact] + public async Task SelectStar_EmptyContainer_ReturnsEmpty() + { + var r = await RunQuery("SELECT * FROM c"); + r.Should().BeEmpty(); + } } // ── V17 Phase 7: LIKE with non-string types ── public class QueryDeepDiveV17_LikeNonStringTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":42,"flag":true,"tags":["a","b"]}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_NumericFieldValue_ReturnsEmpty() - { - await Seed(); - // Real Cosmos: LIKE only operates on string types; numeric fields yield undefined - var r = await RunQuery("SELECT * FROM c WHERE c.val LIKE '%2'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Like_BooleanFieldValue_ReturnsEmpty() - { - await Seed(); - // Real Cosmos: LIKE only operates on string types; boolean fields yield undefined - var r = await RunQuery("SELECT * FROM c WHERE c.flag LIKE '%rue'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Like_ArrayField_ExcludesRow() - { - await Seed(); - // Array LIKE '%' → arrays are not strings, should be excluded from WHERE - var r = await RunQuery("SELECT * FROM c WHERE c.tags LIKE '%'"); - // Arrays can't be LIKE-matched meaningfully - // The exact behavior depends on how JArray.ToString() works - // but this tests that it doesn't throw - r.Should().HaveCountLessThanOrEqualTo(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":42,"flag":true,"tags":["a","b"]}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_NumericFieldValue_ReturnsEmpty() + { + await Seed(); + // Real Cosmos: LIKE only operates on string types; numeric fields yield undefined + var r = await RunQuery("SELECT * FROM c WHERE c.val LIKE '%2'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Like_BooleanFieldValue_ReturnsEmpty() + { + await Seed(); + // Real Cosmos: LIKE only operates on string types; boolean fields yield undefined + var r = await RunQuery("SELECT * FROM c WHERE c.flag LIKE '%rue'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Like_ArrayField_ExcludesRow() + { + await Seed(); + // Array LIKE '%' → arrays are not strings, should be excluded from WHERE + var r = await RunQuery("SELECT * FROM c WHERE c.tags LIKE '%'"); + // Arrays can't be LIKE-matched meaningfully + // The exact behavior depends on how JArray.ToString() works + // but this tests that it doesn't throw + r.Should().HaveCountLessThanOrEqualTo(1); + } } // ── V17 Phase 8: BETWEEN with null ── public class QueryDeepDiveV17_BetweenNullTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":5,"nullVal":null}"""), - new PartitionKey("p1")); - await _container.CreateItemAsync( - JObject.Parse("""{"id":"2","pk":"p1","val":15}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Between_NullValue_ExcludesRow() - { - await Seed(); - // c.nullVal BETWEEN 1 AND 10 → null value → undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.nullVal BETWEEN 1 AND 10"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Between_UndefinedField_ExcludesRow() - { - await Seed(); - // c.missing BETWEEN 1 AND 10 → undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.missing BETWEEN 1 AND 10"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Between_NormalValues_Works() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 10"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":5,"nullVal":null}"""), + new PartitionKey("p1")); + await _container.CreateItemAsync( + JObject.Parse("""{"id":"2","pk":"p1","val":15}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Between_NullValue_ExcludesRow() + { + await Seed(); + // c.nullVal BETWEEN 1 AND 10 → null value → undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.nullVal BETWEEN 1 AND 10"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Between_UndefinedField_ExcludesRow() + { + await Seed(); + // c.missing BETWEEN 1 AND 10 → undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.missing BETWEEN 1 AND 10"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Between_NormalValues_Works() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.val BETWEEN 1 AND 10"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } } // ── V17 Phase 9: IN with null edge cases ── public class QueryDeepDiveV17_InNullEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":1}"""), - new PartitionKey("p1")); - await _container.CreateItemAsync( - JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), - new PartitionKey("p1")); - await _container.CreateItemAsync( - JObject.Parse("""{"id":"3","pk":"p1"}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task In_NullValueMatchesNullInList() - { - await Seed(); - // c.val IN (null, 1) — matches id=1 (val=1) and id=2 (val=null) - var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); - } - - [Fact] - public async Task In_UndefinedField_ExcludesRow() - { - await Seed(); - // id=3 has no val field → undefined → excluded even if null is in the list - var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); - r.Select(x => x["id"]!.Value()).Should().NotContain("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":1}"""), + new PartitionKey("p1")); + await _container.CreateItemAsync( + JObject.Parse("""{"id":"2","pk":"p1","val":null}"""), + new PartitionKey("p1")); + await _container.CreateItemAsync( + JObject.Parse("""{"id":"3","pk":"p1"}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task In_NullValueMatchesNullInList() + { + await Seed(); + // c.val IN (null, 1) — matches id=1 (val=1) and id=2 (val=null) + var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); + } + + [Fact] + public async Task In_UndefinedField_ExcludesRow() + { + await Seed(); + // id=3 has no val field → undefined → excluded even if null is in the list + var r = await RunQuery("SELECT * FROM c WHERE c.val IN (null, 1)"); + r.Select(x => x["id"]!.Value()).Should().NotContain("3"); + } } // ── V17 Phase 10: Ternary with null/zero condition ── public class QueryDeepDiveV17_TernaryEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","val":0,"name":"Alice"}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Ternary_NullCondition_ReturnsFalseBranch() - { - await Seed(); - // null ? 'yes' : 'no' → null is not boolean true → false branch - var r = await RunQuery("SELECT VALUE null ? 'yes' : 'no' FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task Ternary_ZeroCondition_ReturnsFalseBranch() - { - await Seed(); - // 0 ? 'yes' : 'no' → 0 is not boolean true → false branch - var r = await RunQuery("SELECT VALUE 0 ? 'yes' : 'no' FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } - - [Fact] - public async Task Ternary_TrueCondition_ReturnsTrueBranch() - { - await Seed(); - var r = await RunQuery("SELECT VALUE true ? 'yes' : 'no' FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("yes"); - } - - [Fact] - public async Task Ternary_EmptyStringCondition_ReturnsFalseBranch() - { - await Seed(); - // '' ? 'yes' : 'no' → empty string is not boolean true → false branch - var r = await RunQuery("SELECT VALUE '' ? 'yes' : 'no' FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("no"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","val":0,"name":"Alice"}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Ternary_NullCondition_ReturnsFalseBranch() + { + await Seed(); + // null ? 'yes' : 'no' → null is not boolean true → false branch + var r = await RunQuery("SELECT VALUE null ? 'yes' : 'no' FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task Ternary_ZeroCondition_ReturnsFalseBranch() + { + await Seed(); + // 0 ? 'yes' : 'no' → 0 is not boolean true → false branch + var r = await RunQuery("SELECT VALUE 0 ? 'yes' : 'no' FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } + + [Fact] + public async Task Ternary_TrueCondition_ReturnsTrueBranch() + { + await Seed(); + var r = await RunQuery("SELECT VALUE true ? 'yes' : 'no' FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("yes"); + } + + [Fact] + public async Task Ternary_EmptyStringCondition_ReturnsFalseBranch() + { + await Seed(); + // '' ? 'yes' : 'no' → empty string is not boolean true → false branch + var r = await RunQuery("SELECT VALUE '' ? 'yes' : 'no' FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("no"); + } } // ── V17 Phase 11: DISTINCT VALUE edge cases ── public class QueryDeepDiveV17_DistinctValueTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":1}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":1}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DistinctValue_WithDuplicates_Deduplicates() - { - await Seed(); - var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); - // Should have 3 distinct values: 1, null, "hello" - r.Should().HaveCount(3); - } - - [Fact] - public async Task DistinctValue_NullsDeduplicatedToOne() - { - await Seed(); - var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); - // Only one null should appear - r.Count(t => t.Type == JTokenType.Null).Should().Be(1); - } - - [Fact] - public async Task DistinctValue_MixedTypes_AllPresent() - { - await Seed(); - var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); - // Should have integer 1, null, string "hello" - r.Any(t => t.Type == JTokenType.Integer && t.Value() == 1).Should().BeTrue(); - r.Any(t => t.Type == JTokenType.Null).Should().BeTrue(); - r.Any(t => t.Type == JTokenType.String && t.Value() == "hello").Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":1}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":1}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","val":"hello"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DistinctValue_WithDuplicates_Deduplicates() + { + await Seed(); + var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); + // Should have 3 distinct values: 1, null, "hello" + r.Should().HaveCount(3); + } + + [Fact] + public async Task DistinctValue_NullsDeduplicatedToOne() + { + await Seed(); + var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); + // Only one null should appear + r.Count(t => t.Type == JTokenType.Null).Should().Be(1); + } + + [Fact] + public async Task DistinctValue_MixedTypes_AllPresent() + { + await Seed(); + var r = await RunQuery("SELECT DISTINCT VALUE c.val FROM c"); + // Should have integer 1, null, string "hello" + r.Any(t => t.Type == JTokenType.Integer && t.Value() == 1).Should().BeTrue(); + r.Any(t => t.Type == JTokenType.Null).Should().BeTrue(); + r.Any(t => t.Type == JTokenType.String && t.Value() == "hello").Should().BeTrue(); + } } // ── V17 Phase 12: ORDER BY with LIMIT 0 and OFFSET 0 ── public class QueryDeepDiveV17_LimitOffsetEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - for (int i = 1; i <= 5; i++) - await _container.CreateItemAsync( - JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i}}}"), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Limit_Zero_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 0 LIMIT 0"); - r.Should().BeEmpty("LIMIT 0 should return no results"); - } - - [Fact] - public async Task Offset_Zero_HasNoEffect() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 0 LIMIT 3"); - r.Should().HaveCount(3); - r.Select(x => x["val"]!.Value()).Should().Equal(1, 2, 3); - } - - [Fact] - public async Task Top_Zero_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT TOP 0 * FROM c"); - r.Should().BeEmpty("TOP 0 should return no results"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + for (int i = 1; i <= 5; i++) + await _container.CreateItemAsync( + JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i}}}"), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Limit_Zero_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 0 LIMIT 0"); + r.Should().BeEmpty("LIMIT 0 should return no results"); + } + + [Fact] + public async Task Offset_Zero_HasNoEffect() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c ORDER BY c.val ASC OFFSET 0 LIMIT 3"); + r.Should().HaveCount(3); + r.Select(x => x["val"]!.Value()).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task Top_Zero_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT TOP 0 * FROM c"); + r.Should().BeEmpty("TOP 0 should return no results"); + } } // ── V17 Phase 13: LIKE with field that is numeric ── public class QueryDeepDiveV17_LikeFieldTypeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"p1","code":42,"status":"active"}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_NumericField_ReturnsEmpty() - { - await Seed(); - // Real Cosmos: LIKE only operates on string types; numeric fields yield undefined - var r = await RunQuery("SELECT * FROM c WHERE c.code LIKE '%2'"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Like_StringField_Works() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.status LIKE 'act%'"); - r.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"p1","code":42,"status":"active"}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_NumericField_ReturnsEmpty() + { + await Seed(); + // Real Cosmos: LIKE only operates on string types; numeric fields yield undefined + var r = await RunQuery("SELECT * FROM c WHERE c.code LIKE '%2'"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Like_StringField_Works() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.status LIKE 'act%'"); + r.Should().ContainSingle(); + } } // ── V17 Phase 14: Multiple aggregates in SELECT VALUE object literal ── public class QueryDeepDiveV17_MultiAggregateTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10,"cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20,"cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":30,"cat":"B"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task MultipleAggregates_InObjectLiteral() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)} FROM c"""); - var obj = r.Should().ContainSingle().Which; - obj["cnt"]!.Value().Should().Be(3); - obj["total"]!.Value().Should().Be(60); - } - - [Fact] - public async Task MultipleAggregates_GroupBy_InObjectLiteral() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"cat": c.cat, "cnt": COUNT(1), "total": SUM(c.val)} FROM c GROUP BY c.cat"""); - r.Should().HaveCount(2); - var catA = r.First(x => x["cat"]!.Value() == "A"); - catA["cnt"]!.Value().Should().Be(2); - catA["total"]!.Value().Should().Be(30); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10,"cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20,"cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":30,"cat":"B"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task MultipleAggregates_InObjectLiteral() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"cnt": COUNT(1), "total": SUM(c.val)} FROM c"""); + var obj = r.Should().ContainSingle().Which; + obj["cnt"]!.Value().Should().Be(3); + obj["total"]!.Value().Should().Be(60); + } + + [Fact] + public async Task MultipleAggregates_GroupBy_InObjectLiteral() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"cat": c.cat, "cnt": COUNT(1), "total": SUM(c.val)} FROM c GROUP BY c.cat"""); + r.Should().HaveCount(2); + var catA = r.First(x => x["cat"]!.Value() == "A"); + catA["cnt"]!.Value().Should().Be(2); + catA["total"]!.Value().Should().Be(30); + } } // ── V17 Phase 15: Complex HAVING with AND/OR ── public class QueryDeepDiveV17_ComplexHavingTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10,"cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20,"cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":5,"cat":"A"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":100,"cat":"B"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Having_AndCondition_BothMustMatch() - { - await Seed(); - // Category A has 3 items, sum=35. Category B has 1 item, sum=100. - // HAVING COUNT(1) > 1 AND SUM(c.val) > 30 → only category A passes - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING COUNT(1) > 1 AND SUM(c.val) > 30"); - r.Should().ContainSingle(); - r[0]["cat"]!.Value().Should().Be("A"); - } - - [Fact] - public async Task Having_OrCondition_EitherCanMatch() - { - await Seed(); - // HAVING COUNT(1) > 2 OR SUM(c.val) > 50 → A has count 3 (>2), B has sum 100 (>50) - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 2 OR SUM(c.val) > 50"); - r.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10,"cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20,"cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","val":5,"cat":"A"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":100,"cat":"B"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Having_AndCondition_BothMustMatch() + { + await Seed(); + // Category A has 3 items, sum=35. Category B has 1 item, sum=100. + // HAVING COUNT(1) > 1 AND SUM(c.val) > 30 → only category A passes + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt, SUM(c.val) AS total FROM c GROUP BY c.cat HAVING COUNT(1) > 1 AND SUM(c.val) > 30"); + r.Should().ContainSingle(); + r[0]["cat"]!.Value().Should().Be("A"); + } + + [Fact] + public async Task Having_OrCondition_EitherCanMatch() + { + await Seed(); + // HAVING COUNT(1) > 2 OR SUM(c.val) > 50 → A has count 3 (>2), B has sum 100 (>50) + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat HAVING COUNT(1) > 2 OR SUM(c.val) > 50"); + r.Should().HaveCount(2); + } } // ── V17 Phase 16: NOT BETWEEN ── public class QueryDeepDiveV17_NotBetweenTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - for (int i = 1; i <= 5; i++) - await _container.CreateItemAsync( - JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i * 10}}}"), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NotBetween_ExcludesRange() - { - await Seed(); - // NOT BETWEEN 20 AND 40 → excludes val=20,30,40 → keeps 10,50 - var r = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 20 AND 40"); - r.Select(x => x["val"]!.Value()).Should().BeEquivalentTo(new[] { 10, 50 }); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + for (int i = 1; i <= 5; i++) + await _container.CreateItemAsync( + JObject.Parse($"{{\"id\":\"{i}\",\"pk\":\"p1\",\"val\":{i * 10}}}"), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NotBetween_ExcludesRange() + { + await Seed(); + // NOT BETWEEN 20 AND 40 → excludes val=20,30,40 → keeps 10,50 + var r = await RunQuery("SELECT * FROM c WHERE c.val NOT BETWEEN 20 AND 40"); + r.Select(x => x["val"]!.Value()).Should().BeEquivalentTo(new[] { 10, 50 }); + } } // ── V17 Phase 17: NOT LIKE with various values ── public class QueryDeepDiveV17_NotLikeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NotLike_Basic_ExcludesMatches() - { - await Seed(); - // NOT LIKE 'A%' → excludes Alice → keeps Bob - var r = await RunQuery("SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); - r.Should().ContainSingle(); - r[0]["name"]!.Value().Should().Be("Bob"); - } - - [Fact] - public async Task NotLike_NullField_ExcludesRow() - { - await Seed(); - // c.nullName NOT LIKE 'A%' → null NOT LIKE → NOT undefined → undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.nullName NOT LIKE 'A%'"); - // Null field should be excluded (not included as "not matching") - r.Should().BeEmpty("null fields should not pass NOT LIKE filter"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NotLike_Basic_ExcludesMatches() + { + await Seed(); + // NOT LIKE 'A%' → excludes Alice → keeps Bob + var r = await RunQuery("SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); + r.Should().ContainSingle(); + r[0]["name"]!.Value().Should().Be("Bob"); + } + + [Fact] + public async Task NotLike_NullField_ExcludesRow() + { + await Seed(); + // c.nullName NOT LIKE 'A%' → null NOT LIKE → NOT undefined → undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.nullName NOT LIKE 'A%'"); + // Null field should be excluded (not included as "not matching") + r.Should().BeEmpty("null fields should not pass NOT LIKE filter"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -23867,741 +23877,741 @@ public async Task NotLike_NullField_ExcludesRow() // BUG-125: TOSTRING with arrays/objects should return JSON string, not undefined public class QueryDeepDiveV18_ToStringTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","name":"Alice","val":42,"active":true,"tags":["a","b"],"nested":{"x":1},"nullVal":null}"""), - new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse( - """{"id":"2","pk":"p1","name":"Bob","val":3.14}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToString_Integer_ReturnsStringRepresentation() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.val)} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("42"); - } - - [Fact] - public async Task ToString_Float_ReturnsStringRepresentation() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.val)} FROM c WHERE c.id = '2'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("3.14"); - } - - [Fact] - public async Task ToString_Boolean_ReturnsTrueOrFalse() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.active)} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("true"); - } - - [Fact] - public async Task ToString_Array_ReturnsJsonString() - { - // BUG-125: Microsoft docs confirm TOSTRING([1,2,3]) → "[1,2,3]" - // Emulator was returning UndefinedValue — field was omitted from results - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.tags)} FROM c WHERE c.id = '1'"); - var result = r.Should().ContainSingle().Which; - result["result"].Should().NotBeNull("TOSTRING(array) should return a string, not undefined"); - result["result"]!.Value().Should().Be("""["a","b"]"""); - } - - [Fact] - public async Task ToString_Object_ReturnsJsonString() - { - // BUG-125: Microsoft docs confirm TOSTRING({...}) → "{...}" - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nested)} FROM c WHERE c.id = '1'"); - var result = r.Should().ContainSingle().Which; - result["result"].Should().NotBeNull("TOSTRING(object) should return a string, not undefined"); - result["result"]!.Value().Should().Be("""{"x":1}"""); - } - - [Fact] - public async Task ToString_String_Passthrough() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.name)} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task ToString_Null_ReturnsUndefined() - { - await Seed(); - // TOSTRING(null) → undefined per Cosmos docs (field omitted from output) - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nullVal)} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TOSTRING(null) should be undefined → omitted"); - } - - [Fact] - public async Task ToString_MissingField_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nonexistent)} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TOSTRING(missing) should be undefined → omitted"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","name":"Alice","val":42,"active":true,"tags":["a","b"],"nested":{"x":1},"nullVal":null}"""), + new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse( + """{"id":"2","pk":"p1","name":"Bob","val":3.14}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToString_Integer_ReturnsStringRepresentation() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.val)} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("42"); + } + + [Fact] + public async Task ToString_Float_ReturnsStringRepresentation() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.val)} FROM c WHERE c.id = '2'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("3.14"); + } + + [Fact] + public async Task ToString_Boolean_ReturnsTrueOrFalse() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.active)} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("true"); + } + + [Fact] + public async Task ToString_Array_ReturnsJsonString() + { + // BUG-125: Microsoft docs confirm TOSTRING([1,2,3]) → "[1,2,3]" + // Emulator was returning UndefinedValue — field was omitted from results + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.tags)} FROM c WHERE c.id = '1'"); + var result = r.Should().ContainSingle().Which; + result["result"].Should().NotBeNull("TOSTRING(array) should return a string, not undefined"); + result["result"]!.Value().Should().Be("""["a","b"]"""); + } + + [Fact] + public async Task ToString_Object_ReturnsJsonString() + { + // BUG-125: Microsoft docs confirm TOSTRING({...}) → "{...}" + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nested)} FROM c WHERE c.id = '1'"); + var result = r.Should().ContainSingle().Which; + result["result"].Should().NotBeNull("TOSTRING(object) should return a string, not undefined"); + result["result"]!.Value().Should().Be("""{"x":1}"""); + } + + [Fact] + public async Task ToString_String_Passthrough() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.name)} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task ToString_Null_ReturnsUndefined() + { + await Seed(); + // TOSTRING(null) → undefined per Cosmos docs (field omitted from output) + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nullVal)} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TOSTRING(null) should be undefined → omitted"); + } + + [Fact] + public async Task ToString_MissingField_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOSTRING(c.nonexistent)} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TOSTRING(missing) should be undefined → omitted"); + } } // ── V18 Phase 2: TONUMBER edge cases ── public class QueryDeepDiveV18_ToNumberTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","numStr":"42","floatStr":"3.14","word":"hello","boolVal":true,"arr":[1],"nullVal":null,"num":99}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToNumber_ValidIntegerString_ReturnsNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.numStr)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(42); - } - - [Fact] - public async Task ToNumber_ValidFloatString_ReturnsNumber() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.floatStr)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(3.14); - } - - [Fact] - public async Task ToNumber_NumberPassthrough_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.num)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(99); - } - - [Fact] - public async Task ToNumber_NonNumericString_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.word)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER('hello') → undefined → omitted"); - } - - [Fact] - public async Task ToNumber_Boolean_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.boolVal)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(true) → undefined → omitted"); - } - - [Fact] - public async Task ToNumber_Null_ReturnsUndefined() - { - // BUG-126: TONUMBER(null) should return undefined, not null - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.nullVal)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(null) → undefined → omitted"); - } - - [Fact] - public async Task ToNumber_Array_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.arr)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(array) → undefined → omitted"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","numStr":"42","floatStr":"3.14","word":"hello","boolVal":true,"arr":[1],"nullVal":null,"num":99}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToNumber_ValidIntegerString_ReturnsNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.numStr)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(42); + } + + [Fact] + public async Task ToNumber_ValidFloatString_ReturnsNumber() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.floatStr)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(3.14); + } + + [Fact] + public async Task ToNumber_NumberPassthrough_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.num)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(99); + } + + [Fact] + public async Task ToNumber_NonNumericString_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.word)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER('hello') → undefined → omitted"); + } + + [Fact] + public async Task ToNumber_Boolean_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.boolVal)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(true) → undefined → omitted"); + } + + [Fact] + public async Task ToNumber_Null_ReturnsUndefined() + { + // BUG-126: TONUMBER(null) should return undefined, not null + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.nullVal)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(null) → undefined → omitted"); + } + + [Fact] + public async Task ToNumber_Array_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TONUMBER(c.arr)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TONUMBER(array) → undefined → omitted"); + } } // ── V18 Phase 3: TOBOOLEAN edge cases ── public class QueryDeepDiveV18_ToBooleanTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","trueStr":"true","falseStr":"false","boolVal":true,"num":42,"word":"hello","nullVal":null}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToBoolean_TrueString_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.trueStr)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ToBoolean_FalseString_ReturnsFalse() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.falseStr)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task ToBoolean_BoolPassthrough_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.boolVal)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ToBoolean_Number_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.num)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN(42) → undefined → omitted"); - } - - [Fact] - public async Task ToBoolean_NonBoolString_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.word)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN('hello') → undefined → omitted"); - } - - [Fact] - public async Task ToBoolean_Null_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.nullVal)} FROM c"); - r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN(null) → undefined → omitted"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","trueStr":"true","falseStr":"false","boolVal":true,"num":42,"word":"hello","nullVal":null}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToBoolean_TrueString_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.trueStr)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ToBoolean_FalseString_ReturnsFalse() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.falseStr)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task ToBoolean_BoolPassthrough_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.boolVal)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ToBoolean_Number_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.num)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN(42) → undefined → omitted"); + } + + [Fact] + public async Task ToBoolean_NonBoolString_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.word)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN('hello') → undefined → omitted"); + } + + [Fact] + public async Task ToBoolean_Null_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TOBOOLEAN(c.nullVal)} FROM c"); + r.Should().ContainSingle().Which["result"].Should().BeNull("TOBOOLEAN(null) → undefined → omitted"); + } } // ── V18 Phase 4: STRING_EQUALS ── public class QueryDeepDiveV18_StringEqualsTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StringEquals_ExactMatch_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Alice')"); - r.Should().ContainSingle().Which["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task StringEquals_NoMatch_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Charlie')"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task StringEquals_CaseSensitive_DoesNotMatchDifferentCase() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice')"); - r.Should().BeEmpty("STRING_EQUALS is case-sensitive by default"); - } - - [Fact] - public async Task StringEquals_CaseInsensitive_MatchesDifferentCase() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice', true)"); - r.Should().ContainSingle().Which["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task StringEquals_NullField_Excluded() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.nullName, 'Alice')"); - r.Should().BeEmpty("null field in STRING_EQUALS → undefined → excluded"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullName":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StringEquals_ExactMatch_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Alice')"); + r.Should().ContainSingle().Which["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task StringEquals_NoMatch_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Charlie')"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task StringEquals_CaseSensitive_DoesNotMatchDifferentCase() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice')"); + r.Should().BeEmpty("STRING_EQUALS is case-sensitive by default"); + } + + [Fact] + public async Task StringEquals_CaseInsensitive_MatchesDifferentCase() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice', true)"); + r.Should().ContainSingle().Which["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task StringEquals_NullField_Excluded() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE STRING_EQUALS(c.nullName, 'Alice')"); + r.Should().BeEmpty("null field in STRING_EQUALS → undefined → excluded"); + } } // ── V18 Phase 5: BETWEEN edge cases ── public class QueryDeepDiveV18_BetweenEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Apple","val":10,"nullVal":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Banana","val":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Cherry","val":30}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Date","val":40}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Between_StringValues_LexicographicOrder() - { - await Seed(); - // 'Apple' < 'Banana' < 'Cherry' < 'Date' lexicographically - // BETWEEN 'B' AND 'D' should include Banana and Cherry (not Apple, not Date since 'Date' > 'D') - var r = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'B' AND 'D'"); - r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Banana", "Cherry"); - } - - [Fact] - public async Task Between_NullOperand_Excluded() - { - await Seed(); - // c.nullVal BETWEEN 1 AND 100 → null/undefined → excluded - var r = await RunQuery("SELECT * FROM c WHERE c.nullVal BETWEEN 1 AND 100"); - r.Should().BeEmpty("null field in BETWEEN → excluded"); - } - - [Fact] - public async Task Between_MissingField_Excluded() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.nonexistent BETWEEN 1 AND 100"); - r.Should().BeEmpty("missing field in BETWEEN → excluded"); - } - - [Fact] - public async Task NotBetween_StringValues_ExcludesRange() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE c.name NOT BETWEEN 'B' AND 'D'"); - // Apple < 'B' so included; Date >= 'D' so excluded (Date > D) - r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Apple", "Date"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Apple","val":10,"nullVal":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Banana","val":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Cherry","val":30}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Date","val":40}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Between_StringValues_LexicographicOrder() + { + await Seed(); + // 'Apple' < 'Banana' < 'Cherry' < 'Date' lexicographically + // BETWEEN 'B' AND 'D' should include Banana and Cherry (not Apple, not Date since 'Date' > 'D') + var r = await RunQuery("SELECT * FROM c WHERE c.name BETWEEN 'B' AND 'D'"); + r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Banana", "Cherry"); + } + + [Fact] + public async Task Between_NullOperand_Excluded() + { + await Seed(); + // c.nullVal BETWEEN 1 AND 100 → null/undefined → excluded + var r = await RunQuery("SELECT * FROM c WHERE c.nullVal BETWEEN 1 AND 100"); + r.Should().BeEmpty("null field in BETWEEN → excluded"); + } + + [Fact] + public async Task Between_MissingField_Excluded() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.nonexistent BETWEEN 1 AND 100"); + r.Should().BeEmpty("missing field in BETWEEN → excluded"); + } + + [Fact] + public async Task NotBetween_StringValues_ExcludesRange() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE c.name NOT BETWEEN 'B' AND 'D'"); + // Apple < 'B' so included; Date >= 'D' so excluded (Date > D) + r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Apple", "Date"); + } } // ── V18 Phase 6: COALESCE function ── public class QueryDeepDiveV18_CoalesceFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","a":null,"b":"second","c":"third"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse( - """{"id":"2","pk":"p1","a":"first","b":"second"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NullCoalesce_NullLeft_ReturnRight() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.a ?? c.b} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("second"); - } - - [Fact] - public async Task NullCoalesce_DefinedLeft_ReturnLeft() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.a ?? c.b} FROM c WHERE c.id = '2'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("first"); - } - - [Fact] - public async Task NullCoalesce_UndefinedLeft_ReturnRight() - { - await Seed(); - // c.missing is undefined → falls through to c.b - var r = await RunQuery("SELECT VALUE {result: c.missing ?? c.b} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("second"); - } - - [Fact] - public async Task NullCoalesce_ChainedThreeWay() - { - await Seed(); - // c.missing ?? c.a ?? c.c → c.missing is undefined → c.a is null → c.c is "third" - var r = await RunQuery("SELECT VALUE {result: c.missing ?? c.a ?? c.c} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("third"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","a":null,"b":"second","c":"third"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse( + """{"id":"2","pk":"p1","a":"first","b":"second"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NullCoalesce_NullLeft_ReturnRight() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.a ?? c.b} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("second"); + } + + [Fact] + public async Task NullCoalesce_DefinedLeft_ReturnLeft() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.a ?? c.b} FROM c WHERE c.id = '2'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("first"); + } + + [Fact] + public async Task NullCoalesce_UndefinedLeft_ReturnRight() + { + await Seed(); + // c.missing is undefined → falls through to c.b + var r = await RunQuery("SELECT VALUE {result: c.missing ?? c.b} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("second"); + } + + [Fact] + public async Task NullCoalesce_ChainedThreeWay() + { + await Seed(); + // c.missing ?? c.a ?? c.c → c.missing is undefined → c.a is null → c.c is "third" + var r = await RunQuery("SELECT VALUE {result: c.missing ?? c.a ?? c.c} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("third"); + } } // ── V18 Phase 7: Aggregate null/undefined edge cases ── public class QueryDeepDiveV18_AggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); // no val field - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); // null val - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Sum_IgnoresUndefinedFields() - { - await Seed(); - // SUM should ignore docs where 'val' is undefined or null → 10 + 20 = 30 - var r = await RunQuery("SELECT VALUE {total: SUM(c.val)} FROM c"); - r.Should().ContainSingle().Which["total"]!.Value().Should().Be(30); - } - - [Fact] - public async Task Count_WithField_CountsOnlyDefined() - { - await Seed(); - // COUNT(c.val) should count docs where val is defined (even if null) → 3 - // Docs 1,2 have val=number, doc 4 has val=null, doc 3 has no val - var r = await RunQuery("SELECT VALUE {cnt: COUNT(c.val)} FROM c"); - var cnt = r.Should().ContainSingle().Which["cnt"]!.Value(); - cnt.Should().BeGreaterThanOrEqualTo(2, "COUNT(c.val) counts at least the defined values"); - } - - [Fact] - public async Task Avg_WithSomeUndefined_IgnoresUndefined() - { - await Seed(); - // AVG should use only defined numeric values: (10+20)/2 = 15 - var r = await RunQuery("SELECT VALUE {avg: AVG(c.val)} FROM c"); - r.Should().ContainSingle().Which["avg"]!.Value().Should().Be(15); - } - - [Fact] - public async Task Min_AcrossDefinedValues_ReturnsSmallest() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {min: MIN(c.val)} FROM c"); - r.Should().ContainSingle().Which["min"]!.Value().Should().Be(10); - } - - [Fact] - public async Task Max_AcrossDefinedValues_ReturnsLargest() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {max: MAX(c.val)} FROM c"); - r.Should().ContainSingle().Which["max"]!.Value().Should().Be(20); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","val":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","val":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1"}"""), new PartitionKey("p1")); // no val field + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","val":null}"""), new PartitionKey("p1")); // null val + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Sum_IgnoresUndefinedFields() + { + await Seed(); + // SUM should ignore docs where 'val' is undefined or null → 10 + 20 = 30 + var r = await RunQuery("SELECT VALUE {total: SUM(c.val)} FROM c"); + r.Should().ContainSingle().Which["total"]!.Value().Should().Be(30); + } + + [Fact] + public async Task Count_WithField_CountsOnlyDefined() + { + await Seed(); + // COUNT(c.val) should count docs where val is defined (even if null) → 3 + // Docs 1,2 have val=number, doc 4 has val=null, doc 3 has no val + var r = await RunQuery("SELECT VALUE {cnt: COUNT(c.val)} FROM c"); + var cnt = r.Should().ContainSingle().Which["cnt"]!.Value(); + cnt.Should().BeGreaterThanOrEqualTo(2, "COUNT(c.val) counts at least the defined values"); + } + + [Fact] + public async Task Avg_WithSomeUndefined_IgnoresUndefined() + { + await Seed(); + // AVG should use only defined numeric values: (10+20)/2 = 15 + var r = await RunQuery("SELECT VALUE {avg: AVG(c.val)} FROM c"); + r.Should().ContainSingle().Which["avg"]!.Value().Should().Be(15); + } + + [Fact] + public async Task Min_AcrossDefinedValues_ReturnsSmallest() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {min: MIN(c.val)} FROM c"); + r.Should().ContainSingle().Which["min"]!.Value().Should().Be(10); + } + + [Fact] + public async Task Max_AcrossDefinedValues_ReturnsLargest() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {max: MAX(c.val)} FROM c"); + r.Should().ContainSingle().Which["max"]!.Value().Should().Be(20); + } } // ── V18 Phase 8: Complex query combinations ── public class QueryDeepDiveV18_ComplexQueryTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","cat":"A","val":10,"tags":["x","y"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","cat":"A","val":20,"tags":["y","z"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","cat":"B","val":30,"tags":["x"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Diana","cat":"B","val":10,"tags":["z"]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"Alice","cat":"C","val":50,"tags":[]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task TopDistinctOrderBy_CombinesCorrectly() - { - await Seed(); - // DISTINCT names ordered alphabetically, take top 3 - var r = await RunQuery("SELECT DISTINCT TOP 3 VALUE c.name FROM c ORDER BY c.name ASC"); - r.Should().HaveCount(3); - r.Should().BeInAscendingOrder(); - r[0].Should().Be("Alice"); // "Alice" appears twice but DISTINCT deduplicates - } - - [Fact] - public async Task GroupBy_FunctionExpression_Having() - { - await Seed(); - // GROUP BY LOWER(c.cat) and filter with HAVING - var r = await RunQuery( - "SELECT LOWER(c.cat) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.cat) HAVING COUNT(1) >= 2"); - r.Should().HaveCount(2); // a and b each have 2 items - } - - [Fact] - public async Task SubqueryWithOrderByLimit() - { - await Seed(); - // ARRAY subquery with ORDER BY and TOP - var r = await RunQuery( - "SELECT c.name, ARRAY(SELECT TOP 1 VALUE t FROM t IN c.tags ORDER BY t ASC) AS firstTag FROM c WHERE c.id = '1'"); - var result = r.Should().ContainSingle().Which; - result["name"]!.Value().Should().Be("Alice"); - var tags = result["firstTag"] as JArray; - tags.Should().HaveCount(1); - tags![0].Value().Should().Be("x"); - } - - [Fact] - public async Task SelectValueAggregateObjectGroupBy() - { - await Seed(); - var r = await RunQuery( - """SELECT VALUE {"cat": c.cat, "total": SUM(c.val), "cnt": COUNT(1)} FROM c GROUP BY c.cat"""); - r.Should().HaveCount(3); - var catA = r.First(x => x["cat"]!.Value() == "A"); - catA["total"]!.Value().Should().Be(30); - catA["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task MultipleJoins_CrossFilter() - { - await Seed(); - // JOIN on tags — should produce rows for each tag - var r = await RunQuery( - "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE t = 'x'"); - r.Should().HaveCount(2); // Alice has 'x', Charlie has 'x' - r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Alice", "Charlie"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","cat":"A","val":10,"tags":["x","y"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","cat":"A","val":20,"tags":["y","z"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","cat":"B","val":30,"tags":["x"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Diana","cat":"B","val":10,"tags":["z"]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"Alice","cat":"C","val":50,"tags":[]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task TopDistinctOrderBy_CombinesCorrectly() + { + await Seed(); + // DISTINCT names ordered alphabetically, take top 3 + var r = await RunQuery("SELECT DISTINCT TOP 3 VALUE c.name FROM c ORDER BY c.name ASC"); + r.Should().HaveCount(3); + r.Should().BeInAscendingOrder(); + r[0].Should().Be("Alice"); // "Alice" appears twice but DISTINCT deduplicates + } + + [Fact] + public async Task GroupBy_FunctionExpression_Having() + { + await Seed(); + // GROUP BY LOWER(c.cat) and filter with HAVING + var r = await RunQuery( + "SELECT LOWER(c.cat) AS cat, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.cat) HAVING COUNT(1) >= 2"); + r.Should().HaveCount(2); // a and b each have 2 items + } + + [Fact] + public async Task SubqueryWithOrderByLimit() + { + await Seed(); + // ARRAY subquery with ORDER BY and TOP + var r = await RunQuery( + "SELECT c.name, ARRAY(SELECT TOP 1 VALUE t FROM t IN c.tags ORDER BY t ASC) AS firstTag FROM c WHERE c.id = '1'"); + var result = r.Should().ContainSingle().Which; + result["name"]!.Value().Should().Be("Alice"); + var tags = result["firstTag"] as JArray; + tags.Should().HaveCount(1); + tags![0].Value().Should().Be("x"); + } + + [Fact] + public async Task SelectValueAggregateObjectGroupBy() + { + await Seed(); + var r = await RunQuery( + """SELECT VALUE {"cat": c.cat, "total": SUM(c.val), "cnt": COUNT(1)} FROM c GROUP BY c.cat"""); + r.Should().HaveCount(3); + var catA = r.First(x => x["cat"]!.Value() == "A"); + catA["total"]!.Value().Should().Be(30); + catA["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task MultipleJoins_CrossFilter() + { + await Seed(); + // JOIN on tags — should produce rows for each tag + var r = await RunQuery( + "SELECT c.name, t AS tag FROM c JOIN t IN c.tags WHERE t = 'x'"); + r.Should().HaveCount(2); // Alice has 'x', Charlie has 'x' + r.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Alice", "Charlie"); + } } // ── V18 Phase 9: IS_DEFINED edge cases ── public class QueryDeepDiveV18_IsDefinedEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","name":"Alice","nullProp":null,"nested":{"x":1}}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse( - """{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); // no nullProp, no nested - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsDefined_NullValuedProperty_ReturnsTrue() - { - await Seed(); - // nullProp exists with value null → IS_DEFINED should return true - var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nullProp)"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task IsDefined_MissingProperty_ReturnsFalse() - { - await Seed(); - // Doc 2 has no nullProp → IS_DEFINED returns false → excluded - var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nullProp)"); - r.Should().ContainSingle(); // only doc 1 - } - - [Fact] - public async Task IsDefined_NestedPath_Works() - { - await Seed(); - var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nested.x)"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task NotIsDefined_MissingProperty_IncludesDoc() - { - await Seed(); - // NOT IS_DEFINED(c.nullProp) → doc 2 has no nullProp → true → included - var r = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.nullProp)"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task IsDefined_WhereFilter_CombinedWithOtherConditions() - { - await Seed(); - var r = await RunQuery( - "SELECT * FROM c WHERE IS_DEFINED(c.nested) AND c.name = 'Alice'"); - r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","name":"Alice","nullProp":null,"nested":{"x":1}}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse( + """{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); // no nullProp, no nested + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsDefined_NullValuedProperty_ReturnsTrue() + { + await Seed(); + // nullProp exists with value null → IS_DEFINED should return true + var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nullProp)"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task IsDefined_MissingProperty_ReturnsFalse() + { + await Seed(); + // Doc 2 has no nullProp → IS_DEFINED returns false → excluded + var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nullProp)"); + r.Should().ContainSingle(); // only doc 1 + } + + [Fact] + public async Task IsDefined_NestedPath_Works() + { + await Seed(); + var r = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nested.x)"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task NotIsDefined_MissingProperty_IncludesDoc() + { + await Seed(); + // NOT IS_DEFINED(c.nullProp) → doc 2 has no nullProp → true → included + var r = await RunQuery("SELECT * FROM c WHERE NOT IS_DEFINED(c.nullProp)"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task IsDefined_WhereFilter_CombinedWithOtherConditions() + { + await Seed(); + var r = await RunQuery( + "SELECT * FROM c WHERE IS_DEFINED(c.nested) AND c.name = 'Alice'"); + r.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } } // ── V18 Phase 10: Misc function edge cases ── public class QueryDeepDiveV18_FunctionEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse( - """{"id":"1","pk":"p1","name":"Alice","tags":["a","b"],"empty":[],"more":["c","d"]}"""), - new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArrayConcat_EmptyArrays_ReturnsEmptyArray() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: ARRAY_CONCAT(c.empty, c.empty)} FROM c"); - var arr = r.Should().ContainSingle().Which["result"] as JArray; - arr.Should().BeEmpty(); - } - - [Fact] - public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: ARRAY_CONCAT(c.tags, c.empty, c.more)} FROM c"); - var arr = r.Should().ContainSingle().Which["result"] as JArray; - arr!.Select(t => t.Value()).Should().BeEquivalentTo("a", "b", "c", "d"); - } - - [Fact] - public async Task IndexOf_WithStartPosition_SearchesFromOffset() - { - await Seed(); - // "Alice": A=0, l=1, i=2, c=3, e=4 - // INDEX_OF("Alice", "l") → 1; INDEX_OF("Alice", "e", 2) → 4 - var r = await RunQuery( - "SELECT VALUE {noStart: INDEX_OF(c.name, 'l'), withStart: INDEX_OF(c.name, 'e', 2)} FROM c"); - var result = r.Should().ContainSingle().Which; - result["noStart"]!.Value().Should().Be(1); // 'l' at position 1 in "Alice" - result["withStart"]!.Value().Should().Be(4); // 'e' at position 4 in "Alice" (searching from pos 2) - } - - [Fact] - public async Task Replace_EmptySearchString_ReturnsOriginal() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: REPLACE(c.name, '', 'X')} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Substring_OutOfRange_ClampedSafely() - { - await Seed(); - // SUBSTRING("Alice", 3, 100) → "ce" (clamped to remaining length) - var r = await RunQuery("SELECT VALUE {result: SUBSTRING(c.name, 3, 100)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("ce"); - } - - [Fact] - public async Task Left_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: LEFT(c.name, 100)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Right_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: RIGHT(c.name, 100)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Trim_NoWhitespace_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: TRIM(c.name)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Ltrim_NoLeadingWhitespace_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: LTRIM(c.name)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Rtrim_NoTrailingWhitespace_ReturnsSame() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: RTRIM(c.name)} FROM c"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse( + """{"id":"1","pk":"p1","name":"Alice","tags":["a","b"],"empty":[],"more":["c","d"]}"""), + new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArrayConcat_EmptyArrays_ReturnsEmptyArray() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: ARRAY_CONCAT(c.empty, c.empty)} FROM c"); + var arr = r.Should().ContainSingle().Which["result"] as JArray; + arr.Should().BeEmpty(); + } + + [Fact] + public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: ARRAY_CONCAT(c.tags, c.empty, c.more)} FROM c"); + var arr = r.Should().ContainSingle().Which["result"] as JArray; + arr!.Select(t => t.Value()).Should().BeEquivalentTo("a", "b", "c", "d"); + } + + [Fact] + public async Task IndexOf_WithStartPosition_SearchesFromOffset() + { + await Seed(); + // "Alice": A=0, l=1, i=2, c=3, e=4 + // INDEX_OF("Alice", "l") → 1; INDEX_OF("Alice", "e", 2) → 4 + var r = await RunQuery( + "SELECT VALUE {noStart: INDEX_OF(c.name, 'l'), withStart: INDEX_OF(c.name, 'e', 2)} FROM c"); + var result = r.Should().ContainSingle().Which; + result["noStart"]!.Value().Should().Be(1); // 'l' at position 1 in "Alice" + result["withStart"]!.Value().Should().Be(4); // 'e' at position 4 in "Alice" (searching from pos 2) + } + + [Fact] + public async Task Replace_EmptySearchString_ReturnsOriginal() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: REPLACE(c.name, '', 'X')} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Substring_OutOfRange_ClampedSafely() + { + await Seed(); + // SUBSTRING("Alice", 3, 100) → "ce" (clamped to remaining length) + var r = await RunQuery("SELECT VALUE {result: SUBSTRING(c.name, 3, 100)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("ce"); + } + + [Fact] + public async Task Left_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: LEFT(c.name, 100)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Right_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: RIGHT(c.name, 100)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Trim_NoWhitespace_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: TRIM(c.name)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Ltrim_NoLeadingWhitespace_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: LTRIM(c.name)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Rtrim_NoTrailingWhitespace_ReturnsSame() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: RTRIM(c.name)} FROM c"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -24611,773 +24621,773 @@ public async Task Rtrim_NoTrailingWhitespace_ReturnsSame() // ── V19 Phase 1: System Properties in Queries ── public class QueryDeepDiveV19_SystemPropertyTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","value":20}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_Ts_ReturnsTimestamp() - { - await Seed(); - var r = await RunQuery("SELECT c._ts FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task Select_Etag_ReturnsString() - { - await Seed(); - var r = await RunQuery("SELECT c._etag FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["_etag"]!.Value().Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Select_Rid_ReturnsString() - { - await Seed(); - var r = await RunQuery("SELECT c._rid FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["_rid"]!.Value().Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Where_Ts_GreaterThanZero_ReturnsAllItems() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c._ts > 0"); - r.Should().HaveCount(2); - } - - [Fact] - public async Task Where_Etag_NotNull_ReturnsAllItems() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c._etag != null"); - r.Should().HaveCount(2); - } - - [Fact] - public async Task OrderBy_Ts_Asc_Succeeds() - { - await Seed(); - var r = await RunQuery("SELECT c.id, c._ts FROM c ORDER BY c._ts ASC"); - r.Should().HaveCount(2); - r[0]["_ts"]!.Value().Should().BeLessThanOrEqualTo(r[1]["_ts"]!.Value()); - } - - [Fact] - public async Task IsDefined_Ts_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IS_DEFINED(c._ts) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } - - [Fact] - public async Task IsDefined_Attachments_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE IS_DEFINED(c._attachments) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","value":20}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_Ts_ReturnsTimestamp() + { + await Seed(); + var r = await RunQuery("SELECT c._ts FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task Select_Etag_ReturnsString() + { + await Seed(); + var r = await RunQuery("SELECT c._etag FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["_etag"]!.Value().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Select_Rid_ReturnsString() + { + await Seed(); + var r = await RunQuery("SELECT c._rid FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["_rid"]!.Value().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Where_Ts_GreaterThanZero_ReturnsAllItems() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c._ts > 0"); + r.Should().HaveCount(2); + } + + [Fact] + public async Task Where_Etag_NotNull_ReturnsAllItems() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c._etag != null"); + r.Should().HaveCount(2); + } + + [Fact] + public async Task OrderBy_Ts_Asc_Succeeds() + { + await Seed(); + var r = await RunQuery("SELECT c.id, c._ts FROM c ORDER BY c._ts ASC"); + r.Should().HaveCount(2); + r[0]["_ts"]!.Value().Should().BeLessThanOrEqualTo(r[1]["_ts"]!.Value()); + } + + [Fact] + public async Task IsDefined_Ts_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IS_DEFINED(c._ts) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } + + [Fact] + public async Task IsDefined_Attachments_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE IS_DEFINED(c._attachments) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().BeTrue(); + } } // ── V19 Phase 2: Direct Bitwise Operators ── public class QueryDeepDiveV19_BitwiseOperatorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","value":7}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","value":4}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","value":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","value":3.5}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task BitwiseAnd_InSelect_ReturnsResult() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.value & 3} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(7 & 3); // 3 - } - - [Fact] - public async Task BitwiseOr_InSelect_ReturnsResult() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.value | 1} FROM c WHERE c.id = '2'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(4 | 1); // 5 - } - - [Fact] - public async Task BitwiseXor_InSelect_ReturnsResult() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.value ^ 5} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(7 ^ 5); // 2 - } - - [Fact] - public async Task BitwiseNot_InSelect_ReturnsResult() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: ~c.value} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which["result"]!.Value().Should().Be(~7L); // -8 - } - - [Fact] - public async Task BitwiseAnd_InWhere_FiltersCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE (c.value & 1) = 1"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1"); // 7 & 1 = 1, 4 & 1 = 0 - } - - [Fact] - public async Task BitwiseOr_InWhere_FiltersCorrectly() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE (c.value | 2) > 0 AND IS_NUMBER(c.value)"); - r.Should().HaveCountGreaterThan(0); - } - - [Fact] - public async Task Bitwise_WithNullOperand_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.value & 1} FROM c WHERE c.id = '3'"); - r.Should().ContainSingle(); - r[0]["result"].Should().BeNull("null & 1 should produce undefined"); - } - - [Fact] - public async Task Bitwise_WithNonInteger_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE {result: c.value & 1} FROM c WHERE c.id = '4'"); - r.Should().ContainSingle(); - r[0]["result"].Should().BeNull("3.5 & 1 should produce undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","value":7}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","value":4}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","value":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","value":3.5}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task BitwiseAnd_InSelect_ReturnsResult() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.value & 3} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(7 & 3); // 3 + } + + [Fact] + public async Task BitwiseOr_InSelect_ReturnsResult() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.value | 1} FROM c WHERE c.id = '2'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(4 | 1); // 5 + } + + [Fact] + public async Task BitwiseXor_InSelect_ReturnsResult() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.value ^ 5} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(7 ^ 5); // 2 + } + + [Fact] + public async Task BitwiseNot_InSelect_ReturnsResult() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: ~c.value} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which["result"]!.Value().Should().Be(~7L); // -8 + } + + [Fact] + public async Task BitwiseAnd_InWhere_FiltersCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE (c.value & 1) = 1"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1"); // 7 & 1 = 1, 4 & 1 = 0 + } + + [Fact] + public async Task BitwiseOr_InWhere_FiltersCorrectly() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE (c.value | 2) > 0 AND IS_NUMBER(c.value)"); + r.Should().HaveCountGreaterThan(0); + } + + [Fact] + public async Task Bitwise_WithNullOperand_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.value & 1} FROM c WHERE c.id = '3'"); + r.Should().ContainSingle(); + r[0]["result"].Should().BeNull("null & 1 should produce undefined"); + } + + [Fact] + public async Task Bitwise_WithNonInteger_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE {result: c.value & 1} FROM c WHERE c.id = '4'"); + r.Should().ContainSingle(); + r[0]["result"].Should().BeNull("3.5 & 1 should produce undefined"); + } } // ── V19 Phase 3: LIKE Edge Cases ── public class QueryDeepDiveV19_LikeEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"[bracket]"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"(paren)"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"{brace}"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"dot.value"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"plus+plus"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","name":"tab\there"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"7","pk":"p1","name":"abc"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"8","pk":"p1","name":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Like_BracketChar_MatchesLiteral() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%[%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("1"); - } - - [Fact] - public async Task Like_ParenChar_MatchesLiteral() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%(%)%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("2"); - } - - [Fact] - public async Task Like_BraceChar_MatchesLiteral() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%{%}%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("3"); - } - - [Fact] - public async Task Like_DotChar_MatchesLiteralDot() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%.%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("4"); - // Should NOT match "abc" (dot must be literal, not wildcard) - r.Select(x => x["id"]!.Value()).Should().NotContain("7"); - } - - [Fact] - public async Task Like_PlusChar_MatchesLiteral() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%+%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("5"); - } - - [Fact] - public async Task Like_TabChar_MatchesLiteral() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%\t%'"); - r.Select(x => x["id"]!.Value()).Should().Contain("6"); - } - - [Fact] - public async Task Like_ConsecutiveEscapePercent_MatchesLiteral() - { - await Seed(); - // Match anything (%) — should match non-null names - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%'"); - r.Count.Should().BeGreaterThanOrEqualTo(6, "% matches any string"); - } - - [Fact] - public async Task Like_NullPattern_ReturnsNoMatches() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE null"); - r.Should().BeEmpty("LIKE with null pattern should return no matches"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"[bracket]"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"(paren)"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"{brace}"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"dot.value"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"plus+plus"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","name":"tab\there"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"7","pk":"p1","name":"abc"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"8","pk":"p1","name":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Like_BracketChar_MatchesLiteral() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%[%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("1"); + } + + [Fact] + public async Task Like_ParenChar_MatchesLiteral() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%(%)%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("2"); + } + + [Fact] + public async Task Like_BraceChar_MatchesLiteral() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%{%}%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("3"); + } + + [Fact] + public async Task Like_DotChar_MatchesLiteralDot() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%.%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("4"); + // Should NOT match "abc" (dot must be literal, not wildcard) + r.Select(x => x["id"]!.Value()).Should().NotContain("7"); + } + + [Fact] + public async Task Like_PlusChar_MatchesLiteral() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%+%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("5"); + } + + [Fact] + public async Task Like_TabChar_MatchesLiteral() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%\t%'"); + r.Select(x => x["id"]!.Value()).Should().Contain("6"); + } + + [Fact] + public async Task Like_ConsecutiveEscapePercent_MatchesLiteral() + { + await Seed(); + // Match anything (%) — should match non-null names + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%'"); + r.Count.Should().BeGreaterThanOrEqualTo(6, "% matches any string"); + } + + [Fact] + public async Task Like_NullPattern_ReturnsNoMatches() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE null"); + r.Should().BeEmpty("LIKE with null pattern should return no matches"); + } } // ── V19 Phase 4: Parameter Advanced Cases ── public class QueryDeepDiveV19_ParameterEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","value":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","value":30}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(QueryDefinition qd) - { - var iterator = _container.GetItemQueryIterator(qd); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Parameter_InBetweenBounds_Works() - { - await Seed(); - var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.value BETWEEN @min AND @max") - .WithParameter("@min", 10) - .WithParameter("@max", 20); - var r = await RunQuery(qd); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); - } - - [Fact] - public async Task Parameter_BooleanInWhere_Works() - { - await Seed(); - var qd = new QueryDefinition("SELECT c.id FROM c WHERE @flag = true") - .WithParameter("@flag", true); - var r = await RunQuery(qd); - r.Should().HaveCount(3, "true = true should return all items"); - } - - [Fact] - public async Task Parameter_NullComparison_Works() - { - await Seed(); - var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.name = @nullParam") - .WithParameter("@nullParam", null); - var r = await RunQuery(qd); - r.Should().BeEmpty("no names are null in test data"); - } - - [Fact] - public async Task Parameter_InOperator_Works() - { - await Seed(); - var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.name IN (@p1, @p2)") - .WithParameter("@p1", "Alice") - .WithParameter("@p2", "Charlie"); - var r = await RunQuery(qd); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3"); - } - - [Fact] - public async Task Parameter_SelectValueConst_Works() - { - await Seed(); - var qd = new QueryDefinition("SELECT VALUE @constParam FROM c") - .WithParameter("@constParam", 42); - var r = await RunQuery(qd); - r.Should().HaveCount(3); - r.All(x => x.Value() == 42).Should().BeTrue(); - } - - [Fact] - public async Task Parameter_ArrayContains_Works() - { - await Seed(); - var names = new[] { "Alice", "Bob" }; - var qd = new QueryDefinition("SELECT c.id FROM c WHERE ARRAY_CONTAINS(@names, c.name)") - .WithParameter("@names", names); - var r = await RunQuery(qd); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","value":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","value":30}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(QueryDefinition qd) + { + var iterator = _container.GetItemQueryIterator(qd); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Parameter_InBetweenBounds_Works() + { + await Seed(); + var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.value BETWEEN @min AND @max") + .WithParameter("@min", 10) + .WithParameter("@max", 20); + var r = await RunQuery(qd); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); + } + + [Fact] + public async Task Parameter_BooleanInWhere_Works() + { + await Seed(); + var qd = new QueryDefinition("SELECT c.id FROM c WHERE @flag = true") + .WithParameter("@flag", true); + var r = await RunQuery(qd); + r.Should().HaveCount(3, "true = true should return all items"); + } + + [Fact] + public async Task Parameter_NullComparison_Works() + { + await Seed(); + var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.name = @nullParam") + .WithParameter("@nullParam", null); + var r = await RunQuery(qd); + r.Should().BeEmpty("no names are null in test data"); + } + + [Fact] + public async Task Parameter_InOperator_Works() + { + await Seed(); + var qd = new QueryDefinition("SELECT c.id FROM c WHERE c.name IN (@p1, @p2)") + .WithParameter("@p1", "Alice") + .WithParameter("@p2", "Charlie"); + var r = await RunQuery(qd); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3"); + } + + [Fact] + public async Task Parameter_SelectValueConst_Works() + { + await Seed(); + var qd = new QueryDefinition("SELECT VALUE @constParam FROM c") + .WithParameter("@constParam", 42); + var r = await RunQuery(qd); + r.Should().HaveCount(3); + r.All(x => x.Value() == 42).Should().BeTrue(); + } + + [Fact] + public async Task Parameter_ArrayContains_Works() + { + await Seed(); + var names = new[] { "Alice", "Bob" }; + var qd = new QueryDefinition("SELECT c.id FROM c WHERE ARRAY_CONTAINS(@names, c.name)") + .WithParameter("@names", names); + var r = await RunQuery(qd); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "2"); + } } // ── V19 Phase 5: Error Handling ── public class QueryDeepDiveV19_ErrorHandlingTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task EmptyString_ReturnsAllItems() - { - // Empty/null SQL returns all items (read-all behavior per SDK spec) - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var iterator = _container.GetItemQueryIterator(""); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().ContainSingle(); - } - - [Fact] - public async Task IncompleteSql_SelectFrom_ThrowsBadRequest() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var act = async () => - { - var iterator = _container.GetItemQueryIterator("SELECT FROM"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task UnknownFunction_ThrowsBadRequest() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var act = async () => - { - var iterator = _container.GetItemQueryIterator("SELECT FAKEFUNC(c.id) FROM c"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task UnclosedStringLiteral_ThrowsBadRequest() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var act = async () => - { - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'unclosed"); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task WhitespaceOnlySql_ThrowsBadRequest() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); - var act = async () => - { - var iterator = _container.GetItemQueryIterator(" "); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - }; - (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task EmptyString_ReturnsAllItems() + { + // Empty/null SQL returns all items (read-all behavior per SDK spec) + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var iterator = _container.GetItemQueryIterator(""); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().ContainSingle(); + } + + [Fact] + public async Task IncompleteSql_SelectFrom_ThrowsBadRequest() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var act = async () => + { + var iterator = _container.GetItemQueryIterator("SELECT FROM"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UnknownFunction_ThrowsBadRequest() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var act = async () => + { + var iterator = _container.GetItemQueryIterator("SELECT FAKEFUNC(c.id) FROM c"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UnclosedStringLiteral_ThrowsBadRequest() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var act = async () => + { + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.id = 'unclosed"); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task WhitespaceOnlySql_ThrowsBadRequest() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1"}"""), new PartitionKey("p1")); + var act = async () => + { + var iterator = _container.GetItemQueryIterator(" "); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + }; + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ── V19 Phase 6: REGEXMATCH Modifier Tests ── public class QueryDeepDiveV19_RegexMatchTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","text":"Hello World"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","text":"hello\nworld"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","text":""}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","text":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task RegexMatch_XModifier_IgnoresPatternWhitespace() - { - await Seed(); - // 'x' = IgnorePatternWhitespace: spaces in pattern are ignored - // 'H e l l o' with x flag becomes regex 'Hello' which partially matches "Hello World" - var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'H e l l o', 'x')"); - r.Select(x => x["id"]!.Value()).Should().Contain("1"); - } - - [Fact] - public async Task RegexMatch_AllModifiers_Imsx() - { - await Seed(); - // 'imsx' = all modifiers combined - var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'hello .* world', 'imsx')"); - // IgnoreCase + Singleline (. matches \n) + IgnorePatternWhitespace (spaces in pattern literal) - // Actually with 'x', spaces in the pattern 'hello .* world' are ignored → 'hello.*world' - // With 'i' + 's', 'hello.*world' matches "Hello World" and "hello\nworld" - r.Select(x => x["id"]!.Value()).Should().Contain("1"); - } - - [Fact] - public async Task RegexMatch_InvalidModifier_SilentlyIgnored() - { - await Seed(); - // Unknown modifier 'z' should be silently ignored - var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'Hello', 'z')"); - r.Select(x => x["id"]!.Value()).Should().Contain("1"); - } - - [Fact] - public async Task RegexMatch_EmptyPattern_MatchesAll() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, '')"); - // Empty regex matches any string (including empty) - r.Should().HaveCountGreaterThanOrEqualTo(2); // id=1, 2, 3 (non-null text) - } - - [Fact] - public async Task RegexMatch_NullInput_ReturnsFalse() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, '.*') AND c.id = '4'"); - r.Should().BeEmpty("null text should not match regex"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","text":"Hello World"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","text":"hello\nworld"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","text":""}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","text":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task RegexMatch_XModifier_IgnoresPatternWhitespace() + { + await Seed(); + // 'x' = IgnorePatternWhitespace: spaces in pattern are ignored + // 'H e l l o' with x flag becomes regex 'Hello' which partially matches "Hello World" + var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'H e l l o', 'x')"); + r.Select(x => x["id"]!.Value()).Should().Contain("1"); + } + + [Fact] + public async Task RegexMatch_AllModifiers_Imsx() + { + await Seed(); + // 'imsx' = all modifiers combined + var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'hello .* world', 'imsx')"); + // IgnoreCase + Singleline (. matches \n) + IgnorePatternWhitespace (spaces in pattern literal) + // Actually with 'x', spaces in the pattern 'hello .* world' are ignored → 'hello.*world' + // With 'i' + 's', 'hello.*world' matches "Hello World" and "hello\nworld" + r.Select(x => x["id"]!.Value()).Should().Contain("1"); + } + + [Fact] + public async Task RegexMatch_InvalidModifier_SilentlyIgnored() + { + await Seed(); + // Unknown modifier 'z' should be silently ignored + var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, 'Hello', 'z')"); + r.Select(x => x["id"]!.Value()).Should().Contain("1"); + } + + [Fact] + public async Task RegexMatch_EmptyPattern_MatchesAll() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, '')"); + // Empty regex matches any string (including empty) + r.Should().HaveCountGreaterThanOrEqualTo(2); // id=1, 2, 3 (non-null text) + } + + [Fact] + public async Task RegexMatch_NullInput_ReturnsFalse() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE RegexMatch(c.text, '.*') AND c.id = '4'"); + r.Should().BeEmpty("null text should not match regex"); + } } // ── V19 Phase 7: SELECT Advanced Patterns ── public class QueryDeepDiveV19_SelectAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","age":30,"score":85}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","age":25,"score":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_DeeplyNestedObjectLiteral_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE {l1: {l2: {l3: {l4: c.name}}}} FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["l1"]!["l2"]!["l3"]!["l4"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Select_TernaryInProjection_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE {result: c.age > 28 ? 'senior' : 'junior'} FROM c ORDER BY c.id"); - r[0]["result"]!.Value().Should().Be("senior"); // Alice age 30 - r[1]["result"]!.Value().Should().Be("junior"); // Bob age 25 - } - - [Fact] - public async Task Select_ValueWithComplexExpression_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE (c.age * 2 + 10) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle().Which.Value().Should().Be(70); - } - - [Fact] - public async Task Select_CoalesceInProjection_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT VALUE {result: c.score ?? 0} FROM c ORDER BY c.id"); - r[0]["result"]!.Value().Should().Be(85); // Alice has score - r[1]["result"]!.Value().Should().Be(0); // Bob has null score - } - - [Fact] - public async Task Select_ArithmeticAndStringConcat_InSameProjection() - { - await Seed(); - var r = await RunQuery( - "SELECT c.name || ' age:' AS label, c.age * 2 AS doubleAge FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - r[0]["label"]!.Value().Should().Be("Alice age:"); - r[0]["doubleAge"]!.Value().Should().Be(60); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","age":30,"score":85}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","age":25,"score":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_DeeplyNestedObjectLiteral_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE {l1: {l2: {l3: {l4: c.name}}}} FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["l1"]!["l2"]!["l3"]!["l4"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Select_TernaryInProjection_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE {result: c.age > 28 ? 'senior' : 'junior'} FROM c ORDER BY c.id"); + r[0]["result"]!.Value().Should().Be("senior"); // Alice age 30 + r[1]["result"]!.Value().Should().Be("junior"); // Bob age 25 + } + + [Fact] + public async Task Select_ValueWithComplexExpression_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE (c.age * 2 + 10) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle().Which.Value().Should().Be(70); + } + + [Fact] + public async Task Select_CoalesceInProjection_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT VALUE {result: c.score ?? 0} FROM c ORDER BY c.id"); + r[0]["result"]!.Value().Should().Be(85); // Alice has score + r[1]["result"]!.Value().Should().Be(0); // Bob has null score + } + + [Fact] + public async Task Select_ArithmeticAndStringConcat_InSameProjection() + { + await Seed(); + var r = await RunQuery( + "SELECT c.name || ' age:' AS label, c.age * 2 AS doubleAge FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + r[0]["label"]!.Value().Should().Be("Alice age:"); + r[0]["doubleAge"]!.Value().Should().Be(60); + } } // ── V19 Phase 8: Unicode/Special Characters ── public class QueryDeepDiveV19_UnicodeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"party🎉time"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"中文测试"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Héllo Wörld"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"αβγδ"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Contains_Emoji_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, '🎉')"); - r.Select(x => x["id"]!.Value()).Should().ContainSingle().Which.Should().Be("1"); - } - - [Fact] - public async Task Length_CjkString_ReturnsCorrectCount() - { - await Seed(); - var r = await RunQuery("SELECT VALUE LENGTH(c.name) FROM c WHERE c.id = '2'"); - r.Should().ContainSingle().Which.Value().Should().Be(4); // 4 CJK characters - } - - [Fact] - public async Task Upper_MixedUnicode_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE UPPER(c.name) FROM c WHERE c.id = '3'"); - r.Should().ContainSingle().Which.Value().Should().Be("HÉLLO WÖRLD"); - } - - [Fact] - public async Task StartsWith_MultiByteChars_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE STARTSWITH(c.name, '中文')"); - r.Select(x => x["id"]!.Value()).Should().ContainSingle().Which.Should().Be("2"); - } - - [Fact] - public async Task IndexOf_Unicode_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'β') FROM c WHERE c.id = '4'"); - r.Should().ContainSingle().Which.Value().Should().Be(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"party🎉time"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"中文测试"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Héllo Wörld"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"αβγδ"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Contains_Emoji_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, '🎉')"); + r.Select(x => x["id"]!.Value()).Should().ContainSingle().Which.Should().Be("1"); + } + + [Fact] + public async Task Length_CjkString_ReturnsCorrectCount() + { + await Seed(); + var r = await RunQuery("SELECT VALUE LENGTH(c.name) FROM c WHERE c.id = '2'"); + r.Should().ContainSingle().Which.Value().Should().Be(4); // 4 CJK characters + } + + [Fact] + public async Task Upper_MixedUnicode_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE UPPER(c.name) FROM c WHERE c.id = '3'"); + r.Should().ContainSingle().Which.Value().Should().Be("HÉLLO WÖRLD"); + } + + [Fact] + public async Task StartsWith_MultiByteChars_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE STARTSWITH(c.name, '中文')"); + r.Select(x => x["id"]!.Value()).Should().ContainSingle().Which.Should().Be("2"); + } + + [Fact] + public async Task IndexOf_Unicode_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE INDEX_OF(c.name, 'β') FROM c WHERE c.id = '4'"); + r.Should().ContainSingle().Which.Value().Should().Be(1); + } } // ── V19 Phase 9: ORDER BY Advanced ── public class QueryDeepDiveV19_OrderByAdvancedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","active":true}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bo","active":false}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","active":true}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":null,"active":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task OrderBy_FunctionExpression_Length_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, c.name FROM c WHERE c.name != null ORDER BY LENGTH(c.name) ASC"); - // Bo(2) < Alice(5) < Charlie(7) - r[0]["id"]!.Value().Should().Be("2"); - r[1]["id"]!.Value().Should().Be("1"); - r[2]["id"]!.Value().Should().Be("3"); - } - - [Fact] - public async Task OrderBy_ArithmeticExpression_Works() - { - await Seed(); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"Dave","x":3,"y":2}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","name":"Eve","x":1,"y":10}"""), new PartitionKey("p1")); - var r = await RunQuery( - "SELECT c.id FROM c WHERE IS_DEFINED(c.x) ORDER BY c.x + c.y ASC"); - // Dave: 3+2=5, Eve: 1+10=11 - r[0]["id"]!.Value().Should().Be("5"); - r[1]["id"]!.Value().Should().Be("6"); - } - - [Fact] - public async Task OrderBy_MixedNullAndValues_TypeRank() - { - await Seed(); - // Cosmos type rank: undefined < null < boolean < number < string - var r = await RunQuery( - "SELECT c.id, c.name FROM c ORDER BY c.name ASC"); - // null name (id=4) should come before string values - r[0]["name"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task OrderBy_BooleanValues_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, c.active FROM c WHERE c.active != null ORDER BY c.active ASC"); - // false < true in Cosmos ordering - r[0]["active"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task OrderBy_SystemProperty_Ts_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, c._ts FROM c ORDER BY c._ts DESC"); - r.Should().HaveCount(4); - r[0]["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(r[1]["_ts"]!.Value()); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","active":true}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bo","active":false}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","active":true}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":null,"active":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task OrderBy_FunctionExpression_Length_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, c.name FROM c WHERE c.name != null ORDER BY LENGTH(c.name) ASC"); + // Bo(2) < Alice(5) < Charlie(7) + r[0]["id"]!.Value().Should().Be("2"); + r[1]["id"]!.Value().Should().Be("1"); + r[2]["id"]!.Value().Should().Be("3"); + } + + [Fact] + public async Task OrderBy_ArithmeticExpression_Works() + { + await Seed(); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","name":"Dave","x":3,"y":2}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"6","pk":"p1","name":"Eve","x":1,"y":10}"""), new PartitionKey("p1")); + var r = await RunQuery( + "SELECT c.id FROM c WHERE IS_DEFINED(c.x) ORDER BY c.x + c.y ASC"); + // Dave: 3+2=5, Eve: 1+10=11 + r[0]["id"]!.Value().Should().Be("5"); + r[1]["id"]!.Value().Should().Be("6"); + } + + [Fact] + public async Task OrderBy_MixedNullAndValues_TypeRank() + { + await Seed(); + // Cosmos type rank: undefined < null < boolean < number < string + var r = await RunQuery( + "SELECT c.id, c.name FROM c ORDER BY c.name ASC"); + // null name (id=4) should come before string values + r[0]["name"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task OrderBy_BooleanValues_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, c.active FROM c WHERE c.active != null ORDER BY c.active ASC"); + // false < true in Cosmos ordering + r[0]["active"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task OrderBy_SystemProperty_Ts_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, c._ts FROM c ORDER BY c._ts DESC"); + r.Should().HaveCount(4); + r[0]["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(r[1]["_ts"]!.Value()); + } } // ── V19 Phase 10: Cross-Feature Interactions ── public class QueryDeepDiveV19_CrossFeatureTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","category":"A","tags":["x","y"],"value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","category":"A","tags":["y","z"],"value":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","category":"B","tags":["x"],"value":30}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","category":"B","tags":["z"],"value":40}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","category":"A","tags":["x"],"value":10}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Distinct_GroupBy_Interaction() - { - await Seed(); - // GROUP BY already produces distinct groups; DISTINCT on top should be idempotent - var r = await RunQuery( - "SELECT DISTINCT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); - r.Should().HaveCount(2); // A and B - } - - [Fact] - public async Task Join_OrderBy_OffsetLimit_Combined() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id, t AS tag FROM c JOIN t IN c.tags ORDER BY t OFFSET 2 LIMIT 3"); - r.Should().HaveCount(3); - // Tags sorted: x, x, x, y, y, z, z → offset 2 = x, y, y - } - - [Fact] - public async Task Exists_WithJoin_Subquery() - { - await Seed(); - var r = await RunQuery( - "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3", "5"); - } - - [Fact] - public async Task SelectValue_Aggregate_GroupBy_Having() - { - await Seed(); - var r = await RunQuery( - "SELECT c.category, SUM(c.value) AS total FROM c GROUP BY c.category HAVING SUM(c.value) > 30"); - // A: 10+20+10=40 (>30 ✓), B: 30+40=70 (>30 ✓) - r.Should().HaveCount(2); - } - - [Fact] - public async Task ComputedExpression_InHaving() - { - await Seed(); - var r = await RunQuery( - "SELECT c.category, COUNT(1) AS cnt, AVG(c.value) AS avg FROM c GROUP BY c.category HAVING AVG(c.value) >= 20"); - // A: avg=(10+20+10)/3=13.3 (<20 ✗), B: avg=(30+40)/2=35 (≥20 ✓) - r.Should().ContainSingle(); - r[0]["category"]!.Value().Should().Be("B"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","category":"A","tags":["x","y"],"value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","category":"A","tags":["y","z"],"value":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","category":"B","tags":["x"],"value":30}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","category":"B","tags":["z"],"value":40}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"5","pk":"p1","category":"A","tags":["x"],"value":10}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Distinct_GroupBy_Interaction() + { + await Seed(); + // GROUP BY already produces distinct groups; DISTINCT on top should be idempotent + var r = await RunQuery( + "SELECT DISTINCT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); + r.Should().HaveCount(2); // A and B + } + + [Fact] + public async Task Join_OrderBy_OffsetLimit_Combined() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id, t AS tag FROM c JOIN t IN c.tags ORDER BY t OFFSET 2 LIMIT 3"); + r.Should().HaveCount(3); + // Tags sorted: x, x, x, y, y, z, z → offset 2 = x, y, y + } + + [Fact] + public async Task Exists_WithJoin_Subquery() + { + await Seed(); + var r = await RunQuery( + "SELECT c.id FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("1", "3", "5"); + } + + [Fact] + public async Task SelectValue_Aggregate_GroupBy_Having() + { + await Seed(); + var r = await RunQuery( + "SELECT c.category, SUM(c.value) AS total FROM c GROUP BY c.category HAVING SUM(c.value) > 30"); + // A: 10+20+10=40 (>30 ✓), B: 30+40=70 (>30 ✓) + r.Should().HaveCount(2); + } + + [Fact] + public async Task ComputedExpression_InHaving() + { + await Seed(); + var r = await RunQuery( + "SELECT c.category, COUNT(1) AS cnt, AVG(c.value) AS avg FROM c GROUP BY c.category HAVING AVG(c.value) >= 20"); + // A: avg=(10+20+10)/3=13.3 (<20 ✗), B: avg=(30+40)/2=35 (≥20 ✓) + r.Should().ContainSingle(); + r[0]["category"]!.Value().Should().Be("B"); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -25387,355 +25397,355 @@ public async Task ComputedExpression_InHaving() // ── V20 Phase 1a: DOCUMENTID Function ── public class QueryDeepDiveV20_DocumentIdTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DocumentId_InSelect_ReturnsRidOrId() - { - await Seed(); - var r = await RunQuery("SELECT VALUE DOCUMENTID(c) FROM c WHERE c.id = '1'"); - r.Should().ContainSingle(); - // Should return _rid or fall back to id - r[0].Value().Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task DocumentId_InWhere_FiltersCorrectly() - { - await Seed(); - // Get the documentId for item 1 first - var rid = await RunQuery("SELECT VALUE DOCUMENTID(c) FROM c WHERE c.id = '1'"); - var ridValue = rid[0].Value(); - // Now filter by it - var r = await RunQuery($"SELECT * FROM c WHERE DOCUMENTID(c) = '{ridValue}'"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task DocumentId_InOrderBy_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c ORDER BY DOCUMENTID(c) ASC"); - r.Should().HaveCount(3); - } - - [Fact] - public async Task DocumentId_WithOtherFields_InSelect() - { - await Seed(); - var r = await RunQuery("SELECT c.id, DOCUMENTID(c) AS rid FROM c WHERE c.id = '2'"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("2"); - r[0]["rid"]!.Value().Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DocumentId_InSelect_ReturnsRidOrId() + { + await Seed(); + var r = await RunQuery("SELECT VALUE DOCUMENTID(c) FROM c WHERE c.id = '1'"); + r.Should().ContainSingle(); + // Should return _rid or fall back to id + r[0].Value().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task DocumentId_InWhere_FiltersCorrectly() + { + await Seed(); + // Get the documentId for item 1 first + var rid = await RunQuery("SELECT VALUE DOCUMENTID(c) FROM c WHERE c.id = '1'"); + var ridValue = rid[0].Value(); + // Now filter by it + var r = await RunQuery($"SELECT * FROM c WHERE DOCUMENTID(c) = '{ridValue}'"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task DocumentId_InOrderBy_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c ORDER BY DOCUMENTID(c) ASC"); + r.Should().HaveCount(3); + } + + [Fact] + public async Task DocumentId_WithOtherFields_InSelect() + { + await Seed(); + var r = await RunQuery("SELECT c.id, DOCUMENTID(c) AS rid FROM c WHERE c.id = '2'"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("2"); + r[0]["rid"]!.Value().Should().NotBeNullOrEmpty(); + } } // ── V20 Phase 1b: TOSTRING Function Edge Cases ── public class QueryDeepDiveV20_ToStringTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","num":42,"flag":true,"name":"Alice","arr":[1,2],"obj":{"x":1},"nullField":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ToString_Number_ReturnsString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.num) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("42"); - } - - [Fact] - public async Task ToString_Boolean_ReturnsLowercaseString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.flag) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("true"); - } - - [Fact] - public async Task ToString_Array_ReturnsJsonString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.arr) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("[1,2]"); - } - - [Fact] - public async Task ToString_Object_ReturnsJsonString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.obj) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Contain("\"x\""); - } - - [Fact] - public async Task ToString_Null_ReturnsUndefined() - { - await Seed(); - // TOSTRING(null) → undefined (omitted from VALUE results) - var r = await RunQuery("SELECT VALUE TOSTRING(c.nullField) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task ToString_UndefinedField_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.nonexistent) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task ToString_StringInput_ReturnsSameString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE TOSTRING(c.name) FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","num":42,"flag":true,"name":"Alice","arr":[1,2],"obj":{"x":1},"nullField":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ToString_Number_ReturnsString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.num) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("42"); + } + + [Fact] + public async Task ToString_Boolean_ReturnsLowercaseString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.flag) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("true"); + } + + [Fact] + public async Task ToString_Array_ReturnsJsonString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.arr) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("[1,2]"); + } + + [Fact] + public async Task ToString_Object_ReturnsJsonString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.obj) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Contain("\"x\""); + } + + [Fact] + public async Task ToString_Null_ReturnsUndefined() + { + await Seed(); + // TOSTRING(null) → undefined (omitted from VALUE results) + var r = await RunQuery("SELECT VALUE TOSTRING(c.nullField) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task ToString_UndefinedField_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.nonexistent) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task ToString_StringInput_ReturnsSameString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE TOSTRING(c.name) FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("Alice"); + } } // ── V20 Phase 1c: STRINGJOIN Edge Cases ── public class QueryDeepDiveV20_StringJoinTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b","c"],"empty":[],"single":["x"],"withNull":["a",null,"c"]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StringJoin_NormalArray_JoinsWithSeparator() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringJoin(c.tags, ', ') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("a, b, c"); - } - - [Fact] - public async Task StringJoin_EmptyArray_ReturnsEmptyString() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringJoin(c.empty, ',') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be(""); - } - - [Fact] - public async Task StringJoin_SingleElement_ReturnsElement() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringJoin(c.single, ',') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("x"); - } - - [Fact] - public async Task StringJoin_EmptyDelimiter_ConcatenatesDirectly() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringJoin(c.tags, '') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("abc"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b","c"],"empty":[],"single":["x"],"withNull":["a",null,"c"]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StringJoin_NormalArray_JoinsWithSeparator() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringJoin(c.tags, ', ') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("a, b, c"); + } + + [Fact] + public async Task StringJoin_EmptyArray_ReturnsEmptyString() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringJoin(c.empty, ',') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be(""); + } + + [Fact] + public async Task StringJoin_SingleElement_ReturnsElement() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringJoin(c.single, ',') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("x"); + } + + [Fact] + public async Task StringJoin_EmptyDelimiter_ConcatenatesDirectly() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringJoin(c.tags, '') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("abc"); + } } // ── V20 Phase 1d: Bracket Notation c["property"] ── public class QueryDeepDiveV20_BracketNotationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nested":{"child":"deep"},"value":30}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","nested":{"child":"shallow"},"value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","nested":{"child":"mid"},"value":20}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task BracketNotation_InWhere_Works() - { - await Seed(); - var r = await RunQuery("""SELECT * FROM c WHERE c["name"] = 'Alice'"""); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task BracketNotation_InOrderBy_Works() - { - await Seed(); - var r = await RunQuery("""SELECT c.id FROM c ORDER BY c["value"] ASC"""); - r.Should().HaveCount(3); - r[0]["id"]!.Value().Should().Be("2"); // value=10 - } - - [Fact] - public async Task BracketNotation_InSelect_Works() - { - await Seed(); - var r = await RunQuery("""SELECT c["name"] AS n FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle(); - r[0]["n"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task BracketNotation_SystemProperty_Works() - { - await Seed(); - var r = await RunQuery("""SELECT c["_ts"] AS ts FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle(); - r[0]["ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task BracketNotation_Nested_Works() - { - await Seed(); - var r = await RunQuery("""SELECT VALUE c["nested"]["child"] FROM c WHERE c.id = '1'"""); - r.Should().ContainSingle().Which.Value().Should().Be("deep"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nested":{"child":"deep"},"value":30}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob","nested":{"child":"shallow"},"value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie","nested":{"child":"mid"},"value":20}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task BracketNotation_InWhere_Works() + { + await Seed(); + var r = await RunQuery("""SELECT * FROM c WHERE c["name"] = 'Alice'"""); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task BracketNotation_InOrderBy_Works() + { + await Seed(); + var r = await RunQuery("""SELECT c.id FROM c ORDER BY c["value"] ASC"""); + r.Should().HaveCount(3); + r[0]["id"]!.Value().Should().Be("2"); // value=10 + } + + [Fact] + public async Task BracketNotation_InSelect_Works() + { + await Seed(); + var r = await RunQuery("""SELECT c["name"] AS n FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle(); + r[0]["n"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task BracketNotation_SystemProperty_Works() + { + await Seed(); + var r = await RunQuery("""SELECT c["_ts"] AS ts FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle(); + r[0]["ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task BracketNotation_Nested_Works() + { + await Seed(); + var r = await RunQuery("""SELECT VALUE c["nested"]["child"] FROM c WHERE c.id = '1'"""); + r.Should().ContainSingle().Which.Value().Should().Be("deep"); + } } // ── V20 Phase 1e: CHOOSE Edge Cases ── public class QueryDeepDiveV20_ChooseEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","idx":2}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Choose_NegativeIndex_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CHOOSE(-1, 'a', 'b', 'c') FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Choose_NullIndex_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CHOOSE(null, 'a', 'b', 'c') FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task Choose_FloatIndex_TruncatesToInt() - { - await Seed(); - // CHOOSE is 1-based. 1.9 should truncate to 1 and return 'a' - var r = await RunQuery("SELECT VALUE CHOOSE(1.9, 'a', 'b', 'c') FROM c"); - // If it truncates to long 1, returns 'a'. If to 2 it returns 'b'. - // ToLong of 1.9 → 1 in C# (long cast truncates), so expect 'a' - r.Should().ContainSingle().Which.Value().Should().Be("a"); - } - - [Fact] - public async Task Choose_VeryLargeIndex_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE CHOOSE(999, 'a', 'b', 'c') FROM c"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","idx":2}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Choose_NegativeIndex_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CHOOSE(-1, 'a', 'b', 'c') FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Choose_NullIndex_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CHOOSE(null, 'a', 'b', 'c') FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task Choose_FloatIndex_TruncatesToInt() + { + await Seed(); + // CHOOSE is 1-based. 1.9 should truncate to 1 and return 'a' + var r = await RunQuery("SELECT VALUE CHOOSE(1.9, 'a', 'b', 'c') FROM c"); + // If it truncates to long 1, returns 'a'. If to 2 it returns 'b'. + // ToLong of 1.9 → 1 in C# (long cast truncates), so expect 'a' + r.Should().ContainSingle().Which.Value().Should().Be("a"); + } + + [Fact] + public async Task Choose_VeryLargeIndex_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE CHOOSE(999, 'a', 'b', 'c') FROM c"); + r.Should().BeEmpty(); + } } // ── V20 Phase 1f: STRINGSPLIT Edge Cases ── public class QueryDeepDiveV20_StringSplitEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","text":"a,b,,c","nullField":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StringSplit_NullInput_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringSplit(c.nullField, ',') FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task StringSplit_NullDelimiter_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringSplit(c.text, c.nullField) FROM c"); - r.Should().BeEmpty(); - } - - [Fact] - public async Task StringSplit_ConsecutiveDelimiters_IncludesEmpties() - { - await Seed(); - var r = await RunQuery("SELECT VALUE StringSplit(c.text, ',') FROM c"); - // "a,b,,c" → ["a","b","","c"] - r.Should().ContainSingle(); - r[0].Should().HaveCount(4); - r[0][2]!.Value().Should().Be(""); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","text":"a,b,,c","nullField":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StringSplit_NullInput_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringSplit(c.nullField, ',') FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task StringSplit_NullDelimiter_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringSplit(c.text, c.nullField) FROM c"); + r.Should().BeEmpty(); + } + + [Fact] + public async Task StringSplit_ConsecutiveDelimiters_IncludesEmpties() + { + await Seed(); + var r = await RunQuery("SELECT VALUE StringSplit(c.text, ',') FROM c"); + // "a,b,,c" → ["a","b","","c"] + r.Should().ContainSingle(); + r[0].Should().HaveCount(4); + r[0][2]!.Value().Should().Be(""); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -25745,285 +25755,285 @@ public async Task StringSplit_ConsecutiveDelimiters_IncludesEmpties() // ── V20 Phase 2a: SELECT VALUE with OFFSET/LIMIT ── public class QueryDeepDiveV20_SelectValuePaginationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Diana"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task SelectValue_WithOffsetLimit_Works() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.name FROM c ORDER BY c.name OFFSET 1 LIMIT 2"); - r.Should().HaveCount(2); - r[0].Value().Should().Be("Bob"); - r[1].Value().Should().Be("Charlie"); - } - - [Fact] - public async Task SelectValue_OffsetBeyondCount_ReturnsEmpty() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.name FROM c OFFSET 100 LIMIT 10"); - r.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"Charlie"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","name":"Diana"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task SelectValue_WithOffsetLimit_Works() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.name FROM c ORDER BY c.name OFFSET 1 LIMIT 2"); + r.Should().HaveCount(2); + r[0].Value().Should().Be("Bob"); + r[1].Value().Should().Be("Charlie"); + } + + [Fact] + public async Task SelectValue_OffsetBeyondCount_ReturnsEmpty() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.name FROM c OFFSET 100 LIMIT 10"); + r.Should().BeEmpty(); + } } // ── V20 Phase 2b: IS NULL on Function Results ── public class QueryDeepDiveV20_IsNullOnFunctionResultTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullField":null}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsNotNull_WithDefinedField_ReturnsTrue() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name IS NOT NULL"); - r.Should().HaveCount(2); - } - - [Fact] - public async Task IsNull_ExplicitNull_MatchesOnly() - { - await Seed(); - // Only id=1 has nullField=null; id=2 has nullField=undefined - var r = await RunQuery("SELECT c.id FROM c WHERE c.nullField IS NULL"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","nullField":null}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"Bob"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsNotNull_WithDefinedField_ReturnsTrue() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name IS NOT NULL"); + r.Should().HaveCount(2); + } + + [Fact] + public async Task IsNull_ExplicitNull_MatchesOnly() + { + await Seed(); + // Only id=1 has nullField=null; id=2 has nullField=undefined + var r = await RunQuery("SELECT c.id FROM c WHERE c.nullField IS NULL"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } } // ── V20 Phase 2c: ORDER BY Aggregate Expression (Not Alias) ── public class QueryDeepDiveV20_OrderByAggregateDirectTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A","value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"A","value":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"B","value":5}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":"C","value":50}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_OrderBySumDirect_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.cat, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY SUM(c.value) DESC"); - r.Should().HaveCount(3); - r[0]["cat"]!.Value().Should().Be("C"); // 50 - r[1]["cat"]!.Value().Should().Be("A"); // 30 - r[2]["cat"]!.Value().Should().Be("B"); // 5 - } - - [Fact] - public async Task GroupBy_OrderByAvgDirect_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.cat, AVG(c.value) AS avg FROM c GROUP BY c.cat ORDER BY AVG(c.value) ASC"); - r.Should().HaveCount(3); - r[0]["cat"]!.Value().Should().Be("B"); // 5 - r[1]["cat"]!.Value().Should().Be("A"); // 15 - r[2]["cat"]!.Value().Should().Be("C"); // 50 - } - - [Fact] - public async Task GroupBy_OrderByCountDirect_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - r.Should().HaveCount(3); - r[0]["cat"]!.Value().Should().Be("A"); // 2 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A","value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"A","value":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"B","value":5}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"4","pk":"p1","cat":"C","value":50}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_OrderBySumDirect_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.cat, SUM(c.value) AS total FROM c GROUP BY c.cat ORDER BY SUM(c.value) DESC"); + r.Should().HaveCount(3); + r[0]["cat"]!.Value().Should().Be("C"); // 50 + r[1]["cat"]!.Value().Should().Be("A"); // 30 + r[2]["cat"]!.Value().Should().Be("B"); // 5 + } + + [Fact] + public async Task GroupBy_OrderByAvgDirect_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.cat, AVG(c.value) AS avg FROM c GROUP BY c.cat ORDER BY AVG(c.value) ASC"); + r.Should().HaveCount(3); + r[0]["cat"]!.Value().Should().Be("B"); // 5 + r[1]["cat"]!.Value().Should().Be("A"); // 15 + r[2]["cat"]!.Value().Should().Be("C"); // 50 + } + + [Fact] + public async Task GroupBy_OrderByCountDirect_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + r.Should().HaveCount(3); + r[0]["cat"]!.Value().Should().Be("A"); // 2 + } } // ── V20 Phase 2d: NOT EXISTS with Complex Subqueries ── public class QueryDeepDiveV20_NotExistsComplexTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["x","y"],"scores":[90,80]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["z"],"scores":[50]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","tags":[],"scores":[]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task NotExists_WithWhereInSubquery_Works() - { - await Seed(); - // Items that do NOT have tag 'x' - var r = await RunQuery( - "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("2", "3"); - } - - [Fact] - public async Task NotExists_EmptyArray_ReturnsTrue() - { - await Seed(); - // Items with empty tags array: NOT EXISTS on empty = true - var r = await RunQuery( - "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags)"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("3"); - } - - [Fact] - public async Task DoubleNotExists_Works() - { - await Seed(); - // NOT EXISTS(tags with 'x') AND NOT EXISTS(scores > 60) - var r = await RunQuery( - "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x') AND NOT EXISTS(SELECT VALUE s FROM s IN c.scores WHERE s > 60)"); - // id=2: no 'x', score 50 (≤60) → no match because 50 is not > 60 so NOT EXISTS is true → matches - // id=3: no tags, no scores > 60 → matches - r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("2", "3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["x","y"],"scores":[90,80]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["z"],"scores":[50]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","tags":[],"scores":[]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task NotExists_WithWhereInSubquery_Works() + { + await Seed(); + // Items that do NOT have tag 'x' + var r = await RunQuery( + "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x')"); + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("2", "3"); + } + + [Fact] + public async Task NotExists_EmptyArray_ReturnsTrue() + { + await Seed(); + // Items with empty tags array: NOT EXISTS on empty = true + var r = await RunQuery( + "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags)"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("3"); + } + + [Fact] + public async Task DoubleNotExists_Works() + { + await Seed(); + // NOT EXISTS(tags with 'x') AND NOT EXISTS(scores > 60) + var r = await RunQuery( + "SELECT c.id FROM c WHERE NOT EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = 'x') AND NOT EXISTS(SELECT VALUE s FROM s IN c.scores WHERE s > 60)"); + // id=2: no 'x', score 50 (≤60) → no match because 50 is not > 60 so NOT EXISTS is true → matches + // id=3: no tags, no scores > 60 → matches + r.Select(x => x["id"]!.Value()).Should().BeEquivalentTo("2", "3"); + } } // ── V20 Phase 2e: Deeply Nested Object/Array Literals in SELECT ── public class QueryDeepDiveV20_NestedLiteralTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_NestedObjectWithArray_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT {'data': {'items': [c.id, c.name]}} AS nested FROM c"); - r.Should().ContainSingle(); - var nested = r[0]["nested"]!; - nested["data"]!["items"]![0]!.Value().Should().Be("1"); - nested["data"]!["items"]![1]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Select_ArrayOfObjects_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT [{'key': c.id}, {'key': c.name}] AS arr FROM c"); - r.Should().ContainSingle(); - var arr = r[0]["arr"] as JArray; - arr.Should().HaveCount(2); - arr![0]!["key"]!.Value().Should().Be("1"); - arr![1]!["key"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Select_ThreeLevelNesting_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT {'level1': {'level2': {'level3': c.value}}} AS deep FROM c"); - r.Should().ContainSingle(); - r[0]["deep"]!["level1"]!["level2"]!["level3"]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"Alice","value":10}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_NestedObjectWithArray_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT {'data': {'items': [c.id, c.name]}} AS nested FROM c"); + r.Should().ContainSingle(); + var nested = r[0]["nested"]!; + nested["data"]!["items"]![0]!.Value().Should().Be("1"); + nested["data"]!["items"]![1]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Select_ArrayOfObjects_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT [{'key': c.id}, {'key': c.name}] AS arr FROM c"); + r.Should().ContainSingle(); + var arr = r[0]["arr"] as JArray; + arr.Should().HaveCount(2); + arr![0]!["key"]!.Value().Should().Be("1"); + arr![1]!["key"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Select_ThreeLevelNesting_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT {'level1': {'level2': {'level3': c.value}}} AS deep FROM c"); + r.Should().ContainSingle(); + r[0]["deep"]!["level1"]!["level2"]!["level3"]!.Value().Should().Be(10); + } } // ── V20 Phase 2f: Multiple JOINs with GROUP BY ── public class QueryDeepDiveV20_MultiJoinGroupByTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"],"scores":[10,20]}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["a"],"scores":[30]}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_GroupBy_OnJoinedValue_Works() - { - await Seed(); - var r = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); - // tag 'a' appears in both items (count=2), tag 'b' in one (count=1) - r.Should().HaveCount(2); - var tagA = r.First(x => x["tag"]!.Value() == "a"); - tagA["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Join_GroupBy_Having_OrderBy_Combined() - { - await Seed(); - var r = await RunQuery( - "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) >= 2"); - r.Should().ContainSingle(); - r[0]["tag"]!.Value().Should().Be("a"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","tags":["a","b"],"scores":[10,20]}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","tags":["a"],"scores":[30]}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_GroupBy_OnJoinedValue_Works() + { + await Seed(); + var r = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t"); + // tag 'a' appears in both items (count=2), tag 'b' in one (count=1) + r.Should().HaveCount(2); + var tagA = r.First(x => x["tag"]!.Value() == "a"); + tagA["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Join_GroupBy_Having_OrderBy_Combined() + { + await Seed(); + var r = await RunQuery( + "SELECT t AS tag, COUNT(1) AS cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) >= 2"); + r.Should().ContainSingle(); + r[0]["tag"]!.Value().Should().Be("a"); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -26033,141 +26043,141 @@ public async Task Join_GroupBy_Having_OrderBy_Combined() // ── V20 Phase 3a: GROUP BY Key Encoding with Special Characters ── public class QueryDeepDiveV20_GroupByKeyEncodingTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_ValuesWithPipeCharacter_DistinctGroups() - { - // Values containing | should not cause key collisions - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"a|b"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"a|b"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"c"}"""), new PartitionKey("p1")); - - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - r.Should().HaveCount(2); - var ab = r.First(x => x["cat"]!.Value() == "a|b"); - ab["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupBy_MultipleFieldsWithSpecialChars_Works() - { - // Test multiple GROUP BY fields with special characters - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","a":"x\u001Fy","b":"z"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","a":"x","b":"\u001Fyz"}"""), new PartitionKey("p1")); - - var r = await RunQuery( - "SELECT c.a, c.b, COUNT(1) AS cnt FROM c GROUP BY c.a, c.b"); - // These should be distinct groups despite the \x1F character - r.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_ValuesWithPipeCharacter_DistinctGroups() + { + // Values containing | should not cause key collisions + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"a|b"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"a|b"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"c"}"""), new PartitionKey("p1")); + + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + r.Should().HaveCount(2); + var ab = r.First(x => x["cat"]!.Value() == "a|b"); + ab["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupBy_MultipleFieldsWithSpecialChars_Works() + { + // Test multiple GROUP BY fields with special characters + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","a":"x\u001Fy","b":"z"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","a":"x","b":"\u001Fyz"}"""), new PartitionKey("p1")); + + var r = await RunQuery( + "SELECT c.a, c.b, COUNT(1) AS cnt FROM c GROUP BY c.a, c.b"); + // These should be distinct groups despite the \x1F character + r.Should().HaveCount(2); + } } // ── V20 Phase 3b: ORDER BY after GROUP BY — Whitespace Sensitivity ── public class QueryDeepDiveV20_OrderByGroupByWhitespaceTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A","value":10}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"A","value":20}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"B","value":5}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task GroupBy_OrderByAggregate_ExtraWhitespace_Works() - { - await Seed(); - // The SELECT uses "COUNT(1)" but ORDER BY uses "COUNT( 1 )" with extra spaces - // Both should refer to the same aggregate - var r = await RunQuery( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - r.Should().HaveCount(2); - r[0]["cat"]!.Value().Should().Be("A"); // 2 - r[1]["cat"]!.Value().Should().Be("B"); // 1 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","cat":"A","value":10}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","cat":"A","value":20}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","cat":"B","value":5}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task GroupBy_OrderByAggregate_ExtraWhitespace_Works() + { + await Seed(); + // The SELECT uses "COUNT(1)" but ORDER BY uses "COUNT( 1 )" with extra spaces + // Both should refer to the same aggregate + var r = await RunQuery( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + r.Should().HaveCount(2); + r[0]["cat"]!.Value().Should().Be("A"); // 2 + r[1]["cat"]!.Value().Should().Be("B"); // 1 + } } // ── V20 Phase 3c: COALESCE Null vs Undefined Distinction ── public class QueryDeepDiveV20_CoalesceNullUndefinedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - // Item with explicit null and a missing field - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","nullField":null}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Coalesce_UndefinedThenNull_ReturnsNull() - { - await Seed(); - // COALESCE(undefined, null) → null (first non-undefined) - var r = await RunQuery("SELECT VALUE COALESCE(c.missing, c.nullField) FROM c"); - r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Coalesce_UndefinedThenValue_ReturnsValue() - { - await Seed(); - var r = await RunQuery("SELECT VALUE COALESCE(c.missing, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Value().Should().Be("fallback"); - } - - [Fact] - public async Task Coalesce_NullThenValue_ReturnsNull() - { - await Seed(); - // In Cosmos DB, COALESCE returns the first non-undefined value - // null is NOT undefined, so COALESCE(null, 'x') = null - var r = await RunQuery("SELECT VALUE COALESCE(c.nullField, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Coalesce_AllUndefined_ReturnsUndefined() - { - await Seed(); - var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); - r.Should().BeEmpty(); // undefined → omitted from VALUE results - } - - [Fact] - public async Task Coalesce_UndefinedNullThenValue_ReturnsNull() - { - await Seed(); - // COALESCE(undefined, null, 'fallback') → null (null is first non-undefined) - var r = await RunQuery("SELECT VALUE COALESCE(c.missing, c.nullField, 'fallback') FROM c"); - r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + // Item with explicit null and a missing field + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","nullField":null}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Coalesce_UndefinedThenNull_ReturnsNull() + { + await Seed(); + // COALESCE(undefined, null) → null (first non-undefined) + var r = await RunQuery("SELECT VALUE COALESCE(c.missing, c.nullField) FROM c"); + r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Coalesce_UndefinedThenValue_ReturnsValue() + { + await Seed(); + var r = await RunQuery("SELECT VALUE COALESCE(c.missing, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Value().Should().Be("fallback"); + } + + [Fact] + public async Task Coalesce_NullThenValue_ReturnsNull() + { + await Seed(); + // In Cosmos DB, COALESCE returns the first non-undefined value + // null is NOT undefined, so COALESCE(null, 'x') = null + var r = await RunQuery("SELECT VALUE COALESCE(c.nullField, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Coalesce_AllUndefined_ReturnsUndefined() + { + await Seed(); + var r = await RunQuery("SELECT VALUE COALESCE(c.missing1, c.missing2) FROM c"); + r.Should().BeEmpty(); // undefined → omitted from VALUE results + } + + [Fact] + public async Task Coalesce_UndefinedNullThenValue_ReturnsNull() + { + await Seed(); + // COALESCE(undefined, null, 'fallback') → null (null is first non-undefined) + var r = await RunQuery("SELECT VALUE COALESCE(c.missing, c.nullField, 'fallback') FROM c"); + r.Should().ContainSingle().Which.Type.Should().Be(JTokenType.Null); + } } // ════════════════════════════════════════════════════════════════════════════ @@ -26177,96 +26187,96 @@ public async Task Coalesce_UndefinedNullThenValue_ReturnsNull() // ── V20 Phase 4a: Culture-Sensitive Formatting ── public class QueryDeepDiveV20_CultureSensitiveTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","value":1234.5678}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DecimalNumbers_UseDotNotComma_InResults() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.value FROM c"); - r.Should().ContainSingle(); - // The JSON representation should use dot, not comma - r[0].ToString().Should().Contain(".").And.NotContain(","); - } - - [Fact] - public async Task ArithmeticResult_UsesInvariantCulture() - { - await Seed(); - var r = await RunQuery("SELECT VALUE c.value / 3 FROM c"); - r.Should().ContainSingle(); - r[0].Value().Should().BeApproximately(411.5226, 0.001); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","value":1234.5678}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DecimalNumbers_UseDotNotComma_InResults() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.value FROM c"); + r.Should().ContainSingle(); + // The JSON representation should use dot, not comma + r[0].ToString().Should().Contain(".").And.NotContain(","); + } + + [Fact] + public async Task ArithmeticResult_UsesInvariantCulture() + { + await Seed(); + var r = await RunQuery("SELECT VALUE c.value / 3 FROM c"); + r.Should().ContainSingle(); + r[0].Value().Should().BeApproximately(411.5226, 0.001); + } } // ── V20 Phase 4b: Unicode LIKE and CONTAINS ── public class QueryDeepDiveV20_UnicodeSearchTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"café"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"naïve"}"""), new PartitionKey("p1")); - await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"日本語テスト"}"""), new PartitionKey("p1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Contains_WithAccentedChars_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, 'café')"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Like_WithUnicode_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE 'caf%'"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Contains_CJK_Characters_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, '日本')"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("3"); - } - - [Fact] - public async Task Like_CJK_Pattern_Works() - { - await Seed(); - var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%テスト'"); - r.Should().ContainSingle(); - r[0]["id"]!.Value().Should().Be("3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.Parse("""{"id":"1","pk":"p1","name":"café"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"2","pk":"p1","name":"naïve"}"""), new PartitionKey("p1")); + await _container.CreateItemAsync(JObject.Parse("""{"id":"3","pk":"p1","name":"日本語テスト"}"""), new PartitionKey("p1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Contains_WithAccentedChars_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, 'café')"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Like_WithUnicode_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE 'caf%'"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Contains_CJK_Characters_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE CONTAINS(c.name, '日本')"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("3"); + } + + [Fact] + public async Task Like_CJK_Pattern_Works() + { + await Seed(); + var r = await RunQuery("SELECT c.id FROM c WHERE c.name LIKE '%テスト'"); + r.Should().ContainSingle(); + r[0]["id"]!.Value().Should().Be("3"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -26275,703 +26285,703 @@ public async Task Like_CJK_Pattern_Works() public class QueryDeepDiveV21_StringToNullInputTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":null}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task StringToArray_NullInput_ReturnsUndefined() - { - await Seed(); - // StringToArray(null) should return undefined (field omitted by VALUE) - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE StringToArray(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("StringToArray(null) should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToObject_NullInput_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE StringToObject(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("StringToObject(null) should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToNumber_NullInput_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE StringToNumber(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("StringToNumber(null) should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToBoolean_NullInput_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE StringToBoolean(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("StringToBoolean(null) should return undefined, omitted by VALUE"); - } - - [Fact] - public async Task StringToNull_NullInput_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE StringToNull(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("StringToNull(null) should return undefined, omitted by VALUE"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":null}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task StringToArray_NullInput_ReturnsUndefined() + { + await Seed(); + // StringToArray(null) should return undefined (field omitted by VALUE) + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE StringToArray(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("StringToArray(null) should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToObject_NullInput_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE StringToObject(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("StringToObject(null) should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToNumber_NullInput_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE StringToNumber(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("StringToNumber(null) should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToBoolean_NullInput_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE StringToBoolean(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("StringToBoolean(null) should return undefined, omitted by VALUE"); + } + + [Fact] + public async Task StringToNull_NullInput_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE StringToNull(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("StringToNull(null) should return undefined, omitted by VALUE"); + } } public class QueryDeepDiveV21_NumberBinEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task NumberBin_NegativeValue_BinsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":-7}""")), - new PartitionKey("a")); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE NumberBin(c.val, 5) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // NumberBin(-7, 5) = Floor(-7/5) * 5 = Floor(-1.4) * 5 = -2 * 5 = -10 - results.Should().ContainSingle().Which.Value().Should().Be(-10); - } - - [Fact] - public async Task NumberBin_ZeroBinSize_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE NumberBin(c.val, 0) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().BeEmpty("NumberBin with zero bin size should return undefined"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":1}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task NumberBin_NegativeValue_BinsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":-7}""")), + new PartitionKey("a")); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE NumberBin(c.val, 5) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // NumberBin(-7, 5) = Floor(-7/5) * 5 = Floor(-1.4) * 5 = -2 * 5 = -10 + results.Should().ContainSingle().Which.Value().Should().Be(-10); + } + + [Fact] + public async Task NumberBin_ZeroBinSize_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE NumberBin(c.val, 0) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().BeEmpty("NumberBin with zero bin size should return undefined"); + } } public class QueryDeepDiveV21_ArrayContainsPartialMatch { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task ArrayContains_PartialObjectMatch_WithThirdArgTrue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","items":[{"name":"Alice","age":30},{"name":"Bob","age":25}]}""")), - new PartitionKey("a")); - - // ARRAY_CONTAINS with 3rd arg = true does partial match - var results = new List(); - var it = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "Alice"}, true)""", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task ArrayContains_ExactObjectMatch_WithoutThirdArg() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","items":[{"name":"Alice","age":30}]}""")), - new PartitionKey("a")); - - // Without 3rd arg, ARRAY_CONTAINS does exact match — partial won't match - var results = new List(); - var it = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "Alice"})""", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // Exact match of {name:Alice} won't find {name:Alice,age:30} - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task ArrayContains_PartialObjectMatch_WithThirdArgTrue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","items":[{"name":"Alice","age":30},{"name":"Bob","age":25}]}""")), + new PartitionKey("a")); + + // ARRAY_CONTAINS with 3rd arg = true does partial match + var results = new List(); + var it = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "Alice"}, true)""", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task ArrayContains_ExactObjectMatch_WithoutThirdArg() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","items":[{"name":"Alice","age":30}]}""")), + new PartitionKey("a")); + + // Without 3rd arg, ARRAY_CONTAINS does exact match — partial won't match + var results = new List(); + var it = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "Alice"})""", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // Exact match of {name:Alice} won't find {name:Alice,age:30} + results.Should().BeEmpty(); + } } public class QueryDeepDiveV21_ArraySliceEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","items":["a","b","c","d","e"]}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task ArraySlice_StartAndLength_ReturnsSubset() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE ARRAY_SLICE(c.items, 1, 2) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - var arr = results.Should().ContainSingle().Subject; - arr.ToObject().Should().Equal("b", "c"); - } - - [Fact] - public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE ARRAY_SLICE(c.items, 10) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - var arr = results.Should().ContainSingle().Subject; - arr.ToObject().Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","items":["a","b","c","d","e"]}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task ArraySlice_StartAndLength_ReturnsSubset() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE ARRAY_SLICE(c.items, 1, 2) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + var arr = results.Should().ContainSingle().Subject; + arr.ToObject().Should().Equal("b", "c"); + } + + [Fact] + public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE ARRAY_SLICE(c.items, 10) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + var arr = results.Should().ContainSingle().Subject; + arr.ToObject().Should().BeEmpty(); + } } public class QueryDeepDiveV21_AggregateMixedTypes { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":10}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":"not-a-number"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":30}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task Sum_MixedTypes_IgnoresNonNumeric() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE SUM(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // SUM should ignore non-numeric types and sum only numbers - results.Should().ContainSingle().Which.Value().Should().Be(40); - } - - [Fact] - public async Task Avg_MixedTypes_IgnoresNonNumeric() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE AVG(c.val) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // AVG should ignore non-numeric types: (10+30)/2 = 20 - results.Should().ContainSingle().Which.Value().Should().Be(20); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":10}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":"not-a-number"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","val":30}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task Sum_MixedTypes_IgnoresNonNumeric() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE SUM(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // SUM should ignore non-numeric types and sum only numbers + results.Should().ContainSingle().Which.Value().Should().Be(40); + } + + [Fact] + public async Task Avg_MixedTypes_IgnoresNonNumeric() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE AVG(c.val) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // AVG should ignore non-numeric types: (10+30)/2 = 20 + results.Should().ContainSingle().Which.Value().Should().Be(20); + } } public class QueryDeepDiveV21_CountNullSemantics { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task Count_FieldWithNullValue_ExcludesNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","optional":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), - new PartitionKey("a")); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT COUNT(c.optional) AS cnt FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // Cosmos DB semantics: COUNT(c.field) excludes both null AND undefined/missing. - // Only the doc with "optional":"yes" is counted. - results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(1); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task Count_FieldWithNullValue_ExcludesNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","optional":"yes"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","optional":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), + new PartitionKey("a")); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT COUNT(c.optional) AS cnt FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // Cosmos DB semantics: COUNT(c.field) excludes both null AND undefined/missing. + // Only the doc with "optional":"yes" is counted. + results.Should().ContainSingle().Subject["cnt"]!.Value().Should().Be(1); + } } public class QueryDeepDiveV21_SelectComputedProperties { - private static InMemoryContainer CreateContainerWithComputedProps( - params (string Name, string Query)[] definitions) - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new System.Collections.ObjectModel.Collection( - definitions.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) - }; - return new InMemoryContainer(props); - } - - [Fact] - public async Task SelectStar_ExcludesComputedProperties() - { - var container = CreateContainerWithComputedProps(("fullDisplayName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"John","last":"Doe"}""")), - new PartitionKey("a")); - - var results = new List(); - var it = container.GetItemQueryIterator("SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - var doc = results.Should().ContainSingle().Subject; - doc.ContainsKey("fullDisplayName").Should().BeFalse("SELECT * should exclude computed properties"); - } - - [Fact] - public async Task SelectExplicit_IncludesComputedProperties() - { - var container = CreateContainerWithComputedProps(("fullDisplayName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"John","last":"Doe"}""")), - new PartitionKey("a")); - - var results = new List(); - var it = container.GetItemQueryIterator("SELECT c.id, c.fullDisplayName FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - var doc = results.Should().ContainSingle().Subject; - doc["fullDisplayName"]!.Value().Should().Be("John Doe"); - } + private static InMemoryContainer CreateContainerWithComputedProps( + params (string Name, string Query)[] definitions) + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new System.Collections.ObjectModel.Collection( + definitions.Select(d => new ComputedProperty { Name = d.Name, Query = d.Query }).ToList()) + }; + return new InMemoryContainer(props); + } + + [Fact] + public async Task SelectStar_ExcludesComputedProperties() + { + var container = CreateContainerWithComputedProps(("fullDisplayName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"John","last":"Doe"}""")), + new PartitionKey("a")); + + var results = new List(); + var it = container.GetItemQueryIterator("SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + var doc = results.Should().ContainSingle().Subject; + doc.ContainsKey("fullDisplayName").Should().BeFalse("SELECT * should exclude computed properties"); + } + + [Fact] + public async Task SelectExplicit_IncludesComputedProperties() + { + var container = CreateContainerWithComputedProps(("fullDisplayName", "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"John","last":"Doe"}""")), + new PartitionKey("a")); + + var results = new List(); + var it = container.GetItemQueryIterator("SELECT c.id, c.fullDisplayName FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + var doc = results.Should().ContainSingle().Subject; + doc["fullDisplayName"]!.Value().Should().Be("John Doe"); + } } public class QueryDeepDiveV21_OrderByComplexExpression { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task OrderBy_ComplexArithmeticExpression_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":10,"b":5}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","a":1,"b":100}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","a":5,"b":5}""")), - new PartitionKey("a")); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.a + c.b ASC", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // a+b: id1=15, id2=101, id3=10 - results.Should().HaveCount(3); - results[0]["id"]!.Value().Should().Be("3"); // 10 - results[1]["id"]!.Value().Should().Be("1"); // 15 - results[2]["id"]!.Value().Should().Be("2"); // 101 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task OrderBy_ComplexArithmeticExpression_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":10,"b":5}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","a":1,"b":100}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","a":5,"b":5}""")), + new PartitionKey("a")); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.a + c.b ASC", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // a+b: id1=15, id2=101, id3=10 + results.Should().HaveCount(3); + results[0]["id"]!.Value().Should().Be("3"); // 10 + results[1]["id"]!.Value().Should().Be("1"); // 15 + results[2]["id"]!.Value().Should().Be("2"); // 101 + } } public class QueryDeepDiveV21_ReplicateEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"abc"}""")), - new PartitionKey("a")); - } + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"abc"}""")), + new PartitionKey("a")); + } - [Fact] - public async Task Replicate_NegativeCount_ReturnsUndefined() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE REPLICATE(c.val, -1) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + [Fact] + public async Task Replicate_NegativeCount_ReturnsUndefined() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE REPLICATE(c.val, -1) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().BeEmpty("REPLICATE with negative count should return undefined"); - } + results.Should().BeEmpty("REPLICATE with negative count should return undefined"); + } } public class QueryDeepDiveV21_DateTimeAddInvalidPart { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task DateTimeAdd_InvalidPart_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); + [Fact] + public async Task DateTimeAdd_InvalidPart_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE DateTimeAdd('invalid', 1, '2023-01-01T00:00:00Z') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE DateTimeAdd('invalid', 1, '2023-01-01T00:00:00Z') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().BeEmpty("DateTimeAdd with invalid part should return undefined"); - } + results.Should().BeEmpty("DateTimeAdd with invalid part should return undefined"); + } } public class QueryDeepDiveV21_NestedTernary { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task NestedTernary_EvaluatesCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":true,"b":false}""")), - new PartitionKey("a")); + [Fact] + public async Task NestedTernary_EvaluatesCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","a":true,"b":false}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE (c.a ? (c.b ? 'both' : 'onlyA') : 'neither') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE (c.a ? (c.b ? 'both' : 'onlyA') : 'neither') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be("onlyA"); - } + results.Should().ContainSingle().Which.Should().Be("onlyA"); + } } public class QueryDeepDiveV21_MaxItemCountMinusOne { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task MaxItemCount_MinusOne_ReturnsAllInSinglePage() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task MaxItemCount_MinusOne_ReturnsAllInSinglePage() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a" }), + new PartitionKey("a")); - var it = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = -1 }); - var page = await it.ReadNextAsync(); + var it = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = -1 }); + var page = await it.ReadNextAsync(); - page.Count.Should().Be(10); - it.HasMoreResults.Should().BeFalse("MaxItemCount=-1 should return all items in one page"); - } + page.Count.Should().Be(10); + it.HasMoreResults.Should().BeFalse("MaxItemCount=-1 should return all items in one page"); + } } public class QueryDeepDiveV21_StreamIteratorEnvelope { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task StreamIterator_ResponseHas_Count_Rid_Documents() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Test" }), - new PartitionKey("a")); + [Fact] + public async Task StreamIterator_ResponseHas_Count_Rid_Documents() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Test" }), + new PartitionKey("a")); - var it = _container.GetItemQueryStreamIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var response = await it.ReadNextAsync(); - var body = await new StreamReader(response.Content).ReadToEndAsync(); - var envelope = JObject.Parse(body); + var it = _container.GetItemQueryStreamIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var response = await it.ReadNextAsync(); + var body = await new StreamReader(response.Content).ReadToEndAsync(); + var envelope = JObject.Parse(body); - envelope["Documents"].Should().NotBeNull(); - envelope["_count"].Should().NotBeNull(); - envelope["_count"]!.Value().Should().Be(1); - envelope["_rid"].Should().NotBeNull(); - } + envelope["Documents"].Should().NotBeNull(); + envelope["_count"].Should().NotBeNull(); + envelope["_count"]!.Value().Should().Be(1); + envelope["_rid"].Should().NotBeNull(); + } } public class QueryDeepDiveV21_ReplaceEdgeCases { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"hello world"}""")), - new PartitionKey("a")); - } - - [Fact] - public async Task Replace_SearchNotFound_ReturnsOriginal() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE REPLACE(c.val, 'xyz', 'abc') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("hello world"); - } - - [Fact] - public async Task Replace_EmptyReplacement_RemovesOccurrences() - { - await Seed(); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE REPLACE(c.val, ' world', '') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().Be("hello"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"hello world"}""")), + new PartitionKey("a")); + } + + [Fact] + public async Task Replace_SearchNotFound_ReturnsOriginal() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE REPLACE(c.val, 'xyz', 'abc') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("hello world"); + } + + [Fact] + public async Task Replace_EmptyReplacement_RemovesOccurrences() + { + await Seed(); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE REPLACE(c.val, ' world', '') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().Be("hello"); + } } public class QueryDeepDiveV21_ConcatVariadic { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Concat_FiveArgs_ConcatenatesAll() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); + [Fact] + public async Task Concat_FiveArgs_ConcatenatesAll() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT VALUE CONCAT('a', 'b', 'c', 'd', 'e') FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT VALUE CONCAT('a', 'b', 'c', 'd', 'e') FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().ContainSingle().Which.Should().Be("abcde"); - } + results.Should().ContainSingle().Which.Should().Be("abcde"); + } } public class QueryDeepDiveV21_SelectArrayIndex { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Select_ArrayIndex_ProjectsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","tags":["first","second","third"]}""")), - new PartitionKey("a")); + [Fact] + public async Task Select_ArrayIndex_ProjectsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","tags":["first","second","third"]}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT c.tags[0] AS firstTag, c.tags[2] AS thirdTag FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT c.tags[0] AS firstTag, c.tags[2] AS thirdTag FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - var doc = results.Should().ContainSingle().Subject; - doc["firstTag"]!.Value().Should().Be("first"); - doc["thirdTag"]!.Value().Should().Be("third"); - } + var doc = results.Should().ContainSingle().Subject; + doc["firstTag"]!.Value().Should().Be("first"); + doc["thirdTag"]!.Value().Should().Be("third"); + } } public class QueryDeepDiveV21_OrderByRankError { - [Fact] - public async Task OrderByRank_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); + [Fact] + public async Task OrderByRank_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); - var act = () => - { - container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY RANK RRF(c.score, c.rank)", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - return Task.CompletedTask; - }; + var act = () => + { + container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY RANK RRF(c.score, c.rank)", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + return Task.CompletedTask; + }; - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); - } + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.BadRequest); + } } public class QueryDeepDiveV21_DistinctPropertyOrderTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task Distinct_ObjectPropertyOrder_TreatedAsSame_ViaStream() - { - // Documents inserted via stream with different property order - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2,"x":1}""")), - new PartitionKey("a")); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT DISTINCT c.x, c.y FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task Distinct_ObjectPropertyOrder_TreatedAsSame_ViaPatch() - { - // Doc A: created with x and y → property order is id, pk, x, y - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), - new PartitionKey("a")); - - // Doc B: created with only y, then x added via Patch → property order is id, pk, y, x - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2}""")), - new PartitionKey("a")); - await _container.PatchItemAsync("2", - new PartitionKey("a"), - [PatchOperation.Set("/x", 1)]); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT DISTINCT c.x, c.y FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // Both docs have x=1, y=2 — DISTINCT must deduplicate despite different internal property order - results.Should().ContainSingle(); - } - - [Fact] - public async Task Distinct_WholeDocument_DedupsDespiteDifferentPropertyOrder() - { - // SELECT DISTINCT VALUE c — whole-document dedup is the hardest case - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2,"x":1}""")), - new PartitionKey("a")); - - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT DISTINCT c.x, c.y FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task Distinct_ObjectPropertyOrder_TreatedAsSame_ViaStream() + { + // Documents inserted via stream with different property order + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2,"x":1}""")), + new PartitionKey("a")); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT DISTINCT c.x, c.y FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task Distinct_ObjectPropertyOrder_TreatedAsSame_ViaPatch() + { + // Doc A: created with x and y → property order is id, pk, x, y + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), + new PartitionKey("a")); + + // Doc B: created with only y, then x added via Patch → property order is id, pk, y, x + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2}""")), + new PartitionKey("a")); + await _container.PatchItemAsync("2", + new PartitionKey("a"), + [PatchOperation.Set("/x", 1)]); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT DISTINCT c.x, c.y FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // Both docs have x=1, y=2 — DISTINCT must deduplicate despite different internal property order + results.Should().ContainSingle(); + } + + [Fact] + public async Task Distinct_WholeDocument_DedupsDespiteDifferentPropertyOrder() + { + // SELECT DISTINCT VALUE c — whole-document dedup is the hardest case + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","x":1,"y":2}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","y":2,"x":1}""")), + new PartitionKey("a")); + + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT DISTINCT c.x, c.y FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().ContainSingle(); + } } public class QueryDeepDiveV21_WhereChainedFunctions { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Where_ChainedFunctions_LowerTrim() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":" Alice "}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":" Bob "}""")), - new PartitionKey("a")); + [Fact] + public async Task Where_ChainedFunctions_LowerTrim() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":" Alice "}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":" Bob "}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE LOWER(TRIM(c.name)) = 'alice'", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE LOWER(TRIM(c.name)) = 'alice'", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } public class QueryDeepDiveV21_IsNotNullVsIsDefined { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task Where_IsNotNull_ExcludesNullAndUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"yes"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":null}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), - new PartitionKey("a")); + [Fact] + public async Task Where_IsNotNull_ExcludesNullAndUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","val":"yes"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","val":null}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a"}""")), + new PartitionKey("a")); - var results = new List(); - var it = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.val IS NOT NULL", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + var results = new List(); + var it = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.val IS NOT NULL", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - // IS NOT NULL excludes: nulls AND undefined (missing) - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("1"); - } + // IS NOT NULL excludes: nulls AND undefined (missing) + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("1"); + } } @@ -26981,708 +26991,712 @@ await _container.CreateItemStreamAsync( public class QueryDeepDiveV22_OrderByGroupByInteractionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - /// - /// Verifies ORDER BY on a GROUP BY aggregate alias works when the - /// ORDER BY expression text has different whitespace than the SELECT. - /// The parser normalizes via ExprToString, so "COUNT( 1 )" becomes "COUNT(1)". - /// - [Fact] - public async Task OrderBy_GroupByAggregate_NormalizedWhitespace_StillSorts() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "A", val = 10 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "A", val = 20 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "B", val = 5 }), new PartitionKey("a")); - - // ORDER BY uses same aggregate expression as SELECT — should sort groups - var results = await Q( - "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY SUM(c.val) ASC"); - - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("B"); // SUM=5 - results[1]["cat"]!.ToString().Should().Be("A"); // SUM=30 - } - - /// - /// ORDER BY on a GROUP BY alias name directly (e.g., ORDER BY total). - /// - [Fact] - public async Task OrderBy_GroupByAlias_SortsByAlias() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "X", val = 100 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "Y", val = 1 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "Y", val = 2 }), new PartitionKey("a")); - - var results = await Q( - "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY total DESC"); - - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("X"); // SUM=100 - results[1]["cat"]!.ToString().Should().Be("Y"); // SUM=3 - } - - /// - /// ORDER BY COUNT(1) after GROUP BY. - /// - [Fact] - public async Task OrderBy_GroupByCount_SortsByCount() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "A" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "A" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "A" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", cat = "B" }), new PartitionKey("a")); - - var results = await Q( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); - - results.Should().HaveCount(2); - results[0]["cat"]!.ToString().Should().Be("A"); // COUNT=3 - results[1]["cat"]!.ToString().Should().Be("B"); // COUNT=1 - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + /// + /// Verifies ORDER BY on a GROUP BY aggregate alias works when the + /// ORDER BY expression text has different whitespace than the SELECT. + /// The parser normalizes via ExprToString, so "COUNT( 1 )" becomes "COUNT(1)". + /// + [Fact] + public async Task OrderBy_GroupByAggregate_NormalizedWhitespace_StillSorts() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "A", val = 10 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "A", val = 20 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "B", val = 5 }), new PartitionKey("a")); + + // ORDER BY uses same aggregate expression as SELECT — should sort groups + var results = await Q( + "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY SUM(c.val) ASC"); + + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("B"); // SUM=5 + results[1]["cat"]!.ToString().Should().Be("A"); // SUM=30 + } + + /// + /// ORDER BY on a GROUP BY alias name directly (e.g., ORDER BY total). + /// + [Fact] + public async Task OrderBy_GroupByAlias_SortsByAlias() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "X", val = 100 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "Y", val = 1 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "Y", val = 2 }), new PartitionKey("a")); + + var results = await Q( + "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat ORDER BY total DESC"); + + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("X"); // SUM=100 + results[1]["cat"]!.ToString().Should().Be("Y"); // SUM=3 + } + + /// + /// ORDER BY COUNT(1) after GROUP BY. + /// + [Fact] + public async Task OrderBy_GroupByCount_SortsByCount() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", cat = "A" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", cat = "A" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", cat = "A" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", cat = "B" }), new PartitionKey("a")); + + var results = await Q( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat ORDER BY COUNT(1) DESC"); + + results.Should().HaveCount(2); + results[0]["cat"]!.ToString().Should().Be("A"); // COUNT=3 + results[1]["cat"]!.ToString().Should().Be("B"); // COUNT=1 + } } public class QueryDeepDiveV22_LikeEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task Like_EmptyPattern_MatchesOnlyEmptyString() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Alice" }), new PartitionKey("a")); - - var results = await Q("SELECT * FROM c WHERE c.name LIKE ''"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Like_DoublePercent_MatchesAllStrings() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "" }), new PartitionKey("a")); - - var results = await Q("SELECT * FROM c WHERE c.name LIKE '%%'"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Like_OnlyUnderscore_MatchesSingleCharString() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "AB" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "" }), new PartitionKey("a")); - - var results = await Q("SELECT * FROM c WHERE c.name LIKE '_'"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task Like_PercentInMiddle_MatchesSubstring() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A-B" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "A--B" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "AB" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", name = "AXB" }), new PartitionKey("a")); - - var results = await Q("SELECT * FROM c WHERE c.name LIKE 'A%B'"); - // Should match A-B, A--B, AB, AXB — all start with A and end with B - results.Should().HaveCount(4); - } - - [Fact] - public async Task Like_UnderscoreAndPercent_Combined() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Ace" }), new PartitionKey("a")); - - // _l% → matches any single char + "l" + anything - var results = await Q("SELECT * FROM c WHERE c.name LIKE '_l%'"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task Like_EmptyPattern_MatchesOnlyEmptyString() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Alice" }), new PartitionKey("a")); + + var results = await Q("SELECT * FROM c WHERE c.name LIKE ''"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Like_DoublePercent_MatchesAllStrings() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "" }), new PartitionKey("a")); + + var results = await Q("SELECT * FROM c WHERE c.name LIKE '%%'"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Like_OnlyUnderscore_MatchesSingleCharString() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "AB" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "" }), new PartitionKey("a")); + + var results = await Q("SELECT * FROM c WHERE c.name LIKE '_'"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task Like_PercentInMiddle_MatchesSubstring() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A-B" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "A--B" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "AB" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", name = "AXB" }), new PartitionKey("a")); + + var results = await Q("SELECT * FROM c WHERE c.name LIKE 'A%B'"); + // Should match A-B, A--B, AB, AXB — all start with A and end with B + results.Should().HaveCount(4); + } + + [Fact] + public async Task Like_UnderscoreAndPercent_Combined() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Ace" }), new PartitionKey("a")); + + // _l% → matches any single char + "l" + anything + var results = await Q("SELECT * FROM c WHERE c.name LIKE '_l%'"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } } public class QueryDeepDiveV22_AggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task Sum_AllUndefined_ReturnsUndefined() - { - // All items have no "score" field — SUM should be undefined - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"B"}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT SUM(c.score) AS total FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("total").Should().BeFalse("SUM of all-undefined should omit the field"); - } - - [Fact] - public async Task Avg_AllNull_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT AVG(c.score) AS avg FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("avg").Should().BeFalse("AVG of all-null should omit the field"); - } - - [Fact] - public async Task Count_Field_NullAndUndefined_BothExcluded() - { - // Standard SQL semantics: COUNT(expression) counts non-null, non-undefined values. - // COUNT(1) / COUNT(*) counts all items. COUNT(c.field) excludes undefined AND null. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","opt":"yes"}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","opt":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); // no opt field - - var results = await Q("SELECT COUNT(c.opt) AS cnt FROM c"); - results.Should().ContainSingle(); - // Both null and undefined are excluded from COUNT(c.field) - results[0]["cnt"]!.Value().Should().Be(1, "only non-null defined values are counted"); - } - - [Fact] - public async Task Min_AllUndefined_OmitsField() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT MIN(c.score) AS mn FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("mn").Should().BeFalse("MIN of all-undefined should omit the field"); - } - - [Fact] - public async Task Max_AllUndefined_OmitsField() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT MAX(c.score) AS mx FROM c"); - results.Should().ContainSingle(); - results[0].ContainsKey("mx").Should().BeFalse("MAX of all-undefined should omit the field"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task Sum_AllUndefined_ReturnsUndefined() + { + // All items have no "score" field — SUM should be undefined + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","name":"B"}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT SUM(c.score) AS total FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("total").Should().BeFalse("SUM of all-undefined should omit the field"); + } + + [Fact] + public async Task Avg_AllNull_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT AVG(c.score) AS avg FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("avg").Should().BeFalse("AVG of all-null should omit the field"); + } + + [Fact] + public async Task Count_Field_NullAndUndefined_BothExcluded() + { + // Standard SQL semantics: COUNT(expression) counts non-null, non-undefined values. + // COUNT(1) / COUNT(*) counts all items. COUNT(c.field) excludes undefined AND null. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","opt":"yes"}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","opt":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); // no opt field + + var results = await Q("SELECT COUNT(c.opt) AS cnt FROM c"); + results.Should().ContainSingle(); + // Both null and undefined are excluded from COUNT(c.field) + results[0]["cnt"]!.Value().Should().Be(1, "only non-null defined values are counted"); + } + + [Fact] + public async Task Min_AllUndefined_OmitsField() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT MIN(c.score) AS mn FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("mn").Should().BeFalse("MIN of all-undefined should omit the field"); + } + + [Fact] + public async Task Max_AllUndefined_OmitsField() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"A"}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT MAX(c.score) AS mx FROM c"); + results.Should().ContainSingle(); + results[0].ContainsKey("mx").Should().BeFalse("MAX of all-undefined should omit the field"); + } } public class QueryDeepDiveV22_ThreeValueLogicTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task Where_UndefinedAndTrue_FiltersOut() - { - // undefined AND true → undefined → not truthy → filtered out - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","isActive":true}""")), - new PartitionKey("pk1")); - - // c.missing is undefined. undefined AND c.isActive → undefined → no match - var results = await Q("SELECT * FROM c WHERE c.missing AND c.isActive"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Where_UndefinedOrTrue_ReturnsTrue() - { - // undefined OR true → true (according to Cosmos three-value logic) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","isActive":true}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT * FROM c WHERE c.missing OR c.isActive"); - results.Should().ContainSingle(); - } - - [Fact] - public async Task Where_NullEqualsNull_ReturnsUndefined() - { - // In Cosmos: null = null → undefined (not true!) - // The emulator's ValuesEqual returns true for null==null, but in WHERE context - // the comparison of null values should produce undefined behavior. - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - - // This is a known divergence: emulator treats null=null as true - // Real Cosmos: null = null → undefined → no match - var results = await Q("SELECT * FROM c WHERE null = null"); - // Document the actual emulator behavior - results.Should().ContainSingle("emulator treats null=null as true (divergent from Cosmos)"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task Where_UndefinedAndTrue_FiltersOut() + { + // undefined AND true → undefined → not truthy → filtered out + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","isActive":true}""")), + new PartitionKey("pk1")); + + // c.missing is undefined. undefined AND c.isActive → undefined → no match + var results = await Q("SELECT * FROM c WHERE c.missing AND c.isActive"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Where_UndefinedOrTrue_ReturnsTrue() + { + // undefined OR true → true (according to Cosmos three-value logic) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","isActive":true}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT * FROM c WHERE c.missing OR c.isActive"); + results.Should().ContainSingle(); + } + + [Fact] + public async Task Where_NullEqualsNull_ReturnsUndefined() + { + // In Cosmos: null = null → undefined (not true!) + // The emulator's ValuesEqual returns true for null==null, but in WHERE context + // the comparison of null values should produce undefined behavior. + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + + // This is a known divergence: emulator treats null=null as true + // Real Cosmos: null = null → undefined → no match + var results = await Q("SELECT * FROM c WHERE null = null"); + // Document the actual emulator behavior + results.Should().ContainSingle("emulator treats null=null as true (divergent from Cosmos)"); + } } public class QueryDeepDiveV22_DistinctComputedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } - [Fact] - public async Task Distinct_ComputedExpression_Deduplicates() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "ALICE" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); + [Fact] + public async Task Distinct_ComputedExpression_Deduplicates() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "ALICE" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); - var results = await Q("SELECT DISTINCT VALUE LOWER(c.name) FROM c"); - results.Should().HaveCount(2); - results.Select(r => r.Value()).Should().BeEquivalentTo(["alice", "bob"]); - } + var results = await Q("SELECT DISTINCT VALUE LOWER(c.name) FROM c"); + results.Should().HaveCount(2); + results.Select(r => r.Value()).Should().BeEquivalentTo(["alice", "bob"]); + } - [Fact] - public async Task DistinctValue_WithArithmetic_Deduplicates() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", val = 5 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", val = 10 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", val = 5 }), new PartitionKey("a")); + [Fact] + public async Task DistinctValue_WithArithmetic_Deduplicates() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", val = 5 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", val = 10 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", val = 5 }), new PartitionKey("a")); - var results = await Q("SELECT DISTINCT VALUE c.val * 2 FROM c"); - // 5*2=10, 10*2=20, 5*2=10 → distinct: {10, 20} - results.Should().HaveCount(2); - results.Select(r => r.Value()).Should().BeEquivalentTo([10.0, 20.0]); - } + var results = await Q("SELECT DISTINCT VALUE c.val * 2 FROM c"); + // 5*2=10, 10*2=20, 5*2=10 → distinct: {10, 20} + results.Should().HaveCount(2); + results.Select(r => r.Value()).Should().BeEquivalentTo([10.0, 20.0]); + } } public class QueryDeepDiveV22_JoinGroupByTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task Join_WithDistinctAndOrderBy_Combined() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "c", "a", "b", "a" } }), - new PartitionKey("a")); - - var results = await Q( - "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); - // tags: c, a, b, a → distinct: a, b, c → ORDER BY ASC: a, b, c - results.Should().HaveCount(3); - results.Select(r => r.Value()).Should().ContainInOrder("a", "b", "c"); - } - - [Fact] - public async Task Join_WithMultipleJoins_And_Where_OnBoth() - { - var container = new InMemoryContainer("mj", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", - colors = new[] { "red", "blue", "green" }, - sizes = new[] { "S", "M", "L" } }), - new PartitionKey("a")); - - var it = container.GetItemQueryIterator( - "SELECT co AS color, sz AS size FROM c JOIN co IN c.colors JOIN sz IN c.sizes WHERE co != 'green' AND sz != 'L'"); - var results = new List(); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - // 2 colors (red,blue) × 2 sizes (S,M) = 4 - results.Should().HaveCount(4); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task Join_WithDistinctAndOrderBy_Combined() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "c", "a", "b", "a" } }), + new PartitionKey("a")); + + var results = await Q( + "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags ORDER BY t ASC"); + // tags: c, a, b, a → distinct: a, b, c → ORDER BY ASC: a, b, c + results.Should().HaveCount(3); + results.Select(r => r.Value()).Should().ContainInOrder("a", "b", "c"); + } + + [Fact] + public async Task Join_WithMultipleJoins_And_Where_OnBoth() + { + var container = new InMemoryContainer("mj", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new + { + id = "1", + pk = "a", + colors = new[] { "red", "blue", "green" }, + sizes = new[] { "S", "M", "L" } + }), + new PartitionKey("a")); + + var it = container.GetItemQueryIterator( + "SELECT co AS color, sz AS size FROM c JOIN co IN c.colors JOIN sz IN c.sizes WHERE co != 'green' AND sz != 'L'"); + var results = new List(); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + // 2 colors (red,blue) × 2 sizes (S,M) = 4 + results.Should().HaveCount(4); + } } public class QueryDeepDiveV22_SubqueryEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } - [Fact] - public async Task Exists_WithNullArray_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":null}""")), - new PartitionKey("pk1")); + [Fact] + public async Task Exists_WithNullArray_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","tags":null}""")), + new PartitionKey("pk1")); - var results = await Q( - """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); - results.Should().BeEmpty(); - } + var results = await Q( + """SELECT * FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = "a")"""); + results.Should().BeEmpty(); + } - [Fact] - public async Task ArraySubquery_NestedWithDistinct_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","items":["a","b","a","c","b"]}""")), - new PartitionKey("pk1")); + [Fact] + public async Task ArraySubquery_NestedWithDistinct_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","items":["a","b","a","c","b"]}""")), + new PartitionKey("pk1")); - var results = await Q( - "SELECT ARRAY(SELECT DISTINCT VALUE t FROM t IN c.items ORDER BY t ASC) AS unique FROM c WHERE c.id = '1'"); + var results = await Q( + "SELECT ARRAY(SELECT DISTINCT VALUE t FROM t IN c.items ORDER BY t ASC) AS unique FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - var unique = results[0]["unique"]!.ToObject(); - unique.Should().Equal("a", "b", "c"); - } + results.Should().ContainSingle(); + var unique = results[0]["unique"]!.ToObject(); + unique.Should().Equal("a", "b", "c"); + } } public class QueryDeepDiveV22_OrderByExpressionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task OrderBy_TernaryExpression_SortsCorrectly() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", active = true, value = 30 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", active = false, value = 10 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", active = true, value = 20 }), new PartitionKey("a")); - - // Active items get priority (sort key 0), inactive get sort key 1 - var results = await Q( - "SELECT * FROM c ORDER BY (c.active ? 0 : 1) ASC, c.value ASC"); - - // Active items first sorted by value, then inactive - results[0]["id"]!.ToString().Should().Be("3"); // active, val=20 - results[1]["id"]!.ToString().Should().Be("1"); // active, val=30 - results[2]["id"]!.ToString().Should().Be("2"); // inactive, val=10 - } - - [Fact] - public async Task OrderBy_CoalesceExpression_SortsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","nick":"Zorro"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","nick":"Bob","name":"Robert"}""")), - new PartitionKey("a")); - - // Use nick if available, else name - var results = await Q( - "SELECT * FROM c ORDER BY (c.nick ?? c.name) ASC"); - - // id=2: nick=undef, name=Alice → "Alice" - // id=3: nick=Bob → "Bob" - // id=1: nick=Zorro → "Zorro" - results[0]["id"]!.ToString().Should().Be("2"); // Alice - results[1]["id"]!.ToString().Should().Be("3"); // Bob - results[2]["id"]!.ToString().Should().Be("1"); // Zorro - } - - [Fact] - public async Task OrderBy_ConcatExpression_SortsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"Charlie","last":"Zulu"}""")), - new PartitionKey("a")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","first":"Alice","last":"Baker"}""")), - new PartitionKey("a")); - - var results = await Q( - "SELECT * FROM c ORDER BY CONCAT(c.first, ' ', c.last) ASC"); - - results[0]["id"]!.ToString().Should().Be("2"); // Alice Baker - results[1]["id"]!.ToString().Should().Be("1"); // Charlie Zulu - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task OrderBy_TernaryExpression_SortsCorrectly() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", active = true, value = 30 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", active = false, value = 10 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", active = true, value = 20 }), new PartitionKey("a")); + + // Active items get priority (sort key 0), inactive get sort key 1 + var results = await Q( + "SELECT * FROM c ORDER BY (c.active ? 0 : 1) ASC, c.value ASC"); + + // Active items first sorted by value, then inactive + results[0]["id"]!.ToString().Should().Be("3"); // active, val=20 + results[1]["id"]!.ToString().Should().Be("1"); // active, val=30 + results[2]["id"]!.ToString().Should().Be("2"); // inactive, val=10 + } + + [Fact] + public async Task OrderBy_CoalesceExpression_SortsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","nick":"Zorro"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","nick":"Bob","name":"Robert"}""")), + new PartitionKey("a")); + + // Use nick if available, else name + var results = await Q( + "SELECT * FROM c ORDER BY (c.nick ?? c.name) ASC"); + + // id=2: nick=undef, name=Alice → "Alice" + // id=3: nick=Bob → "Bob" + // id=1: nick=Zorro → "Zorro" + results[0]["id"]!.ToString().Should().Be("2"); // Alice + results[1]["id"]!.ToString().Should().Be("3"); // Bob + results[2]["id"]!.ToString().Should().Be("1"); // Zorro + } + + [Fact] + public async Task OrderBy_ConcatExpression_SortsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","first":"Charlie","last":"Zulu"}""")), + new PartitionKey("a")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","first":"Alice","last":"Baker"}""")), + new PartitionKey("a")); + + var results = await Q( + "SELECT * FROM c ORDER BY CONCAT(c.first, ' ', c.last) ASC"); + + results[0]["id"]!.ToString().Should().Be("2"); // Alice Baker + results[1]["id"]!.ToString().Should().Be("1"); // Charlie Zulu + } } public class QueryDeepDiveV22_PaginationWithComplexQueriesTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task ContinuationToken_WithOrderBy_MaintainsOrder() - { - for (var i = 1; i <= 6; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "a", val = i * 10 }), - new PartitionKey("a")); - - var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }; - - var allResults = new List(); - string? token = null; - - do - { - var it = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c ORDER BY c.val DESC"), - continuationToken: token, - requestOptions: opts); - var page = await it.ReadNextAsync(); - allResults.AddRange(page); - token = page.ContinuationToken; - } while (token != null); - - allResults.Should().HaveCount(6); - // Verify descending order maintained across pages - allResults.Select(r => r["val"]!.Value()).Should().ContainInOrder(60, 50, 40, 30, 20, 10); - } - - [Fact] - public async Task ContinuationToken_WithWhere_NoDuplicatesOrMisses() - { - for (var i = 1; i <= 10; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "a", val = i }), - new PartitionKey("a")); - - var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 3 }; - - var allResults = new List(); - string? token = null; - - do - { - var it = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.val > 3 ORDER BY c.val ASC"), - continuationToken: token, - requestOptions: opts); - var page = await it.ReadNextAsync(); - allResults.AddRange(page); - token = page.ContinuationToken; - } while (token != null); - - allResults.Should().HaveCount(7); // items 4-10 - allResults.Select(r => r["id"]!.ToString()).Should().OnlyHaveUniqueItems("no duplicates"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task ContinuationToken_WithOrderBy_MaintainsOrder() + { + for (var i = 1; i <= 6; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", val = i * 10 }), + new PartitionKey("a")); + + var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }; + + var allResults = new List(); + string? token = null; + + do + { + var it = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c ORDER BY c.val DESC"), + continuationToken: token, + requestOptions: opts); + var page = await it.ReadNextAsync(); + allResults.AddRange(page); + token = page.ContinuationToken; + } while (token != null); + + allResults.Should().HaveCount(6); + // Verify descending order maintained across pages + allResults.Select(r => r["val"]!.Value()).Should().ContainInOrder(60, 50, 40, 30, 20, 10); + } + + [Fact] + public async Task ContinuationToken_WithWhere_NoDuplicatesOrMisses() + { + for (var i = 1; i <= 10; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", val = i }), + new PartitionKey("a")); + + var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 3 }; + + var allResults = new List(); + string? token = null; + + do + { + var it = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.val > 3 ORDER BY c.val ASC"), + continuationToken: token, + requestOptions: opts); + var page = await it.ReadNextAsync(); + allResults.AddRange(page); + token = page.ContinuationToken; + } while (token != null); + + allResults.Should().HaveCount(7); // items 4-10 + allResults.Select(r => r["id"]!.ToString()).Should().OnlyHaveUniqueItems("no duplicates"); + } } public class QueryDeepDiveV22_GroupByKeyEdgeTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task GroupBy_EmptyStringKey_FormsOwnGroup() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","val":20}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"","val":30}""")), - new PartitionKey("pk1")); - - var results = await Q( - "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat"); - - results.Should().HaveCount(2); - var empty = results.FirstOrDefault(r => r["cat"]?.ToString() == ""); - empty.Should().NotBeNull(); - empty!["total"]!.Value().Should().Be(40); - } - - [Fact] - public async Task GroupBy_BooleanKey_FormsTwoGroups() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true,"val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false,"val":20}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","active":true,"val":30}""")), - new PartitionKey("pk1")); - - var results = await Q( - "SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task GroupBy_NullAndUndefined_FormSeparateGroups() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null,"val":20}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":30}""")), - new PartitionKey("pk1")); // no cat field — undefined - - var results = await Q( - "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); - - // 3 groups: "A", null, undefined - results.Should().HaveCount(3); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task GroupBy_EmptyStringKey_FormsOwnGroup() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":"A","val":20}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","cat":"","val":30}""")), + new PartitionKey("pk1")); + + var results = await Q( + "SELECT c.cat, SUM(c.val) AS total FROM c GROUP BY c.cat"); + + results.Should().HaveCount(2); + var empty = results.FirstOrDefault(r => r["cat"]?.ToString() == ""); + empty.Should().NotBeNull(); + empty!["total"]!.Value().Should().Be(40); + } + + [Fact] + public async Task GroupBy_BooleanKey_FormsTwoGroups() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","active":true,"val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","active":false,"val":20}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","active":true,"val":30}""")), + new PartitionKey("pk1")); + + var results = await Q( + "SELECT c.active, COUNT(1) AS cnt FROM c GROUP BY c.active"); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task GroupBy_NullAndUndefined_FormSeparateGroups() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","cat":"A","val":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","cat":null,"val":20}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1","val":30}""")), + new PartitionKey("pk1")); // no cat field — undefined + + var results = await Q( + "SELECT c.cat, COUNT(1) AS cnt FROM c GROUP BY c.cat"); + + // 3 groups: "A", null, undefined + results.Should().HaveCount(3); + } } public class QueryDeepDiveV22_SelectEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private async Task> Q(string sql) - { - var it = _container.GetItemQueryIterator(sql); - var r = new List(); - while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); - return r; - } - - [Fact] - public async Task SelectValue_Null_ReturnsSingleNull() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var results = await Q("SELECT VALUE null FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Where_InWithNull_MatchesNullValue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":"active"}""")), - new PartitionKey("pk1")); - - var results = await Q("SELECT * FROM c WHERE c.status IN (null, 'active')"); - results.Should().HaveCount(2); - } - - [Fact] - public async Task Select_WithPartitionKeyAndTop_Combined() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "a", val = i }), - new PartitionKey("a")); - - var it = _container.GetItemQueryIterator( - "SELECT TOP 3 * FROM c ORDER BY c.val DESC", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); - - results.Should().HaveCount(3); - results[0]["val"]!.Value().Should().Be(5); - } - - [Fact] - public async Task StreamIterator_WithOrderBy_ReturnsCorrectEnvelope() - { - for (var i = 1; i <= 3; i++) - await _container.CreateItemAsync( - JObject.FromObject(new { id = i.ToString(), pk = "a", val = i * 10 }), - new PartitionKey("a")); - - var it = _container.GetItemQueryStreamIterator( - new QueryDefinition("SELECT * FROM c ORDER BY c.val DESC"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - using var response = await it.ReadNextAsync(); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - var docs = (JArray)jObj["Documents"]!; - docs.Should().HaveCount(3); - // Verify ordering in stream result - docs[0]["val"]!.Value().Should().Be(30); - docs[2]["val"]!.Value().Should().Be(10); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private async Task> Q(string sql) + { + var it = _container.GetItemQueryIterator(sql); + var r = new List(); + while (it.HasMoreResults) r.AddRange(await it.ReadNextAsync()); + return r; + } + + [Fact] + public async Task SelectValue_Null_ReturnsSingleNull() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var results = await Q("SELECT VALUE null FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Where_InWithNull_MatchesNullValue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","status":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","status":"active"}""")), + new PartitionKey("pk1")); + + var results = await Q("SELECT * FROM c WHERE c.status IN (null, 'active')"); + results.Should().HaveCount(2); + } + + [Fact] + public async Task Select_WithPartitionKeyAndTop_Combined() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", val = i }), + new PartitionKey("a")); + + var it = _container.GetItemQueryIterator( + "SELECT TOP 3 * FROM c ORDER BY c.val DESC", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (it.HasMoreResults) results.AddRange(await it.ReadNextAsync()); + + results.Should().HaveCount(3); + results[0]["val"]!.Value().Should().Be(5); + } + + [Fact] + public async Task StreamIterator_WithOrderBy_ReturnsCorrectEnvelope() + { + for (var i = 1; i <= 3; i++) + await _container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", val = i * 10 }), + new PartitionKey("a")); + + var it = _container.GetItemQueryStreamIterator( + new QueryDefinition("SELECT * FROM c ORDER BY c.val DESC"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + using var response = await it.ReadNextAsync(); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + var docs = (JArray)jObj["Documents"]!; + docs.Should().HaveCount(3); + // Verify ordering in stream result + docs[0]["val"]!.Value().Should().Be(30); + docs[2]["val"]!.Value().Should().Be(10); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -27698,64 +27712,64 @@ await _container.CreateItemAsync( /// public class QueryDeepDiveV20_DistinctOffsetLimitTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - // Create docs with duplicate category values: A,A,B,B,C,C,D,D - var docs = new[] - { - new { id = "1", pk = "p", cat = "Alpha" }, - new { id = "2", pk = "p", cat = "Alpha" }, - new { id = "3", pk = "p", cat = "Beta" }, - new { id = "4", pk = "p", cat = "Beta" }, - new { id = "5", pk = "p", cat = "Gamma" }, - new { id = "6", pk = "p", cat = "Gamma" }, - new { id = "7", pk = "p", cat = "Delta" }, - new { id = "8", pk = "p", cat = "Delta" }, - }; - foreach (var d in docs) - await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task DistinctOffset_WithDuplicates_AppliesDistinctFirst() - { - // DISTINCT should yield [Alpha, Beta, Gamma, Delta], then OFFSET 2 → [Gamma, Delta] - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 2 LIMIT 10"); - results.Should().BeEquivalentTo(["Gamma", "Delta"]); - } - - [Fact] - public async Task DistinctOffsetLimit_WithDuplicates_CorrectSubset() - { - // DISTINCT → [Alpha, Beta, Delta, Gamma] (sorted), then OFFSET 1 LIMIT 2 → [Beta, Delta] - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 1 LIMIT 2"); - results.Should().BeEquivalentTo(["Beta", "Delta"]); - } - - [Fact] - public async Task DistinctOffset_BeyondDistinctCount_ReturnsEmpty() - { - // DISTINCT → 4 items, OFFSET 10 → empty - await Seed(); - var results = await RunQuery( - "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 10 LIMIT 5"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + // Create docs with duplicate category values: A,A,B,B,C,C,D,D + var docs = new[] + { + new { id = "1", pk = "p", cat = "Alpha" }, + new { id = "2", pk = "p", cat = "Alpha" }, + new { id = "3", pk = "p", cat = "Beta" }, + new { id = "4", pk = "p", cat = "Beta" }, + new { id = "5", pk = "p", cat = "Gamma" }, + new { id = "6", pk = "p", cat = "Gamma" }, + new { id = "7", pk = "p", cat = "Delta" }, + new { id = "8", pk = "p", cat = "Delta" }, + }; + foreach (var d in docs) + await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task DistinctOffset_WithDuplicates_AppliesDistinctFirst() + { + // DISTINCT should yield [Alpha, Beta, Gamma, Delta], then OFFSET 2 → [Gamma, Delta] + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 2 LIMIT 10"); + results.Should().BeEquivalentTo(["Gamma", "Delta"]); + } + + [Fact] + public async Task DistinctOffsetLimit_WithDuplicates_CorrectSubset() + { + // DISTINCT → [Alpha, Beta, Delta, Gamma] (sorted), then OFFSET 1 LIMIT 2 → [Beta, Delta] + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 1 LIMIT 2"); + results.Should().BeEquivalentTo(["Beta", "Delta"]); + } + + [Fact] + public async Task DistinctOffset_BeyondDistinctCount_ReturnsEmpty() + { + // DISTINCT → 4 items, OFFSET 10 → empty + await Seed(); + var results = await RunQuery( + "SELECT DISTINCT VALUE c.cat FROM c ORDER BY c.cat OFFSET 10 LIMIT 5"); + results.Should().BeEmpty(); + } } /// @@ -27766,63 +27780,63 @@ public async Task DistinctOffset_BeyondDistinctCount_ReturnsEmpty() /// public class QueryDeepDiveV20_HavingCountNullTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - var docs = new[] - { - new { id = "1", pk = "p", cat = "A", opt = (int?)10 }, - new { id = "2", pk = "p", cat = "A", opt = (int?)null }, - new { id = "3", pk = "p", cat = "A", opt = (int?)30 }, - new { id = "4", pk = "p", cat = "B", opt = (int?)null }, - new { id = "5", pk = "p", cat = "B", opt = (int?)null }, - new { id = "6", pk = "p", cat = "C", opt = (int?)10 }, - }; - foreach (var d in docs) - await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Having_CountField_ExcludesNullValues() - { - // cat A: opt defined & not null in 2 items (id=1,3). opt=null in id=2 should not count. - // cat B: opt is null for both → COUNT(c.opt) = 0 - // cat C: opt defined in 1 item → COUNT(c.opt) = 1 - // HAVING COUNT(c.opt) >= 2 should return only cat A - await Seed(); - var results = await RunQuery( - "SELECT c.cat, COUNT(c.opt) as cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); - results.Should().HaveCount(1); - results[0]["cat"]!.Value().Should().Be("A"); - results[0]["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task Having_CountField_NullVsUndefined_BothExcluded() - { - // Add a doc where the field is entirely missing (undefined) - await Seed(); - await _container.CreateItemAsync( - JObject.FromObject(new { id = "7", pk = "p", cat = "A" }), // opt is undefined - new PartitionKey("p")); - - // cat A now has: opt=10, opt=null, opt=30, opt=undefined → COUNT(c.opt) should be 2 - var results = await RunQuery( - "SELECT c.cat, COUNT(c.opt) as cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); - results.Should().HaveCount(1); - results[0]["cat"]!.Value().Should().Be("A"); - results[0]["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + var docs = new[] + { + new { id = "1", pk = "p", cat = "A", opt = (int?)10 }, + new { id = "2", pk = "p", cat = "A", opt = (int?)null }, + new { id = "3", pk = "p", cat = "A", opt = (int?)30 }, + new { id = "4", pk = "p", cat = "B", opt = (int?)null }, + new { id = "5", pk = "p", cat = "B", opt = (int?)null }, + new { id = "6", pk = "p", cat = "C", opt = (int?)10 }, + }; + foreach (var d in docs) + await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Having_CountField_ExcludesNullValues() + { + // cat A: opt defined & not null in 2 items (id=1,3). opt=null in id=2 should not count. + // cat B: opt is null for both → COUNT(c.opt) = 0 + // cat C: opt defined in 1 item → COUNT(c.opt) = 1 + // HAVING COUNT(c.opt) >= 2 should return only cat A + await Seed(); + var results = await RunQuery( + "SELECT c.cat, COUNT(c.opt) as cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); + results.Should().HaveCount(1); + results[0]["cat"]!.Value().Should().Be("A"); + results[0]["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task Having_CountField_NullVsUndefined_BothExcluded() + { + // Add a doc where the field is entirely missing (undefined) + await Seed(); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "7", pk = "p", cat = "A" }), // opt is undefined + new PartitionKey("p")); + + // cat A now has: opt=10, opt=null, opt=30, opt=undefined → COUNT(c.opt) should be 2 + var results = await RunQuery( + "SELECT c.cat, COUNT(c.opt) as cnt FROM c GROUP BY c.cat HAVING COUNT(c.opt) >= 2"); + results.Should().HaveCount(1); + results[0]["cat"]!.Value().Should().Be("A"); + results[0]["cnt"]!.Value().Should().Be(2); + } } /// @@ -27833,77 +27847,77 @@ await _container.CreateItemAsync( /// public class QueryDeepDiveV20_StringFunctionTypeSafetyTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", num = 42, str = "Hello", flag = true, arr = new[] { 1, 2 } }), - new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Lower_NumberArg_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT LOWER(c.num) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("LOWER of a number should produce undefined (omitted from output)"); - } - - [Fact] - public async Task Upper_BoolArg_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT UPPER(c.flag) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("UPPER of a boolean should produce undefined (omitted from output)"); - } - - [Fact] - public async Task Trim_NumberArg_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT TRIM(c.num) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("TRIM of a number should produce undefined (omitted from output)"); - } - - [Fact] - public async Task Ltrim_ArrayArg_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT LTRIM(c.arr) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("LTRIM of an array should produce undefined (omitted from output)"); - } - - [Fact] - public async Task Rtrim_BoolArg_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT RTRIM(c.flag) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("RTRIM of a boolean should produce undefined (omitted from output)"); - } - - [Fact] - public async Task Lower_StringArg_StillWorks() - { - await Seed(); - var results = await RunQuery("SELECT LOWER(c.str) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"]!.Value().Should().Be("hello"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", num = 42, str = "Hello", flag = true, arr = new[] { 1, 2 } }), + new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Lower_NumberArg_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT LOWER(c.num) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("LOWER of a number should produce undefined (omitted from output)"); + } + + [Fact] + public async Task Upper_BoolArg_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT UPPER(c.flag) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("UPPER of a boolean should produce undefined (omitted from output)"); + } + + [Fact] + public async Task Trim_NumberArg_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT TRIM(c.num) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("TRIM of a number should produce undefined (omitted from output)"); + } + + [Fact] + public async Task Ltrim_ArrayArg_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT LTRIM(c.arr) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("LTRIM of an array should produce undefined (omitted from output)"); + } + + [Fact] + public async Task Rtrim_BoolArg_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT RTRIM(c.flag) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("RTRIM of a boolean should produce undefined (omitted from output)"); + } + + [Fact] + public async Task Lower_StringArg_StillWorks() + { + await Seed(); + var results = await RunQuery("SELECT LOWER(c.str) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"]!.Value().Should().Be("hello"); + } } /// @@ -27913,50 +27927,50 @@ public async Task Lower_StringArg_StillWorks() /// public class QueryDeepDiveV20_ConcatTypeSafetyTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", name = "item", num = 42, flag = true }), - new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Concat_WithNumber_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT CONCAT(c.name, '-', c.num) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("CONCAT with a numeric arg should return undefined"); - } - - [Fact] - public async Task Concat_WithBoolean_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT CONCAT(c.name, '-', c.flag) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("CONCAT with a boolean arg should return undefined"); - } - - [Fact] - public async Task Concat_AllStrings_Works() - { - await Seed(); - var results = await RunQuery("SELECT CONCAT(c.name, '-', 'suffix') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"]!.Value().Should().Be("item-suffix"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", name = "item", num = 42, flag = true }), + new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Concat_WithNumber_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT CONCAT(c.name, '-', c.num) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("CONCAT with a numeric arg should return undefined"); + } + + [Fact] + public async Task Concat_WithBoolean_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT CONCAT(c.name, '-', c.flag) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("CONCAT with a boolean arg should return undefined"); + } + + [Fact] + public async Task Concat_AllStrings_Works() + { + await Seed(); + var results = await RunQuery("SELECT CONCAT(c.name, '-', 'suffix') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"]!.Value().Should().Be("item-suffix"); + } } /// @@ -27967,68 +27981,68 @@ public async Task Concat_AllStrings_Works() /// public class QueryDeepDiveV20_StringComparisonTypeSafetyTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", num = 123, str = "Hello World" }), - new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task StartsWith_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT STARTSWITH(c.num, '1') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("STARTSWITH with numeric target should return undefined"); - } - - [Fact] - public async Task EndsWith_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT ENDSWITH(c.num, '3') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("ENDSWITH with numeric target should return undefined"); - } - - [Fact] - public async Task Contains_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT CONTAINS(c.num, '2') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("CONTAINS with numeric target should return undefined"); - } - - [Fact] - public async Task StartsWith_NumberSearch_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT STARTSWITH(c.str, c.num) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("STARTSWITH with numeric search arg should return undefined"); - } - - [Fact] - public async Task Contains_StringArgs_StillWorks() - { - await Seed(); - var results = await RunQuery("SELECT CONTAINS(c.str, 'World') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"]!.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", num = 123, str = "Hello World" }), + new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task StartsWith_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT STARTSWITH(c.num, '1') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("STARTSWITH with numeric target should return undefined"); + } + + [Fact] + public async Task EndsWith_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT ENDSWITH(c.num, '3') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("ENDSWITH with numeric target should return undefined"); + } + + [Fact] + public async Task Contains_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT CONTAINS(c.num, '2') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("CONTAINS with numeric target should return undefined"); + } + + [Fact] + public async Task StartsWith_NumberSearch_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT STARTSWITH(c.str, c.num) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("STARTSWITH with numeric search arg should return undefined"); + } + + [Fact] + public async Task Contains_StringArgs_StillWorks() + { + await Seed(); + var results = await RunQuery("SELECT CONTAINS(c.str, 'World') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"]!.Value().Should().BeTrue(); + } } /// @@ -28038,59 +28052,59 @@ public async Task Contains_StringArgs_StillWorks() /// public class QueryDeepDiveV20_SubstringReplaceTypeSafetyTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", num = 12345, str = "Hello" }), - new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Substring_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT SUBSTRING(c.num, 0, 2) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("SUBSTRING with numeric arg should return undefined"); - } - - [Fact] - public async Task Replace_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT REPLACE(c.num, '1', '9') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("REPLACE with numeric target should return undefined"); - } - - [Fact] - public async Task IndexOf_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT INDEX_OF(c.num, '2') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("INDEX_OF with numeric target should return undefined"); - } - - [Fact] - public async Task Substring_StringArg_StillWorks() - { - await Seed(); - var results = await RunQuery("SELECT SUBSTRING(c.str, 1, 3) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"]!.Value().Should().Be("ell"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", num = 12345, str = "Hello" }), + new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Substring_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT SUBSTRING(c.num, 0, 2) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("SUBSTRING with numeric arg should return undefined"); + } + + [Fact] + public async Task Replace_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT REPLACE(c.num, '1', '9') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("REPLACE with numeric target should return undefined"); + } + + [Fact] + public async Task IndexOf_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT INDEX_OF(c.num, '2') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("INDEX_OF with numeric target should return undefined"); + } + + [Fact] + public async Task Substring_StringArg_StillWorks() + { + await Seed(); + var results = await RunQuery("SELECT SUBSTRING(c.str, 1, 3) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"]!.Value().Should().Be("ell"); + } } /// @@ -28099,68 +28113,68 @@ public async Task Substring_StringArg_StillWorks() /// public class QueryDeepDiveV20_ReplicateStringEqualsTypeSafetyTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "p", num = 42, str = "abc" }), - new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Replicate_NumberTarget_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT REPLICATE(c.num, 3) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("REPLICATE with numeric target should return undefined"); - } - - [Fact] - public async Task StringEquals_NumberArgs_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT STRING_EQUALS(c.num, '42') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("STRING_EQUALS with numeric arg should return undefined"); - } - - [Fact] - public async Task RegexMatch_NumberInput_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT RegexMatch(c.num, '\\\\d+') as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("RegexMatch with numeric input should return undefined"); - } - - [Fact] - public async Task Replicate_ExactlyAtLimit_Works() - { - await Seed(); - var results = await RunQuery("SELECT REPLICATE('x', 10000) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"]!.Value().Should().HaveLength(10000); - } - - [Fact] - public async Task Replicate_Over10000_ReturnsUndefined() - { - await Seed(); - var results = await RunQuery("SELECT REPLICATE('x', 10001) as v FROM c"); - results.Should().HaveCount(1); - results[0]["v"].Should().BeNull("REPLICATE count > 10000 should return undefined"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", num = 42, str = "abc" }), + new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Replicate_NumberTarget_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT REPLICATE(c.num, 3) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("REPLICATE with numeric target should return undefined"); + } + + [Fact] + public async Task StringEquals_NumberArgs_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT STRING_EQUALS(c.num, '42') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("STRING_EQUALS with numeric arg should return undefined"); + } + + [Fact] + public async Task RegexMatch_NumberInput_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT RegexMatch(c.num, '\\\\d+') as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("RegexMatch with numeric input should return undefined"); + } + + [Fact] + public async Task Replicate_ExactlyAtLimit_Works() + { + await Seed(); + var results = await RunQuery("SELECT REPLICATE('x', 10000) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"]!.Value().Should().HaveLength(10000); + } + + [Fact] + public async Task Replicate_Over10000_ReturnsUndefined() + { + await Seed(); + var results = await RunQuery("SELECT REPLICATE('x', 10001) as v FROM c"); + results.Should().HaveCount(1); + results[0]["v"].Should().BeNull("REPLICATE count > 10000 should return undefined"); + } } /// @@ -28168,66 +28182,66 @@ public async Task Replicate_Over10000_ReturnsUndefined() /// public class QueryDeepDiveV20_ComplexInteractionTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - var docs = new[] - { - new { id = "1", pk = "p", cat = "fruit", name = "apple", tags = new[] { "red", "sweet" }, price = 1.5 }, - new { id = "2", pk = "p", cat = "fruit", name = "banana", tags = new[] { "yellow", "sweet" }, price = 0.8 }, - new { id = "3", pk = "p", cat = "fruit", name = "cherry", tags = new[] { "red", "sour" }, price = 3.0 }, - new { id = "4", pk = "p", cat = "veggie", name = "carrot", tags = new[] { "orange" }, price = 1.2 }, - new { id = "5", pk = "p", cat = "veggie", name = "broccoli", tags = new[] { "green" }, price = 2.5 }, - new { id = "6", pk = "p", cat = "veggie", name = "pepper", tags = new[] { "red", "green" }, price = 1.8 }, - }; - foreach (var d in docs) - await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); - } - - private async Task> RunQuery(string sql) - { - var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Join_GroupBy_Having_OrderBy_Combined() - { - await Seed(); - // JOIN tags, GROUP BY tag, HAVING count > 1, ORDER BY count DESC - var results = await RunQuery( - "SELECT t as tag, COUNT(1) as cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) > 1 ORDER BY COUNT(1) DESC"); - results.Should().HaveCountGreaterThanOrEqualTo(2); - // "red" appears in 3 docs (apple, cherry, pepper), "sweet" in 2 (apple, banana), "green" in 2 (broccoli, pepper) - results[0]["tag"]!.Value().Should().Be("red"); - results[0]["cnt"]!.Value().Should().Be(3); - } - - [Fact] - public async Task OrderBy_ArrayField_TypeRanking() - { - // Insert docs with different value types for the same field - var container = new InMemoryContainer("test2", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", val = 42 }), new PartitionKey("p")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "p", val = "hello" }), new PartitionKey("p")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "p", val = true }), new PartitionKey("p")); - await container.CreateItemAsync(JObject.Parse("{\"id\":\"4\",\"pk\":\"p\",\"val\":null}"), new PartitionKey("p")); - - var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT c.id, c.val FROM c ORDER BY c.val ASC"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - - // Type ranking: null < bool < number < string - results[0]["id"]!.Value().Should().Be("4"); // null - results[1]["id"]!.Value().Should().Be("3"); // bool - results[2]["id"]!.Value().Should().Be("1"); // number - results[3]["id"]!.Value().Should().Be("2"); // string - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + var docs = new[] + { + new { id = "1", pk = "p", cat = "fruit", name = "apple", tags = new[] { "red", "sweet" }, price = 1.5 }, + new { id = "2", pk = "p", cat = "fruit", name = "banana", tags = new[] { "yellow", "sweet" }, price = 0.8 }, + new { id = "3", pk = "p", cat = "fruit", name = "cherry", tags = new[] { "red", "sour" }, price = 3.0 }, + new { id = "4", pk = "p", cat = "veggie", name = "carrot", tags = new[] { "orange" }, price = 1.2 }, + new { id = "5", pk = "p", cat = "veggie", name = "broccoli", tags = new[] { "green" }, price = 2.5 }, + new { id = "6", pk = "p", cat = "veggie", name = "pepper", tags = new[] { "red", "green" }, price = 1.8 }, + }; + foreach (var d in docs) + await _container.CreateItemAsync(JObject.FromObject(d), new PartitionKey("p")); + } + + private async Task> RunQuery(string sql) + { + var iter = _container.GetItemQueryIterator(new QueryDefinition(sql), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Join_GroupBy_Having_OrderBy_Combined() + { + await Seed(); + // JOIN tags, GROUP BY tag, HAVING count > 1, ORDER BY count DESC + var results = await RunQuery( + "SELECT t as tag, COUNT(1) as cnt FROM c JOIN t IN c.tags GROUP BY t HAVING COUNT(1) > 1 ORDER BY COUNT(1) DESC"); + results.Should().HaveCountGreaterThanOrEqualTo(2); + // "red" appears in 3 docs (apple, cherry, pepper), "sweet" in 2 (apple, banana), "green" in 2 (broccoli, pepper) + results[0]["tag"]!.Value().Should().Be("red"); + results[0]["cnt"]!.Value().Should().Be(3); + } + + [Fact] + public async Task OrderBy_ArrayField_TypeRanking() + { + // Insert docs with different value types for the same field + var container = new InMemoryContainer("test2", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p", val = 42 }), new PartitionKey("p")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "p", val = "hello" }), new PartitionKey("p")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "p", val = true }), new PartitionKey("p")); + await container.CreateItemAsync(JObject.Parse("{\"id\":\"4\",\"pk\":\"p\",\"val\":null}"), new PartitionKey("p")); + + var iter = container.GetItemQueryIterator(new QueryDefinition("SELECT c.id, c.val FROM c ORDER BY c.val ASC"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("p") }); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + // Type ranking: null < bool < number < string + results[0]["id"]!.Value().Should().Be("4"); // null + results[1]["id"]!.Value().Should().Be("3"); // bool + results[2]["id"]!.Value().Should().Be("1"); // number + results[3]["id"]!.Value().Should().Be("2"); // string + } } // ═══════════════════════════════════════════════════════════════════════ @@ -28236,284 +28250,285 @@ public async Task OrderBy_ArrayField_TypeRanking() public class QueryDeepDiveV23_TypeFunctionBugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"flag":true,"tags":["a"],"nested":{"x":1}}""")), - new PartitionKey("pk1")); - } - - // Bug 1: TYPE() catch-all branch returns "Undefined" (capital U) instead of "undefined" - [Fact] - public async Task Type_ReturnsCorrectStrings_ForAllTypes() - { - await Seed(); - - var nullResult = await RunQuery("SELECT VALUE TYPE(null) FROM c"); - nullResult.Should().ContainSingle().Which.Should().Be("null"); - - var boolResult = await RunQuery("SELECT VALUE TYPE(true) FROM c"); - boolResult.Should().ContainSingle().Which.Should().Be("boolean"); - - var numberResult = await RunQuery("SELECT VALUE TYPE(42) FROM c"); - numberResult.Should().ContainSingle().Which.Should().Be("number"); - - var stringResult = await RunQuery("SELECT VALUE TYPE('hello') FROM c"); - stringResult.Should().ContainSingle().Which.Should().Be("string"); - - var arrayResult = await RunQuery("SELECT VALUE TYPE(c.tags) FROM c"); - arrayResult.Should().ContainSingle().Which.Should().Be("array"); - - var objectResult = await RunQuery("SELECT VALUE TYPE(c.nested) FROM c"); - objectResult.Should().ContainSingle().Which.Should().Be("object"); - } - - [Fact] - public async Task Type_UndefinedField_ReturnsNoRows() - { - await Seed(); - // TYPE(undefined) returns undefined per Cosmos spec, which means the row is excluded from SELECT VALUE - var result = await RunQuery("SELECT VALUE TYPE(c.nonexistent) FROM c"); - result.Should().BeEmpty(); - } - - [Fact] - public async Task Type_NumberField_ReturnsNumber() - { - await Seed(); - var result = await RunQuery("SELECT VALUE TYPE(c.value) FROM c"); - result.Should().ContainSingle().Which.Should().Be("number"); - } - - [Fact] - public async Task Type_BoolField_ReturnsBoolean() - { - await Seed(); - var result = await RunQuery("SELECT VALUE TYPE(c.flag) FROM c"); - result.Should().ContainSingle().Which.Should().Be("boolean"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":"Alice","value":10,"flag":true,"tags":["a"],"nested":{"x":1}}""")), + new PartitionKey("pk1")); + } + + // Bug 1: TYPE() catch-all branch returns "Undefined" (capital U) instead of "undefined" + [Fact] + public async Task Type_ReturnsCorrectStrings_ForAllTypes() + { + await Seed(); + + var nullResult = await RunQuery("SELECT VALUE TYPE(null) FROM c"); + nullResult.Should().ContainSingle().Which.Should().Be("null"); + + var boolResult = await RunQuery("SELECT VALUE TYPE(true) FROM c"); + boolResult.Should().ContainSingle().Which.Should().Be("boolean"); + + var numberResult = await RunQuery("SELECT VALUE TYPE(42) FROM c"); + numberResult.Should().ContainSingle().Which.Should().Be("number"); + + var stringResult = await RunQuery("SELECT VALUE TYPE('hello') FROM c"); + stringResult.Should().ContainSingle().Which.Should().Be("string"); + + var arrayResult = await RunQuery("SELECT VALUE TYPE(c.tags) FROM c"); + arrayResult.Should().ContainSingle().Which.Should().Be("array"); + + var objectResult = await RunQuery("SELECT VALUE TYPE(c.nested) FROM c"); + objectResult.Should().ContainSingle().Which.Should().Be("object"); + } + + [Fact] + public async Task Type_UndefinedField_ReturnsNoRows() + { + await Seed(); + // TYPE(undefined) returns undefined per Cosmos spec, which means the row is excluded from SELECT VALUE + var result = await RunQuery("SELECT VALUE TYPE(c.nonexistent) FROM c"); + result.Should().BeEmpty(); + } + + [Fact] + public async Task Type_NumberField_ReturnsNumber() + { + await Seed(); + var result = await RunQuery("SELECT VALUE TYPE(c.value) FROM c"); + result.Should().ContainSingle().Which.Should().Be("number"); + } + + [Fact] + public async Task Type_BoolField_ReturnsBoolean() + { + await Seed(); + var result = await RunQuery("SELECT VALUE TYPE(c.flag) FROM c"); + result.Should().ContainSingle().Which.Should().Be("boolean"); + } } public class QueryDeepDiveV23_GetCurrentDateTimeBugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // Bug 2: GETCURRENTDATETIME uses static cached value (same as STATIC variant) - // The non-static version should NOT use __staticDateTime parameter - [Fact] - public async Task GetCurrentDateTimeStatic_ReturnsSameValueForAllRows() - { - // Seed multiple items - for (var i = 1; i <= 5; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentDateTimeStatic() FROM c"); - results.Should().HaveCount(5); - // All values should be identical for STATIC variant - results.Distinct().Should().HaveCount(1); - } - - [Fact] - public async Task GetCurrentTimestampStatic_ReturnsSameValueForAllRows() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentTimestampStatic() FROM c"); - results.Should().HaveCount(5); - results.Distinct().Should().HaveCount(1); - } - - [Fact] - public async Task GetCurrentTicksStatic_ReturnsSameValueForAllRows() - { - for (var i = 1; i <= 5; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentTicksStatic() FROM c"); - results.Should().HaveCount(5); - results.Distinct().Should().HaveCount(1); - } - - [Fact] - public async Task GetCurrentDateTime_ReturnsValidDateTimeString() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c"); - results.Should().ContainSingle(); - // Should be a valid ISO 8601 datetime - DateTime.TryParse(results[0], out _).Should().BeTrue(); - } - - [Fact] - public async Task GetCurrentTimestamp_ReturnsReasonableValue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c"); - results.Should().ContainSingle(); - // Should be a valid Unix epoch millis (after 2020 = 1577836800000) - results[0].Should().BeGreaterThan(1577836800000L); - } - - [Fact] - public async Task GetCurrentTicks_ReturnsReasonableValue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c"); - results.Should().ContainSingle(); - // Should be a valid ticks value (after 2020) - results[0].Should().BeGreaterThan(new DateTime(2020, 1, 1).Ticks); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // Bug 2: GETCURRENTDATETIME uses static cached value (same as STATIC variant) + // The non-static version should NOT use __staticDateTime parameter + [Fact] + public async Task GetCurrentDateTimeStatic_ReturnsSameValueForAllRows() + { + // Seed multiple items + for (var i = 1; i <= 5; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentDateTimeStatic() FROM c"); + results.Should().HaveCount(5); + // All values should be identical for STATIC variant + results.Distinct().Should().HaveCount(1); + } + + [Fact] + public async Task GetCurrentTimestampStatic_ReturnsSameValueForAllRows() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentTimestampStatic() FROM c"); + results.Should().HaveCount(5); + results.Distinct().Should().HaveCount(1); + } + + [Fact] + public async Task GetCurrentTicksStatic_ReturnsSameValueForAllRows() + { + for (var i = 1; i <= 5; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"id\":\"{i}\",\"partitionKey\":\"pk1\"}}")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentTicksStatic() FROM c"); + results.Should().HaveCount(5); + results.Distinct().Should().HaveCount(1); + } + + [Fact] + public async Task GetCurrentDateTime_ReturnsValidDateTimeString() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentDateTime() FROM c"); + results.Should().ContainSingle(); + // Should be a valid ISO 8601 datetime + DateTime.TryParse(results[0], out _).Should().BeTrue(); + } + + [Fact] + public async Task GetCurrentTimestamp_ReturnsReasonableValue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentTimestamp() FROM c"); + results.Should().ContainSingle(); + // Should be a valid Unix epoch millis (after 2020 = 1577836800000) + results[0].Should().BeGreaterThan(1577836800000L); + } + + [Fact] + public async Task GetCurrentTicks_ReturnsReasonableValue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE GetCurrentTicks() FROM c"); + results.Should().ContainSingle(); + // Should be a valid ticks value (after 2020) + results[0].Should().BeGreaterThan(new DateTime(2020, 1, 1).Ticks); + } } public class QueryDeepDiveV23_MathOpIntegerPreservationBugTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task SeedIntItem() - { - // Seed an item with an integer value (will be stored as long) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","intVal":5,"negVal":-3}""")), - new PartitionKey("pk1")); - } - - // Bug 3: MathOp always returns double, never preserves integer type - [Fact] - public async Task Abs_IntegerInput_ReturnsInteger() - { - await SeedIntItem(); - var results = await RunQuery("SELECT VALUE ABS(c.negVal) FROM c"); - results.Should().ContainSingle(); - // ABS(-3) where -3 is an integer should return integer 3, not 3.0 - results[0].Type.Should().Be(JTokenType.Integer); - results[0].Value().Should().Be(3); - } - - [Fact] - public async Task Floor_IntegerInput_ReturnsInteger() - { - await SeedIntItem(); - var results = await RunQuery("SELECT VALUE FLOOR(c.intVal) FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Integer); - results[0].Value().Should().Be(5); - } - - [Fact] - public async Task Ceiling_IntegerInput_ReturnsInteger() - { - await SeedIntItem(); - var results = await RunQuery("SELECT VALUE CEILING(c.intVal) FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Integer); - results[0].Value().Should().Be(5); - } - - [Fact] - public async Task Round_IntegerInput_ReturnsInteger() - { - await SeedIntItem(); - var results = await RunQuery("SELECT VALUE ROUND(c.intVal) FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Integer); - results[0].Value().Should().Be(5); - } - - [Fact] - public async Task Floor_DoubleInput_ReturnsFloat() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dblVal":5.7}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE FLOOR(c.dblVal) FROM c"); - results.Should().ContainSingle(); - // FLOOR(5.7) = 5, but since input was double, result is double - results[0].Type.Should().Be(JTokenType.Float); - results[0].Value().Should().Be(5.0); - } - - [Fact] - public async Task Abs_NegativeDouble_ReturnsFloat() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dblVal":-3.5}""")), - new PartitionKey("pk1")); - var results = await RunQuery("SELECT VALUE ABS(c.dblVal) FROM c"); - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Float); - results[0].Value().Should().Be(3.5); - } - - [Fact] - public async Task Abs_LiteralInteger_ReturnsInteger() - { - await SeedIntItem(); - // Literal -7 — the parser may represent this as long or double - var results = await RunQuery("SELECT VALUE ABS(-7) FROM c"); - results.Should().ContainSingle(); - results[0].Value().Should().Be(7); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task SeedIntItem() + { + // Seed an item with an integer value (will be stored as long) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","intVal":5,"negVal":-3}""")), + new PartitionKey("pk1")); + } + + // Bug 3: MathOp always returns double, never preserves integer type + [Fact] + public async Task Abs_IntegerInput_ReturnsInteger() + { + await SeedIntItem(); + var results = await RunQuery("SELECT VALUE ABS(c.negVal) FROM c"); + results.Should().ContainSingle(); + // ABS(-3) where -3 is an integer should return integer 3, not 3.0 + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(3); + } + + [Fact] + public async Task Floor_IntegerInput_ReturnsInteger() + { + await SeedIntItem(); + var results = await RunQuery("SELECT VALUE FLOOR(c.intVal) FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(5); + } + + [Fact] + public async Task Ceiling_IntegerInput_ReturnsInteger() + { + await SeedIntItem(); + var results = await RunQuery("SELECT VALUE CEILING(c.intVal) FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(5); + } + + [Fact] + public async Task Round_IntegerInput_ReturnsInteger() + { + await SeedIntItem(); + var results = await RunQuery("SELECT VALUE ROUND(c.intVal) FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(5); + } + + [Fact] + public async Task Floor_DoubleInput_ReturnsInteger() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dblVal":5.7}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE FLOOR(c.dblVal) FROM c"); + results.Should().ContainSingle(); + // Ref: Real Cosmos DB (JavaScript engine): Math.floor(5.7) = 5, serialised as integer "5" in JSON. + // Whole-number results from math functions are integers, regardless of input type. + results[0].Type.Should().Be(JTokenType.Integer); + results[0].Value().Should().Be(5L); + } + + [Fact] + public async Task Abs_NegativeDouble_ReturnsFloat() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dblVal":-3.5}""")), + new PartitionKey("pk1")); + var results = await RunQuery("SELECT VALUE ABS(c.dblVal) FROM c"); + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Float); + results[0].Value().Should().Be(3.5); + } + + [Fact] + public async Task Abs_LiteralInteger_ReturnsInteger() + { + await SeedIntItem(); + // Literal -7 — the parser may represent this as long or double + var results = await RunQuery("SELECT VALUE ABS(-7) FROM c"); + results.Should().ContainSingle(); + results[0].Value().Should().Be(7); + } } public class QueryDeepDiveV23_ThreeLevelJoinTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ThreeLevelNestedJoin_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(""" + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ThreeLevelNestedJoin_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(""" { "id": "1", "partitionKey": "pk1", @@ -28534,258 +28549,258 @@ await _container.CreateItemStreamAsync( ] } """)), - new PartitionKey("pk1")); + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT d.name AS dept, t.name AS team, m AS member " + - "FROM c JOIN d IN c.departments JOIN t IN d.teams JOIN m IN t.members"); + var results = await RunQuery( + "SELECT d.name AS dept, t.name AS team, m AS member " + + "FROM c JOIN d IN c.departments JOIN t IN d.teams JOIN m IN t.members"); - results.Should().HaveCount(4); // Alice, Bob, Charlie, Diana - results.Select(r => r["member"]!.Value()).Should() - .BeEquivalentTo(new[] { "Alice", "Bob", "Charlie", "Diana" }); - } + results.Should().HaveCount(4); // Alice, Bob, Charlie, Diana + results.Select(r => r["member"]!.Value()).Should() + .BeEquivalentTo(new[] { "Alice", "Bob", "Charlie", "Diana" }); + } } public class QueryDeepDiveV23_GroupByOffsetLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - var items = new[] - { - new { id = "1", partitionKey = "pk1", category = "A", value = 10 }, - new { id = "2", partitionKey = "pk1", category = "B", value = 20 }, - new { id = "3", partitionKey = "pk1", category = "A", value = 30 }, - new { id = "4", partitionKey = "pk1", category = "C", value = 40 }, - new { id = "5", partitionKey = "pk1", category = "B", value = 50 }, - }; - foreach (var item in items) - await _container.CreateItemAsync(JObject.FromObject(item), new PartitionKey("pk1")); - } - - [Fact] - public async Task GroupBy_WithOffsetLimit_PaginatesGroups() - { - await Seed(); - // 3 groups (A, B, C), skip first 1, take 2 - var results = await RunQuery( - "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category ORDER BY c.category OFFSET 1 LIMIT 2"); - - results.Should().HaveCount(2); - results[0]["category"]!.Value().Should().Be("B"); - results[1]["category"]!.Value().Should().Be("C"); - } - - [Fact] - public async Task GroupBy_WithOrderByAndOffsetLimit_SortsAndPaginates() - { - await Seed(); - // Order groups by count DESC, then paginate - var results = await RunQuery( - "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category ORDER BY COUNT(1) DESC OFFSET 0 LIMIT 2"); - - results.Should().HaveCount(2); - // A has 2 items, B has 2 items, C has 1 item → first two should have cnt=2 - results[0]["cnt"]!.Value().Should().Be(2); - results[1]["cnt"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + var items = new[] + { + new { id = "1", partitionKey = "pk1", category = "A", value = 10 }, + new { id = "2", partitionKey = "pk1", category = "B", value = 20 }, + new { id = "3", partitionKey = "pk1", category = "A", value = 30 }, + new { id = "4", partitionKey = "pk1", category = "C", value = 40 }, + new { id = "5", partitionKey = "pk1", category = "B", value = 50 }, + }; + foreach (var item in items) + await _container.CreateItemAsync(JObject.FromObject(item), new PartitionKey("pk1")); + } + + [Fact] + public async Task GroupBy_WithOffsetLimit_PaginatesGroups() + { + await Seed(); + // 3 groups (A, B, C), skip first 1, take 2 + var results = await RunQuery( + "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category ORDER BY c.category OFFSET 1 LIMIT 2"); + + results.Should().HaveCount(2); + results[0]["category"]!.Value().Should().Be("B"); + results[1]["category"]!.Value().Should().Be("C"); + } + + [Fact] + public async Task GroupBy_WithOrderByAndOffsetLimit_SortsAndPaginates() + { + await Seed(); + // Order groups by count DESC, then paginate + var results = await RunQuery( + "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category ORDER BY COUNT(1) DESC OFFSET 0 LIMIT 2"); + + results.Should().HaveCount(2); + // A has 2 items, B has 2 items, C has 1 item → first two should have cnt=2 + results[0]["cnt"]!.Value().Should().Be(2); + results[1]["cnt"]!.Value().Should().Be(2); + } } public class QueryDeepDiveV23_MultipleArraySubqueryTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Select_MultipleArraySubqueries_BothEvaluateCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(""" + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Select_MultipleArraySubqueries_BothEvaluateCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(""" {"id":"1","partitionKey":"pk1","scores":[80,90,100],"tags":["a","b","c"]} """)), - new PartitionKey("pk1")); + new PartitionKey("pk1")); - var results = await RunQuery( - "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores WHERE s >= 90) AS highScores, " + - "ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'b') AS filteredTags " + - "FROM c"); + var results = await RunQuery( + "SELECT ARRAY(SELECT VALUE s FROM s IN c.scores WHERE s >= 90) AS highScores, " + + "ARRAY(SELECT VALUE t FROM t IN c.tags WHERE t != 'b') AS filteredTags " + + "FROM c"); - results.Should().ContainSingle(); - var highScores = results[0]["highScores"]!.ToObject>(); - highScores.Should().BeEquivalentTo(new[] { 90, 100 }); + results.Should().ContainSingle(); + var highScores = results[0]["highScores"]!.ToObject>(); + highScores.Should().BeEquivalentTo(new[] { 90, 100 }); - var filteredTags = results[0]["filteredTags"]!.ToObject>(); - filteredTags.Should().BeEquivalentTo(new[] { "a", "c" }); - } + var filteredTags = results[0]["filteredTags"]!.ToObject>(); + filteredTags.Should().BeEquivalentTo(new[] { "a", "c" }); + } } public class QueryDeepDiveV23_DateTimePartEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dt":"2024-03-15T14:30:45.123Z"}""")), - new PartitionKey("pk1")); - } - - [Fact] - public async Task DateTimePart_Minute_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimePart('mi', c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().Be(30); - } - - [Fact] - public async Task DateTimePart_Second_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimePart('ss', c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().Be(45); - } - - [Fact] - public async Task DateTimePart_Millisecond_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimePart('ms', c.dt) FROM c"); - results.Should().ContainSingle().Which.Should().Be(123); - } - - [Fact] - public async Task DateTimeAdd_WithMinutePart_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimeAdd('mi', 15, c.dt) FROM c"); - results.Should().ContainSingle(); - var dt = DateTime.Parse(results[0]); - dt.Minute.Should().Be(45); // 30 + 15 - } - - [Fact] - public async Task DateTimeAdd_WithSecondPart_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimeAdd('ss', 10, c.dt) FROM c"); - results.Should().ContainSingle(); - var dt = DateTime.Parse(results[0]); - dt.Second.Should().Be(55); // 45 + 10 - } - - [Fact] - public async Task DateTimeAdd_WithMillisecondPart_Works() - { - await Seed(); - var results = await RunQuery("SELECT VALUE DateTimeAdd('ms', 100, c.dt) FROM c"); - results.Should().ContainSingle(); - var dt = DateTime.Parse(results[0]); - dt.Millisecond.Should().Be(223); // 123 + 100 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","dt":"2024-03-15T14:30:45.123Z"}""")), + new PartitionKey("pk1")); + } + + [Fact] + public async Task DateTimePart_Minute_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimePart('mi', c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().Be(30); + } + + [Fact] + public async Task DateTimePart_Second_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimePart('ss', c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().Be(45); + } + + [Fact] + public async Task DateTimePart_Millisecond_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimePart('ms', c.dt) FROM c"); + results.Should().ContainSingle().Which.Should().Be(123); + } + + [Fact] + public async Task DateTimeAdd_WithMinutePart_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimeAdd('mi', 15, c.dt) FROM c"); + results.Should().ContainSingle(); + var dt = DateTime.Parse(results[0]); + dt.Minute.Should().Be(45); // 30 + 15 + } + + [Fact] + public async Task DateTimeAdd_WithSecondPart_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimeAdd('ss', 10, c.dt) FROM c"); + results.Should().ContainSingle(); + var dt = DateTime.Parse(results[0]); + dt.Second.Should().Be(55); // 45 + 10 + } + + [Fact] + public async Task DateTimeAdd_WithMillisecondPart_Works() + { + await Seed(); + var results = await RunQuery("SELECT VALUE DateTimeAdd('ms', 100, c.dt) FROM c"); + results.Should().ContainSingle(); + var dt = DateTime.Parse(results[0]); + dt.Millisecond.Should().Be(223); // 123 + 100 + } } public class QueryDeepDiveV23_LogWithBaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task Log_WithBase2_ReturnsCorrectResult() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE LOG(8, 2) FROM c"); - results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); - } - - [Fact] - public async Task Log_WithBase10_MatchesLog10() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var logResults = await RunQuery("SELECT VALUE LOG(100, 10) FROM c"); - var log10Results = await RunQuery("SELECT VALUE LOG10(100) FROM c"); - - logResults.Should().ContainSingle().Which.Should().BeApproximately(2.0, 0.0001); - log10Results.Should().ContainSingle().Which.Should().BeApproximately(2.0, 0.0001); - } - - [Fact] - public async Task Log_NaturalLog_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); - - var results = await RunQuery("SELECT VALUE LOG(1) FROM c"); - results.Should().ContainSingle().Which.Should().Be(0.0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task Log_WithBase2_ReturnsCorrectResult() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE LOG(8, 2) FROM c"); + results.Should().ContainSingle().Which.Should().BeApproximately(3.0, 0.0001); + } + + [Fact] + public async Task Log_WithBase10_MatchesLog10() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var logResults = await RunQuery("SELECT VALUE LOG(100, 10) FROM c"); + var log10Results = await RunQuery("SELECT VALUE LOG10(100) FROM c"); + + logResults.Should().ContainSingle().Which.Should().BeApproximately(2.0, 0.0001); + log10Results.Should().ContainSingle().Which.Should().BeApproximately(2.0, 0.0001); + } + + [Fact] + public async Task Log_NaturalLog_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); + + var results = await RunQuery("SELECT VALUE LOG(1) FROM c"); + results.Should().ContainSingle().Which.Should().Be(0.0); + } } public class QueryDeepDiveV23_OrderByMixedTypeEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task OrderBy_UndefinedBeforeNull_InMixedTypes() - { - // Item with missing field (undefined), item with null, item with value - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), - new PartitionKey("pk1")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), - new PartitionKey("pk1")); // no "score" field = undefined - - var iterator = _container.GetItemQueryIterator( - new QueryDefinition("SELECT c.id, c.score FROM c ORDER BY c.score ASC"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - // Ordering: undefined < null < number - results[0]["id"]!.Value().Should().Be("3"); // undefined - results[1]["id"]!.Value().Should().Be("2"); // null - results[2]["id"]!.Value().Should().Be("1"); // number 10 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task OrderBy_UndefinedBeforeNull_InMixedTypes() + { + // Item with missing field (undefined), item with null, item with value + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","score":10}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk1","score":null}""")), + new PartitionKey("pk1")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","partitionKey":"pk1"}""")), + new PartitionKey("pk1")); // no "score" field = undefined + + var iterator = _container.GetItemQueryIterator( + new QueryDefinition("SELECT c.id, c.score FROM c ORDER BY c.score ASC"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + // Ordering: undefined < null < number + results[0]["id"]!.Value().Should().Be("3"); // undefined + results[1]["id"]!.Value().Should().Be("2"); // null + results[2]["id"]!.Value().Should().Be("1"); // number 10 + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTestsComprehensiveDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTestsComprehensiveDeepDiveTests.cs index 1d4911e..40d5189 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTestsComprehensiveDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/QueryTestsComprehensiveDeepDiveTests.cs @@ -16,51 +16,51 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class StringToFunctionNullTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task SeedAsync() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), - new PartitionKey("p")); - } - - [Fact] - public async Task StringToArray_NullInput_ReturnsUndefined() - { - await SeedAsync(); - var results = await QueryAsync("SELECT StringToArray(c.val) AS r FROM c"); - // null input → undefined → field excluded from projection - results[0]["r"].Should().BeNull(); - } - - [Fact] - public async Task StringToObject_NullInput_ReturnsUndefined() - { - await SeedAsync(); - var results = await QueryAsync("SELECT StringToObject(c.val) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - [Fact] - public async Task StringToNumber_NullInput_ReturnsUndefined() - { - await SeedAsync(); - var results = await QueryAsync("SELECT StringToNumber(c.val) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task SeedAsync() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), + new PartitionKey("p")); + } + + [Fact] + public async Task StringToArray_NullInput_ReturnsUndefined() + { + await SeedAsync(); + var results = await QueryAsync("SELECT StringToArray(c.val) AS r FROM c"); + // null input → undefined → field excluded from projection + results[0]["r"].Should().BeNull(); + } + + [Fact] + public async Task StringToObject_NullInput_ReturnsUndefined() + { + await SeedAsync(); + var results = await QueryAsync("SELECT StringToObject(c.val) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + [Fact] + public async Task StringToNumber_NullInput_ReturnsUndefined() + { + await SeedAsync(); + var results = await QueryAsync("SELECT StringToNumber(c.val) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -68,39 +68,39 @@ private async Task> QueryAsync(string sql) public class StringToBooleanEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task StringToBoolean_NumberInput_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":42}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT StringToBoolean(c.val) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - [Fact] - public async Task StringToBoolean_ObjectInput_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":{"a":1}}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT StringToBoolean(c.val) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task StringToBoolean_NumberInput_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":42}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT StringToBoolean(c.val) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + [Fact] + public async Task StringToBoolean_ObjectInput_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":{"a":1}}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT StringToBoolean(c.val) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -108,40 +108,40 @@ private async Task> QueryAsync(string sql) public class NumberBinEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task NumberBin_NegativeValue_BinsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":-7}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT NumberBin(c.val, 5) AS r FROM c"); - // Floor(-7/5)*5 = Floor(-1.4)*5 = -2*5 = -10 - results[0]["r"]!.Value().Should().Be(-10); - } - - [Fact] - public async Task NumberBin_ZeroBinSize_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":10}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT NumberBin(c.val, 0) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task NumberBin_NegativeValue_BinsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":-7}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT NumberBin(c.val, 5) AS r FROM c"); + // Floor(-7/5)*5 = Floor(-1.4)*5 = -2*5 = -10 + results[0]["r"]!.Value().Should().Be(-10); + } + + [Fact] + public async Task NumberBin_ZeroBinSize_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":10}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT NumberBin(c.val, 0) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -149,42 +149,42 @@ private async Task> QueryAsync(string sql) public class DistinctObjectPropertyOrderTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Distinct_ObjectPropertyOrder_TreatedAsSame() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","obj":{"a":1,"b":2}}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"p","obj":{"b":2,"a":1}}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT DISTINCT VALUE c.obj FROM c"); - // Emulator uses JToken.DeepEquals which treats same properties as equal regardless of order - results.Should().HaveCount(1); - } - - [Fact] - public async Task Distinct_ObjectPropertyOrder_Divergent_EmulatorTreatsAsDifferent() - { - // No divergence here — emulator correctly handles property order via JToken.DeepEquals - await Task.CompletedTask; - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Distinct_ObjectPropertyOrder_TreatedAsSame() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","obj":{"a":1,"b":2}}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"p","obj":{"b":2,"a":1}}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT DISTINCT VALUE c.obj FROM c"); + // Emulator uses JToken.DeepEquals which treats same properties as equal regardless of order + results.Should().HaveCount(1); + } + + [Fact] + public async Task Distinct_ObjectPropertyOrder_Divergent_EmulatorTreatsAsDifferent() + { + // No divergence here — emulator correctly handles property order via JToken.DeepEquals + await Task.CompletedTask; + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -192,60 +192,60 @@ private async Task> QueryAsync(string sql) public class IsPrimitiveComprehensiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task IsPrimitive_Null_ReturnsTrue() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); - results[0]["r"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task IsPrimitive_Object_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":{"a":1}}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsPrimitive_Array_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":[1,2]}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsPrimitive_UndefinedField_ReturnsFalse() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT IS_PRIMITIVE(c.missing) AS r FROM c"); - // Emulator returns false for undefined fields - results[0]["r"]!.Value().Should().BeFalse(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task IsPrimitive_Null_ReturnsTrue() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); + results[0]["r"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task IsPrimitive_Object_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":{"a":1}}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsPrimitive_Array_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":[1,2]}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT IS_PRIMITIVE(c.val) AS r FROM c"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsPrimitive_UndefinedField_ReturnsFalse() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT IS_PRIMITIVE(c.missing) AS r FROM c"); + // Emulator returns false for undefined fields + results[0]["r"]!.Value().Should().BeFalse(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -253,37 +253,37 @@ private async Task> QueryAsync(string sql) public class CountSemanticsTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Count_FieldWithNullValue_CountsNullAsPresent() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":42}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT COUNT(c.val) AS r FROM c"); - // Emulator counts only non-null, non-undefined fields - // item 1: val=null → excluded, item 2: val=42 → counted, item 3: missing → excluded - results[0]["r"]!.Value().Should().Be(1); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Count_FieldWithNullValue_CountsNullAsPresent() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":null}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":42}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT COUNT(c.val) AS r FROM c"); + // Emulator counts only non-null, non-undefined fields + // item 1: val=null → excluded, item 2: val=42 → counted, item 3: missing → excluded + results[0]["r"]!.Value().Should().Be(1); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -291,44 +291,44 @@ private async Task> QueryAsync(string sql) public class ArrayContainsPartialMatchTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ArrayContains_PartialObjectMatch_WithThirdArg() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","items":[{"id":"a","name":"Apple","price":1},{"id":"b","name":"Banana","price":2}]}""")), - new PartitionKey("p")); - var results = await QueryAsync( - """SELECT VALUE ARRAY_CONTAINS(c.items, {"id": "a"}, true) FROM c"""); - results[0].Should().BeTrue(); - } - - [Fact] - public async Task ArrayContains_PartialObjectMatch_NoThirdArg_ExactOnly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","items":[{"id":"a","name":"Apple","price":1}]}""")), - new PartitionKey("p")); - // Without 3rd arg or with false — requires exact match - var results = await QueryAsync( - """SELECT VALUE ARRAY_CONTAINS(c.items, {"id": "a"}) FROM c"""); - results[0].Should().BeFalse(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ArrayContains_PartialObjectMatch_WithThirdArg() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","items":[{"id":"a","name":"Apple","price":1},{"id":"b","name":"Banana","price":2}]}""")), + new PartitionKey("p")); + var results = await QueryAsync( + """SELECT VALUE ARRAY_CONTAINS(c.items, {"id": "a"}, true) FROM c"""); + results[0].Should().BeTrue(); + } + + [Fact] + public async Task ArrayContains_PartialObjectMatch_NoThirdArg_ExactOnly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","items":[{"id":"a","name":"Apple","price":1}]}""")), + new PartitionKey("p")); + // Without 3rd arg or with false — requires exact match + var results = await QueryAsync( + """SELECT VALUE ARRAY_CONTAINS(c.items, {"id": "a"}) FROM c"""); + results[0].Should().BeFalse(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -336,42 +336,42 @@ private async Task> QueryAsync(string sql) public class ArraySliceEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ArraySlice_StartAndLength_ReturnsSubset() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","arr":[1,2,3,4,5]}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT ARRAY_SLICE(c.arr, 1, 2) AS r FROM c"); - var arr = results[0]["r"]!.ToObject()!; - arr.Should().Equal(2, 3); - } - - [Fact] - public async Task ArraySlice_StartBeyondArrayLength_ReturnsEmpty() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","arr":[1,2,3]}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT ARRAY_SLICE(c.arr, 10) AS r FROM c"); - results[0]["r"]!.ToObject()!.Should().BeEmpty(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ArraySlice_StartAndLength_ReturnsSubset() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","arr":[1,2,3,4,5]}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT ARRAY_SLICE(c.arr, 1, 2) AS r FROM c"); + var arr = results[0]["r"]!.ToObject()!; + arr.Should().Equal(2, 3); + } + + [Fact] + public async Task ArraySlice_StartBeyondArrayLength_ReturnsEmpty() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","arr":[1,2,3]}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT ARRAY_SLICE(c.arr, 10) AS r FROM c"); + results[0]["r"]!.ToObject()!.Should().BeEmpty(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -379,55 +379,55 @@ private async Task> QueryAsync(string sql) public class ComputedPropertySelectTests { - [Fact] - public async Task SelectStar_ExcludesComputedProperties() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - [ - new ComputedProperty { Name = "fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } - ]) - }; - var container = new InMemoryContainer(props); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","first":"John","last":"Doe"}""")), - new PartitionKey("p")); - var results = await QueryAsync(container, "SELECT * FROM c"); - results[0]["fullName"].Should().BeNull(); - } - - [Fact] - public async Task SelectExplicit_IncludesComputedProperties() - { - var props = new ContainerProperties("test", "/pk") - { - ComputedProperties = new Collection( - [ - new ComputedProperty { Name = "fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } - ]) - }; - var container = new InMemoryContainer(props); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","first":"John","last":"Doe"}""")), - new PartitionKey("p")); - var results = await QueryAsync(container, "SELECT c.fullName FROM c"); - results[0]["fullName"]!.ToString().Should().Be("John Doe"); - } - - private static async Task> QueryAsync(InMemoryContainer container, string sql) - { - var query = container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + [Fact] + public async Task SelectStar_ExcludesComputedProperties() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + [ + new ComputedProperty { Name = "fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } + ]) + }; + var container = new InMemoryContainer(props); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","first":"John","last":"Doe"}""")), + new PartitionKey("p")); + var results = await QueryAsync(container, "SELECT * FROM c"); + results[0]["fullName"].Should().BeNull(); + } + + [Fact] + public async Task SelectExplicit_IncludesComputedProperties() + { + var props = new ContainerProperties("test", "/pk") + { + ComputedProperties = new Collection( + [ + new ComputedProperty { Name = "fullName", Query = "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c" } + ]) + }; + var container = new InMemoryContainer(props); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","first":"John","last":"Doe"}""")), + new PartitionKey("p")); + var results = await QueryAsync(container, "SELECT c.fullName FROM c"); + results[0]["fullName"]!.ToString().Should().Be("John Doe"); + } + + private static async Task> QueryAsync(InMemoryContainer container, string sql) + { + var query = container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -435,40 +435,40 @@ private static async Task> QueryAsync(InMemoryContainer container, st public class OrderByComplexExpressionTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task OrderBy_ComplexArithmeticExpression_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","a":10,"b":5}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","pk":"p","a":3,"b":1}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"3","pk":"p","a":7,"b":3}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT c.id FROM c ORDER BY c.a + c.b"); - results[0]["id"]!.ToString().Should().Be("2"); // 3+1=4 - results[1]["id"]!.ToString().Should().Be("3"); // 7+3=10 - results[2]["id"]!.ToString().Should().Be("1"); // 10+5=15 - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task OrderBy_ComplexArithmeticExpression_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","a":10,"b":5}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","pk":"p","a":3,"b":1}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"3","pk":"p","a":7,"b":3}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT c.id FROM c ORDER BY c.a + c.b"); + results[0]["id"]!.ToString().Should().Be("2"); // 3+1=4 + results[1]["id"]!.ToString().Should().Be("3"); // 7+3=10 + results[2]["id"]!.ToString().Should().Be("1"); // 10+5=15 + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -476,30 +476,30 @@ private async Task> QueryAsync(string sql) public class ConcatVariadicTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Concat_FiveArgs_ConcatenatesAll() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync( - "SELECT CONCAT('a', 'b', 'c', 'd', 'e') AS r FROM c"); - results[0]["r"]!.ToString().Should().Be("abcde"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Concat_FiveArgs_ConcatenatesAll() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync( + "SELECT CONCAT('a', 'b', 'c', 'd', 'e') AS r FROM c"); + results[0]["r"]!.ToString().Should().Be("abcde"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -507,31 +507,31 @@ private async Task> QueryAsync(string sql) public class NestedTernaryTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task NestedTernary_EvaluatesCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","a":true,"b":false}""")), - new PartitionKey("p")); - var results = await QueryAsync( - "SELECT (c.a ? (c.b ? 'x' : 'y') : 'z') AS r FROM c"); - // a=true, b=false → 'y' - results[0]["r"]!.ToString().Should().Be("y"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task NestedTernary_EvaluatesCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","a":true,"b":false}""")), + new PartitionKey("p")); + var results = await QueryAsync( + "SELECT (c.a ? (c.b ? 'x' : 'y') : 'z') AS r FROM c"); + // a=true, b=false → 'y' + results[0]["r"]!.ToString().Should().Be("y"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -539,24 +539,24 @@ private async Task> QueryAsync(string sql) public class MaxItemCountTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task MaxItemCount_MinusOne_ReturnsAllInSinglePage() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"p"}""")), - new PartitionKey("p")); - - var query = _container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { MaxItemCount = -1 }); - - var page = await query.ReadNextAsync(); - page.Count.Should().Be(10); - query.HasMoreResults.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task MaxItemCount_MinusOne_ReturnsAllInSinglePage() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"p"}""")), + new PartitionKey("p")); + + var query = _container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { MaxItemCount = -1 }); + + var page = await query.ReadNextAsync(); + page.Count.Should().Be(10); + query.HasMoreResults.Should().BeFalse(); + } } @@ -564,31 +564,31 @@ await _container.CreateItemStreamAsync( public class CoalesceNullTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Coalesce_NullLeft_ReturnsFallback() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","val":null}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT (c.val ?? 'fallback') AS r FROM c"); - // Emulator treats null same as undefined for COALESCE — returns the right side - results[0]["r"]!.ToString().Should().Be("fallback"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Coalesce_NullLeft_ReturnsFallback() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","val":null}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT (c.val ?? 'fallback') AS r FROM c"); + // Emulator treats null same as undefined for COALESCE — returns the right side + results[0]["r"]!.ToString().Should().Be("fallback"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -596,39 +596,39 @@ private async Task> QueryAsync(string sql) public class ReplaceFunctionEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Replace_SearchNotFound_ReturnsOriginal() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":"Hello"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT REPLACE(c.name, 'xyz', 'abc') AS r FROM c"); - results[0]["r"]!.ToString().Should().Be("Hello"); - } - - [Fact] - public async Task Replace_EmptyReplacement_RemovesOccurrences() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":"Hello World"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT REPLACE(c.name, ' ', '') AS r FROM c"); - results[0]["r"]!.ToString().Should().Be("HelloWorld"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Replace_SearchNotFound_ReturnsOriginal() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":"Hello"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT REPLACE(c.name, 'xyz', 'abc') AS r FROM c"); + results[0]["r"]!.ToString().Should().Be("Hello"); + } + + [Fact] + public async Task Replace_EmptyReplacement_RemovesOccurrences() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":"Hello World"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT REPLACE(c.name, ' ', '') AS r FROM c"); + results[0]["r"]!.ToString().Should().Be("HelloWorld"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -636,29 +636,29 @@ private async Task> QueryAsync(string sql) public class ReplicateEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Replicate_NegativeCount_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT REPLICATE('abc', -1) AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Replicate_NegativeCount_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT REPLICATE('abc', -1) AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -666,30 +666,30 @@ private async Task> QueryAsync(string sql) public class DateTimeAddEdgeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task DateTimeAdd_InvalidPart_ReturnsUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync( - "SELECT DateTimeAdd('invalid', 1, '2023-01-01T00:00:00Z') AS r FROM c"); - results[0]["r"].Should().BeNull(); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task DateTimeAdd_InvalidPart_ReturnsUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync( + "SELECT DateTimeAdd('invalid', 1, '2023-01-01T00:00:00Z') AS r FROM c"); + results[0]["r"].Should().BeNull(); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -697,30 +697,30 @@ private async Task> QueryAsync(string sql) public class CompositePartitionKeyQueryTests { - [Fact] - public async Task Query_WithCompositePartitionKey_FiltersCorrectly() - { - var container = new InMemoryContainer("test", ["/tenant", "/region"]); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","tenant":"a","region":"us","name":"One"}""")), - new PartitionKeyBuilder().Add("a").Add("us").Build()); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"2","tenant":"b","region":"eu","name":"Two"}""")), - new PartitionKeyBuilder().Add("b").Add("eu").Build()); - - var query = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.tenant = 'a'")); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("One"); - } + [Fact] + public async Task Query_WithCompositePartitionKey_FiltersCorrectly() + { + var container = new InMemoryContainer("test", ["/tenant", "/region"]); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","tenant":"a","region":"us","name":"One"}""")), + new PartitionKeyBuilder().Add("a").Add("us").Build()); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"2","tenant":"b","region":"eu","name":"Two"}""")), + new PartitionKeyBuilder().Add("b").Add("eu").Build()); + + var query = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.tenant = 'a'")); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which["name"]!.ToString().Should().Be("One"); + } } @@ -728,30 +728,30 @@ await container.CreateItemStreamAsync( public class ArrayIndexSelectTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Select_ArrayIndex_ProjectsCorrectly() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","tags":["first","second","third"]}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT c.tags[0] AS first_tag FROM c"); - results[0]["first_tag"]!.ToString().Should().Be("first"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Select_ArrayIndex_ProjectsCorrectly() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","tags":["first","second","third"]}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT c.tags[0] AS first_tag FROM c"); + results[0]["first_tag"]!.ToString().Should().Be("first"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -759,33 +759,33 @@ private async Task> QueryAsync(string sql) public class ChainedStringFunctionTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Where_ChainedFunctions_Works() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":" Alice "}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","name":"Bob"}""")), - new PartitionKey("p")); - var results = await QueryAsync( - "SELECT c.id FROM c WHERE LOWER(TRIM(c.name)) = 'alice'"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Where_ChainedFunctions_Works() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","name":" Alice "}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","name":"Bob"}""")), + new PartitionKey("p")); + var results = await QueryAsync( + "SELECT c.id FROM c WHERE LOWER(TRIM(c.name)) = 'alice'"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -793,31 +793,31 @@ private async Task> QueryAsync(string sql) public class ArrayConcatThreeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"p","a":[1],"b":[2,3],"c":[4,5,6]}""")), - new PartitionKey("p")); - var results = await QueryAsync( - "SELECT ARRAY_CONCAT(c.a, c.b, c.c) AS r FROM c"); - results[0]["r"]!.ToObject()!.Should().Equal(1, 2, 3, 4, 5, 6); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ArrayConcat_ThreeArrays_ConcatenatesAll() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"p","a":[1],"b":[2,3],"c":[4,5,6]}""")), + new PartitionKey("p")); + var results = await QueryAsync( + "SELECT ARRAY_CONCAT(c.a, c.b, c.c) AS r FROM c"); + results[0]["r"]!.ToObject()!.Should().Equal(1, 2, 3, 4, 5, 6); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -825,35 +825,35 @@ private async Task> QueryAsync(string sql) public class IsNotNullTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Where_IsNotNull_ExcludesNullAndUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":"hello"}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":null}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p"}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT c.id FROM c WHERE c.val IS NOT NULL"); - results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Where_IsNotNull_ExcludesNullAndUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":"hello"}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":null}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p"}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT c.id FROM c WHERE c.val IS NOT NULL"); + results.Should().ContainSingle().Which["id"]!.ToString().Should().Be("1"); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -861,35 +861,35 @@ private async Task> QueryAsync(string sql) public class AggregateMixedTypeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task Sum_MixedTypes_IgnoresNonNumeric() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":10}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":"hello"}""")), - new PartitionKey("p")); - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p","val":20}""")), - new PartitionKey("p")); - var results = await QueryAsync("SELECT SUM(c.val) AS r FROM c"); - results[0]["r"]!.Value().Should().Be(30); - } - - private async Task> QueryAsync(string sql) - { - var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - return results; - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task Sum_MixedTypes_IgnoresNonNumeric() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"p","val":10}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"p","val":"hello"}""")), + new PartitionKey("p")); + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"p","val":20}""")), + new PartitionKey("p")); + var results = await QueryAsync("SELECT SUM(c.val) AS r FROM c"); + results[0]["r"]!.Value().Should().Be(30); + } + + private async Task> QueryAsync(string sql) + { + var query = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + return results; + } } @@ -897,23 +897,23 @@ private async Task> QueryAsync(string sql) public class StreamIteratorEnvelopeTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task StreamIterator_ResponseHas_Count_Rid_Documents() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"p"}""")), - new PartitionKey("p")); - - var feed = _container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await feed.ReadNextAsync(); - var json = await new StreamReader(response.Content).ReadToEndAsync(); - var envelope = JObject.Parse(json); - - envelope["_count"]!.Value().Should().Be(3); - envelope["_rid"].Should().NotBeNull(); - envelope["Documents"]!.Should().BeOfType().Which.Should().HaveCount(3); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task StreamIterator_ResponseHas_Count_Rid_Documents() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"p"}""")), + new PartitionKey("p")); + + var feed = _container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await feed.ReadNextAsync(); + var json = await new StreamReader(response.Content).ReadToEndAsync(); + var envelope = JObject.Parse(json); + + envelope["_count"]!.Value().Should().Be(3); + envelope["_rid"].Should().NotBeNull(); + envelope["Documents"]!.Should().BeOfType().Which.Should().HaveCount(3); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyStreamDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyStreamDeepDiveTests.cs index d582eaf..9cd0d8d 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyStreamDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyStreamDeepDiveTests.cs @@ -14,262 +14,262 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ReadManyStreamDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - private async Task SeedAsync() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, - new PartitionKey("pk2")); - } - - [Fact] - public async Task ReadManyStream_CancelledToken_ThrowsOperationCanceledException() - { - await SeedAsync(); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var act = () => _container.ReadManyItemsStreamAsync(items, cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadManyStream_WithIfNoneMatchEtag_Returns304WhenUnchanged() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")) + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + private async Task SeedAsync() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, + new PartitionKey("pk2")); + } + + [Fact] + public async Task ReadManyStream_CancelledToken_ThrowsOperationCanceledException() + { + await SeedAsync(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var act = () => _container.ReadManyItemsStreamAsync(items, cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadManyStream_WithIfNoneMatchEtag_Returns304WhenUnchanged() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")) + }; + // First read to get ETag + var first = await _container.ReadManyItemsStreamAsync(items); + var etag = first.Headers["ETag"]; + etag.Should().NotBeNullOrEmpty(); + // Second read with IfNoneMatchEtag + var second = await _container.ReadManyItemsStreamAsync(items, + new ReadManyRequestOptions { IfNoneMatchEtag = etag }); + second.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task ReadManyStream_ResponseContainsCompositeETagHeader() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")) + }; + var response = await _container.ReadManyItemsStreamAsync(items); + response.Headers["ETag"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadManyStream_SomeNotExist_ReturnsOnlyExisting() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("999", new PartitionKey("pk1")) // doesn't exist }; - // First read to get ETag - var first = await _container.ReadManyItemsStreamAsync(items); - var etag = first.Headers["ETag"]; - etag.Should().NotBeNullOrEmpty(); - // Second read with IfNoneMatchEtag - var second = await _container.ReadManyItemsStreamAsync(items, - new ReadManyRequestOptions { IfNoneMatchEtag = etag }); - second.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task ReadManyStream_ResponseContainsCompositeETagHeader() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")) - }; - var response = await _container.ReadManyItemsStreamAsync(items); - response.Headers["ETag"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadManyStream_SomeNotExist_ReturnsOnlyExisting() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("999", new PartitionKey("pk1")) // doesn't exist - }; - var response = await _container.ReadManyItemsStreamAsync(items); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task ReadManyStream_WrongPartitionKey_ReturnsEmptyDocuments() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("wrong-pk")) - }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStream_MixedPartitionKeys_ReturnsAll() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("3", new PartitionKey("pk2")) - }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(2); - } - - [Fact] - public async Task ReadManyStream_AfterItemUpdate_ReturnsUpdatedVersion() - { - await SeedAsync(); - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Updated" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["Documents"]![0]!["name"]!.Value().Should().Be("Alice Updated"); - } - - [Fact] - public async Task ReadManyStream_AfterItemDelete_ExcludesDeletedItem() - { - await SeedAsync(); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")) - }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - body["Documents"]![0]!["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task ReadManyStream_AfterItemReplace_ReturnsReplacedVersion() - { - await SeedAsync(); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["Documents"]![0]!["name"]!.Value().Should().Be("Replaced"); - } - - [Fact] - public async Task ReadManyStream_AfterPatchOperation_ReturnsPatchedVersion() - { - await SeedAsync(); - await _container.PatchItemAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")]); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["Documents"]![0]!["name"]!.Value().Should().Be("Patched"); - } - - [Fact] - public async Task ReadManyStream_LargeList_100Items_ReturnsAll() - { - for (int i = 0; i < 100; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - var items = Enumerable.Range(0, 100) - .Select(i => ($"{i}", new PartitionKey("pk1"))).ToList(); - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(100); - } - - [Fact] - public async Task ReadManyStream_HierarchicalPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer(new ContainerProperties("test", - ["/tenantId", "/userId"])); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemAsync(JObject.FromObject(new - { - id = "1", - tenantId = "t1", - userId = "u1", - name = "Doc1" - }), pk); - var items = new List<(string, PartitionKey)> { ("1", pk) }; - var response = await container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task ReadManyStream_NumericPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer("test", "/num"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", num = 42, name = "Doc" }), - new PartitionKey(42)); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey(42)) }; - var response = await container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task ReadManyStream_SpecialCharactersInId_ReturnsItem() - { - var specialId = "item/with+special&chars"; - await _container.CreateItemAsync( - new TestDocument { Id = specialId, PartitionKey = "pk1", Name = "Special" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { (specialId, new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task ReadManyStream_UnicodeId_ReturnsItem() - { - var unicodeId = "ドキュメント_1"; - await _container.CreateItemAsync( - new TestDocument { Id = unicodeId, PartitionKey = "pk1", Name = "Unicode" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { (unicodeId, new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task ReadManyStream_CaseSensitiveIds_TreatedDistinct() - { - await _container.CreateItemAsync( - new TestDocument { Id = "ABC", PartitionKey = "pk1", Name = "Upper" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "abc", PartitionKey = "pk1", Name = "Lower" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> - { - ("ABC", new PartitionKey("pk1")), - ("abc", new PartitionKey("pk1")) - }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(2); - } - - [Fact] - public async Task ReadManyStream_ResponseHeaders_ContainRequestChargeAndActivityId() - { - await SeedAsync(); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } + var response = await _container.ReadManyItemsStreamAsync(items); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task ReadManyStream_WrongPartitionKey_ReturnsEmptyDocuments() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("wrong-pk")) + }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStream_MixedPartitionKeys_ReturnsAll() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("3", new PartitionKey("pk2")) + }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(2); + } + + [Fact] + public async Task ReadManyStream_AfterItemUpdate_ReturnsUpdatedVersion() + { + await SeedAsync(); + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Updated" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["Documents"]![0]!["name"]!.Value().Should().Be("Alice Updated"); + } + + [Fact] + public async Task ReadManyStream_AfterItemDelete_ExcludesDeletedItem() + { + await SeedAsync(); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")) + }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + body["Documents"]![0]!["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task ReadManyStream_AfterItemReplace_ReturnsReplacedVersion() + { + await SeedAsync(); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["Documents"]![0]!["name"]!.Value().Should().Be("Replaced"); + } + + [Fact] + public async Task ReadManyStream_AfterPatchOperation_ReturnsPatchedVersion() + { + await SeedAsync(); + await _container.PatchItemAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")]); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["Documents"]![0]!["name"]!.Value().Should().Be("Patched"); + } + + [Fact] + public async Task ReadManyStream_LargeList_100Items_ReturnsAll() + { + for (int i = 0; i < 100; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + var items = Enumerable.Range(0, 100) + .Select(i => ($"{i}", new PartitionKey("pk1"))).ToList(); + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(100); + } + + [Fact] + public async Task ReadManyStream_HierarchicalPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer(new ContainerProperties("test", + ["/tenantId", "/userId"])); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemAsync(JObject.FromObject(new + { + id = "1", + tenantId = "t1", + userId = "u1", + name = "Doc1" + }), pk); + var items = new List<(string, PartitionKey)> { ("1", pk) }; + var response = await container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task ReadManyStream_NumericPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer("test", "/num"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", num = 42, name = "Doc" }), + new PartitionKey(42)); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey(42)) }; + var response = await container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task ReadManyStream_SpecialCharactersInId_ReturnsItem() + { + var specialId = "item/with+special&chars"; + await _container.CreateItemAsync( + new TestDocument { Id = specialId, PartitionKey = "pk1", Name = "Special" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { (specialId, new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task ReadManyStream_UnicodeId_ReturnsItem() + { + var unicodeId = "ドキュメント_1"; + await _container.CreateItemAsync( + new TestDocument { Id = unicodeId, PartitionKey = "pk1", Name = "Unicode" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { (unicodeId, new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task ReadManyStream_CaseSensitiveIds_TreatedDistinct() + { + await _container.CreateItemAsync( + new TestDocument { Id = "ABC", PartitionKey = "pk1", Name = "Upper" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "abc", PartitionKey = "pk1", Name = "Lower" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> + { + ("ABC", new PartitionKey("pk1")), + ("abc", new PartitionKey("pk1")) + }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(2); + } + + [Fact] + public async Task ReadManyStream_ResponseHeaders_ContainRequestChargeAndActivityId() + { + await SeedAsync(); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -278,223 +278,223 @@ public async Task ReadManyStream_ResponseHeaders_ContainRequestChargeAndActivity public class ReadManyPartitionKeyDeepDiveV2Tests { - [Fact] - public async Task ReadMany_PartitionKeyNone_ReturnsItems() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", name = "NoPartition" }), - PartitionKey.Null); - var items = new List<(string, PartitionKey)> { ("1", PartitionKey.Null) }; - var response = await container.ReadManyItemsAsync(items); - response.Should().HaveCount(1); - response.First()["name"]!.Value().Should().Be("NoPartition"); - } - - [Fact] - public async Task ReadMany_ThreeLevelHierarchicalPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer(new ContainerProperties("test", - ["/tenantId", "/region", "/userId"])); - var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Add("u1").Build(); - await container.CreateItemAsync(JObject.FromObject(new - { - id = "1", - tenantId = "t1", - region = "us-east", - userId = "u1" - }), pk); - var items = new List<(string, PartitionKey)> { ("1", pk) }; - var response = await container.ReadManyItemsAsync(items); - response.Should().HaveCount(1); - } + [Fact] + public async Task ReadMany_PartitionKeyNone_ReturnsItems() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", name = "NoPartition" }), + PartitionKey.Null); + var items = new List<(string, PartitionKey)> { ("1", PartitionKey.Null) }; + var response = await container.ReadManyItemsAsync(items); + response.Should().HaveCount(1); + response.First()["name"]!.Value().Should().Be("NoPartition"); + } + + [Fact] + public async Task ReadMany_ThreeLevelHierarchicalPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer(new ContainerProperties("test", + ["/tenantId", "/region", "/userId"])); + var pk = new PartitionKeyBuilder().Add("t1").Add("us-east").Add("u1").Build(); + await container.CreateItemAsync(JObject.FromObject(new + { + id = "1", + tenantId = "t1", + region = "us-east", + userId = "u1" + }), pk); + var items = new List<(string, PartitionKey)> { ("1", pk) }; + var response = await container.ReadManyItemsAsync(items); + response.Should().HaveCount(1); + } } public class ReadManyOptionsDeepDiveV2Tests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task ReadMany_CompositeEtag_ChangesAfterMutation() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var first = await _container.ReadManyItemsAsync(items); - var etag1 = first.ETag; - // Mutate - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - var second = await _container.ReadManyItemsAsync(items); - var etag2 = second.ETag; - etag2.Should().NotBe(etag1); - } - - [Fact] - public async Task ReadMany_CompositeEtag_IsNullWhenNoItemsFound() - { - var items = new List<(string, PartitionKey)> { ("nonexistent", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsAsync(items); - response.Count.Should().Be(0); - response.Headers.ETag.Should().BeNullOrEmpty(); - } - - [Fact] - public async Task ReadMany_WithStaleIfNoneMatchEtag_Returns200WithItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var first = await _container.ReadManyItemsAsync(items); - var etag = first.Headers.ETag; - // Mutate to make ETag stale - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - var second = await _container.ReadManyItemsAsync(items, - new ReadManyRequestOptions { IfNoneMatchEtag = etag }); - second.StatusCode.Should().Be(HttpStatusCode.OK); - second.Count.Should().Be(1); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReadMany_CompositeEtag_ChangesAfterMutation() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var first = await _container.ReadManyItemsAsync(items); + var etag1 = first.ETag; + // Mutate + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + var second = await _container.ReadManyItemsAsync(items); + var etag2 = second.ETag; + etag2.Should().NotBe(etag1); + } + + [Fact] + public async Task ReadMany_CompositeEtag_IsNullWhenNoItemsFound() + { + var items = new List<(string, PartitionKey)> { ("nonexistent", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsAsync(items); + response.Count.Should().Be(0); + response.Headers.ETag.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadMany_WithStaleIfNoneMatchEtag_Returns200WithItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var first = await _container.ReadManyItemsAsync(items); + var etag = first.Headers.ETag; + // Mutate to make ETag stale + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + var second = await _container.ReadManyItemsAsync(items, + new ReadManyRequestOptions { IfNoneMatchEtag = etag }); + second.StatusCode.Should().Be(HttpStatusCode.OK); + second.Count.Should().Be(1); + } } public class ReadManyMutationDeepDiveV2Tests { - [Fact] - public async Task ReadMany_AfterClearItems_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - container.ClearItems(); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await container.ReadManyItemsAsync(items); - response.Should().BeEmpty(); - } + [Fact] + public async Task ReadMany_AfterClearItems_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + container.ClearItems(); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await container.ReadManyItemsAsync(items); + response.Should().BeEmpty(); + } } public class ReadManyDeserializationDeepDiveV2Tests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task ReadManyStream_SystemProperties_IncludeRidSelfAttachments() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - var doc = body["Documents"]![0]!; - doc["_rid"].Should().NotBeNull(); - doc["_self"].Should().NotBeNull(); - doc["_attachments"].Should().NotBeNull(); - doc["_ts"].Should().NotBeNull(); - doc["_etag"].Should().NotBeNull(); - } - - [Fact] - public async Task ReadManyStream_CountFieldMatchesFoundItemsWhenSomeMissing() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("999", new PartitionKey("pk1")) // doesn't exist + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReadManyStream_SystemProperties_IncludeRidSelfAttachments() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + var doc = body["Documents"]![0]!; + doc["_rid"].Should().NotBeNull(); + doc["_self"].Should().NotBeNull(); + doc["_attachments"].Should().NotBeNull(); + doc["_ts"].Should().NotBeNull(); + doc["_etag"].Should().NotBeNull(); + } + + [Fact] + public async Task ReadManyStream_CountFieldMatchesFoundItemsWhenSomeMissing() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("999", new PartitionKey("pk1")) // doesn't exist }; - var response = await _container.ReadManyItemsStreamAsync(items); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["_count"]!.Value().Should().Be(1); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } + var response = await _container.ReadManyItemsStreamAsync(items); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["_count"]!.Value().Should().Be(1); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } } public class ReadManyResponseDeepDiveV2Tests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task ReadMany_Headers_ContainItemCount() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")) - }; - var response = await _container.ReadManyItemsAsync(items); - response.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_Headers_ContainActivityId() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; - var response = await _container.ReadManyItemsAsync(items); - response.ActivityId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadMany_StatusCode_IsOkWhenNoneExist() - { - var items = new List<(string, PartitionKey)> - { - ("nonexistent1", new PartitionKey("pk1")), - ("nonexistent2", new PartitionKey("pk2")) - }; - var response = await _container.ReadManyItemsAsync(items); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task ReadMany_Headers_ContainItemCount() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")) + }; + var response = await _container.ReadManyItemsAsync(items); + response.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_Headers_ContainActivityId() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }; + var response = await _container.ReadManyItemsAsync(items); + response.ActivityId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadMany_StatusCode_IsOkWhenNoneExist() + { + var items = new List<(string, PartitionKey)> + { + ("nonexistent1", new PartitionKey("pk1")), + ("nonexistent2", new PartitionKey("pk2")) + }; + var response = await _container.ReadManyItemsAsync(items); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Should().BeEmpty(); + } } public class ReadManyConcurrencyDeepDiveTests { - [Fact] - public async Task ReadManyStream_ConcurrentCalls_AllSucceed() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (int i = 0; i < 10; i++) - { - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - var items = Enumerable.Range(0, 10) - .Select(i => ($"{i}", new PartitionKey("pk1"))).ToList(); - - var tasks = Enumerable.Range(0, 20).Select(_ => - container.ReadManyItemsStreamAsync(items)); - var results = await Task.WhenAll(tasks); - results.Should().AllSatisfy(r => r.StatusCode.Should().Be(HttpStatusCode.OK)); - } + [Fact] + public async Task ReadManyStream_ConcurrentCalls_AllSucceed() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (int i = 0; i < 10; i++) + { + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + var items = Enumerable.Range(0, 10) + .Select(i => ($"{i}", new PartitionKey("pk1"))).ToList(); + + var tasks = Enumerable.Range(0, 20).Select(_ => + container.ReadManyItemsStreamAsync(items)); + var results = await Task.WhenAll(tasks); + results.Should().AllSatisfy(r => r.StatusCode.Should().Be(HttpStatusCode.OK)); + } } public class ReadManyEmptyIdDeepDiveTests { - [Fact] - public async Task CreateItem_EmptyStringId_ThrowsBadRequest() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var act = () => container.CreateItemAsync( - new TestDocument { Id = "", PartitionKey = "pk1", Name = "EmptyId" }, - new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task CreateItem_EmptyStringId_ThrowsBadRequest() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var act = () => container.CreateItemAsync( + new TestDocument { Id = "", PartitionKey = "pk1", Name = "EmptyId" }, + new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyTests.cs index 9a22bc5..2f3b919 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ReadManyTests.cs @@ -8,523 +8,523 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ReadManyTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - #region Happy Path (typed) - - [Fact] - public async Task ReadMany_AllExist_ReturnsAll() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk1")), - ("3", new PartitionKey("pk2")), - }; - - var response = await _container.ReadManyItemsAsync(itemsToRead); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().HaveCount(2); - response.Resource.Select(d => d.Name).Should().Contain("Alice").And.Contain("Charlie"); - } - - [Fact] - public async Task ReadMany_SingleItem_ReturnsIt() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("2", new PartitionKey("pk1")), - }; - - var response = await _container.ReadManyItemsAsync(itemsToRead); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task ReadMany_MixedPartitionKeys_ReturnsAll() - { - await SeedItems(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("3", new PartitionKey("pk2")), - }; - - var response = await _container.ReadManyItemsAsync(items); - - response.Resource.Should().HaveCount(2); - } - - #endregion - - #region Missing Items (typed) - - [Fact] - public async Task ReadMany_SomeNotExist_ReturnsOnlyExisting() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk1")), - ("nonexistent", new PartitionKey("pk1")), - }; - - var response = await _container.ReadManyItemsAsync(itemsToRead); - - response.Resource.Should().HaveCount(1); - response.Resource.First().Name.Should().Be("Alice"); - } - - [Fact] - public async Task ReadMany_NoneExist_ReturnsEmpty() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("nonexistent1", new PartitionKey("pk1")), - ("nonexistent2", new PartitionKey("pk2")), - }; - - var response = await _container.ReadManyItemsAsync(itemsToRead); - - response.Resource.Should().BeEmpty(); - } - - [Fact] - public async Task ReadMany_WrongPartitionKey_DoesNotReturn() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk2")), - }; - - var response = await _container.ReadManyItemsAsync(itemsToRead); - - response.Resource.Should().BeEmpty(); - } - - [Fact] - public async Task ReadMany_EmptyList_ReturnsEmpty() - { - await SeedItems(); - var response = await _container.ReadManyItemsAsync([]); - - response.Resource.Should().BeEmpty(); - } - - #endregion - - #region Duplicate Handling - - [Fact] - public async Task ReadMany_DuplicateIdsInList_ReturnsDuplicates() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("1", new PartitionKey("pk1")), - }; - - var response = await _container.ReadManyItemsAsync(items); - - response.Resource.Should().HaveCount(2); - } - - [Fact] - public async Task ReadMany_DuplicateIdsInList_Stream_ReturnsDuplicates() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("1", new PartitionKey("pk1")), - }; - - using var response = await _container.ReadManyItemsStreamAsync(items); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().HaveCount(2); - } - - #endregion - - #region Stream Variant - - [Fact] - public async Task ReadManyStream_AllExist_ReturnsOkWithDocuments() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")), - }; - - using var response = await _container.ReadManyItemsStreamAsync(itemsToRead); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().HaveCount(2); - } - - [Fact] - public async Task ReadManyStream_EmptyList_ReturnsOkWithEmptyDocuments() - { - var emptyList = new List<(string id, PartitionKey pk)>(); - - using var response = await _container.ReadManyItemsStreamAsync(emptyList); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStream_AllMissing_ReturnsOkWithEmptyDocuments() - { - var items = new List<(string id, PartitionKey pk)> - { - ("nonexistent1", new PartitionKey("pk1")), - ("nonexistent2", new PartitionKey("pk2")), - }; - - using var response = await _container.ReadManyItemsStreamAsync(items); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - ((JArray)jObj["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStream_ContainsCountField() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk1")), - ("3", new PartitionKey("pk2")), - }; - - using var response = await _container.ReadManyItemsStreamAsync(itemsToRead); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - - jObj["_count"]!.Value().Should().Be(2); - } - - #endregion - - #region Response Metadata - - [Fact] - public async Task ReadMany_StatusCode_IsOk() - { - await SeedItems(); - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1"))]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReadMany_Count_MatchesFoundItems() - { - await SeedItems(); - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")), - ("nonexistent", new PartitionKey("pk1")), - }; - - var response = await _container.ReadManyItemsAsync(items); - - response.Resource.Should().HaveCount(2); - response.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_RequestCharge_IsPositive() - { - await SeedItems(); - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1"))]); - - response.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task ReadMany_ActivityId_IsPresent() - { - await SeedItems(); - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1"))]); - - response.ActivityId.Should().NotBeNull(); - } - - #endregion - - #region Error Handling - - [Fact] - public async Task ReadMany_NullList_ThrowsArgumentNullException() - { - var act = () => _container.ReadManyItemsAsync(null!); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadManyStream_NullList_ThrowsArgumentNullException() - { - var act = () => _container.ReadManyItemsStreamAsync(null!); - - await act.Should().ThrowAsync(); - } - - #endregion - - #region Scale - - [Fact] - public async Task ReadMany_LargeList_100Items_ReturnsAll() - { - for (var i = 0; i < 110; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie" }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + #region Happy Path (typed) + + [Fact] + public async Task ReadMany_AllExist_ReturnsAll() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk1")), + ("3", new PartitionKey("pk2")), + }; + + var response = await _container.ReadManyItemsAsync(itemsToRead); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().HaveCount(2); + response.Resource.Select(d => d.Name).Should().Contain("Alice").And.Contain("Charlie"); + } + + [Fact] + public async Task ReadMany_SingleItem_ReturnsIt() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("2", new PartitionKey("pk1")), + }; + + var response = await _container.ReadManyItemsAsync(itemsToRead); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task ReadMany_MixedPartitionKeys_ReturnsAll() + { + await SeedItems(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("3", new PartitionKey("pk2")), + }; + + var response = await _container.ReadManyItemsAsync(items); + + response.Resource.Should().HaveCount(2); + } + + #endregion + + #region Missing Items (typed) + + [Fact] + public async Task ReadMany_SomeNotExist_ReturnsOnlyExisting() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk1")), + ("nonexistent", new PartitionKey("pk1")), + }; + + var response = await _container.ReadManyItemsAsync(itemsToRead); + + response.Resource.Should().HaveCount(1); + response.Resource.First().Name.Should().Be("Alice"); + } + + [Fact] + public async Task ReadMany_NoneExist_ReturnsEmpty() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("nonexistent1", new PartitionKey("pk1")), + ("nonexistent2", new PartitionKey("pk2")), + }; + + var response = await _container.ReadManyItemsAsync(itemsToRead); + + response.Resource.Should().BeEmpty(); + } + + [Fact] + public async Task ReadMany_WrongPartitionKey_DoesNotReturn() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk2")), + }; + + var response = await _container.ReadManyItemsAsync(itemsToRead); + + response.Resource.Should().BeEmpty(); + } + + [Fact] + public async Task ReadMany_EmptyList_ReturnsEmpty() + { + await SeedItems(); + var response = await _container.ReadManyItemsAsync([]); + + response.Resource.Should().BeEmpty(); + } + + #endregion + + #region Duplicate Handling + + [Fact] + public async Task ReadMany_DuplicateIdsInList_ReturnsDuplicates() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("1", new PartitionKey("pk1")), + }; + + var response = await _container.ReadManyItemsAsync(items); + + response.Resource.Should().HaveCount(2); + } + + [Fact] + public async Task ReadMany_DuplicateIdsInList_Stream_ReturnsDuplicates() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("1", new PartitionKey("pk1")), + }; + + using var response = await _container.ReadManyItemsStreamAsync(items); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().HaveCount(2); + } + + #endregion + + #region Stream Variant + + [Fact] + public async Task ReadManyStream_AllExist_ReturnsOkWithDocuments() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")), + }; + + using var response = await _container.ReadManyItemsStreamAsync(itemsToRead); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().HaveCount(2); + } + + [Fact] + public async Task ReadManyStream_EmptyList_ReturnsOkWithEmptyDocuments() + { + var emptyList = new List<(string id, PartitionKey pk)>(); + + using var response = await _container.ReadManyItemsStreamAsync(emptyList); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStream_AllMissing_ReturnsOkWithEmptyDocuments() + { + var items = new List<(string id, PartitionKey pk)> + { + ("nonexistent1", new PartitionKey("pk1")), + ("nonexistent2", new PartitionKey("pk2")), + }; + + using var response = await _container.ReadManyItemsStreamAsync(items); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + ((JArray)jObj["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStream_ContainsCountField() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk1")), + ("3", new PartitionKey("pk2")), + }; + + using var response = await _container.ReadManyItemsStreamAsync(itemsToRead); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + + jObj["_count"]!.Value().Should().Be(2); + } + + #endregion + + #region Response Metadata + + [Fact] + public async Task ReadMany_StatusCode_IsOk() + { + await SeedItems(); + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1"))]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReadMany_Count_MatchesFoundItems() + { + await SeedItems(); + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")), + ("nonexistent", new PartitionKey("pk1")), + }; + + var response = await _container.ReadManyItemsAsync(items); + + response.Resource.Should().HaveCount(2); + response.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_RequestCharge_IsPositive() + { + await SeedItems(); + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1"))]); + + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task ReadMany_ActivityId_IsPresent() + { + await SeedItems(); + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1"))]); + + response.ActivityId.Should().NotBeNull(); + } + + #endregion + + #region Error Handling + + [Fact] + public async Task ReadMany_NullList_ThrowsArgumentNullException() + { + var act = () => _container.ReadManyItemsAsync(null!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadManyStream_NullList_ThrowsArgumentNullException() + { + var act = () => _container.ReadManyItemsStreamAsync(null!); + + await act.Should().ThrowAsync(); + } + + #endregion + + #region Scale + + [Fact] + public async Task ReadMany_LargeList_100Items_ReturnsAll() + { + for (var i = 0; i < 110; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } - var items = Enumerable.Range(0, 110) - .Select(i => ($"{i}", new PartitionKey("pk1"))) - .ToList(); + var items = Enumerable.Range(0, 110) + .Select(i => ($"{i}", new PartitionKey("pk1"))) + .ToList(); - var response = await _container.ReadManyItemsAsync(items); + var response = await _container.ReadManyItemsAsync(items); - response.Resource.Should().HaveCount(110); - } - - #endregion + response.Resource.Should().HaveCount(110); + } + + #endregion - #region After Mutations + #region After Mutations - [Fact] - public async Task ReadMany_AfterItemUpdate_ReturnsUpdatedVersion() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Updated" }, - new PartitionKey("pk1")); - - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1"))]); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice Updated"); - } - - [Fact] - public async Task ReadMany_AfterItemDelete_ExcludesDeletedItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); + [Fact] + public async Task ReadMany_AfterItemUpdate_ReturnsUpdatedVersion() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Updated" }, + new PartitionKey("pk1")); + + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1"))]); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice Updated"); + } + + [Fact] + public async Task ReadMany_AfterItemDelete_ExcludesDeletedItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); - await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1")), ("2", new PartitionKey("pk1"))]); + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1")), ("2", new PartitionKey("pk1"))]); - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } - [Fact] - public async Task ReadMany_AfterItemReplace_ReturnsReplacedVersion() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task ReadMany_AfterItemReplace_ReturnsReplacedVersion() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Replaced" }, - "1", new PartitionKey("pk1")); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Replaced" }, + "1", new PartitionKey("pk1")); - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey("pk1"))]); + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey("pk1"))]); - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice Replaced"); - } + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Alice Replaced"); + } - #endregion + #endregion - #region Partition Key Edge Cases + #region Partition Key Edge Cases - [Fact] - public async Task ReadMany_HierarchicalPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer("hierarchical-container", ["/tenantId", "/userId"]); - var doc = JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }); - var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); - await container.CreateItemAsync(doc, pk); - - var response = await container.ReadManyItemsAsync([("1", pk)]); - - response.Resource.Should().ContainSingle(); - response.Resource.First()["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task ReadMany_EmptyStringPartitionKey_ReturnsItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "", Name = "EmptyPk" }, - new PartitionKey("")); - - var response = await _container.ReadManyItemsAsync( - [("1", new PartitionKey(""))]); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("EmptyPk"); - } - - #endregion - - #region ID Edge Cases - - [Fact] - public async Task ReadMany_CaseSensitiveIds_TreatedDistinct() - { - await _container.CreateItemAsync( - new TestDocument { Id = "ABC", PartitionKey = "pk1", Name = "Upper" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "abc", PartitionKey = "pk1", Name = "Lower" }, - new PartitionKey("pk1")); - - var response = await _container.ReadManyItemsAsync( - [("ABC", new PartitionKey("pk1")), ("abc", new PartitionKey("pk1"))]); - - response.Resource.Should().HaveCount(2); - response.Resource.Select(d => d.Name).Should().BeEquivalentTo(["Upper", "Lower"]); - } - - [Fact] - public async Task ReadMany_SpecialCharactersInId_ReturnsItem() - { - var specialId = "item/with spaces.and-dots"; - await _container.CreateItemAsync( - new TestDocument { Id = specialId, PartitionKey = "pk1", Name = "Special" }, - new PartitionKey("pk1")); - - var response = await _container.ReadManyItemsAsync( - [(specialId, new PartitionKey("pk1"))]); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Special"); - } - - [Fact] - public async Task ReadMany_UnicodeId_ReturnsItem() - { - var unicodeId = "日本語テスト🚀"; - await _container.CreateItemAsync( - new TestDocument { Id = unicodeId, PartitionKey = "pk1", Name = "Unicode" }, - new PartitionKey("pk1")); - - var response = await _container.ReadManyItemsAsync( - [(unicodeId, new PartitionKey("pk1"))]); - - response.Resource.Should().ContainSingle().Which.Name.Should().Be("Unicode"); - } - - #endregion - - #region System Properties - - [Fact] - public async Task ReadManyStream_ResultsIncludeSystemProperties() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - using var response = await _container.ReadManyItemsStreamAsync( - [("1", new PartitionKey("pk1"))]); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - var doc = ((JArray)jObj["Documents"]!).First as JObject; - - doc!["_etag"].Should().NotBeNull(); - doc["_ts"].Should().NotBeNull(); - } - - #endregion - - #region Concurrency - - [Fact] - public async Task ReadMany_ConcurrentCalls_AllSucceed() - { - await SeedItems(); - var itemsToRead = new List<(string id, PartitionKey pk)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")), - ("3", new PartitionKey("pk2")), - }; - - var tasks = Enumerable.Range(0, 10) - .Select(_ => _container.ReadManyItemsAsync(itemsToRead)) - .ToArray(); - - var results = await Task.WhenAll(tasks); - - foreach (var response in results) - { - response.Resource.Should().HaveCount(3); - } - } - - #endregion + [Fact] + public async Task ReadMany_HierarchicalPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer("hierarchical-container", ["/tenantId", "/userId"]); + var doc = JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }); + var pk = new PartitionKeyBuilder().Add("t1").Add("u1").Build(); + await container.CreateItemAsync(doc, pk); + + var response = await container.ReadManyItemsAsync([("1", pk)]); + + response.Resource.Should().ContainSingle(); + response.Resource.First()["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task ReadMany_EmptyStringPartitionKey_ReturnsItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "", Name = "EmptyPk" }, + new PartitionKey("")); + + var response = await _container.ReadManyItemsAsync( + [("1", new PartitionKey(""))]); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("EmptyPk"); + } + + #endregion + + #region ID Edge Cases + + [Fact] + public async Task ReadMany_CaseSensitiveIds_TreatedDistinct() + { + await _container.CreateItemAsync( + new TestDocument { Id = "ABC", PartitionKey = "pk1", Name = "Upper" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "abc", PartitionKey = "pk1", Name = "Lower" }, + new PartitionKey("pk1")); + + var response = await _container.ReadManyItemsAsync( + [("ABC", new PartitionKey("pk1")), ("abc", new PartitionKey("pk1"))]); + + response.Resource.Should().HaveCount(2); + response.Resource.Select(d => d.Name).Should().BeEquivalentTo(["Upper", "Lower"]); + } + + [Fact] + public async Task ReadMany_SpecialCharactersInId_ReturnsItem() + { + var specialId = "item/with spaces.and-dots"; + await _container.CreateItemAsync( + new TestDocument { Id = specialId, PartitionKey = "pk1", Name = "Special" }, + new PartitionKey("pk1")); + + var response = await _container.ReadManyItemsAsync( + [(specialId, new PartitionKey("pk1"))]); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Special"); + } + + [Fact] + public async Task ReadMany_UnicodeId_ReturnsItem() + { + var unicodeId = "日本語テスト🚀"; + await _container.CreateItemAsync( + new TestDocument { Id = unicodeId, PartitionKey = "pk1", Name = "Unicode" }, + new PartitionKey("pk1")); + + var response = await _container.ReadManyItemsAsync( + [(unicodeId, new PartitionKey("pk1"))]); + + response.Resource.Should().ContainSingle().Which.Name.Should().Be("Unicode"); + } + + #endregion + + #region System Properties + + [Fact] + public async Task ReadManyStream_ResultsIncludeSystemProperties() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + using var response = await _container.ReadManyItemsStreamAsync( + [("1", new PartitionKey("pk1"))]); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + var doc = ((JArray)jObj["Documents"]!).First as JObject; + + doc!["_etag"].Should().NotBeNull(); + doc["_ts"].Should().NotBeNull(); + } + + #endregion + + #region Concurrency + + [Fact] + public async Task ReadMany_ConcurrentCalls_AllSucceed() + { + await SeedItems(); + var itemsToRead = new List<(string id, PartitionKey pk)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")), + ("3", new PartitionKey("pk2")), + }; + + var tasks = Enumerable.Range(0, 10) + .Select(_ => _container.ReadManyItemsAsync(itemsToRead)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + foreach (var response in results) + { + response.Resource.Should().HaveCount(3); + } + } + + #endregion } // ═══════════════════════════════════════════════════════════════════════════════ @@ -533,45 +533,45 @@ public async Task ReadMany_ConcurrentCalls_AllSucceed() public class ReadManyTtlDeepDiveTests { - [Fact] - public async Task ReadMany_TTLExpiredItem_ExcludesExpiredItem() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await Task.Delay(1500); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.Count.Should().Be(0); - } - - [Fact] - public async Task ReadMany_MixOfExpiredAndLive_ReturnsOnlyLive() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await Task.Delay(1500); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("2", new PartitionKey("a")) - }); - result.Count.Should().Be(1); - result.First()["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task ReadManyStream_TTLExpiredItem_ExcludesExpiredItem() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await Task.Delay(1500); - var response = await container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((int)body["_count"]!).Should().Be(0); - } + [Fact] + public async Task ReadMany_TTLExpiredItem_ExcludesExpiredItem() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await Task.Delay(1500); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.Count.Should().Be(0); + } + + [Fact] + public async Task ReadMany_MixOfExpiredAndLive_ReturnsOnlyLive() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await Task.Delay(1500); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("2", new PartitionKey("a")) + }); + result.Count.Should().Be(1); + result.First()["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task ReadManyStream_TTLExpiredItem_ExcludesExpiredItem() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await Task.Delay(1500); + var response = await container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((int)body["_count"]!).Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -580,49 +580,49 @@ public async Task ReadManyStream_TTLExpiredItem_ExcludesExpiredItem() public class ReadManyPartitionKeyDeepDiveTests { - [Fact] - public async Task ReadMany_NumericPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer("test", "/numPk"); - var item = JObject.FromObject(new { id = "1", numPk = 42 }); - await container.CreateItemAsync(item, new PartitionKey(42)); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey(42)) }); - result.Count.Should().Be(1); - } - - [Fact] - public async Task ReadMany_BooleanPartitionKey_ReturnsItems() - { - var container = new InMemoryContainer("test", "/boolPk"); - var item = JObject.FromObject(new { id = "1", boolPk = true }); - await container.CreateItemAsync(item, new PartitionKey(true)); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey(true)) }); - result.Count.Should().Be(1); - } - - [Fact] - public async Task ReadMany_SameIdDifferentPartitionKeys_ReturnsBoth() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "b", name = "Bob" }), new PartitionKey("b")); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("1", new PartitionKey("b")) - }); - result.Count.Should().Be(2); - } - - [Fact] - public async Task ReadMany_PartitionKeyNull_ReturnsNullPkItems() - { - var container = new InMemoryContainer("test", "/pk"); - var item = JObject.Parse("{\"id\":\"1\",\"pk\":null}"); - await container.CreateItemAsync(item, PartitionKey.Null); - var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", PartitionKey.Null) }); - result.Count.Should().Be(1); - } + [Fact] + public async Task ReadMany_NumericPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer("test", "/numPk"); + var item = JObject.FromObject(new { id = "1", numPk = 42 }); + await container.CreateItemAsync(item, new PartitionKey(42)); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey(42)) }); + result.Count.Should().Be(1); + } + + [Fact] + public async Task ReadMany_BooleanPartitionKey_ReturnsItems() + { + var container = new InMemoryContainer("test", "/boolPk"); + var item = JObject.FromObject(new { id = "1", boolPk = true }); + await container.CreateItemAsync(item, new PartitionKey(true)); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey(true)) }); + result.Count.Should().Be(1); + } + + [Fact] + public async Task ReadMany_SameIdDifferentPartitionKeys_ReturnsBoth() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "b", name = "Bob" }), new PartitionKey("b")); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("1", new PartitionKey("b")) + }); + result.Count.Should().Be(2); + } + + [Fact] + public async Task ReadMany_PartitionKeyNull_ReturnsNullPkItems() + { + var container = new InMemoryContainer("test", "/pk"); + var item = JObject.Parse("{\"id\":\"1\",\"pk\":null}"); + await container.CreateItemAsync(item, PartitionKey.Null); + var result = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", PartitionKey.Null) }); + result.Count.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -631,65 +631,65 @@ public async Task ReadMany_PartitionKeyNull_ReturnsNullPkItems() public class ReadManyOptionsDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ReadMany_WithRequestOptions_DoesNotThrow() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var options = new ReadManyRequestOptions(); - var result = await _container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, options); - result.Count.Should().Be(1); - } - - [Fact] - public async Task ReadManyStream_WithRequestOptions_DoesNotThrow() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var options = new ReadManyRequestOptions(); - var response = await _container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, options); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReadMany_CancelledToken_ThrowsOperationCanceledException() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => _container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadMany_WithIfNoneMatchEtag_Returns304WhenUnchanged() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var first = await _container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - first.StatusCode.Should().Be(HttpStatusCode.OK); - first.ETag.Should().NotBeNullOrEmpty(); - - var second = await _container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, - new ReadManyRequestOptions { IfNoneMatchEtag = first.ETag }); - second.StatusCode.Should().Be(HttpStatusCode.NotModified); - second.Count.Should().Be(0); - } - - [Fact] - public async Task ReadMany_WithIfNoneMatchEtag_AlwaysReturns200_Divergent() - { - // DIVERGENT BEHAVIOR: IfNoneMatchEtag is silently ignored. - // The emulator does not compute composite response ETags across multiple items. - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var result = await _container.ReadManyItemsAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ReadMany_WithRequestOptions_DoesNotThrow() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var options = new ReadManyRequestOptions(); + var result = await _container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, options); + result.Count.Should().Be(1); + } + + [Fact] + public async Task ReadManyStream_WithRequestOptions_DoesNotThrow() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var options = new ReadManyRequestOptions(); + var response = await _container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, options); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReadMany_CancelledToken_ThrowsOperationCanceledException() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => _container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadMany_WithIfNoneMatchEtag_Returns304WhenUnchanged() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var first = await _container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + first.StatusCode.Should().Be(HttpStatusCode.OK); + first.ETag.Should().NotBeNullOrEmpty(); + + var second = await _container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, + new ReadManyRequestOptions { IfNoneMatchEtag = first.ETag }); + second.StatusCode.Should().Be(HttpStatusCode.NotModified); + second.Count.Should().Be(0); + } + + [Fact] + public async Task ReadMany_WithIfNoneMatchEtag_AlwaysReturns200_Divergent() + { + // DIVERGENT BEHAVIOR: IfNoneMatchEtag is silently ignored. + // The emulator does not compute composite response ETags across multiple items. + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var result = await _container.ReadManyItemsAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -698,17 +698,17 @@ public async Task ReadMany_WithIfNoneMatchEtag_AlwaysReturns200_Divergent() public class ReadManyMutationDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ReadMany_AfterPatchOperation_ReturnsPatchedVersion() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await _container.PatchItemAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Set("/name", "Alicia") }); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.First()["name"]!.ToString().Should().Be("Alicia"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ReadMany_AfterPatchOperation_ReturnsPatchedVersion() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await _container.PatchItemAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Set("/name", "Alicia") }); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.First()["name"]!.ToString().Should().Be("Alicia"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -717,54 +717,54 @@ public async Task ReadMany_AfterPatchOperation_ReturnsPatchedVersion() public class ReadManyResponseDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - } - - [Fact] - public async Task ReadMany_Diagnostics_IsNotNull() - { - await Seed(); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.Diagnostics.Should().NotBeNull(); - } - - [Fact] - public async Task ReadMany_ContinuationToken_IsNull() - { - await Seed(); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task ReadMany_IndexMetrics_IsNull() - { - await Seed(); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.IndexMetrics.Should().BeNull(); - } - - [Fact] - public async Task ReadMany_ResponseIsEnumerable() - { - await Seed(); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var count = 0; - foreach (var _ in result) count++; - count.Should().Be(1); - } - - [Fact] - public async Task ReadMany_Headers_ContainRequestCharge() - { - await Seed(); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.Headers["x-ms-request-charge"].Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + } + + [Fact] + public async Task ReadMany_Diagnostics_IsNotNull() + { + await Seed(); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.Diagnostics.Should().NotBeNull(); + } + + [Fact] + public async Task ReadMany_ContinuationToken_IsNull() + { + await Seed(); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task ReadMany_IndexMetrics_IsNull() + { + await Seed(); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.IndexMetrics.Should().BeNull(); + } + + [Fact] + public async Task ReadMany_ResponseIsEnumerable() + { + await Seed(); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var count = 0; + foreach (var _ in result) count++; + count.Should().Be(1); + } + + [Fact] + public async Task ReadMany_Headers_ContainRequestCharge() + { + await Seed(); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.Headers["x-ms-request-charge"].Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -773,92 +773,92 @@ public async Task ReadMany_Headers_ContainRequestCharge() public class ReadManyDeserializationDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/pk"); - - [Fact] - public async Task ReadMany_ComplexNestedDocument_Roundtrips() - { - var complex = JObject.FromObject(new { id = "1", pk = "a", nested = new { inner = new { value = 42 } }, tags = new[] { "x", "y" } }); - await _container.CreateItemAsync(complex, new PartitionKey("a")); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.First()["nested"]!["inner"]!["value"]!.Value().Should().Be(42); - ((JArray)result.First()["tags"]!).Should().HaveCount(2); - } - - [Fact] - public async Task ReadMany_VeryLongId_ReturnsItem() - { - var longId = new string('x', 255); - await _container.CreateItemAsync(JObject.FromObject(new { id = longId, pk = "a" }), new PartitionKey("a")); - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { (longId, new PartitionKey("a")) }); - result.Count.Should().Be(1); - } - - [Fact] - public async Task ReadMany_EmptyContainer_ReturnsEmpty() - { - var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - result.Count.Should().Be(0); - } - - [Fact] - public async Task ReadManyStream_EmptyContainer_ReturnsEmptyEnvelope() - { - var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((int)body["_count"]!).Should().Be(0); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStream_HasRidField() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["_rid"].Should().NotBeNull(); - } - - [Fact] - public async Task ReadManyStream_ContentIsValidJson() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var body = await new StreamReader(response.Content).ReadToEndAsync(); - var parsed = JObject.Parse(body); // Should not throw - parsed["Documents"].Should().NotBeNull(); - } - - [Fact] - public async Task ReadMany_TypedAndStream_ReturnSameLogicalResults() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); - var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("a")), ("2", new PartitionKey("a")) }; - - var typed = await _container.ReadManyItemsAsync(items); - var stream = await _container.ReadManyItemsStreamAsync(items); - var streamBody = JObject.Parse(await new StreamReader(stream.Content).ReadToEndAsync()); - - typed.Count.Should().Be(((JArray)streamBody["Documents"]!).Count); - } - - [Fact] - public async Task ReadMany_ResultOrderMatchesRequestOrder() - { - // Emulator returns items in request order (real Cosmos may not guarantee order) - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); - - var items = new List<(string, PartitionKey)> - { - ("2", new PartitionKey("a")), - ("3", new PartitionKey("a")), - ("1", new PartitionKey("a")) - }; - var result = await _container.ReadManyItemsAsync(items); - result.Select(r => r["id"]!.ToString()).Should().ContainInConsecutiveOrder("2", "3", "1"); - } + private readonly InMemoryContainer _container = new("test", "/pk"); + + [Fact] + public async Task ReadMany_ComplexNestedDocument_Roundtrips() + { + var complex = JObject.FromObject(new { id = "1", pk = "a", nested = new { inner = new { value = 42 } }, tags = new[] { "x", "y" } }); + await _container.CreateItemAsync(complex, new PartitionKey("a")); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.First()["nested"]!["inner"]!["value"]!.Value().Should().Be(42); + ((JArray)result.First()["tags"]!).Should().HaveCount(2); + } + + [Fact] + public async Task ReadMany_VeryLongId_ReturnsItem() + { + var longId = new string('x', 255); + await _container.CreateItemAsync(JObject.FromObject(new { id = longId, pk = "a" }), new PartitionKey("a")); + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { (longId, new PartitionKey("a")) }); + result.Count.Should().Be(1); + } + + [Fact] + public async Task ReadMany_EmptyContainer_ReturnsEmpty() + { + var result = await _container.ReadManyItemsAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + result.Count.Should().Be(0); + } + + [Fact] + public async Task ReadManyStream_EmptyContainer_ReturnsEmptyEnvelope() + { + var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((int)body["_count"]!).Should().Be(0); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStream_HasRidField() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["_rid"].Should().NotBeNull(); + } + + [Fact] + public async Task ReadManyStream_ContentIsValidJson() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var response = await _container.ReadManyItemsStreamAsync(new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var body = await new StreamReader(response.Content).ReadToEndAsync(); + var parsed = JObject.Parse(body); // Should not throw + parsed["Documents"].Should().NotBeNull(); + } + + [Fact] + public async Task ReadMany_TypedAndStream_ReturnSameLogicalResults() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); + var items = new List<(string, PartitionKey)> { ("1", new PartitionKey("a")), ("2", new PartitionKey("a")) }; + + var typed = await _container.ReadManyItemsAsync(items); + var stream = await _container.ReadManyItemsStreamAsync(items); + var streamBody = JObject.Parse(await new StreamReader(stream.Content).ReadToEndAsync()); + + typed.Count.Should().Be(((JArray)streamBody["Documents"]!).Count); + } + + [Fact] + public async Task ReadMany_ResultOrderMatchesRequestOrder() + { + // Emulator returns items in request order (real Cosmos may not guarantee order) + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); + + var items = new List<(string, PartitionKey)> + { + ("2", new PartitionKey("a")), + ("3", new PartitionKey("a")), + ("1", new PartitionKey("a")) + }; + var result = await _container.ReadManyItemsAsync(items); + result.Select(r => r["id"]!.ToString()).Should().ContainInConsecutiveOrder("2", "3", "1"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealFeedIteratorDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealFeedIteratorDeepDiveTests.cs index cdf2ee5..e27b0af 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealFeedIteratorDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealFeedIteratorDeepDiveTests.cs @@ -15,139 +15,139 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class RealToFeedIteratorPaginationDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public RealToFeedIteratorPaginationDeepDiveTests() - { - _handler = new FakeCosmosHandler(_backing); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("fakeDb", "fakeContainer"); - } - - public async ValueTask InitializeAsync() - { - for (int i = 1; i <= 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, - new PartitionKey("pk1")); - } - - public ValueTask DisposeAsync() - { - _client.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task Pagination_WithMaxItemCount_ReturnsMultiplePages() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var allItems = new List(); - int pages = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - pages++; - } - allItems.Should().HaveCount(5); - pages.Should().BeGreaterThan(1); - } - - [Fact] - public async Task Pagination_WithMaxItemCount1_ReturnsOneItemPerPage() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); - var pagesSizes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - pagesSizes.Add(page.Count); - } - pagesSizes.Should().AllSatisfy(s => s.Should().BeLessThanOrEqualTo(1)); - pagesSizes.Sum().Should().Be(5); - } - - [Fact] - public async Task Pagination_ContinuationToken_AllowsDrainToComplete() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var allItems = new List(); - string? lastToken = null; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - lastToken = page.ContinuationToken; - } - allItems.Should().HaveCount(5); - lastToken.Should().BeNull(); - } - - [Fact] - public async Task Pagination_WithOrderBy_MaintainsOrderAcrossPages() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c[\"value\"] ASC", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var allValues = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allValues.AddRange(page.Select(d => d.Value)); - } - allValues.Should().BeInAscendingOrder(); - } - - [Fact] - public async Task Pagination_WithWhereFilter_PaginatesFilteredResults() - { - // Add more items so we have enough to filter - await _container.CreateItemAsync( - new TestDocument { Id = "6", PartitionKey = "pk1", Name = "Item6", Value = 60 }, - new PartitionKey("pk1")); - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c[\"value\"] > 20", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - } - allItems.Should().HaveCount(4); // 30, 40, 50, 60 - } - - [Fact] - public async Task ReadFeed_ViaGetItemQueryIterator_WithoutSql_ReturnsAll() - { - var iterator = _container.GetItemQueryIterator(); - var allItems = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - } - allItems.Should().HaveCount(5); - } + private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public RealToFeedIteratorPaginationDeepDiveTests() + { + _handler = new FakeCosmosHandler(_backing); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("fakeDb", "fakeContainer"); + } + + public async ValueTask InitializeAsync() + { + for (int i = 1; i <= 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i * 10 }, + new PartitionKey("pk1")); + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task Pagination_WithMaxItemCount_ReturnsMultiplePages() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var allItems = new List(); + int pages = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + pages++; + } + allItems.Should().HaveCount(5); + pages.Should().BeGreaterThan(1); + } + + [Fact] + public async Task Pagination_WithMaxItemCount1_ReturnsOneItemPerPage() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var pagesSizes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + pagesSizes.Add(page.Count); + } + pagesSizes.Should().AllSatisfy(s => s.Should().BeLessThanOrEqualTo(1)); + pagesSizes.Sum().Should().Be(5); + } + + [Fact] + public async Task Pagination_ContinuationToken_AllowsDrainToComplete() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var allItems = new List(); + string? lastToken = null; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + lastToken = page.ContinuationToken; + } + allItems.Should().HaveCount(5); + lastToken.Should().BeNull(); + } + + [Fact] + public async Task Pagination_WithOrderBy_MaintainsOrderAcrossPages() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c[\"value\"] ASC", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var allValues = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allValues.AddRange(page.Select(d => d.Value)); + } + allValues.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task Pagination_WithWhereFilter_PaginatesFilteredResults() + { + // Add more items so we have enough to filter + await _container.CreateItemAsync( + new TestDocument { Id = "6", PartitionKey = "pk1", Name = "Item6", Value = 60 }, + new PartitionKey("pk1")); + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c[\"value\"] > 20", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + } + allItems.Should().HaveCount(4); // 30, 40, 50, 60 + } + + [Fact] + public async Task ReadFeed_ViaGetItemQueryIterator_WithoutSql_ReturnsAll() + { + var iterator = _container.GetItemQueryIterator(); + var allItems = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + } + allItems.Should().HaveCount(5); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -156,97 +156,97 @@ public async Task ReadFeed_ViaGetItemQueryIterator_WithoutSql_ReturnsAll() public class RealToFeedIteratorSqlQueryDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public RealToFeedIteratorSqlQueryDeepDiveTests() - { - _handler = new FakeCosmosHandler(_backing); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("fakeDb", "fakeContainer"); - } - - public async ValueTask InitializeAsync() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, - new PartitionKey("pk2")); - } - - public ValueTask DisposeAsync() - { - _client.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task SqlQuery_WithParameterizedQuery_ReturnsCorrectResults() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Alice"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice"); - } - - [Fact] - public async Task SqlQuery_WithOrderBy_ReturnsOrderedResults() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c[\"value\"] ASC"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Select(r => r.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task SqlQuery_WithTopN_LimitsResults() - { - var iterator = _container.GetItemQueryIterator( - "SELECT TOP 2 * FROM c ORDER BY c[\"value\"] ASC"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); - } - - [Fact] - public async Task SqlQuery_WithGroupByCount_ReturnsGroupedResults() - { - var iterator = _container.GetItemQueryIterator( - "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); - } - - [Fact] - public async Task SqlQuery_EmptyResult_ReturnsEmptyIterator() - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Nobody'"); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public RealToFeedIteratorSqlQueryDeepDiveTests() + { + _handler = new FakeCosmosHandler(_backing); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("fakeDb", "fakeContainer"); + } + + public async ValueTask InitializeAsync() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, + new PartitionKey("pk2")); + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task SqlQuery_WithParameterizedQuery_ReturnsCorrectResults() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Alice"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task SqlQuery_WithOrderBy_ReturnsOrderedResults() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c[\"value\"] ASC"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Select(r => r.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task SqlQuery_WithTopN_LimitsResults() + { + var iterator = _container.GetItemQueryIterator( + "SELECT TOP 2 * FROM c ORDER BY c[\"value\"] ASC"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); + } + + [Fact] + public async Task SqlQuery_WithGroupByCount_ReturnsGroupedResults() + { + var iterator = _container.GetItemQueryIterator( + "SELECT c.partitionKey, COUNT(1) AS cnt FROM c GROUP BY c.partitionKey"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); + } + + [Fact] + public async Task SqlQuery_EmptyResult_ReturnsEmptyIterator() + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Nobody'"); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -255,85 +255,85 @@ public async Task SqlQuery_EmptyResult_ReturnsEmptyIterator() public class RealToFeedIteratorStreamDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public RealToFeedIteratorStreamDeepDiveTests() - { - _handler = new FakeCosmosHandler(_backing); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("fakeDb", "fakeContainer"); - } - - public async ValueTask InitializeAsync() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - } - - public ValueTask DisposeAsync() - { - _client.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task StreamQuery_WithWhereFilter_ReturnsFilteredJson() - { - var iterator = _container.GetItemQueryStreamIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().HaveCount(1); - } - - [Fact] - public async Task StreamQuery_WithOrderBy_ReturnsOrderedJson() - { - var iterator = _container.GetItemQueryStreamIterator( - "SELECT * FROM c ORDER BY c[\"value\"] DESC"); - var response = await iterator.ReadNextAsync(); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - var docs = (JArray)body["Documents"]!; - docs[0]!["value"]!.Value().Should().BeGreaterThan(docs[1]!["value"]!.Value()); - } - - [Fact] - public async Task StreamQuery_EmptyContainer_ReturnsEmptyDocuments() - { - var emptyBacking = new InMemoryContainer("empty", "/partitionKey"); - using var handler = new FakeCosmosHandler(emptyBacking); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var container = client.GetContainer("db", "c"); - var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } + private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public RealToFeedIteratorStreamDeepDiveTests() + { + _handler = new FakeCosmosHandler(_backing); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("fakeDb", "fakeContainer"); + } + + public async ValueTask InitializeAsync() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task StreamQuery_WithWhereFilter_ReturnsFilteredJson() + { + var iterator = _container.GetItemQueryStreamIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().HaveCount(1); + } + + [Fact] + public async Task StreamQuery_WithOrderBy_ReturnsOrderedJson() + { + var iterator = _container.GetItemQueryStreamIterator( + "SELECT * FROM c ORDER BY c[\"value\"] DESC"); + var response = await iterator.ReadNextAsync(); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + var docs = (JArray)body["Documents"]!; + docs[0]!["value"]!.Value().Should().BeGreaterThan(docs[1]!["value"]!.Value()); + } + + [Fact] + public async Task StreamQuery_EmptyContainer_ReturnsEmptyDocuments() + { + var emptyBacking = new InMemoryContainer("empty", "/partitionKey"); + using var handler = new FakeCosmosHandler(emptyBacking); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var container = client.GetContainer("db", "c"); + var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -342,177 +342,177 @@ public async Task StreamQuery_EmptyContainer_ReturnsEmptyDocuments() public class RealToFeedIteratorLinqDeepDiveV2Tests : IAsyncLifetime { - private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public RealToFeedIteratorLinqDeepDiveV2Tests() - { - _handler = new FakeCosmosHandler(_backing); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("fakeDb", "fakeContainer"); - } - - public async ValueTask InitializeAsync() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Charlie", Value = 40, IsActive = false }, - new PartitionKey("pk2")); - } - - public ValueTask DisposeAsync() - { - _client.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - // -- Section D: DISTINCT -- - - [Fact] - public async Task Distinct_OnStringField_ReturnsUniqueStrings() - { - var queryable = _container.GetItemLinqQueryable() - .Select(d => d.Name).Distinct(); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(3); // Alice, Bob, Charlie - } - - [Fact] - public async Task Distinct_OnBoolField_ReturnsTrueAndFalse() - { - var queryable = _container.GetItemLinqQueryable() - .Select(d => d.IsActive).Distinct(); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); - results.Should().Contain(true); - results.Should().Contain(false); - } - - // -- Section E: ORDER BY -- - - [Fact] - public async Task OrderByDescending_ThenByAscending_MixedDirections() - { - var queryable = _container.GetItemLinqQueryable() - .OrderByDescending(d => d.Name).ThenBy(d => d.Value); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(4); - // Charlie(40), Bob(20), Alice(10), Alice(30) — desc name, asc value - results[0].Name.Should().Be("Charlie"); - } - - [Fact] - public async Task OrderBy_EmptyContainer_ReturnsEmpty() - { - var emptyBacking = new InMemoryContainer("empty", "/partitionKey"); - using var handler = new FakeCosmosHandler(emptyBacking); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var container = client.GetContainer("db", "c"); - var queryable = container.GetItemLinqQueryable().OrderBy(d => d.Value); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - } - - [Fact] - public async Task OrderBy_SingleItem_ReturnsThatItem() - { - var singleBacking = new InMemoryContainer("single", "/partitionKey"); - using var handler = new FakeCosmosHandler(singleBacking); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var container = client.GetContainer("db", "c"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only", Value = 42 }, - new PartitionKey("pk1")); - var queryable = container.GetItemLinqQueryable().OrderBy(d => d.Value); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Only"); - } - - // -- Section O: Offset/Skip, Aggregates -- - - [Fact] - public async Task OffsetWithLimit_ViaLinq_SkipsAndTakes() - { - var queryable = _container.GetItemLinqQueryable() - .OrderBy(d => d.Name).Skip(1).Take(2); - var iterator = queryable.ToFeedIterator(); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - results.Should().HaveCount(2); - } - - [Fact] - public async Task CountAsync_ReturnsCorrectValue() - { - var count = await _container.GetItemLinqQueryable().CountAsync(); - count.Resource.Should().Be(4); - } - - [Fact] - public async Task SumAsync_WithFilter_ReturnsSumOfFilteredItems() - { - var queryable = _container.GetItemLinqQueryable() - .Where(d => d.IsActive); - var sum = await queryable.Select(d => d.Value).SumAsync(); - sum.Resource.Should().Be(40); // 10 + 30 - } - - // -- Section P: CancellationToken -- - - [Fact] - public async Task ReadNextAsync_WithCancelledToken_ThrowsOrCompletes() - { - var cts = new CancellationTokenSource(); - cts.Cancel(); - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - var act = () => iterator.ReadNextAsync(cts.Token); - // May throw TaskCanceledException or OperationCanceledException - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public RealToFeedIteratorLinqDeepDiveV2Tests() + { + _handler = new FakeCosmosHandler(_backing); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("fakeDb", "fakeContainer"); + } + + public async ValueTask InitializeAsync() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 30, IsActive = true }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk2", Name = "Charlie", Value = 40, IsActive = false }, + new PartitionKey("pk2")); + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + // -- Section D: DISTINCT -- + + [Fact] + public async Task Distinct_OnStringField_ReturnsUniqueStrings() + { + var queryable = _container.GetItemLinqQueryable() + .Select(d => d.Name).Distinct(); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(3); // Alice, Bob, Charlie + } + + [Fact] + public async Task Distinct_OnBoolField_ReturnsTrueAndFalse() + { + var queryable = _container.GetItemLinqQueryable() + .Select(d => d.IsActive).Distinct(); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); + results.Should().Contain(true); + results.Should().Contain(false); + } + + // -- Section E: ORDER BY -- + + [Fact] + public async Task OrderByDescending_ThenByAscending_MixedDirections() + { + var queryable = _container.GetItemLinqQueryable() + .OrderByDescending(d => d.Name).ThenBy(d => d.Value); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(4); + // Charlie(40), Bob(20), Alice(10), Alice(30) — desc name, asc value + results[0].Name.Should().Be("Charlie"); + } + + [Fact] + public async Task OrderBy_EmptyContainer_ReturnsEmpty() + { + var emptyBacking = new InMemoryContainer("empty", "/partitionKey"); + using var handler = new FakeCosmosHandler(emptyBacking); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var container = client.GetContainer("db", "c"); + var queryable = container.GetItemLinqQueryable().OrderBy(d => d.Value); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + } + + [Fact] + public async Task OrderBy_SingleItem_ReturnsThatItem() + { + var singleBacking = new InMemoryContainer("single", "/partitionKey"); + using var handler = new FakeCosmosHandler(singleBacking); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var container = client.GetContainer("db", "c"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Only", Value = 42 }, + new PartitionKey("pk1")); + var queryable = container.GetItemLinqQueryable().OrderBy(d => d.Value); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(1); + results[0].Name.Should().Be("Only"); + } + + // -- Section O: Offset/Skip, Aggregates -- + + [Fact] + public async Task OffsetWithLimit_ViaLinq_SkipsAndTakes() + { + var queryable = _container.GetItemLinqQueryable() + .OrderBy(d => d.Name).Skip(1).Take(2); + var iterator = queryable.ToFeedIterator(); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + results.Should().HaveCount(2); + } + + [Fact] + public async Task CountAsync_ReturnsCorrectValue() + { + var count = await _container.GetItemLinqQueryable().CountAsync(); + count.Resource.Should().Be(4); + } + + [Fact] + public async Task SumAsync_WithFilter_ReturnsSumOfFilteredItems() + { + var queryable = _container.GetItemLinqQueryable() + .Where(d => d.IsActive); + var sum = await queryable.Select(d => d.Value).SumAsync(); + sum.Resource.Should().Be(40); // 10 + 30 + } + + // -- Section P: CancellationToken -- + + [Fact] + public async Task ReadNextAsync_WithCancelledToken_ThrowsOrCompletes() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + var act = () => iterator.ReadNextAsync(cts.Token); + // May throw TaskCanceledException or OperationCanceledException + await act.Should().ThrowAsync(); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -521,285 +521,285 @@ public async Task ReadNextAsync_WithCancelledToken_ThrowsOrCompletes() public class RealToFeedIteratorHandlerDeepDiveV2Tests : IAsyncLifetime { - private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _container; - - public RealToFeedIteratorHandlerDeepDiveV2Tests() - { - _handler = new FakeCosmosHandler(_backing); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _container = _client.GetContainer("fakeDb", "fakeContainer"); - } - - public async ValueTask InitializeAsync() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - } - - public ValueTask DisposeAsync() - { - _client.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - // -- Section G: RequestLog & QueryLog -- - - [Fact] - public async Task QueryLog_AfterLinqQuery_ContainsGeneratedSql() - { - var queryable = _container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice"); - var iterator = queryable.ToFeedIterator(); - while (iterator.HasMoreResults) await iterator.ReadNextAsync(); - _handler.QueryLog.Should().NotBeEmpty(); - _handler.QueryLog.Should().Contain(q => q.Contains("Alice")); - } - - [Fact] - public async Task RequestLog_AfterCrud_ContainsHttpMethods() - { - // Create already happened in InitializeAsync (POST) - // Read - await _container.ReadItemAsync("1", new PartitionKey("pk1")); - // Delete - await _container.DeleteItemAsync("2", new PartitionKey("pk1")); - - _handler.RequestLog.Should().Contain(e => e.Contains("POST")); - _handler.RequestLog.Should().Contain(e => e.Contains("GET")); - _handler.RequestLog.Should().Contain(e => e.Contains("DELETE")); - } - - [Fact] - public async Task QueryLog_AfterMultipleQueries_RecordsAll() - { - var initialCount = _handler.QueryLog.Count; - var q1 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - while (q1.HasMoreResults) await q1.ReadNextAsync(); - var q2 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); - while (q2.HasMoreResults) await q2.ReadNextAsync(); - _handler.QueryLog.Count.Should().BeGreaterThanOrEqualTo(initialCount + 2); - } - - // -- Section H: Fault Injection -- - - [Fact] - public async Task FaultInjector_ConditionalByRequest_SelectivelyFaults() - { - int callCount = 0; - _handler.FaultInjector = req => - { - // Let query plan and metadata through - if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) - return null; - callCount++; - if (callCount <= 1) return null; // let first data call through - return new HttpResponseMessage((HttpStatusCode)503) { Content = new StringContent("{}") }; - }; - // First query succeeds - var q1 = _container.GetItemQueryIterator("SELECT * FROM c"); - while (q1.HasMoreResults) await q1.ReadNextAsync(); - - // Second query gets faulted - var q2 = _container.GetItemQueryIterator("SELECT * FROM c"); - var act = async () => { while (q2.HasMoreResults) await q2.ReadNextAsync(); }; - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task FaultInjector_OnCrudNotQuery_FaultsCreate() - { - _handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)503) - { Content = new StringContent("{}") }; - var act = () => _container.CreateItemAsync( - new TestDocument { Id = "99", PartitionKey = "pk1", Name = "Fail" }, - new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - _handler.FaultInjector = null; - // After removing fault, create succeeds - var response = await _container.CreateItemAsync( - new TestDocument { Id = "99", PartitionKey = "pk1", Name = "Success" }, - new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - // -- Section I: Patch -- - - [Fact] - public async Task Patch_MultipleOps_ViaRealSdk_AllApplied() - { - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [ - PatchOperation.Set("/name", "Patched"), - PatchOperation.Increment("/value", 5), - PatchOperation.Add("/tags/0", "new-tag") - ]); - response.Resource.Name.Should().Be("Patched"); - response.Resource.Value.Should().Be(15); - response.Resource.Tags.Should().Contain("new-tag"); - } - - [Fact] - public async Task Patch_WithConditionalFilter_Matching_Succeeds() - { - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Filtered")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); - response.Resource.Name.Should().Be("Filtered"); - } - - [Fact] - public async Task Patch_WithConditionalFilter_NonMatching_Fails() - { - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "NoMatch")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - // -- Section J: ETag Concurrency -- - - [Fact] - public async Task Upsert_WithIfMatchEtag_Succeeds() - { - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Upsert_WithStaleEtag_Fails() - { - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Stale" }, - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task Delete_WithIfMatchEtag_Succeeds() - { - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - var etag = read.ETag; - var response = await _container.DeleteItemAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Delete_WithStaleEtag_Fails() - { - var act = () => _container.DeleteItemAsync( - "2", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - // -- Section K: BackingContainer -- - - [Fact] - public void BackingContainer_IsSameAsConstructorContainer() - { - _handler.BackingContainer.Should().BeSameAs(_backing); - } - - [Fact] - public async Task Upsert_ViaHandler_ReadViaBackingContainer() - { - await _container.UpsertItemAsync( - new TestDocument { Id = "99", PartitionKey = "pk1", Name = "ViaHandler" }, - new PartitionKey("pk1")); - var direct = await _backing.ReadItemAsync("99", new PartitionKey("pk1")); - direct.Resource.Name.Should().Be("ViaHandler"); - } - - [Fact] - public async Task Patch_ViaHandler_ReadViaBackingContainer() - { - await _container.PatchItemAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "PatchedViaHandler")]); - var direct = await _backing.ReadItemAsync("1", new PartitionKey("pk1")); - direct.Resource.Name.Should().Be("PatchedViaHandler"); - } - - // -- Section N: Concurrent Operations -- - - [Fact] - public async Task ParallelIterators_IndependentQueries_BothComplete() - { - var t1 = Task.Run(async () => - { - var q = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var r = new List(); - while (q.HasMoreResults) r.AddRange(await q.ReadNextAsync()); - return r; - }); - var t2 = Task.Run(async () => - { - var q = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Bob'"); - var r = new List(); - while (q.HasMoreResults) r.AddRange(await q.ReadNextAsync()); - return r; - }); - var results = await Task.WhenAll(t1, t2); - results[0].Should().HaveCount(1); - results[1].Should().HaveCount(1); - } - - [Fact] - public async Task ConcurrentSeedAndQuery_DoesNotThrow() - { - var seedTask = Task.Run(async () => - { - for (int i = 100; i < 110; i++) - { - await _container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Concurrent{i}" }, - new PartitionKey("pk1")); - } - }); - var queryTask = Task.Run(async () => - { - for (int j = 0; j < 5; j++) - { - var q = _container.GetItemQueryIterator("SELECT * FROM c"); - while (q.HasMoreResults) await q.ReadNextAsync(); - await Task.Delay(10); - } - }); - await Task.WhenAll(seedTask, queryTask); - // No exception thrown = success - } + private readonly InMemoryContainer _backing = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _container; + + public RealToFeedIteratorHandlerDeepDiveV2Tests() + { + _handler = new FakeCosmosHandler(_backing); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _container = _client.GetContainer("fakeDb", "fakeContainer"); + } + + public async ValueTask InitializeAsync() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + } + + public ValueTask DisposeAsync() + { + _client.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + // -- Section G: RequestLog & QueryLog -- + + [Fact] + public async Task QueryLog_AfterLinqQuery_ContainsGeneratedSql() + { + var queryable = _container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice"); + var iterator = queryable.ToFeedIterator(); + while (iterator.HasMoreResults) await iterator.ReadNextAsync(); + _handler.QueryLog.Should().NotBeEmpty(); + _handler.QueryLog.Should().Contain(q => q.Contains("Alice")); + } + + [Fact] + public async Task RequestLog_AfterCrud_ContainsHttpMethods() + { + // Create already happened in InitializeAsync (POST) + // Read + await _container.ReadItemAsync("1", new PartitionKey("pk1")); + // Delete + await _container.DeleteItemAsync("2", new PartitionKey("pk1")); + + _handler.RequestLog.Should().Contain(e => e.Contains("POST")); + _handler.RequestLog.Should().Contain(e => e.Contains("GET")); + _handler.RequestLog.Should().Contain(e => e.Contains("DELETE")); + } + + [Fact] + public async Task QueryLog_AfterMultipleQueries_RecordsAll() + { + var initialCount = _handler.QueryLog.Count; + var q1 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + while (q1.HasMoreResults) await q1.ReadNextAsync(); + var q2 = _container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Bob'"); + while (q2.HasMoreResults) await q2.ReadNextAsync(); + _handler.QueryLog.Count.Should().BeGreaterThanOrEqualTo(initialCount + 2); + } + + // -- Section H: Fault Injection -- + + [Fact] + public async Task FaultInjector_ConditionalByRequest_SelectivelyFaults() + { + int callCount = 0; + _handler.FaultInjector = req => + { + // Let query plan and metadata through + if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) + return null; + callCount++; + if (callCount <= 1) return null; // let first data call through + return new HttpResponseMessage((HttpStatusCode)503) { Content = new StringContent("{}") }; + }; + // First query succeeds + var q1 = _container.GetItemQueryIterator("SELECT * FROM c"); + while (q1.HasMoreResults) await q1.ReadNextAsync(); + + // Second query gets faulted + var q2 = _container.GetItemQueryIterator("SELECT * FROM c"); + var act = async () => { while (q2.HasMoreResults) await q2.ReadNextAsync(); }; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FaultInjector_OnCrudNotQuery_FaultsCreate() + { + _handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)503) + { Content = new StringContent("{}") }; + var act = () => _container.CreateItemAsync( + new TestDocument { Id = "99", PartitionKey = "pk1", Name = "Fail" }, + new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + _handler.FaultInjector = null; + // After removing fault, create succeeds + var response = await _container.CreateItemAsync( + new TestDocument { Id = "99", PartitionKey = "pk1", Name = "Success" }, + new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + // -- Section I: Patch -- + + [Fact] + public async Task Patch_MultipleOps_ViaRealSdk_AllApplied() + { + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [ + PatchOperation.Set("/name", "Patched"), + PatchOperation.Increment("/value", 5), + PatchOperation.Add("/tags/0", "new-tag") + ]); + response.Resource.Name.Should().Be("Patched"); + response.Resource.Value.Should().Be(15); + response.Resource.Tags.Should().Contain("new-tag"); + } + + [Fact] + public async Task Patch_WithConditionalFilter_Matching_Succeeds() + { + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Filtered")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 10" }); + response.Resource.Name.Should().Be("Filtered"); + } + + [Fact] + public async Task Patch_WithConditionalFilter_NonMatching_Fails() + { + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "NoMatch")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.value = 999" }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + // -- Section J: ETag Concurrency -- + + [Fact] + public async Task Upsert_WithIfMatchEtag_Succeeds() + { + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Upsert_WithStaleEtag_Fails() + { + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Stale" }, + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task Delete_WithIfMatchEtag_Succeeds() + { + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + var etag = read.ETag; + var response = await _container.DeleteItemAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Delete_WithStaleEtag_Fails() + { + var act = () => _container.DeleteItemAsync( + "2", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + // -- Section K: BackingContainer -- + + [Fact] + public void BackingContainer_IsSameAsConstructorContainer() + { + _handler.BackingContainer.Should().BeSameAs(_backing); + } + + [Fact] + public async Task Upsert_ViaHandler_ReadViaBackingContainer() + { + await _container.UpsertItemAsync( + new TestDocument { Id = "99", PartitionKey = "pk1", Name = "ViaHandler" }, + new PartitionKey("pk1")); + var direct = await _backing.ReadItemAsync("99", new PartitionKey("pk1")); + direct.Resource.Name.Should().Be("ViaHandler"); + } + + [Fact] + public async Task Patch_ViaHandler_ReadViaBackingContainer() + { + await _container.PatchItemAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "PatchedViaHandler")]); + var direct = await _backing.ReadItemAsync("1", new PartitionKey("pk1")); + direct.Resource.Name.Should().Be("PatchedViaHandler"); + } + + // -- Section N: Concurrent Operations -- + + [Fact] + public async Task ParallelIterators_IndependentQueries_BothComplete() + { + var t1 = Task.Run(async () => + { + var q = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var r = new List(); + while (q.HasMoreResults) r.AddRange(await q.ReadNextAsync()); + return r; + }); + var t2 = Task.Run(async () => + { + var q = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Bob'"); + var r = new List(); + while (q.HasMoreResults) r.AddRange(await q.ReadNextAsync()); + return r; + }); + var results = await Task.WhenAll(t1, t2); + results[0].Should().HaveCount(1); + results[1].Should().HaveCount(1); + } + + [Fact] + public async Task ConcurrentSeedAndQuery_DoesNotThrow() + { + var seedTask = Task.Run(async () => + { + for (int i = 100; i < 110; i++) + { + await _container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Concurrent{i}" }, + new PartitionKey("pk1")); + } + }); + var queryTask = Task.Run(async () => + { + for (int j = 0; j < 5; j++) + { + var q = _container.GetItemQueryIterator("SELECT * FROM c"); + while (q.HasMoreResults) await q.ReadNextAsync(); + await Task.Delay(10); + } + }); + await Task.WhenAll(seedTask, queryTask); + // No exception thrown = success + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -808,38 +808,38 @@ await _container.UpsertItemAsync( public class InMemoryStreamFeedIteratorDirectDeepDiveTests { - [Fact] - public async Task StreamFeedIterator_HasMoreResults_FalseAfterRead() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); - iterator.HasMoreResults.Should().BeTrue(); - await iterator.ReadNextAsync(); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task StreamFeedIterator_EmptyItems_ReturnsValidJson() - { - var container = new InMemoryContainer("test", "/pk"); - var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iterator.ReadNextAsync(); - var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); - body["Documents"].Should().NotBeNull(); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task StreamFeedIterator_ResponseHeaders_ContainMetadata() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); - var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iterator.ReadNextAsync(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task StreamFeedIterator_HasMoreResults_FalseAfterRead() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); + iterator.HasMoreResults.Should().BeTrue(); + await iterator.ReadNextAsync(); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task StreamFeedIterator_EmptyItems_ReturnsValidJson() + { + var container = new InMemoryContainer("test", "/pk"); + var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iterator.ReadNextAsync(); + var body = JObject.Parse(await new StreamReader(response.Content).ReadToEndAsync()); + body["Documents"].Should().NotBeNull(); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task StreamFeedIterator_ResponseHeaders_ContainMetadata() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "p" }), new PartitionKey("p")); + var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iterator.ReadNextAsync(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } } // ══════════════════════════════════════════════════════════════════════════════ @@ -848,62 +848,62 @@ public async Task StreamFeedIterator_ResponseHeaders_ContainMetadata() public class InMemoryFeedIteratorEdgeCaseDeepDiveTests { - [Fact] - public async Task FeedIterator_MaxItemCount_ExactMultiple_NoExtraPage() - { - var items = Enumerable.Range(1, 6).ToList(); - var iterator = new InMemoryFeedIterator(items, maxItemCount: 3); - int pages = 0; - var all = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - all.AddRange(page); - pages++; - } - all.Should().HaveCount(6); - pages.Should().Be(2); - } - - [Fact] - public async Task FeedIterator_SingleItem_OnePage() - { - var iterator = new InMemoryFeedIterator([42]); - var page = await iterator.ReadNextAsync(); - page.Should().HaveCount(1); - page.First().Should().Be(42); - iterator.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task FeedIterator_FeedResponse_StatusCode_IsOk() - { - var iterator = new InMemoryFeedIterator([1, 2, 3]); - var page = await iterator.ReadNextAsync(); - page.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task FeedIterator_FeedResponse_RequestCharge_IsOne() - { - var iterator = new InMemoryFeedIterator([1, 2, 3]); - var page = await iterator.ReadNextAsync(); - page.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task FeedIterator_FeedResponse_ActivityId_IsValidGuid() - { - var iterator = new InMemoryFeedIterator([1]); - var page = await iterator.ReadNextAsync(); - Guid.TryParse(page.ActivityId, out _).Should().BeTrue(); - } - - [Fact] - public async Task FeedIterator_FeedResponse_Diagnostics_NotNull() - { - var iterator = new InMemoryFeedIterator([1]); - var page = await iterator.ReadNextAsync(); - page.Diagnostics.Should().NotBeNull(); - } + [Fact] + public async Task FeedIterator_MaxItemCount_ExactMultiple_NoExtraPage() + { + var items = Enumerable.Range(1, 6).ToList(); + var iterator = new InMemoryFeedIterator(items, maxItemCount: 3); + int pages = 0; + var all = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + all.AddRange(page); + pages++; + } + all.Should().HaveCount(6); + pages.Should().Be(2); + } + + [Fact] + public async Task FeedIterator_SingleItem_OnePage() + { + var iterator = new InMemoryFeedIterator([42]); + var page = await iterator.ReadNextAsync(); + page.Should().HaveCount(1); + page.First().Should().Be(42); + iterator.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task FeedIterator_FeedResponse_StatusCode_IsOk() + { + var iterator = new InMemoryFeedIterator([1, 2, 3]); + var page = await iterator.ReadNextAsync(); + page.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task FeedIterator_FeedResponse_RequestCharge_IsOne() + { + var iterator = new InMemoryFeedIterator([1, 2, 3]); + var page = await iterator.ReadNextAsync(); + page.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task FeedIterator_FeedResponse_ActivityId_IsValidGuid() + { + var iterator = new InMemoryFeedIterator([1]); + var page = await iterator.ReadNextAsync(); + Guid.TryParse(page.ActivityId, out _).Should().BeTrue(); + } + + [Fact] + public async Task FeedIterator_FeedResponse_Diagnostics_NotNull() + { + var iterator = new InMemoryFeedIterator([1]); + var page = await iterator.ReadNextAsync(); + page.Diagnostics.Should().NotBeNull(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealToFeedIteratorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealToFeedIteratorTests.cs index 2efa8c0..83a67a5 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealToFeedIteratorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/RealToFeedIteratorTests.cs @@ -17,1230 +17,1230 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection("FeedIteratorSetup")] public class RealToFeedIteratorTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) - { - Timeout = TimeSpan.FromSeconds(10) - } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task ToFeedIterator_ReturnsAllItems() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob"]); - } - - [Fact] - public async Task ToFeedIterator_WithWhereClause_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Value > 15) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - [Fact] - public async Task ToFeedIterator_WithOrderBy_ReturnsOrderedResults() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task ToFeedIterator_WithSelectProjection_ReturnsProjectedValues() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Select(document => document.Name) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Should().Be("Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithNoMatchingItems_ReturnsEmpty() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name == "Nobody") - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ToFeedIterator_WithComplexFilter_ChainsMultipleOperators() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Value >= 20) - .OrderByDescending(document => document.Value) - .Take(2) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Diana", "Charlie"); - } - - [Fact] - public async Task ToFeedIterator_WithWhereAndSelect_ReturnsProjectedFiltered() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Value >= 20) - .Select(document => document.Name) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - [Fact] - public async Task ToFeedIterator_WithOrderByDescending_ReturnsSortedDescending() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderByDescending(document => document.Value) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Charlie", "Bob", "Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithTake_LimitsResults() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Take(2) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ToFeedIterator_WithBoolFilter_HandlesBoolean() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.IsActive) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); - } - - [Fact] - public async Task ToFeedIterator_WithStringContains_FiltersOnSubstring() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alicia", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name.Contains("Ali")) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Alicia"]); - } - - [Fact] - public async Task ToFeedIterator_WithMultiplePartitionKeys_ReturnsFromAllPartitions() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task ToFeedIterator_WithWhereOrderByTake_ChainsAllThree() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 50 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 40 }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 30 }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Value >= 20) - .OrderBy(document => document.Value) - .Take(3) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(3); - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Eve", "Diana", "Charlie"); - } - - [Fact] - public async Task ToFeedIterator_WithMultiFieldOrderBy_WrapsAllOrderByItems() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 20 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 10 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ThenBy(document => document.Value) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(3); - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(10); - results[1].Name.Should().Be("Alice"); - results[1].Value.Should().Be(20); - results[2].Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithFaultInjector_Returns503() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); - - _handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - - var act = async () => - { - var iterator = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - await DrainAsync(iterator); - }; - - await act.Should().ThrowAsync() - .Where(exception => exception.StatusCode == HttpStatusCode.ServiceUnavailable); - } - - [Fact] - public async Task ToFeedIterator_WithIsDefinedInUserCode_PreservesIsDefined() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 1.0 } }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - // Query with a WHERE that InMemoryContainer can evaluate. - // This verifies IS_DEFINED is not incorrectly stripped for user-written conditions. - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name != null) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ToFeedIterator_WithCountAggregate_ReturnsCount() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .CountAsync(); - - var count = await iterator; - - count.Resource.Should().Be(3); - } - - [Fact] - public async Task ToFeedIterator_WithDistinct_ReturnsUniqueValues() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Select(document => document.Value) - .Distinct() - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().BeEquivalentTo([10, 20]); - } - - [Fact] - public async Task ToFeedIterator_WithAnonymousProjection_ReturnsProjectedShape() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Select(document => new { document.Name, document.Value }) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle(); - var item = results[0]; - item.Name.Should().Be("Alice"); - item.Value.Should().Be(42); - } - - [Fact] - public async Task ToFeedIterator_WithMetadataFaultInjection_FailsOnCollectionFetch() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); - - // Create a separate client/handler pair to test metadata faults. - // The existing _cosmosClient has already cached metadata, so we need a fresh one. - using var faultHandler = new FakeCosmosHandler(_inMemoryContainer); - var callCount = 0; - faultHandler.FaultInjectorIncludesMetadata = true; - faultHandler.FaultInjector = request => - { - callCount++; - // Fault on the 2nd+ request (after account metadata) to hit pkranges/colls - if (callCount > 1) - { - var faultResp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - faultResp.Headers.Add("x-ms-request-charge", "0"); - faultResp.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); - return faultResp; - } - - return null; - }; - - using var faultClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(faultHandler) - { - Timeout = TimeSpan.FromSeconds(10) - } - }); - - var container = faultClient.GetContainer("fakeDb", "fakeContainer"); - var act = async () => - { - var iterator = container.GetItemLinqQueryable().ToFeedIterator(); - await DrainAsync(iterator); - }; - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ToFeedIterator_WithSumAggregate_ReturnsSum() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var sum = await _realContainer.GetItemLinqQueryable() - .Select(document => document.Value) - .SumAsync(); - - sum.Resource.Should().Be(60); - } - - [Fact] - public async Task ToFeedIterator_WithAverageAggregate_ReturnsAverage() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var avg = await _realContainer.GetItemLinqQueryable() - .Select(document => (double)document.Value) - .AverageAsync(); - - avg.Resource.Should().Be(20); - } - - [Fact] - public async Task ToFeedIterator_WithMinAggregate_ReturnsMin() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var min = await _realContainer.GetItemLinqQueryable() - .Select(document => document.Value) - .MinAsync(); - - min.Resource.Should().Be(10); - } - - [Fact] - public async Task ToFeedIterator_WithMaxAggregate_ReturnsMax() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var max = await _realContainer.GetItemLinqQueryable() - .Select(document => document.Value) - .MaxAsync(); - - max.Resource.Should().Be(30); - } - - [Fact] - public async Task ToFeedIterator_WithSelectMany_FlattensArrays() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["a", "b"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Tags = ["c"] }); - - var iterator = _realContainer.GetItemLinqQueryable() - .SelectMany(document => document.Tags) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().BeEquivalentTo(["a", "b", "c"]); - } - - [Fact] - public async Task ToFeedIterator_WithSkipTake_PagesCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Value) - .Skip(1) - .Take(2) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Bob", "Charlie"); - } - - [Fact] - public async Task ToFeedIterator_WithNegatedBoolFilter_HandlesNotOperator() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => !document.IsActive) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithNestedPropertyAccess_FiltersOnNestedField() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 5.0 } }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Nested = new NestedObject { Description = "other", Score = 3.0 } }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Nested!.Score > 4.0) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithOrCondition_ReturnsUnion() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name == "Alice" || document.Name == "Charlie") - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); - } - - [Fact] - public async Task ToFeedIterator_WithMultipleProjectionFields_ReturnsComplexProjection() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, IsActive = true }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Select(document => new { document.Name, document.Value, document.IsActive }) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle(); - var item = results[0]; - item.Name.Should().Be("Alice"); - item.Value.Should().Be(42); - item.IsActive.Should().BeTrue(); - } - - [Fact] - public async Task ToFeedIterator_WithWhereContainsAndOrderBy_CombinesFilterWithSort() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alicia", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name.Contains("Ali")) - .OrderBy(document => document.Value) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alicia", "Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithCountAfterWhere_ReturnsFilteredCount() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true }); - - var count = await _realContainer.GetItemLinqQueryable() - .Where(document => document.IsActive) - .CountAsync(); - - count.Resource.Should().Be(2); - } - - [Fact] - public async Task ToFeedIterator_WithStringStartsWith_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Amanda", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Name.StartsWith("A")) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Amanda"]); - } - - [Fact] - public async Task ToFeedIterator_WithNullCheck_FiltersNulls() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 5.0 } }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Nested = null }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.Nested != null) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithChainedWhere_AppliesBothFilters() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = true }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = false }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Where(document => document.IsActive) - .Where(document => document.Value > 15) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithThenBy_AppliesSecondarySort() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Charlie", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Value) - .ThenBy(document => document.Name) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie", "Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithSelectComputation_ProjectsCalculatedValues() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .Select(document => new { document.Name, DoubleValue = document.Value * 2 }) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Should().Contain(item => item.Name == "Alice" && item.DoubleValue == 20); - results.Should().Contain(item => item.Name == "Bob" && item.DoubleValue == 40); - } - - [Fact] - public async Task ToFeedIterator_WithOrderedTake_LimitsResults() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); - - var iterator = _realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Value) - .Take(2) - .ToFeedIterator(); - - var results = await DrainAsync(iterator); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob"]); - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var document in documents) - { - await _inMemoryContainer.CreateItemAsync(document, new PartitionKey(document.PartitionKey)); - } - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - return results; - } - - [Fact] - public async Task FakeCosmosHandler_UnknownRoute_Returns404() - { - using var client = new HttpClient(_handler); - var response = await client.DeleteAsync("https://localhost:9999/dbs/fakeDb/colls/fakeContainer/sprocs/unknown"); - - response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); - var body = await response.Content.ReadAsStringAsync(); - body.Should().Contain("unrecognised route"); - } - - [Fact] - public void FakeCosmosHandler_RequestLog_RecordsRequests() - { - _handler.RequestLog.Should().NotBeNull(); - } - - [Fact] - public void FakeCosmosHandler_QueryLog_RecordsQueries() - { - _handler.QueryLog.Should().NotBeNull(); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) + { + Timeout = TimeSpan.FromSeconds(10) + } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task ToFeedIterator_ReturnsAllItems() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob"]); + } + + [Fact] + public async Task ToFeedIterator_WithWhereClause_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Value > 15) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + [Fact] + public async Task ToFeedIterator_WithOrderBy_ReturnsOrderedResults() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task ToFeedIterator_WithSelectProjection_ReturnsProjectedValues() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Select(document => document.Name) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Should().Be("Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithNoMatchingItems_ReturnsEmpty() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name == "Nobody") + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ToFeedIterator_WithComplexFilter_ChainsMultipleOperators() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Value >= 20) + .OrderByDescending(document => document.Value) + .Take(2) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Diana", "Charlie"); + } + + [Fact] + public async Task ToFeedIterator_WithWhereAndSelect_ReturnsProjectedFiltered() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Value >= 20) + .Select(document => document.Name) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + [Fact] + public async Task ToFeedIterator_WithOrderByDescending_ReturnsSortedDescending() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderByDescending(document => document.Value) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Charlie", "Bob", "Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithTake_LimitsResults() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Take(2) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ToFeedIterator_WithBoolFilter_HandlesBoolean() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.IsActive) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); + } + + [Fact] + public async Task ToFeedIterator_WithStringContains_FiltersOnSubstring() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alicia", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name.Contains("Ali")) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Alicia"]); + } + + [Fact] + public async Task ToFeedIterator_WithMultiplePartitionKeys_ReturnsFromAllPartitions() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task ToFeedIterator_WithWhereOrderByTake_ChainsAllThree() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 50 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 40 }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 30 }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Value >= 20) + .OrderBy(document => document.Value) + .Take(3) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(3); + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Eve", "Diana", "Charlie"); + } + + [Fact] + public async Task ToFeedIterator_WithMultiFieldOrderBy_WrapsAllOrderByItems() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 20 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Alice", Value = 10 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ThenBy(document => document.Value) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(3); + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(10); + results[1].Name.Should().Be("Alice"); + results[1].Value.Should().Be(20); + results[2].Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithFaultInjector_Returns503() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); + + _handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var act = async () => + { + var iterator = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + await DrainAsync(iterator); + }; + + await act.Should().ThrowAsync() + .Where(exception => exception.StatusCode == HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public async Task ToFeedIterator_WithIsDefinedInUserCode_PreservesIsDefined() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 1.0 } }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + // Query with a WHERE that InMemoryContainer can evaluate. + // This verifies IS_DEFINED is not incorrectly stripped for user-written conditions. + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name != null) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ToFeedIterator_WithCountAggregate_ReturnsCount() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .CountAsync(); + + var count = await iterator; + + count.Resource.Should().Be(3); + } + + [Fact] + public async Task ToFeedIterator_WithDistinct_ReturnsUniqueValues() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Select(document => document.Value) + .Distinct() + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().BeEquivalentTo([10, 20]); + } + + [Fact] + public async Task ToFeedIterator_WithAnonymousProjection_ReturnsProjectedShape() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Select(document => new { document.Name, document.Value }) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle(); + var item = results[0]; + item.Name.Should().Be("Alice"); + item.Value.Should().Be(42); + } + + [Fact] + public async Task ToFeedIterator_WithMetadataFaultInjection_FailsOnCollectionFetch() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); + + // Create a separate client/handler pair to test metadata faults. + // The existing _cosmosClient has already cached metadata, so we need a fresh one. + using var faultHandler = new FakeCosmosHandler(_inMemoryContainer); + var callCount = 0; + faultHandler.FaultInjectorIncludesMetadata = true; + faultHandler.FaultInjector = request => + { + callCount++; + // Fault on the 2nd+ request (after account metadata) to hit pkranges/colls + if (callCount > 1) + { + var faultResp = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + faultResp.Headers.Add("x-ms-request-charge", "0"); + faultResp.Headers.Add("x-ms-activity-id", Guid.NewGuid().ToString()); + return faultResp; + } + + return null; + }; + + using var faultClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(faultHandler) + { + Timeout = TimeSpan.FromSeconds(10) + } + }); + + var container = faultClient.GetContainer("fakeDb", "fakeContainer"); + var act = async () => + { + var iterator = container.GetItemLinqQueryable().ToFeedIterator(); + await DrainAsync(iterator); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ToFeedIterator_WithSumAggregate_ReturnsSum() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var sum = await _realContainer.GetItemLinqQueryable() + .Select(document => document.Value) + .SumAsync(); + + sum.Resource.Should().Be(60); + } + + [Fact] + public async Task ToFeedIterator_WithAverageAggregate_ReturnsAverage() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var avg = await _realContainer.GetItemLinqQueryable() + .Select(document => (double)document.Value) + .AverageAsync(); + + avg.Resource.Should().Be(20); + } + + [Fact] + public async Task ToFeedIterator_WithMinAggregate_ReturnsMin() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var min = await _realContainer.GetItemLinqQueryable() + .Select(document => document.Value) + .MinAsync(); + + min.Resource.Should().Be(10); + } + + [Fact] + public async Task ToFeedIterator_WithMaxAggregate_ReturnsMax() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var max = await _realContainer.GetItemLinqQueryable() + .Select(document => document.Value) + .MaxAsync(); + + max.Resource.Should().Be(30); + } + + [Fact] + public async Task ToFeedIterator_WithSelectMany_FlattensArrays() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Tags = ["a", "b"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Tags = ["c"] }); + + var iterator = _realContainer.GetItemLinqQueryable() + .SelectMany(document => document.Tags) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().BeEquivalentTo(["a", "b", "c"]); + } + + [Fact] + public async Task ToFeedIterator_WithSkipTake_PagesCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = "Diana", Value = 40 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Value) + .Skip(1) + .Take(2) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Bob", "Charlie"); + } + + [Fact] + public async Task ToFeedIterator_WithNegatedBoolFilter_HandlesNotOperator() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => !document.IsActive) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithNestedPropertyAccess_FiltersOnNestedField() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 5.0 } }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Nested = new NestedObject { Description = "other", Score = 3.0 } }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Nested!.Score > 4.0) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithOrCondition_ReturnsUnion() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name == "Alice" || document.Name == "Charlie") + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Charlie"]); + } + + [Fact] + public async Task ToFeedIterator_WithMultipleProjectionFields_ReturnsComplexProjection() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, IsActive = true }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Select(document => new { document.Name, document.Value, document.IsActive }) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle(); + var item = results[0]; + item.Name.Should().Be("Alice"); + item.Value.Should().Be(42); + item.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ToFeedIterator_WithWhereContainsAndOrderBy_CombinesFilterWithSort() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 30 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alicia", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name.Contains("Ali")) + .OrderBy(document => document.Value) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alicia", "Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithCountAfterWhere_ReturnsFilteredCount() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = false }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true }); + + var count = await _realContainer.GetItemLinqQueryable() + .Where(document => document.IsActive) + .CountAsync(); + + count.Resource.Should().Be(2); + } + + [Fact] + public async Task ToFeedIterator_WithStringStartsWith_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Amanda", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Name.StartsWith("A")) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Amanda"]); + } + + [Fact] + public async Task ToFeedIterator_WithNullCheck_FiltersNulls() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, Nested = new NestedObject { Description = "test", Score = 5.0 } }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, Nested = null }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.Nested != null) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithChainedWhere_AppliesBothFilters() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20, IsActive = true }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = false }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Where(document => document.IsActive) + .Where(document => document.Value > 15) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithThenBy_AppliesSecondarySort() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Charlie", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Value) + .ThenBy(document => document.Name) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie", "Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithSelectComputation_ProjectsCalculatedValues() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .Select(document => new { document.Name, DoubleValue = document.Value * 2 }) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Should().Contain(item => item.Name == "Alice" && item.DoubleValue == 20); + results.Should().Contain(item => item.Name == "Bob" && item.DoubleValue == 40); + } + + [Fact] + public async Task ToFeedIterator_WithOrderedTake_LimitsResults() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30 }); + + var iterator = _realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Value) + .Take(2) + .ToFeedIterator(); + + var results = await DrainAsync(iterator); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob"]); + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var document in documents) + { + await _inMemoryContainer.CreateItemAsync(document, new PartitionKey(document.PartitionKey)); + } + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + return results; + } + + [Fact] + public async Task FakeCosmosHandler_UnknownRoute_Returns404() + { + using var client = new HttpClient(_handler); + var response = await client.DeleteAsync("https://localhost:9999/dbs/fakeDb/colls/fakeContainer/sprocs/unknown"); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("unrecognised route"); + } + + [Fact] + public void FakeCosmosHandler_RequestLog_RecordsRequests() + { + _handler.RequestLog.Should().NotBeNull(); + } + + [Fact] + public void FakeCosmosHandler_QueryLog_RecordsQueries() + { + _handler.QueryLog.Should().NotBeNull(); + } } public class FakeCosmosHandlerOptionsTests : IAsyncLifetime { - [Fact] - public async Task CustomCacheOptions_AreRespected() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - CacheTtl = TimeSpan.FromSeconds(1), - CacheMaxEntries = 5 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "fakeContainer"); - - var iterator = realContainer.GetItemLinqQueryable().ToFeedIterator(); - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task MultiplePartitionRanges_QueriesStillReturnAllData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, - new PartitionKey("pk3")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "fakeContainer"); - - var iterator = realContainer.GetItemLinqQueryable().ToFeedIterator(); - var results = await DrainAsync(iterator); - - results.Should().HaveCount(3); - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie"]); - } - - [Fact] - public async Task MultiplePartitionRanges_OrderByStillWorks() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alice", Value = 10 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bob", Value = 20 }, - new PartitionKey("pk3")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 3 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "fakeContainer"); - - var iterator = realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIterator(); - var results = await DrainAsync(iterator); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task MultiplePartitionRanges_WhereFilterStillWorks() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, - new PartitionKey("pk2")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 2 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "fakeContainer"); - - var iterator = realContainer.GetItemLinqQueryable() - .Where(document => document.Value > 15) - .ToFeedIterator(); - var results = await DrainAsync(iterator); - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public void DefaultOptions_HaveSensibleDefaults() - { - var options = new FakeCosmosHandlerOptions(); - - options.CacheTtl.Should().Be(TimeSpan.FromMinutes(5)); - options.CacheMaxEntries.Should().Be(100); - options.PartitionKeyRangeCount.Should().Be(1); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) - { - Timeout = TimeSpan.FromSeconds(10) - } - }); - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - return results; - } + [Fact] + public async Task CustomCacheOptions_AreRespected() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + CacheTtl = TimeSpan.FromSeconds(1), + CacheMaxEntries = 5 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "fakeContainer"); + + var iterator = realContainer.GetItemLinqQueryable().ToFeedIterator(); + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task MultiplePartitionRanges_QueriesStillReturnAllData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, + new PartitionKey("pk3")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "fakeContainer"); + + var iterator = realContainer.GetItemLinqQueryable().ToFeedIterator(); + var results = await DrainAsync(iterator); + + results.Should().HaveCount(3); + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie"]); + } + + [Fact] + public async Task MultiplePartitionRanges_OrderByStillWorks() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alice", Value = 10 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bob", Value = 20 }, + new PartitionKey("pk3")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 3 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "fakeContainer"); + + var iterator = realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIterator(); + var results = await DrainAsync(iterator); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task MultiplePartitionRanges_WhereFilterStillWorks() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, + new PartitionKey("pk2")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 2 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "fakeContainer"); + + var iterator = realContainer.GetItemLinqQueryable() + .Where(document => document.Value > 15) + .ToFeedIterator(); + var results = await DrainAsync(iterator); + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public void DefaultOptions_HaveSensibleDefaults() + { + var options = new FakeCosmosHandlerOptions(); + + options.CacheTtl.Should().Be(TimeSpan.FromMinutes(5)); + options.CacheMaxEntries.Should().Be(100); + options.PartitionKeyRangeCount.Should().Be(1); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10) + } + }); + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + return results; + } } public class SdkCompatibilityTests { - [Fact] - public async Task VerifySdkCompatibilityAsync_PassesWithCurrentSdk() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } + [Fact] + public async Task VerifySdkCompatibilityAsync_PassesWithCurrentSdk() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } } public class MultiContainerRoutingTests : IAsyncLifetime { - [Fact] - public async Task CreateRouter_QueriesDifferentContainers() - { - var container1 = new InMemoryContainer("container1", "/partitionKey"); - var container2 = new InMemoryContainer("container2", "/partitionKey"); - - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - await container2.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new PartitionKey("pk")); - - using var handler1 = new FakeCosmosHandler(container1); - using var handler2 = new FakeCosmosHandler(container2); - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["container1"] = handler1, - ["container2"] = handler2 - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c1 = client.GetContainer("fakeDb", "container1"); - var c2 = client.GetContainer("fakeDb", "container2"); - - var results1 = await DrainAsync(c1.GetItemLinqQueryable().ToFeedIterator()); - var results2 = await DrainAsync(c2.GetItemLinqQueryable().ToFeedIterator()); - - results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); - results2.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task CreateRouter_OrderByWorksAcrossContainers() - { - var container1 = new InMemoryContainer("orders", "/partitionKey"); - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Charlie", Value = 30 }, - new PartitionKey("pk")); - await container1.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - using var handler = new FakeCosmosHandler(container1); - using var router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["orders"] = handler - }); - - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } - }); - - var c = client.GetContainer("fakeDb", "orders"); - var results = await DrainAsync( - c.GetItemLinqQueryable().OrderBy(document => document.Name).ToFeedIterator()); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - return results; - } + [Fact] + public async Task CreateRouter_QueriesDifferentContainers() + { + var container1 = new InMemoryContainer("container1", "/partitionKey"); + var container2 = new InMemoryContainer("container2", "/partitionKey"); + + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + await container2.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new PartitionKey("pk")); + + using var handler1 = new FakeCosmosHandler(container1); + using var handler2 = new FakeCosmosHandler(container2); + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["container1"] = handler1, + ["container2"] = handler2 + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c1 = client.GetContainer("fakeDb", "container1"); + var c2 = client.GetContainer("fakeDb", "container2"); + + var results1 = await DrainAsync(c1.GetItemLinqQueryable().ToFeedIterator()); + var results2 = await DrainAsync(c2.GetItemLinqQueryable().ToFeedIterator()); + + results1.Should().ContainSingle().Which.Name.Should().Be("Alice"); + results2.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task CreateRouter_OrderByWorksAcrossContainers() + { + var container1 = new InMemoryContainer("orders", "/partitionKey"); + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Charlie", Value = 30 }, + new PartitionKey("pk")); + await container1.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + using var handler = new FakeCosmosHandler(container1); + using var router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["orders"] = handler + }); + + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(router) { Timeout = TimeSpan.FromSeconds(10) } + }); + + var c = client.GetContainer("fakeDb", "orders"); + var results = await DrainAsync( + c.GetItemLinqQueryable().OrderBy(document => document.Name).ToFeedIterator()); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Charlie"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + return results; + } } public class HashBasedPartitionRoutingTests : IAsyncLifetime { - [Fact] - public async Task MultipleRanges_DistributesDocumentsAcrossRanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, - new PartitionKey("pk3")); - await container.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk4", Name = "Diana", Value = 40 }, - new PartitionKey("pk4")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 4 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "test"); - - var results = await DrainAsync( - realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().HaveCount(4); - results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie", "Diana"]); - } - - [Fact] - public async Task MultipleRanges_OrderByMergeSortsCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alice", Value = 10 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bob", Value = 20 }, - new PartitionKey("pk3")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 3 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "test"); - - var results = await DrainAsync( - realContainer.GetItemLinqQueryable() - .OrderBy(document => document.Name) - .ToFeedIterator()); - - results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); - } - - [Fact] - public async Task MultipleRanges_FilterWorksWithHashRouting() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, - new PartitionKey("pk3")); - - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions - { - PartitionKeyRangeCount = 2 - }); - - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "test"); - - var results = await DrainAsync( - realContainer.GetItemLinqQueryable() - .Where(document => document.Value > 15) - .ToFeedIterator()); - - results.Should().HaveCount(2); - results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); - } - - [Fact] - public async Task DynamicCollectionMetadata_UsesContainerPartitionKeyPath() - { - var container = new InMemoryContainer("custom-container", "/customKey"); - await container.CreateItemAsync( - new CustomKeyDocument { Id = "1", CustomKey = "ck1", Name = "Alice" }, - new PartitionKey("ck1")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var realContainer = client.GetContainer("fakeDb", "custom-container"); - - var results = await DrainAsync( - realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(handler) - { - Timeout = TimeSpan.FromSeconds(10) - } - }); - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - return results; - } + [Fact] + public async Task MultipleRanges_DistributesDocumentsAcrossRanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, + new PartitionKey("pk3")); + await container.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk4", Name = "Diana", Value = 40 }, + new PartitionKey("pk4")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 4 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "test"); + + var results = await DrainAsync( + realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().HaveCount(4); + results.Select(document => document.Name).Should().BeEquivalentTo(["Alice", "Bob", "Charlie", "Diana"]); + } + + [Fact] + public async Task MultipleRanges_OrderByMergeSortsCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie", Value = 30 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alice", Value = 10 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bob", Value = 20 }, + new PartitionKey("pk3")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 3 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "test"); + + var results = await DrainAsync( + realContainer.GetItemLinqQueryable() + .OrderBy(document => document.Name) + .ToFeedIterator()); + + results.Select(document => document.Name).Should().ContainInConsecutiveOrder("Alice", "Bob", "Charlie"); + } + + [Fact] + public async Task MultipleRanges_FilterWorksWithHashRouting() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Bob", Value = 20 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Charlie", Value = 30 }, + new PartitionKey("pk3")); + + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions + { + PartitionKeyRangeCount = 2 + }); + + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "test"); + + var results = await DrainAsync( + realContainer.GetItemLinqQueryable() + .Where(document => document.Value > 15) + .ToFeedIterator()); + + results.Should().HaveCount(2); + results.Select(document => document.Name).Should().BeEquivalentTo(["Bob", "Charlie"]); + } + + [Fact] + public async Task DynamicCollectionMetadata_UsesContainerPartitionKeyPath() + { + var container = new InMemoryContainer("custom-container", "/customKey"); + await container.CreateItemAsync( + new CustomKeyDocument { Id = "1", CustomKey = "ck1", Name = "Alice" }, + new PartitionKey("ck1")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var realContainer = client.GetContainer("fakeDb", "custom-container"); + + var results = await DrainAsync( + realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10) + } + }); + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + return results; + } } [Collection("FeedIteratorSetup")] public class ReflectionBasedRegistrationTests { - [Fact] - public async Task Register_WorksWithReflectionInsteadOfDynamic() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - InMemoryFeedIteratorSetup.Register(); - try - { - var iterator = container.GetItemLinqQueryable() - .Where(document => document.Name == "Alice") - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } - - [Fact] - public void Register_WithInvalidQueryable_ThrowsDescriptiveError() - { - InMemoryFeedIteratorSetup.Register(); - try - { - var factory = CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory; - factory.Should().NotBeNull(); - - var act = () => factory!("not a queryable"); - - act.Should().Throw() - .WithMessage("*does not implement IQueryable*"); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } + [Fact] + public async Task Register_WorksWithReflectionInsteadOfDynamic() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + InMemoryFeedIteratorSetup.Register(); + try + { + var iterator = container.GetItemLinqQueryable() + .Where(document => document.Name == "Alice") + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } + + [Fact] + public void Register_WithInvalidQueryable_ThrowsDescriptiveError() + { + InMemoryFeedIteratorSetup.Register(); + try + { + var factory = CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory; + factory.Should().NotBeNull(); + + var act = () => factory!("not a queryable"); + + act.Should().Throw() + .WithMessage("*does not implement IQueryable*"); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1250,172 +1250,172 @@ public void Register_WithInvalidQueryable_ThrowsDescriptiveError() [Collection("FeedIteratorSetup")] public class RealToFeedIteratorStringTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorStringTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var doc in documents) - await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - [Fact] - public async Task ToFeedIterator_WithStringEndsWith_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Grace" }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.EndsWith("ce")) - .ToFeedIterator()); - - results.Should().HaveCount(2); - results.Select(d => d.Name).Should().BeEquivalentTo(["Alice", "Grace"]); - } - - [Fact] - public async Task ToFeedIterator_WithToLower_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "ALICE" }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "bob" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.ToLower() == "alice") - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task ToFeedIterator_WithToUpper_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "alice" }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "BOB" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.ToUpper() == "ALICE") - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task ToFeedIterator_WithTrim_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = " Alice " }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.Trim() == "Alice") - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task ToFeedIterator_WithSubstring_ProjectsCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => d.Name.Substring(0, 3)) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Should().Be("Ali"); - } - - [Fact] - public async Task ToFeedIterator_WithStringLength_FiltersCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Al" }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.Length > 3) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ToFeedIterator_WithReplace_ProjectsCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Hello World" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => d.Name.Replace("World", "Cosmos")) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Should().Be("Hello Cosmos"); - } - - [Fact] - public async Task ToFeedIterator_WithIndexOf_ProjectsCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => d.Name.IndexOf("ic")) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Should().Be(2); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorStringTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var doc in documents) + await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + [Fact] + public async Task ToFeedIterator_WithStringEndsWith_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Grace" }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.EndsWith("ce")) + .ToFeedIterator()); + + results.Should().HaveCount(2); + results.Select(d => d.Name).Should().BeEquivalentTo(["Alice", "Grace"]); + } + + [Fact] + public async Task ToFeedIterator_WithToLower_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "ALICE" }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "bob" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.ToLower() == "alice") + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task ToFeedIterator_WithToUpper_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "alice" }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "BOB" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.ToUpper() == "ALICE") + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task ToFeedIterator_WithTrim_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = " Alice " }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.Trim() == "Alice") + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task ToFeedIterator_WithSubstring_ProjectsCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => d.Name.Substring(0, 3)) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Should().Be("Ali"); + } + + [Fact] + public async Task ToFeedIterator_WithStringLength_FiltersCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Al" }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.Length > 3) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ToFeedIterator_WithReplace_ProjectsCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Hello World" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => d.Name.Replace("World", "Cosmos")) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Should().Be("Hello Cosmos"); + } + + [Fact] + public async Task ToFeedIterator_WithIndexOf_ProjectsCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => d.Name.IndexOf("ic")) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1425,165 +1425,165 @@ await SeedAsync( [Collection("FeedIteratorSetup")] public class RealToFeedIteratorCrudTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorCrudTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task CreateItem_ViaRealSdk_ItemIsQueryable() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - var results = new List(); - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task UpsertItem_ViaRealSdk_UpdatesExisting() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - await _realContainer.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice Updated", Value = 20 }, - new PartitionKey("pk")); - - var results = new List(); - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice Updated"); - } - - [Fact] - public async Task DeleteItem_ViaRealSdk_ItemNoLongerQueryable() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - await _realContainer.DeleteItemAsync("1", new PartitionKey("pk")); - - var results = new List(); - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ReadItem_ViaRealSdk_ReturnsCorrectItem() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, - new PartitionKey("pk")); - - var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - - response.Resource.Name.Should().Be("Alice"); - response.Resource.Value.Should().Be(42); - } - - [Fact] - public async Task ReplaceItem_ViaRealSdk_UpdatesItem() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice Replaced", Value = 99 }, - "1", new PartitionKey("pk")); - - var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("Alice Replaced"); - response.Resource.Value.Should().Be(99); - } - - [Fact] - public async Task PatchItem_ViaRealSdk_AppliesPatchOperations() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), - new[] { PatchOperation.Replace("/name", "Patched") }); - - var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task CreateItem_ViaRealSdk_ReadViaInMemoryContainer() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - // Verify item is accessible directly through in-memory container - var response = await _inMemoryContainer.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task StreamCreate_ViaRealSdk_ItemIsCreated() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk", Name = "StreamTest" }; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(doc); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - var response = await _realContainer.CreateItemStreamAsync(stream, new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await _inMemoryContainer.ReadItemAsync("1", new PartitionKey("pk")); - readResponse.Resource.Name.Should().Be("StreamTest"); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorCrudTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task CreateItem_ViaRealSdk_ItemIsQueryable() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + var results = new List(); + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task UpsertItem_ViaRealSdk_UpdatesExisting() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + await _realContainer.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice Updated", Value = 20 }, + new PartitionKey("pk")); + + var results = new List(); + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice Updated"); + } + + [Fact] + public async Task DeleteItem_ViaRealSdk_ItemNoLongerQueryable() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + await _realContainer.DeleteItemAsync("1", new PartitionKey("pk")); + + var results = new List(); + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ReadItem_ViaRealSdk_ReturnsCorrectItem() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, + new PartitionKey("pk")); + + var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + + response.Resource.Name.Should().Be("Alice"); + response.Resource.Value.Should().Be(42); + } + + [Fact] + public async Task ReplaceItem_ViaRealSdk_UpdatesItem() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice Replaced", Value = 99 }, + "1", new PartitionKey("pk")); + + var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("Alice Replaced"); + response.Resource.Value.Should().Be(99); + } + + [Fact] + public async Task PatchItem_ViaRealSdk_AppliesPatchOperations() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), + new[] { PatchOperation.Replace("/name", "Patched") }); + + var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task CreateItem_ViaRealSdk_ReadViaInMemoryContainer() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + // Verify item is accessible directly through in-memory container + var response = await _inMemoryContainer.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task StreamCreate_ViaRealSdk_ItemIsCreated() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk", Name = "StreamTest" }; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(doc); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var response = await _realContainer.CreateItemStreamAsync(stream, new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await _inMemoryContainer.ReadItemAsync("1", new PartitionKey("pk")); + readResponse.Resource.Name.Should().Be("StreamTest"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1593,113 +1593,113 @@ public async Task StreamCreate_ViaRealSdk_ItemIsCreated() [Collection("FeedIteratorSetup")] public class RealToFeedIteratorResponseMetadataTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorResponseMetadataTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task Query_ResponseHeaders_ContainRequestCharge() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - break; - } - } - - [Fact] - public async Task Query_ResponseHeaders_ContainActivityId() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - response.Headers.ActivityId.Should().NotBeNullOrEmpty(); - break; - } - } - - [Fact] - public async Task ReadItem_Response_StatusCode200() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateItem_Response_StatusCode201() - { - var response = await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteItem_Response_StatusCode204() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _realContainer.DeleteItemAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task ReadItem_NotFound_Returns404() - { - try - { - await _realContainer.ReadItemAsync("nonexistent", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorResponseMetadataTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task Query_ResponseHeaders_ContainRequestCharge() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + break; + } + } + + [Fact] + public async Task Query_ResponseHeaders_ContainActivityId() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + response.Headers.ActivityId.Should().NotBeNullOrEmpty(); + break; + } + } + + [Fact] + public async Task ReadItem_Response_StatusCode200() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateItem_Response_StatusCode201() + { + var response = await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteItem_Response_StatusCode204() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _realContainer.DeleteItemAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task ReadItem_NotFound_Returns404() + { + try + { + await _realContainer.ReadItemAsync("nonexistent", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1709,227 +1709,227 @@ public async Task ReadItem_NotFound_Returns404() [Collection("FeedIteratorSetup")] public class RealToFeedIteratorEdgeCaseTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorEdgeCaseTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var doc in documents) - await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - [Fact] - public async Task LargeResultSet_AllItemsReturned() - { - for (var i = 0; i < 200; i++) - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}", Value = i }, - new PartitionKey("pk")); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().HaveCount(200); - } - - [Fact] - public async Task SpecialCharacters_InPartitionKey_Handled() - { - var pk = "pk/with/slashes&special=chars"; - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = pk, Name = "Special" }, - new PartitionKey(pk)); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Special") - .ToFeedIterator()); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task EmptyStringValues_HandleCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "", Value = 0 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "NotEmpty", Value = 1 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "") - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Id.Should().Be("1"); - } - - [Fact] - public async Task NumericEdgeCases_IntMinMax_QueryCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "MinVal", Value = int.MinValue }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "MaxVal", Value = int.MaxValue }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Zero", Value = 0 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Value > 0) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("MaxVal"); - } - - [Fact] - public async Task MultiplePartitions_OrderByAcrossPartitions() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C", Value = 30 }, - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "A", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "B", Value = 20 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name) - .ToFeedIterator()); - - results.Select(d => d.Name).Should().ContainInConsecutiveOrder("A", "B", "C"); - } - - [Fact] - public async Task WhereOnNestedProperty_FiltersCorrectly() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", Nested = new NestedObject { Score = 9.5 } }, - new PartitionKey("pk")); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B", Nested = new NestedObject { Score = 3.0 } }, - new PartitionKey("pk")); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Nested != null && d.Nested.Score > 5.0) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("A"); - } - - [Fact] - public async Task SelectWithConcat_ProjectsCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => d.Name + "-" + d.Id) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Should().Be("Alice-1"); - } - - [Fact] - public async Task ConditionalFilter_TernaryInWhere() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Active", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Inactive", Value = 20, IsActive = false }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.IsActive ? d.Value > 5 : d.Value > 100) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Active"); - } - - [Fact] - public async Task NullNestedProperty_DoesNotThrow() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "NoNested" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Nested == null) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("NoNested"); - } - - [Fact] - public async Task ArrayContains_FiltersOnTag() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Tagged", Tags = ["important", "urgent"] }, - new PartitionKey("pk")); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "NotTagged", Tags = ["low"] }, - new PartitionKey("pk")); - - // Use SQL ARRAY_CONTAINS instead of LINQ .Contains() — the Cosmos SDK LINQ - // translator does not support List.Contains() on Linux. - var results = await DrainAsync( - _realContainer.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'urgent')"))); - - results.Should().ContainSingle().Which.Name.Should().Be("Tagged"); - } - - [Fact] - public async Task MultipleAggregates_AllReturnCorrectValues() - { - for (var i = 1; i <= 5; i++) - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}", Value = i * 10 }, - new PartitionKey("pk")); - - var q = _realContainer.GetItemLinqQueryable(); - - var count = await DrainAsync(q.Select(d => 1).ToFeedIterator()); - count.Should().HaveCount(5); - - var ordered = await DrainAsync( - q.OrderByDescending(d => d.Value).Take(1).ToFeedIterator()); - ordered.Should().ContainSingle().Which.Value.Should().Be(50); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorEdgeCaseTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var doc in documents) + await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + [Fact] + public async Task LargeResultSet_AllItemsReturned() + { + for (var i = 0; i < 200; i++) + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}", Value = i }, + new PartitionKey("pk")); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().HaveCount(200); + } + + [Fact] + public async Task SpecialCharacters_InPartitionKey_Handled() + { + var pk = "pk/with/slashes&special=chars"; + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = pk, Name = "Special" }, + new PartitionKey(pk)); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Special") + .ToFeedIterator()); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task EmptyStringValues_HandleCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "", Value = 0 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "NotEmpty", Value = 1 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "") + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Id.Should().Be("1"); + } + + [Fact] + public async Task NumericEdgeCases_IntMinMax_QueryCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "MinVal", Value = int.MinValue }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "MaxVal", Value = int.MaxValue }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Zero", Value = 0 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Value > 0) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("MaxVal"); + } + + [Fact] + public async Task MultiplePartitions_OrderByAcrossPartitions() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C", Value = 30 }, + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "A", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "B", Value = 20 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name) + .ToFeedIterator()); + + results.Select(d => d.Name).Should().ContainInConsecutiveOrder("A", "B", "C"); + } + + [Fact] + public async Task WhereOnNestedProperty_FiltersCorrectly() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", Nested = new NestedObject { Score = 9.5 } }, + new PartitionKey("pk")); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B", Nested = new NestedObject { Score = 3.0 } }, + new PartitionKey("pk")); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Nested != null && d.Nested.Score > 5.0) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("A"); + } + + [Fact] + public async Task SelectWithConcat_ProjectsCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => d.Name + "-" + d.Id) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Should().Be("Alice-1"); + } + + [Fact] + public async Task ConditionalFilter_TernaryInWhere() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Active", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Inactive", Value = 20, IsActive = false }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.IsActive ? d.Value > 5 : d.Value > 100) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Active"); + } + + [Fact] + public async Task NullNestedProperty_DoesNotThrow() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "NoNested" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Nested == null) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("NoNested"); + } + + [Fact] + public async Task ArrayContains_FiltersOnTag() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Tagged", Tags = ["important", "urgent"] }, + new PartitionKey("pk")); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "NotTagged", Tags = ["low"] }, + new PartitionKey("pk")); + + // Use SQL ARRAY_CONTAINS instead of LINQ .Contains() — the Cosmos SDK LINQ + // translator does not support List.Contains() on Linux. + var results = await DrainAsync( + _realContainer.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'urgent')"))); + + results.Should().ContainSingle().Which.Name.Should().Be("Tagged"); + } + + [Fact] + public async Task MultipleAggregates_AllReturnCorrectValues() + { + for (var i = 1; i <= 5; i++) + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}", Value = i * 10 }, + new PartitionKey("pk")); + + var q = _realContainer.GetItemLinqQueryable(); + + var count = await DrainAsync(q.Select(d => 1).ToFeedIterator()); + count.Should().HaveCount(5); + + var ordered = await DrainAsync( + q.OrderByDescending(d => d.Value).Take(1).ToFeedIterator()); + ordered.Should().ContainSingle().Which.Value.Should().Be(50); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1939,288 +1939,288 @@ await _inMemoryContainer.CreateItemAsync( [Collection("FeedIteratorSetup")] public class RealToFeedIteratorLinqDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorLinqDeepDiveTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var doc in documents) - await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - [Fact] - public async Task ToFeedIterator_WithThenByDescending_AppliesSecondaryDescSort() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 20 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob", Value = 15 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name) - .ThenByDescending(d => d.Value) - .ToFeedIterator()); - - results.Should().HaveCount(3); - results[0].Name.Should().Be("Alice"); - results[0].Value.Should().Be(20); - results[1].Name.Should().Be("Alice"); - results[1].Value.Should().Be(10); - results[2].Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithGroupBy_GroupsCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .GroupBy(d => d.Name, (key, elements) => new { Name = key, Count = elements.Count() }) - .ToFeedIterator()); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ToFeedIterator_WithTakeOne_ReturnsSingleItem() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Bob") - .Take(1) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Bob"); - } - - [Fact] - public async Task ToFeedIterator_WithTakeOneNoMatch_ReturnsEmpty() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Nobody") - .Take(1) - .ToFeedIterator()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ToFeedIterator_WithDistinctAfterWhere_ReturnsUniqueFilteredValues() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10, IsActive = true }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 10, IsActive = true }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 20, IsActive = false }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.IsActive) - .Select(d => d.Value) - .Distinct() - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Should().Be(10); - } - - [Fact] - public async Task ToFeedIterator_WithSelectToNamedType_ReturnsTypedResults() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 99 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => new NameProjection { Label = d.Name, Number = d.Value }) - .ToFeedIterator()); - - results.Should().HaveCount(2); - results.Should().Contain(r => r.Label == "Alice" && r.Number == 42); - results.Should().Contain(r => r.Label == "Bob" && r.Number == 99); - } - - [Fact(Skip = "Cosmos SDK LINQ provider does not translate C# ?? (null-coalescing) operator to Cosmos SQL. Use ternary with null check instead.")] - public async Task ToFeedIterator_WithCoalesceInProjection_ReturnsDefaultOnNull() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Nested = new NestedObject { Description = "real" } }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => d.Nested!.Description ?? "N/A") - .ToFeedIterator()); - - results.Should().BeEquivalentTo(["real", "N/A"]); - } - - [Fact] - public async Task ToFeedIterator_WithCoalesceInProjection_Divergent_ThrowsOnTranslation() - { - // DIVERGENT BEHAVIOUR: Attempting to use ?? in a LINQ Select projection causes the - // Cosmos SDK LINQ provider to throw during query translation, because there is no - // Cosmos SQL equivalent. Use explicit ternary: d.Nested != null ? d.Nested.Description : "N/A" instead. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Nested = new NestedObject { Description = "real" } }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); - - // Ternary works as the alternative approach - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Select(d => new { d.Name, Desc = d.Nested != null ? d.Nested.Description : "N/A" }) - .ToFeedIterator()); - - results.Should().HaveCount(2); - } - - // ── Approach 3 LINQ rejection tests ────────────────────────────────────── - // These operations work in Approach 1 (InMemoryContainer → LINQ-to-Objects) but are - // correctly rejected in Approach 3 (CosmosClient + FakeCosmosHandler) because the real - // SDK's CosmosLinqQueryProvider cannot translate them to Cosmos SQL. - // See Integration-Approaches wiki, Decision 2, for why this distinction exists. - - [Fact] - public async Task ToFeedIterator_GroupBy_SdkRejectsTranslation() - { - // Approach 3: The real SDK's CosmosLinqQueryProvider throws DocumentQueryException - // because GroupBy has no Cosmos SQL equivalent. In Approach 1 (InMemoryContainer), - // this works via LINQ-to-Objects — see LinqDivergentBehaviorTests.Linq_GroupBy_WorksInMemory_DivergentBehavior. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }); - - var act = () => _realContainer.GetItemLinqQueryable() - .GroupBy(d => d.PartitionKey) - .ToFeedIterator(); - - act.Should().Throw().WithMessage("*GroupBy*"); - } - - [Fact] - public async Task ToFeedIterator_Last_SdkRejectsTranslation() - { - // Approach 3: Last()/LastOrDefault() have no Cosmos SQL equivalent. - // In Approach 1, this works via LINQ-to-Objects — see LinqDivergentBehaviorTests.Linq_Last_WorksInMemory_DivergentBehavior. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); - - var act = () => _realContainer.GetItemLinqQueryable().LastOrDefault(); - - act.Should().Throw().WithMessage("*LastOrDefault*"); - } - - [Fact] - public async Task ToFeedIterator_Aggregate_SdkRejectsTranslation() - { - // Approach 3: Custom Aggregate() has no Cosmos SQL equivalent. Only Sum/Average/Count/Min/Max - // are translated. In Approach 1, this works via LINQ-to-Objects — - // see LinqDivergentBehaviorTests.Linq_Aggregate_WorksInMemory_DivergentBehavior. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); - - var act = () => _realContainer.GetItemLinqQueryable() - .Aggregate(0, (acc, d) => acc + d.Value); - - act.Should().Throw().WithMessage("*Aggregate*"); - } - - [Fact] - public async Task ToFeedIterator_Reverse_SdkRejectsTranslation() - { - // Approach 3: Reverse() has no Cosmos SQL equivalent. Use OrderByDescending() instead. - // In Approach 1, this works via LINQ-to-Objects — - // see LinqDivergentBehaviorTests.Linq_Reverse_WorksInMemory_DivergentBehavior. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); - - var act = () => _realContainer.GetItemLinqQueryable() - .Reverse() - .ToFeedIterator(); - - act.Should().Throw().WithMessage("*Reverse*"); - } - - [Fact] - public async Task ToFeedIterator_AllowSynchronousQueryExecution_False_SdkEnforcesAsync() - { - // Approach 3: The real SDK enforces allowSynchronousQueryExecution=false by throwing - // on synchronous enumeration (ToList()/foreach), requiring ToFeedIterator() instead. - // In Approach 1 (InMemoryContainer), this flag is ignored because the queryable is - // always synchronously enumerable via LINQ-to-Objects — - // see LinqDivergentBehaviorTests.Linq_AllowSynchronousQueryExecution_False_StillWorksInMemory_DivergentBehavior. - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); - - var queryable = _realContainer.GetItemLinqQueryable( - allowSynchronousQueryExecution: false); - - var act = () => queryable.ToList(); - act.Should().Throw(); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorLinqDeepDiveTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var doc in documents) + await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + [Fact] + public async Task ToFeedIterator_WithThenByDescending_AppliesSecondaryDescSort() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 20 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Bob", Value = 15 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name) + .ThenByDescending(d => d.Value) + .ToFeedIterator()); + + results.Should().HaveCount(3); + results[0].Name.Should().Be("Alice"); + results[0].Value.Should().Be(20); + results[1].Name.Should().Be("Alice"); + results[1].Value.Should().Be(10); + results[2].Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithGroupBy_GroupsCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Alice", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Bob", Value = 30 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .GroupBy(d => d.Name, (key, elements) => new { Name = key, Count = elements.Count() }) + .ToFeedIterator()); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ToFeedIterator_WithTakeOne_ReturnsSingleItem() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Bob") + .Take(1) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Bob"); + } + + [Fact] + public async Task ToFeedIterator_WithTakeOneNoMatch_ReturnsEmpty() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Nobody") + .Take(1) + .ToFeedIterator()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ToFeedIterator_WithDistinctAfterWhere_ReturnsUniqueFilteredValues() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10, IsActive = true }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 10, IsActive = true }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 20, IsActive = false }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.IsActive) + .Select(d => d.Value) + .Distinct() + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Should().Be(10); + } + + [Fact] + public async Task ToFeedIterator_WithSelectToNamedType_ReturnsTypedResults() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 99 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => new NameProjection { Label = d.Name, Number = d.Value }) + .ToFeedIterator()); + + results.Should().HaveCount(2); + results.Should().Contain(r => r.Label == "Alice" && r.Number == 42); + results.Should().Contain(r => r.Label == "Bob" && r.Number == 99); + } + + [Fact(Skip = "Cosmos SDK LINQ provider does not translate C# ?? (null-coalescing) operator to Cosmos SQL. Use ternary with null check instead.")] + public async Task ToFeedIterator_WithCoalesceInProjection_ReturnsDefaultOnNull() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Nested = new NestedObject { Description = "real" } }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => d.Nested!.Description ?? "N/A") + .ToFeedIterator()); + + results.Should().BeEquivalentTo(["real", "N/A"]); + } + + [Fact] + public async Task ToFeedIterator_WithCoalesceInProjection_Divergent_ThrowsOnTranslation() + { + // DIVERGENT BEHAVIOUR: Attempting to use ?? in a LINQ Select projection causes the + // Cosmos SDK LINQ provider to throw during query translation, because there is no + // Cosmos SQL equivalent. Use explicit ternary: d.Nested != null ? d.Nested.Description : "N/A" instead. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Nested = new NestedObject { Description = "real" } }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }); + + // Ternary works as the alternative approach + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Select(d => new { d.Name, Desc = d.Nested != null ? d.Nested.Description : "N/A" }) + .ToFeedIterator()); + + results.Should().HaveCount(2); + } + + // ── Approach 3 LINQ rejection tests ────────────────────────────────────── + // These operations work in Approach 1 (InMemoryContainer → LINQ-to-Objects) but are + // correctly rejected in Approach 3 (CosmosClient + FakeCosmosHandler) because the real + // SDK's CosmosLinqQueryProvider cannot translate them to Cosmos SQL. + // See Integration-Approaches wiki, Decision 2, for why this distinction exists. + + [Fact] + public async Task ToFeedIterator_GroupBy_SdkRejectsTranslation() + { + // Approach 3: The real SDK's CosmosLinqQueryProvider throws DocumentQueryException + // because GroupBy has no Cosmos SQL equivalent. In Approach 1 (InMemoryContainer), + // this works via LINQ-to-Objects — see LinqDivergentBehaviorTests.Linq_GroupBy_WorksInMemory_DivergentBehavior. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }); + + var act = () => _realContainer.GetItemLinqQueryable() + .GroupBy(d => d.PartitionKey) + .ToFeedIterator(); + + act.Should().Throw().WithMessage("*GroupBy*"); + } + + [Fact] + public async Task ToFeedIterator_Last_SdkRejectsTranslation() + { + // Approach 3: Last()/LastOrDefault() have no Cosmos SQL equivalent. + // In Approach 1, this works via LINQ-to-Objects — see LinqDivergentBehaviorTests.Linq_Last_WorksInMemory_DivergentBehavior. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); + + var act = () => _realContainer.GetItemLinqQueryable().LastOrDefault(); + + act.Should().Throw().WithMessage("*LastOrDefault*"); + } + + [Fact] + public async Task ToFeedIterator_Aggregate_SdkRejectsTranslation() + { + // Approach 3: Custom Aggregate() has no Cosmos SQL equivalent. Only Sum/Average/Count/Min/Max + // are translated. In Approach 1, this works via LINQ-to-Objects — + // see LinqDivergentBehaviorTests.Linq_Aggregate_WorksInMemory_DivergentBehavior. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); + + var act = () => _realContainer.GetItemLinqQueryable() + .Aggregate(0, (acc, d) => acc + d.Value); + + act.Should().Throw().WithMessage("*Aggregate*"); + } + + [Fact] + public async Task ToFeedIterator_Reverse_SdkRejectsTranslation() + { + // Approach 3: Reverse() has no Cosmos SQL equivalent. Use OrderByDescending() instead. + // In Approach 1, this works via LINQ-to-Objects — + // see LinqDivergentBehaviorTests.Linq_Reverse_WorksInMemory_DivergentBehavior. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); + + var act = () => _realContainer.GetItemLinqQueryable() + .Reverse() + .ToFeedIterator(); + + act.Should().Throw().WithMessage("*Reverse*"); + } + + [Fact] + public async Task ToFeedIterator_AllowSynchronousQueryExecution_False_SdkEnforcesAsync() + { + // Approach 3: The real SDK enforces allowSynchronousQueryExecution=false by throwing + // on synchronous enumeration (ToList()/foreach), requiring ToFeedIterator() instead. + // In Approach 1 (InMemoryContainer), this flag is ignored because the queryable is + // always synchronously enumerable via LINQ-to-Objects — + // see LinqDivergentBehaviorTests.Linq_AllowSynchronousQueryExecution_False_StillWorksInMemory_DivergentBehavior. + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }); + + var queryable = _realContainer.GetItemLinqQueryable( + allowSynchronousQueryExecution: false); + + var act = () => queryable.ToList(); + act.Should().Throw(); + } } public class NameProjection { - [Newtonsoft.Json.JsonProperty("label")] - public string Label { get; set; } = default!; + [Newtonsoft.Json.JsonProperty("label")] + public string Label { get; set; } = default!; - [Newtonsoft.Json.JsonProperty("number")] - public int Number { get; set; } + [Newtonsoft.Json.JsonProperty("number")] + public int Number { get; set; } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2230,220 +2230,220 @@ public class NameProjection [Collection("FeedIteratorSetup")] public class RealToFeedIteratorHandlerRouteDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorHandlerRouteDeepDiveTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var doc in documents) - await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - [Fact] - public async Task ToFeedIterator_WithParameterizedWhere_UsesParameters() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); - - var targetName = "Alice"; - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == targetName) - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - - // Verify the query was captured in the log (SDK may inline params or use @params - // depending on version — the emulator processes both forms correctly) - _handler.QueryLog.Should().Contain(q => q.Contains("Alice") || q.Contains("@")); - } - - [Fact] - public async Task ReadFeed_ViaLinq_ReturnsAllItems() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, - new TestDocument { Id = "4", PartitionKey = "pk", Name = "Diana", Value = 40 }, - new TestDocument { Id = "5", PartitionKey = "pk", Name = "Eve", Value = 50 }); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().HaveCount(5); - } - - [Fact] - public async Task CreateItem_Response_ContainsETag() - { - var response = await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadItem_Response_ContainsETag() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReplaceItem_WithMatchingETag_Succeeds() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - var readResponse = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - var etag = readResponse.ETag; - - var replaceResponse = await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = etag }); - - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResponse.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task ReplaceItem_WithStaleETag_Returns412Or409() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - var readResponse = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - var staleEtag = readResponse.ETag; - - // Update without ETag to change the version - await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, - "1", new PartitionKey("pk")); - - // Now try with stale ETag - var act = async () => await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "StaleUpdate", Value = 30 }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = staleEtag }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed || e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task UpsertItem_WhenNotExists_CreatesNew() - { - var response = await _realContainer.UpsertItemAsync( - new TestDocument { Id = "new-1", PartitionKey = "pk", Name = "Created", Value = 42 }, - new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var readBack = await _realContainer.ReadItemAsync("new-1", new PartitionKey("pk")); - readBack.Resource.Name.Should().Be("Created"); - } - - [Fact] - public async Task PatchItem_WithAddOp_AddsField() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), - new[] { PatchOperation.Add("/tags", new[] { "new-tag" }) }); - - var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Resource.Tags.Should().Contain("new-tag"); - } - - [Fact] - public async Task PatchItem_WithRemoveOp_RemovesField() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10, Tags = ["a", "b"] }, - new PartitionKey("pk")); - - await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), - new[] { PatchOperation.Remove("/tags") }); - - var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Resource.Tags.Should().BeNullOrEmpty(); - } - - [Fact] - public async Task PatchItem_WithIncrementOp_IncrementsValue() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")); - - await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), - new[] { PatchOperation.Increment("/value", 5) }); - - var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Resource.Value.Should().Be(15); - } - - [Fact] - public async Task PatchItem_WhenNotExists_Returns404() - { - var act = async () => await _realContainer.PatchItemAsync( - "nonexistent", new PartitionKey("pk"), - new[] { PatchOperation.Replace("/name", "Updated") }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorHandlerRouteDeepDiveTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var doc in documents) + await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + [Fact] + public async Task ToFeedIterator_WithParameterizedWhere_UsesParameters() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }); + + var targetName = "Alice"; + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == targetName) + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + + // Verify the query was captured in the log (SDK may inline params or use @params + // depending on version — the emulator processes both forms correctly) + _handler.QueryLog.Should().Contain(q => q.Contains("Alice") || q.Contains("@")); + } + + [Fact] + public async Task ReadFeed_ViaLinq_ReturnsAllItems() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, + new TestDocument { Id = "4", PartitionKey = "pk", Name = "Diana", Value = 40 }, + new TestDocument { Id = "5", PartitionKey = "pk", Name = "Eve", Value = 50 }); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().HaveCount(5); + } + + [Fact] + public async Task CreateItem_Response_ContainsETag() + { + var response = await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadItem_Response_ContainsETag() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var response = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReplaceItem_WithMatchingETag_Succeeds() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + var readResponse = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + var etag = readResponse.ETag; + + var replaceResponse = await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = etag }); + + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResponse.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task ReplaceItem_WithStaleETag_Returns412Or409() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + var readResponse = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + var staleEtag = readResponse.ETag; + + // Update without ETag to change the version + await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated", Value = 20 }, + "1", new PartitionKey("pk")); + + // Now try with stale ETag + var act = async () => await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "StaleUpdate", Value = 30 }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = staleEtag }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed || e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task UpsertItem_WhenNotExists_CreatesNew() + { + var response = await _realContainer.UpsertItemAsync( + new TestDocument { Id = "new-1", PartitionKey = "pk", Name = "Created", Value = 42 }, + new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var readBack = await _realContainer.ReadItemAsync("new-1", new PartitionKey("pk")); + readBack.Resource.Name.Should().Be("Created"); + } + + [Fact] + public async Task PatchItem_WithAddOp_AddsField() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), + new[] { PatchOperation.Add("/tags", new[] { "new-tag" }) }); + + var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Resource.Tags.Should().Contain("new-tag"); + } + + [Fact] + public async Task PatchItem_WithRemoveOp_RemovesField() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10, Tags = ["a", "b"] }, + new PartitionKey("pk")); + + await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), + new[] { PatchOperation.Remove("/tags") }); + + var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Resource.Tags.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task PatchItem_WithIncrementOp_IncrementsValue() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")); + + await _realContainer.PatchItemAsync("1", new PartitionKey("pk"), + new[] { PatchOperation.Increment("/value", 5) }); + + var result = await _realContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Resource.Value.Should().Be(15); + } + + [Fact] + public async Task PatchItem_WhenNotExists_Returns404() + { + var act = async () => await _realContainer.PatchItemAsync( + "nonexistent", new PartitionKey("pk"), + new[] { PatchOperation.Replace("/name", "Updated") }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2453,259 +2453,259 @@ await act.Should().ThrowAsync() [Collection("FeedIteratorSetup")] public class RealToFeedIteratorErrorHandlingDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorErrorHandlingDeepDiveTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - private async Task SeedAsync(params TestDocument[] documents) - { - foreach (var doc in documents) - await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); - } - - private static async Task> DrainAsync(FeedIterator iterator) - { - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - [Fact] - public async Task ToFeedIterator_OnEmptyContainer_ReturnsEmpty() - { - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task CountAsync_OnEmptyContainer_ReturnsZero() - { - var count = await _realContainer.GetItemLinqQueryable().CountAsync(); - - count.Resource.Should().Be(0); - } - - [Fact] - public async Task TwoIterators_SameContainer_BothDrainCorrectly() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); - - var iter1 = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - var iter2 = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - - var results1 = await DrainAsync(iter1); - var results2 = await DrainAsync(iter2); - - results1.Should().HaveCount(3); - results2.Should().HaveCount(3); - } - - [Fact] - public async Task Iterator_AfterMutation_ReflectsUpdatedState() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); - - // Create iterator (FakeCosmosHandler re-executes on each page fetch) - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - - // Mutate container before draining - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "4", PartitionKey = "pk", Name = "Diana", Value = 40 }, - new PartitionKey("pk")); - - // The emulator returns all items since it re-evaluates per page - var results = await DrainAsync(iter); - results.Count.Should().BeGreaterThanOrEqualTo(3); - } - - [Fact] - public async Task CreateItem_WhenAlreadyExists_Returns409() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var act = async () => await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Duplicate" }, - new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReplaceItem_WhenNotExists_Returns404() - { - var act = async () => await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "nonexistent", PartitionKey = "pk", Name = "Ghost" }, - "nonexistent", new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItem_WhenNotExists_Returns404() - { - var act = async () => await _realContainer.DeleteItemAsync( - "nonexistent", new PartitionKey("pk")); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task SpecialCharacters_Unicode_InPartitionKey() - { - var unicodePk = "日本語テスト"; - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = unicodePk, Name = "Unicode" }, - new PartitionKey(unicodePk)); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Unicode") - .ToFeedIterator()); - - results.Should().ContainSingle().Which.Name.Should().Be("Unicode"); - } - - [Fact] - public async Task DuplicateIds_DifferentPartitions_BothRetrievable() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "Alice" }, - new PartitionKey("pk-a")); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-b", Name = "Bob" }, - new PartitionKey("pk-b")); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable().ToFeedIterator()); - - results.Should().HaveCount(2); - results.Select(d => d.Name).Should().BeEquivalentTo(["Alice", "Bob"]); - } - - [Fact] - public async Task FaultInjector_With429_ThrowsCosmosExceptionWithTooManyRequests() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); - - _handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - - var act = async () => - { - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - await DrainAsync(iter); - }; - - await act.Should().ThrowAsync() - .Where(e => (int)e.StatusCode == 429); - } - - [Fact] - public async Task FaultInjector_With500_ThrowsCosmosException() - { - await SeedAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); - - _handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json") - }; - - var act = async () => - { - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - await DrainAsync(iter); - }; - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.InternalServerError); - } - - [Fact] - public async Task LongStringValues_QueryCorrectly() - { - var longName = new string('A', 10_000); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = longName }, - new PartitionKey("pk")); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name.StartsWith("AAAA")) - .ToFeedIterator()); - - results.Should().ContainSingle(); - results[0].Name.Length.Should().Be(10_000); - } - - [Fact(Skip = "Would need a separate container with a numeric PK path — not available in this fixture's shared container.")] - public async Task NumericPartitionKey_CreatesAndQueriesCorrectly() - { - // Numeric PKs through FakeCosmosHandler are tested in FakeCosmosHandlerCrudTests.Handler_CreateItem_WithNumericPartitionKey_Succeeds - await Task.CompletedTask; - } - - [Fact] - public async Task NumericPartitionKey_StringPkAlsoWorksAsAlternative() - { - // String PKs with numeric values work as expected. - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "12345", Name = "NumericAsString" }, - new PartitionKey("12345")); - - var results = await DrainAsync( - _realContainer.GetItemLinqQueryable() - .Where(d => d.Name == "NumericAsString") - .ToFeedIterator()); - - results.Should().ContainSingle(); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorErrorHandlingDeepDiveTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task SeedAsync(params TestDocument[] documents) + { + foreach (var doc in documents) + await _inMemoryContainer.CreateItemAsync(doc, new PartitionKey(doc.PartitionKey)); + } + + private static async Task> DrainAsync(FeedIterator iterator) + { + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + [Fact] + public async Task ToFeedIterator_OnEmptyContainer_ReturnsEmpty() + { + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task CountAsync_OnEmptyContainer_ReturnsZero() + { + var count = await _realContainer.GetItemLinqQueryable().CountAsync(); + + count.Resource.Should().Be(0); + } + + [Fact] + public async Task TwoIterators_SameContainer_BothDrainCorrectly() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); + + var iter1 = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + var iter2 = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + + var results1 = await DrainAsync(iter1); + var results2 = await DrainAsync(iter2); + + results1.Should().HaveCount(3); + results2.Should().HaveCount(3); + } + + [Fact] + public async Task Iterator_AfterMutation_ReflectsUpdatedState() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }); + + // Create iterator (FakeCosmosHandler re-executes on each page fetch) + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + + // Mutate container before draining + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "4", PartitionKey = "pk", Name = "Diana", Value = 40 }, + new PartitionKey("pk")); + + // The emulator returns all items since it re-evaluates per page + var results = await DrainAsync(iter); + results.Count.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task CreateItem_WhenAlreadyExists_Returns409() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var act = async () => await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Duplicate" }, + new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceItem_WhenNotExists_Returns404() + { + var act = async () => await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "nonexistent", PartitionKey = "pk", Name = "Ghost" }, + "nonexistent", new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItem_WhenNotExists_Returns404() + { + var act = async () => await _realContainer.DeleteItemAsync( + "nonexistent", new PartitionKey("pk")); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task SpecialCharacters_Unicode_InPartitionKey() + { + var unicodePk = "日本語テスト"; + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = unicodePk, Name = "Unicode" }, + new PartitionKey(unicodePk)); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Unicode") + .ToFeedIterator()); + + results.Should().ContainSingle().Which.Name.Should().Be("Unicode"); + } + + [Fact] + public async Task DuplicateIds_DifferentPartitions_BothRetrievable() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "Alice" }, + new PartitionKey("pk-a")); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-b", Name = "Bob" }, + new PartitionKey("pk-b")); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable().ToFeedIterator()); + + results.Should().HaveCount(2); + results.Select(d => d.Name).Should().BeEquivalentTo(["Alice", "Bob"]); + } + + [Fact] + public async Task FaultInjector_With429_ThrowsCosmosExceptionWithTooManyRequests() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); + + _handler.FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var act = async () => + { + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + await DrainAsync(iter); + }; + + await act.Should().ThrowAsync() + .Where(e => (int)e.StatusCode == 429); + } + + [Fact] + public async Task FaultInjector_With500_ThrowsCosmosException() + { + await SeedAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }); + + _handler.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + var act = async () => + { + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + await DrainAsync(iter); + }; + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.InternalServerError); + } + + [Fact] + public async Task LongStringValues_QueryCorrectly() + { + var longName = new string('A', 10_000); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = longName }, + new PartitionKey("pk")); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name.StartsWith("AAAA")) + .ToFeedIterator()); + + results.Should().ContainSingle(); + results[0].Name.Length.Should().Be(10_000); + } + + [Fact(Skip = "Would need a separate container with a numeric PK path — not available in this fixture's shared container.")] + public async Task NumericPartitionKey_CreatesAndQueriesCorrectly() + { + // Numeric PKs through FakeCosmosHandler are tested in FakeCosmosHandlerCrudTests.Handler_CreateItem_WithNumericPartitionKey_Succeeds + await Task.CompletedTask; + } + + [Fact] + public async Task NumericPartitionKey_StringPkAlsoWorksAsAlternative() + { + // String PKs with numeric values work as expected. + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "12345", Name = "NumericAsString" }, + new PartitionKey("12345")); + + var results = await DrainAsync( + _realContainer.GetItemLinqQueryable() + .Where(d => d.Name == "NumericAsString") + .ToFeedIterator()); + + results.Should().ContainSingle(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2714,152 +2714,152 @@ await _inMemoryContainer.CreateItemAsync( public class InMemoryFeedIteratorDirectTests { - [Fact] - public void InMemoryFeedIterator_DeferredFactory_EvaluatesLazily() - { - var called = false; - var iter = new InMemoryFeedIterator(() => - { - called = true; - return [1, 2, 3]; - }); - - called.Should().BeFalse(); - _ = iter.HasMoreResults; - called.Should().BeTrue(); - } - - [Fact] - public async Task InMemoryFeedIterator_WithMaxItemCount_ReturnsCorrectPages() - { - var items = Enumerable.Range(1, 7).ToList(); - var iter = new InMemoryFeedIterator(items, maxItemCount: 3); - - var pages = new List>(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - pages.Add(response.ToList()); - } - - pages.Should().HaveCount(3); - pages[0].Should().HaveCount(3); - pages[1].Should().HaveCount(3); - pages[2].Should().HaveCount(1); - pages.SelectMany(p => p).Should().BeEquivalentTo(items); - } - - [Fact] - public async Task InMemoryFeedIterator_ContinuationTokens_IncrementCorrectly() - { - var items = Enumerable.Range(1, 5).ToList(); - var iter = new InMemoryFeedIterator(items, maxItemCount: 2); - - var page1 = await iter.ReadNextAsync(); - page1.ContinuationToken.Should().Be("2"); - - var page2 = await iter.ReadNextAsync(); - page2.ContinuationToken.Should().Be("4"); - - var page3 = await iter.ReadNextAsync(); - page3.ContinuationToken.Should().BeNull(); - } - - [Fact] - public void InMemoryFeedIterator_WithEmptySource_HasNoResults() - { - var iter = new InMemoryFeedIterator(Array.Empty()); - - iter.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public void InMemoryFeedIterator_WhenFactoryThrows_PropagatesException() - { - var iter = new InMemoryFeedIterator(() => throw new ArgumentException("boom")); - - var act = () => iter.HasMoreResults; - - // The factory exception is wrapped in InvalidOperationException by Materialize, - // or may propagate directly depending on the code path - act.Should().Throw(); - } - - [Fact] - public async Task InMemoryFeedIterator_WithInitialOffset_SkipsItems() - { - var items = Enumerable.Range(1, 5).ToList(); - var iter = new InMemoryFeedIterator(items, maxItemCount: 2, initialOffset: 3); - - var allResults = new List(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - allResults.AddRange(response); - } - - allResults.Should().BeEquivalentTo([4, 5]); - } - - [Fact] - public async Task InMemoryFeedIterator_ReadAfterDrained_ReturnsEmptyPage() - { - var iter = new InMemoryFeedIterator([1, 2]); - - // Drain all items - while (iter.HasMoreResults) - await iter.ReadNextAsync(); - - // Read again after drained - var extra = await iter.ReadNextAsync(); - extra.Count.Should().Be(0); - } - - [Fact] - public async Task InMemoryFeedIterator_WithEnumerableSource_MaterializesEagerly() - { - var evaluationCount = 0; - IEnumerable LazySource() - { - evaluationCount++; - yield return 1; - yield return 2; - yield return 3; - } - - var iter = new InMemoryFeedIterator(LazySource()); - - evaluationCount.Should().Be(1); - - // Reading should not re-evaluate - await iter.ReadNextAsync(); - evaluationCount.Should().Be(1); - } - - [Fact] - public async Task InMemoryFeedIterator_ContinuationToken_IsOffsetBased_Divergent() - { - // DIVERGENT BEHAVIOUR: Real Cosmos DB uses opaque JSON continuation tokens. - // The InMemoryFeedIterator uses simple integer offset strings (e.g., "2", "4"). - // Code that parses continuation tokens expecting JSON will break. - var iter = new InMemoryFeedIterator(Enumerable.Range(1, 5).ToList(), maxItemCount: 2); - - var page = await iter.ReadNextAsync(); - page.ContinuationToken.Should().Be("2"); - int.TryParse(page.ContinuationToken, out _).Should().BeTrue(); - } - - [Fact] - public async Task InMemoryFeedIterator_WithNoMaxItemCount_ReturnsAllInOnePage() - { - var items = Enumerable.Range(1, 10).ToList(); - var iter = new InMemoryFeedIterator(items); - - iter.HasMoreResults.Should().BeTrue(); - var page = await iter.ReadNextAsync(); - page.Count.Should().Be(10); - iter.HasMoreResults.Should().BeFalse(); - } + [Fact] + public void InMemoryFeedIterator_DeferredFactory_EvaluatesLazily() + { + var called = false; + var iter = new InMemoryFeedIterator(() => + { + called = true; + return [1, 2, 3]; + }); + + called.Should().BeFalse(); + _ = iter.HasMoreResults; + called.Should().BeTrue(); + } + + [Fact] + public async Task InMemoryFeedIterator_WithMaxItemCount_ReturnsCorrectPages() + { + var items = Enumerable.Range(1, 7).ToList(); + var iter = new InMemoryFeedIterator(items, maxItemCount: 3); + + var pages = new List>(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + pages.Add(response.ToList()); + } + + pages.Should().HaveCount(3); + pages[0].Should().HaveCount(3); + pages[1].Should().HaveCount(3); + pages[2].Should().HaveCount(1); + pages.SelectMany(p => p).Should().BeEquivalentTo(items); + } + + [Fact] + public async Task InMemoryFeedIterator_ContinuationTokens_IncrementCorrectly() + { + var items = Enumerable.Range(1, 5).ToList(); + var iter = new InMemoryFeedIterator(items, maxItemCount: 2); + + var page1 = await iter.ReadNextAsync(); + page1.ContinuationToken.Should().Be("2"); + + var page2 = await iter.ReadNextAsync(); + page2.ContinuationToken.Should().Be("4"); + + var page3 = await iter.ReadNextAsync(); + page3.ContinuationToken.Should().BeNull(); + } + + [Fact] + public void InMemoryFeedIterator_WithEmptySource_HasNoResults() + { + var iter = new InMemoryFeedIterator(Array.Empty()); + + iter.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public void InMemoryFeedIterator_WhenFactoryThrows_PropagatesException() + { + var iter = new InMemoryFeedIterator(() => throw new ArgumentException("boom")); + + var act = () => iter.HasMoreResults; + + // The factory exception is wrapped in InvalidOperationException by Materialize, + // or may propagate directly depending on the code path + act.Should().Throw(); + } + + [Fact] + public async Task InMemoryFeedIterator_WithInitialOffset_SkipsItems() + { + var items = Enumerable.Range(1, 5).ToList(); + var iter = new InMemoryFeedIterator(items, maxItemCount: 2, initialOffset: 3); + + var allResults = new List(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + allResults.AddRange(response); + } + + allResults.Should().BeEquivalentTo([4, 5]); + } + + [Fact] + public async Task InMemoryFeedIterator_ReadAfterDrained_ReturnsEmptyPage() + { + var iter = new InMemoryFeedIterator([1, 2]); + + // Drain all items + while (iter.HasMoreResults) + await iter.ReadNextAsync(); + + // Read again after drained + var extra = await iter.ReadNextAsync(); + extra.Count.Should().Be(0); + } + + [Fact] + public async Task InMemoryFeedIterator_WithEnumerableSource_MaterializesEagerly() + { + var evaluationCount = 0; + IEnumerable LazySource() + { + evaluationCount++; + yield return 1; + yield return 2; + yield return 3; + } + + var iter = new InMemoryFeedIterator(LazySource()); + + evaluationCount.Should().Be(1); + + // Reading should not re-evaluate + await iter.ReadNextAsync(); + evaluationCount.Should().Be(1); + } + + [Fact] + public async Task InMemoryFeedIterator_ContinuationToken_IsOffsetBased_Divergent() + { + // DIVERGENT BEHAVIOUR: Real Cosmos DB uses opaque JSON continuation tokens. + // The InMemoryFeedIterator uses simple integer offset strings (e.g., "2", "4"). + // Code that parses continuation tokens expecting JSON will break. + var iter = new InMemoryFeedIterator(Enumerable.Range(1, 5).ToList(), maxItemCount: 2); + + var page = await iter.ReadNextAsync(); + page.ContinuationToken.Should().Be("2"); + int.TryParse(page.ContinuationToken, out _).Should().BeTrue(); + } + + [Fact] + public async Task InMemoryFeedIterator_WithNoMaxItemCount_ReturnsAllInOnePage() + { + var items = Enumerable.Range(1, 10).ToList(); + var iter = new InMemoryFeedIterator(items); + + iter.HasMoreResults.Should().BeTrue(); + var page = await iter.ReadNextAsync(); + page.Count.Should().Be(10); + iter.HasMoreResults.Should().BeFalse(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2868,100 +2868,100 @@ public async Task InMemoryFeedIterator_WithNoMaxItemCount_ReturnsAllInOnePage() public class InMemoryFeedIteratorSetupDeepDiveTests { - [Fact] - public void Deregister_ClearsBothFactories() - { - InMemoryFeedIteratorSetup.Register(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); - - InMemoryFeedIteratorSetup.Deregister(); - CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); - } - - [Fact] - public async Task ReRegister_AfterDeregister_WorksAgain() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - InMemoryFeedIteratorSetup.Register(); - InMemoryFeedIteratorSetup.Deregister(); - InMemoryFeedIteratorSetup.Register(); - try - { - var iter = container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } - - [Fact] - public async Task Register_WorksWithMultipleTypes() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, - new PartitionKey("pk")); - - InMemoryFeedIteratorSetup.Register(); - try - { - // Use with TestDocument - var docIter = container.GetItemLinqQueryable().ToFeedIteratorOverridable(); - var docs = new List(); - while (docIter.HasMoreResults) - { - var response = await docIter.ReadNextAsync(); - docs.AddRange(response); - } - docs.Should().ContainSingle().Which.Name.Should().Be("Alice"); - - // Use with string projection - var strIter = container.GetItemLinqQueryable() - .Select(d => d.Name) - .ToFeedIteratorOverridable(); - var names = new List(); - while (strIter.HasMoreResults) - { - var response = await strIter.ReadNextAsync(); - names.AddRange(response); - } - names.Should().ContainSingle().Which.Should().Be("Alice"); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } - - [Fact] - public void ToFeedIteratorOverridable_WithoutRegistration_ThrowsArgumentOutOfRange() - { - InMemoryFeedIteratorSetup.Deregister(); - - var container = new InMemoryContainer("test", "/partitionKey"); - var queryable = container.GetItemLinqQueryable(); - - var act = () => queryable.ToFeedIteratorOverridable(); - - act.Should().Throw(); - } + [Fact] + public void Deregister_ClearsBothFactories() + { + InMemoryFeedIteratorSetup.Register(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().NotBeNull(); + + InMemoryFeedIteratorSetup.Deregister(); + CosmosOverridableFeedIteratorExtensions.FeedIteratorFactory.Should().BeNull(); + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); + } + + [Fact] + public async Task ReRegister_AfterDeregister_WorksAgain() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + InMemoryFeedIteratorSetup.Register(); + InMemoryFeedIteratorSetup.Deregister(); + InMemoryFeedIteratorSetup.Register(); + try + { + var iter = container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } + + [Fact] + public async Task Register_WorksWithMultipleTypes() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 42 }, + new PartitionKey("pk")); + + InMemoryFeedIteratorSetup.Register(); + try + { + // Use with TestDocument + var docIter = container.GetItemLinqQueryable().ToFeedIteratorOverridable(); + var docs = new List(); + while (docIter.HasMoreResults) + { + var response = await docIter.ReadNextAsync(); + docs.AddRange(response); + } + docs.Should().ContainSingle().Which.Name.Should().Be("Alice"); + + // Use with string projection + var strIter = container.GetItemLinqQueryable() + .Select(d => d.Name) + .ToFeedIteratorOverridable(); + var names = new List(); + while (strIter.HasMoreResults) + { + var response = await strIter.ReadNextAsync(); + names.AddRange(response); + } + names.Should().ContainSingle().Which.Should().Be("Alice"); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } + + [Fact] + public void ToFeedIteratorOverridable_WithoutRegistration_ThrowsArgumentOutOfRange() + { + InMemoryFeedIteratorSetup.Deregister(); + + var container = new InMemoryContainer("test", "/partitionKey"); + var queryable = container.GetItemLinqQueryable(); + + var act = () => queryable.ToFeedIteratorOverridable(); + + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2971,150 +2971,150 @@ public void ToFeedIteratorOverridable_WithoutRegistration_ThrowsArgumentOutOfRan [Collection("FeedIteratorSetup")] public class RealToFeedIteratorResponseFidelityDeepDiveTests : IAsyncLifetime { - private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _cosmosClient; - private readonly Container _realContainer; - - public RealToFeedIteratorResponseFidelityDeepDiveTests() - { - _handler = new FakeCosmosHandler(_inMemoryContainer); - _cosmosClient = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(5), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); - } - - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public ValueTask DisposeAsync() - { - _cosmosClient.Dispose(); - _handler.Dispose(); - return ValueTask.CompletedTask; - } - - [Fact] - public async Task Query_Response_ContainsSessionToken() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - break; - } - } - - [Fact] - public async Task Query_Response_ItemCountMatchesResults() - { - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, - new PartitionKey("pk")); - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie" }, - new PartitionKey("pk")); - - var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); - var totalCount = 0; - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - totalCount += response.Count; - } - - totalCount.Should().Be(3); - } - - [Fact] - public async Task ReplaceItem_Response_StatusCode200() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var response = await _realContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - "1", new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task UpsertItem_NewItem_Response_StatusCode201() - { - var response = await _realContainer.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Brand New" }, - new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UpsertItem_ExistingItem_Response_StatusCode200() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - - var response = await _realContainer.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task PatchItem_Response_StatusCode200() - { - await _realContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, - new PartitionKey("pk")); - - var response = await _realContainer.PatchItemAsync( - "1", new PartitionKey("pk"), - new[] { PatchOperation.Replace("/name", "Patched") }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task StreamFeedIterator_ReturnsAllItemsInSinglePage() - { - for (var i = 0; i < 10; i++) - await _inMemoryContainer.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, - new PartitionKey("pk")); - - var iter = _realContainer.GetItemQueryStreamIterator("SELECT * FROM c"); - - var pageCount = 0; - var totalContent = ""; - while (iter.HasMoreResults) - { - var response = await iter.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - totalContent += await reader.ReadToEndAsync(); - pageCount++; - } - - totalContent.Should().Contain("Item0"); - totalContent.Should().Contain("Item9"); - } + private readonly InMemoryContainer _inMemoryContainer = new("test-container", "/partitionKey"); + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _cosmosClient; + private readonly Container _realContainer; + + public RealToFeedIteratorResponseFidelityDeepDiveTests() + { + _handler = new FakeCosmosHandler(_inMemoryContainer); + _cosmosClient = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(5), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _realContainer = _cosmosClient.GetContainer("fakeDb", "fakeContainer"); + } + + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() + { + _cosmosClient.Dispose(); + _handler.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task Query_Response_ContainsSessionToken() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + break; + } + } + + [Fact] + public async Task Query_Response_ItemCountMatchesResults() + { + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob" }, + new PartitionKey("pk")); + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie" }, + new PartitionKey("pk")); + + var iter = _realContainer.GetItemLinqQueryable().ToFeedIterator(); + var totalCount = 0; + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + totalCount += response.Count; + } + + totalCount.Should().Be(3); + } + + [Fact] + public async Task ReplaceItem_Response_StatusCode200() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var response = await _realContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + "1", new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItem_NewItem_Response_StatusCode201() + { + var response = await _realContainer.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Brand New" }, + new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UpsertItem_ExistingItem_Response_StatusCode200() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + + var response = await _realContainer.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchItem_Response_StatusCode200() + { + await _realContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, + new PartitionKey("pk")); + + var response = await _realContainer.PatchItemAsync( + "1", new PartitionKey("pk"), + new[] { PatchOperation.Replace("/name", "Patched") }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task StreamFeedIterator_ReturnsAllItemsInSinglePage() + { + for (var i = 0; i < 10; i++) + await _inMemoryContainer.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, + new PartitionKey("pk")); + + var iter = _realContainer.GetItemQueryStreamIterator("SELECT * FROM c"); + + var pageCount = 0; + var totalContent = ""; + while (iter.HasMoreResults) + { + var response = await iter.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + totalContent += await reader.ReadToEndAsync(); + pageCount++; + } + + totalContent.Should().Contain("Item0"); + totalContent.Should().Contain("Item9"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs index fd2a6c4..020c997 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ResponseMetadataTests.cs @@ -1,8 +1,8 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -13,85 +13,85 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class TypedResponseStatusCodeTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_Returns_Created() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReadItemAsync_Returns_OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task UpsertItemAsync_NewItem_Returns_Created() - { - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UpsertItemAsync_ExistingItem_Returns_OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemAsync_Returns_OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Replaced" }, - "1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteItemAsync_Returns_NoContent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task PatchItemAsync_Returns_OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched")]); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_Returns_Created() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReadItemAsync_Returns_OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItemAsync_NewItem_Returns_Created() + { + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UpsertItemAsync_ExistingItem_Returns_OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemAsync_Returns_OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Replaced" }, + "1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteItemAsync_Returns_NoContent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task PatchItemAsync_Returns_OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched")]); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -100,129 +100,129 @@ await _container.CreateItemAsync( public class TypedResponseMetadataTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_HasAllMetadata() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - response.ETag.Should().NotBeNullOrEmpty(); - response.Headers.Should().NotBeNull(); - } - - [Fact] - public async Task ReadItemAsync_HasAllMetadata() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk")); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task UpsertItemAsync_HasAllMetadata() - { - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReplaceItemAsync_HasAllMetadata() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, - "1", new PartitionKey("pk")); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task DeleteItemAsync_HasMetadata() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - } - - [Fact] - public async Task PatchItemAsync_HasAllMetadata() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched")]); - - response.RequestCharge.Should().BeGreaterThan(0); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - response.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ActivityId_IsUnique_PerOperation() - { - var r1 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - var r2 = await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, - new PartitionKey("pk")); - - r1.ActivityId.Should().NotBe(r2.ActivityId); - } - - [Fact] - public async Task ETag_IsQuotedString() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.ETag.Should().StartWith("\"").And.EndWith("\""); - } - - [Fact] - public async Task Diagnostics_GetClientElapsedTime_ReturnsTimeSpan() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_HasAllMetadata() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + response.ETag.Should().NotBeNullOrEmpty(); + response.Headers.Should().NotBeNull(); + } + + [Fact] + public async Task ReadItemAsync_HasAllMetadata() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk")); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task UpsertItemAsync_HasAllMetadata() + { + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReplaceItemAsync_HasAllMetadata() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, + "1", new PartitionKey("pk")); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task DeleteItemAsync_HasMetadata() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + } + + [Fact] + public async Task PatchItemAsync_HasAllMetadata() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched")]); + + response.RequestCharge.Should().BeGreaterThan(0); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + response.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ActivityId_IsUnique_PerOperation() + { + var r1 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + var r2 = await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, + new PartitionKey("pk")); + + r1.ActivityId.Should().NotBe(r2.ActivityId); + } + + [Fact] + public async Task ETag_IsQuotedString() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.ETag.Should().StartWith("\"").And.EndWith("\""); + } + + [Fact] + public async Task Diagnostics_GetClientElapsedTime_ReturnsTimeSpan() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -231,88 +231,88 @@ public async Task Diagnostics_GetClientElapsedTime_ReturnsTimeSpan() public class StreamResponseStatusCodeTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - private static MemoryStream MakeStream(string json) => - new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateItemStreamAsync_Returns_Created() - { - var response = await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReadItemStreamAsync_Returns_OK() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task UpsertItemStreamAsync_NewItem_Returns_Created() - { - var response = await _container.UpsertItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UpsertItemStreamAsync_ExistingItem_Returns_OK() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.UpsertItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"Updated"}"""), - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemStreamAsync_Returns_OK() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.ReplaceItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"Replaced"}"""), - "1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteItemStreamAsync_Returns_NoContent() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task PatchItemStreamAsync_Returns_OK() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"A"}"""), - new PartitionKey("pk")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched")]); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + private static MemoryStream MakeStream(string json) => + new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateItemStreamAsync_Returns_Created() + { + var response = await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReadItemStreamAsync_Returns_OK() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItemStreamAsync_NewItem_Returns_Created() + { + var response = await _container.UpsertItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UpsertItemStreamAsync_ExistingItem_Returns_OK() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.UpsertItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"Updated"}"""), + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemStreamAsync_Returns_OK() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"Replaced"}"""), + "1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteItemStreamAsync_Returns_NoContent() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task PatchItemStreamAsync_Returns_OK() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"A"}"""), + new PartitionKey("pk")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched")]); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -321,77 +321,77 @@ await _container.CreateItemStreamAsync( public class StreamResponseHeaderTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - private static MemoryStream MakeStream(string json) => - new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task StreamCreate_HasActivityId_RequestCharge_ETag() - { - var response = await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamRead_HasActivityId_RequestCharge_ETag() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamUpsert_HasActivityId_RequestCharge_ETag() - { - var response = await _container.UpsertItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamReplace_HasActivityId_RequestCharge_ETag() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.ReplaceItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), - "1", new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamDelete_HasActivityId_RequestCharge() - { - await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), - new PartitionKey("pk")); - - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + private static MemoryStream MakeStream(string json) => + new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task StreamCreate_HasActivityId_RequestCharge_ETag() + { + var response = await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamRead_HasActivityId_RequestCharge_ETag() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamUpsert_HasActivityId_RequestCharge_ETag() + { + var response = await _container.UpsertItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamReplace_HasActivityId_RequestCharge_ETag() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), + "1", new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamDelete_HasActivityId_RequestCharge() + { + await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), + new PartitionKey("pk")); + + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -400,92 +400,92 @@ await _container.CreateItemStreamAsync( public class ErrorResponseStatusCodeTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_Duplicate_Throws_Conflict_409() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var act = () => _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }, - new PartitionKey("pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReadItemAsync_NotFound_Throws_404() - { - var act = () => _container.ReadItemAsync("nope", new PartitionKey("pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItemAsync_NotFound_Throws_404() - { - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "nope", PartitionKey = "pk", Name = "X" }, - "nope", new PartitionKey("pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItemAsync_NotFound_Throws_404() - { - var act = () => _container.DeleteItemAsync("nope", new PartitionKey("pk")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PatchItemAsync_NotFound_Throws_404() - { - var act = () => _container.PatchItemAsync("nope", new PartitionKey("pk"), - [PatchOperation.Set("/name", "X")]); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItemAsync_StaleETag_Throws_PreconditionFailed_412() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var act = () => _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemAsync_FilterPredicate_Throws_PreconditionFailed_412() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", IsActive = false }, - new PartitionKey("pk")); - - var act = () => _container.PatchItemAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "X")], - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_Duplicate_Throws_Conflict_409() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var act = () => _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }, + new PartitionKey("pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReadItemAsync_NotFound_Throws_404() + { + var act = () => _container.ReadItemAsync("nope", new PartitionKey("pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItemAsync_NotFound_Throws_404() + { + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "nope", PartitionKey = "pk", Name = "X" }, + "nope", new PartitionKey("pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItemAsync_NotFound_Throws_404() + { + var act = () => _container.DeleteItemAsync("nope", new PartitionKey("pk")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PatchItemAsync_NotFound_Throws_404() + { + var act = () => _container.PatchItemAsync("nope", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")]); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItemAsync_StaleETag_Throws_PreconditionFailed_412() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var act = () => _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemAsync_FilterPredicate_Throws_PreconditionFailed_412() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A", IsActive = false }, + new PartitionKey("pk")); + + var act = () => _container.PatchItemAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")], + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.isActive = true" }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -494,76 +494,76 @@ await _container.CreateItemAsync( public class FeedResponseMetadataTests { - [Fact] - public async Task FeedResponse_HasRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iterator.ReadNextAsync(); - - page.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task FeedResponse_HasDiagnostics() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iterator.ReadNextAsync(); - - page.Diagnostics.Should().NotBeNull(); - } - - [Fact] - public async Task FeedResponse_HasStatusCode_OK() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iterator.ReadNextAsync(); - - page.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task FeedResponse_ActivityId_IsValidGuid() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iterator.ReadNextAsync(); - - Guid.TryParse(page.ActivityId, out _).Should().BeTrue(); - } - - [Fact] - public async Task FeedResponse_ActivityId_EmulatorBehavior_IsValidGuid() - { - // Previously divergent: ActivityId was empty string. Now returns a valid GUID. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iterator.ReadNextAsync(); - - Guid.TryParse(page.ActivityId, out _).Should().BeTrue("emulator now returns a valid GUID for FeedResponse.ActivityId"); - } + [Fact] + public async Task FeedResponse_HasRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iterator.ReadNextAsync(); + + page.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task FeedResponse_HasDiagnostics() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iterator.ReadNextAsync(); + + page.Diagnostics.Should().NotBeNull(); + } + + [Fact] + public async Task FeedResponse_HasStatusCode_OK() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iterator.ReadNextAsync(); + + page.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task FeedResponse_ActivityId_IsValidGuid() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iterator.ReadNextAsync(); + + Guid.TryParse(page.ActivityId, out _).Should().BeTrue(); + } + + [Fact] + public async Task FeedResponse_ActivityId_EmulatorBehavior_IsValidGuid() + { + // Previously divergent: ActivityId was empty string. Now returns a valid GUID. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iterator.ReadNextAsync(); + + Guid.TryParse(page.ActivityId, out _).Should().BeTrue("emulator now returns a valid GUID for FeedResponse.ActivityId"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -572,63 +572,63 @@ await container.CreateItemAsync( public class BatchResponseMetadataTests { - [Fact] - public async Task BatchResponse_HasStatusCode_OnSuccess() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task BatchResponse_HasRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - - using var response = await batch.ExecuteAsync(); - response.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task BatchResponse_Count_MatchesOperations() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); - - using var response = await batch.ExecuteAsync(); - response.Count.Should().Be(2); - } - - [Fact] - public async Task BatchResponse_IsSuccessStatusCode_TrueOnSuccess() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task BatchResponse_PerOperation_HasStatusCode() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); - - using var response = await batch.ExecuteAsync(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - response[1].StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task BatchResponse_HasStatusCode_OnSuccess() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task BatchResponse_HasRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + + using var response = await batch.ExecuteAsync(); + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task BatchResponse_Count_MatchesOperations() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); + + using var response = await batch.ExecuteAsync(); + response.Count.Should().Be(2); + } + + [Fact] + public async Task BatchResponse_IsSuccessStatusCode_TrueOnSuccess() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task BatchResponse_PerOperation_HasStatusCode() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); + + using var response = await batch.ExecuteAsync(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + response[1].StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -637,34 +637,34 @@ public async Task BatchResponse_PerOperation_HasStatusCode() public class DatabaseContainerResponseMetadataTests { - [Fact] - public async Task CreateDatabaseAsync_Returns_Created() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("testdb"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerAsync_Returns_Created() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var response = await db.CreateContainerAsync("mycontainer", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerAsync_HasResource() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - - var response = await db.CreateContainerAsync("mycontainer", "/pk"); - response.Resource.Should().NotBeNull(); - response.Resource.Id.Should().Be("mycontainer"); - } + [Fact] + public async Task CreateDatabaseAsync_Returns_Created() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("testdb"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerAsync_Returns_Created() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var response = await db.CreateContainerAsync("mycontainer", "/pk"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerAsync_HasResource() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + + var response = await db.CreateContainerAsync("mycontainer", "/pk"); + response.Resource.Should().NotBeNull(); + response.Resource.Id.Should().Be("mycontainer"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -673,79 +673,79 @@ public async Task CreateContainerAsync_HasResource() public class ResponseMetadataDivergentBehaviorTests { - [Fact(Skip = "Real Cosmos DB: writes ~6-10 RU, reads ~1 RU, queries vary by complexity. " + - "The emulator always returns a synthetic 1.0 RU charge.")] - public void RequestCharge_ShouldVaryByOperationType() - { - // Placeholder — real Cosmos varies RU by operation - } - - [Fact] - public async Task RequestCharge_EmulatorBehavior_AlwaysSynthetic() - { - // ── Divergent behavior documentation ── - // Real Cosmos: RU charge varies (writes ~6-10, reads ~1, queries vary). - // Emulator: Always returns 1.0 RU. - var container = new InMemoryContainer("test", "/partitionKey"); - - var createResp = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - var readResp = await container.ReadItemAsync("1", new PartitionKey("pk")); - - createResp.RequestCharge.Should().Be(readResp.RequestCharge, - "emulator returns the same synthetic RU for all operations"); - } - - [Fact(Skip = "Real Cosmos DB: Diagnostics contain query plan, execution timing, retries, " + - "contacted regions, and partitions. The emulator returns a stub with TimeSpan.Zero.")] - public void Diagnostics_ShouldContainQueryPlanAndTimings() - { - // Placeholder - } - - [Fact] - public async Task Diagnostics_EmulatorBehavior_StubReturnsZeroElapsed() - { - // ── Divergent behavior documentation ── - // Real Cosmos: Rich diagnostics with timing, retries, regions. - // Emulator: Stub returns TimeSpan.Zero. - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } - - [Fact(Skip = "Real Cosmos DB: Session tokens are cumulative LSN-based (e.g. '0:0#12345'). " + - "The emulator generates a random GUID per response with no cumulative state.")] - public void SessionToken_ShouldBeCumulative() - { - // Placeholder - } - - [Fact] - public async Task SessionToken_EmulatorBehavior_FixedPerResponse() - { - // Emulator returns a fixed session token on all stream responses. - // Real Cosmos DB uses LSN-based cumulative tokens. - var container = new InMemoryContainer("test", "/partitionKey"); - - var json = """{"id":"1","partitionKey":"pk"}"""; - var r1 = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk")); - - json = """{"id":"2","partitionKey":"pk"}"""; - var r2 = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk")); - - var token1 = r1.Headers["x-ms-session-token"]; - var token2 = r2.Headers["x-ms-session-token"]; - - token1.Should().NotBeNullOrEmpty(); - token2.Should().NotBeNullOrEmpty(); - } + [Fact(Skip = "Real Cosmos DB: writes ~6-10 RU, reads ~1 RU, queries vary by complexity. " + + "The emulator always returns a synthetic 1.0 RU charge.")] + public void RequestCharge_ShouldVaryByOperationType() + { + // Placeholder — real Cosmos varies RU by operation + } + + [Fact] + public async Task RequestCharge_EmulatorBehavior_AlwaysSynthetic() + { + // ── Divergent behavior documentation ── + // Real Cosmos: RU charge varies (writes ~6-10, reads ~1, queries vary). + // Emulator: Always returns 1.0 RU. + var container = new InMemoryContainer("test", "/partitionKey"); + + var createResp = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + var readResp = await container.ReadItemAsync("1", new PartitionKey("pk")); + + createResp.RequestCharge.Should().Be(readResp.RequestCharge, + "emulator returns the same synthetic RU for all operations"); + } + + [Fact(Skip = "Real Cosmos DB: Diagnostics contain query plan, execution timing, retries, " + + "contacted regions, and partitions. The emulator returns a stub with TimeSpan.Zero.")] + public void Diagnostics_ShouldContainQueryPlanAndTimings() + { + // Placeholder + } + + [Fact] + public async Task Diagnostics_EmulatorBehavior_StubReturnsZeroElapsed() + { + // ── Divergent behavior documentation ── + // Real Cosmos: Rich diagnostics with timing, retries, regions. + // Emulator: Stub returns TimeSpan.Zero. + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } + + [Fact(Skip = "Real Cosmos DB: Session tokens are cumulative LSN-based (e.g. '0:0#12345'). " + + "The emulator generates a random GUID per response with no cumulative state.")] + public void SessionToken_ShouldBeCumulative() + { + // Placeholder + } + + [Fact] + public async Task SessionToken_EmulatorBehavior_FixedPerResponse() + { + // Emulator returns a fixed session token on all stream responses. + // Real Cosmos DB uses LSN-based cumulative tokens. + var container = new InMemoryContainer("test", "/partitionKey"); + + var json = """{"id":"1","partitionKey":"pk"}"""; + var r1 = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk")); + + json = """{"id":"2","partitionKey":"pk"}"""; + var r2 = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk")); + + var token1 = r1.Headers["x-ms-session-token"]; + var token2 = r2.Headers["x-ms-session-token"]; + + token1.Should().NotBeNullOrEmpty(); + token2.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -754,21 +754,21 @@ public async Task SessionToken_EmulatorBehavior_FixedPerResponse() public class StreamPatchHeaderTests { - [Fact] - public async Task StreamPatch_HasActivityId_RequestCharge_ETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"A"}""")), - new PartitionKey("pk")); - - var response = await container.PatchItemStreamAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched")]); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task StreamPatch_HasActivityId_RequestCharge_ETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"A"}""")), + new PartitionKey("pk")); + + var response = await container.PatchItemStreamAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched")]); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -777,83 +777,83 @@ await container.CreateItemStreamAsync( public class StreamErrorResponseTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - private static MemoryStream MakeStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task StreamCreate_Duplicate_Returns_Conflict_409() - { - await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); - var response = await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task StreamRead_NotFound_Returns_404() - { - var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StreamReplace_NotFound_Returns_404() - { - var response = await _container.ReplaceItemStreamAsync( - MakeStream("""{"id":"nope","partitionKey":"pk"}"""), "nope", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StreamDelete_NotFound_Returns_404() - { - var response = await _container.DeleteItemStreamAsync("nope", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StreamPatch_NotFound_Returns_404() - { - var response = await _container.PatchItemStreamAsync("nope", new PartitionKey("pk"), - [PatchOperation.Set("/name", "X")]); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task StreamReplace_StaleETag_Returns_PreconditionFailed_412() - { - await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); - - var response = await _container.ReplaceItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task StreamRead_IfNoneMatch_Returns_NotModified_304() - { - var create = await _container.CreateItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); - var etag = create.Headers.ETag; - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk"), - new ItemRequestOptions { IfNoneMatchEtag = etag }); - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task StreamUpsert_StaleETag_Returns_PreconditionFailed_412() - { - await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); - - var response = await _container.UpsertItemStreamAsync( - MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), - new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + private static MemoryStream MakeStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task StreamCreate_Duplicate_Returns_Conflict_409() + { + await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + var response = await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task StreamRead_NotFound_Returns_404() + { + var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamReplace_NotFound_Returns_404() + { + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"nope","partitionKey":"pk"}"""), "nope", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamDelete_NotFound_Returns_404() + { + var response = await _container.DeleteItemStreamAsync("nope", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamPatch_NotFound_Returns_404() + { + var response = await _container.PatchItemStreamAsync("nope", new PartitionKey("pk"), + [PatchOperation.Set("/name", "X")]); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task StreamReplace_StaleETag_Returns_PreconditionFailed_412() + { + await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + + var response = await _container.ReplaceItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task StreamRead_IfNoneMatch_Returns_NotModified_304() + { + var create = await _container.CreateItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + var etag = create.Headers.ETag; + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfNoneMatchEtag = etag }); + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task StreamUpsert_StaleETag_Returns_PreconditionFailed_412() + { + await _container.CreateItemStreamAsync(MakeStream("""{"id":"1","partitionKey":"pk"}"""), new PartitionKey("pk")); + + var response = await _container.UpsertItemStreamAsync( + MakeStream("""{"id":"1","partitionKey":"pk","name":"B"}"""), + new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -862,55 +862,55 @@ public async Task StreamUpsert_StaleETag_Returns_PreconditionFailed_412() public class StreamErrorResponseHeaderTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task Stream_NotFound_HasActivityId_RequestCharge() - { - var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Stream_Conflict_HasActivityId_RequestCharge() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Stream_PreconditionFailed_HasActivityId_RequestCharge() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Stream_NotModified_HasETag_InHeaders() - { - var create = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var etag = create.Headers.ETag; - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk"), - new ItemRequestOptions { IfNoneMatchEtag = etag }); - - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task Stream_NotFound_HasActivityId_RequestCharge() + { + var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Stream_Conflict_HasActivityId_RequestCharge() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Stream_PreconditionFailed_HasActivityId_RequestCharge() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Stream_NotModified_HasETag_InHeaders() + { + var create = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var etag = create.Headers.ETag; + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfNoneMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -919,85 +919,85 @@ public async Task Stream_NotModified_HasETag_InHeaders() public class ContentSuppressionTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_SuppressContent_ResourceIsDefault() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task UpsertItemAsync_SuppressContent_ResourceIsDefault() - { - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task ReplaceItemAsync_SuppressContent_ResourceIsDefault() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task PatchItemAsync_SuppressContent_ResourceIsDefault() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task DeleteItemAsync_Resource_IsAlwaysDefault() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); - - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task SuppressContent_StillHasMetadata() - { - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, - new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.ETag.Should().NotBeNullOrEmpty(); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.RequestCharge.Should().BeGreaterThan(0); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_SuppressContent_ResourceIsDefault() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task UpsertItemAsync_SuppressContent_ResourceIsDefault() + { + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task ReplaceItemAsync_SuppressContent_ResourceIsDefault() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task PatchItemAsync_SuppressContent_ResourceIsDefault() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task DeleteItemAsync_Resource_IsAlwaysDefault() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); + + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task SuppressContent_StillHasMetadata() + { + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, + new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.ETag.Should().NotBeNullOrEmpty(); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.RequestCharge.Should().BeGreaterThan(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1006,77 +1006,77 @@ public async Task SuppressContent_StillHasMetadata() public class ETagLifecycleDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); + private readonly InMemoryContainer _container = new("test", "/partitionKey"); - [Fact] - public async Task ETag_Changes_AfterUpsert() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task ETag_Changes_AfterUpsert() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var upsert = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + var upsert = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - create.ETag.Should().NotBe(upsert.ETag); - } + create.ETag.Should().NotBe(upsert.ETag); + } - [Fact] - public async Task ETag_Changes_AfterReplace() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task ETag_Changes_AfterReplace() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var replace = await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, "1", new PartitionKey("pk")); + var replace = await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, "1", new PartitionKey("pk")); - create.ETag.Should().NotBe(replace.ETag); - } + create.ETag.Should().NotBe(replace.ETag); + } - [Fact] - public async Task ETag_Changes_AfterPatch() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task ETag_Changes_AfterPatch() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var patch = await _container.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "B")]); + var patch = await _container.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "B")]); - create.ETag.Should().NotBe(patch.ETag); - } + create.ETag.Should().NotBe(patch.ETag); + } - [Fact] - public async Task ETag_Consistent_BetweenWriteAndRead() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task ETag_Consistent_BetweenWriteAndRead() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk")); + var read = await _container.ReadItemAsync("1", new PartitionKey("pk")); - create.ETag.Should().Be(read.ETag); - } + create.ETag.Should().Be(read.ETag); + } - [Fact] - public async Task ETag_OnDocument_MatchesResponseETag() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task ETag_OnDocument_MatchesResponseETag() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk")); - var docEtag = read.Resource["_etag"]?.ToString(); + var read = await _container.ReadItemAsync("1", new PartitionKey("pk")); + var docEtag = read.Resource["_etag"]?.ToString(); - docEtag.Should().Be(create.ETag); - } + docEtag.Should().Be(create.ETag); + } - [Fact] - public async Task Stream_ETag_Matches_Typed_ETag() - { - var typed = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + [Fact] + public async Task Stream_ETag_Matches_Typed_ETag() + { + var typed = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var stream = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); + var stream = await _container.ReadItemStreamAsync("1", new PartitionKey("pk")); - stream.Headers.ETag.Should().Be(typed.ETag); - } + stream.Headers.ETag.Should().Be(typed.ETag); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1085,77 +1085,77 @@ public async Task Stream_ETag_Matches_Typed_ETag() public class ExceptionMetadataTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CosmosException_HasActivityId_OnNotFound() - { - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - ex.ActivityId.Should().NotBeNullOrEmpty(); - Guid.TryParse(ex.ActivityId, out _).Should().BeTrue(); - } - } - - [Fact] - public async Task CosmosException_HasRequestCharge_OnConflict() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - try - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }, new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - ex.RequestCharge.Should().BeGreaterThan(0); - } - } - - [Fact] - public async Task CosmosException_SubStatusCode_IsZero() - { - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - ex.SubStatusCode.Should().Be(0); - } - } - - [Fact(Skip = "CosmosException.Diagnostics requires CosmosDiagnostics construction which is not available through the public API. The emulator does not populate Diagnostics on exceptions.")] - public async Task CosmosException_HasDiagnostics_OnError() - { - await Task.CompletedTask; - } - - [Fact] - public async Task CosmosException_HasDiagnostics_Divergent_DiagnosticsIsNull() - { - // DIVERGENT BEHAVIOUR: Real Cosmos DB includes Diagnostics on CosmosException. - // The emulator does not populate Diagnostics on exceptions — it's null. - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - // Diagnostics may or may not be null — just document the behavior - _ = ex.Diagnostics; - } - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CosmosException_HasActivityId_OnNotFound() + { + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + ex.ActivityId.Should().NotBeNullOrEmpty(); + Guid.TryParse(ex.ActivityId, out _).Should().BeTrue(); + } + } + + [Fact] + public async Task CosmosException_HasRequestCharge_OnConflict() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + try + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }, new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + ex.RequestCharge.Should().BeGreaterThan(0); + } + } + + [Fact] + public async Task CosmosException_SubStatusCode_IsZero() + { + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + ex.SubStatusCode.Should().Be(0); + } + } + + [Fact(Skip = "CosmosException.Diagnostics requires CosmosDiagnostics construction which is not available through the public API. The emulator does not populate Diagnostics on exceptions.")] + public async Task CosmosException_HasDiagnostics_OnError() + { + await Task.CompletedTask; + } + + [Fact] + public async Task CosmosException_HasDiagnostics_Divergent_DiagnosticsIsNull() + { + // DIVERGENT BEHAVIOUR: Real Cosmos DB includes Diagnostics on CosmosException. + // The emulator does not populate Diagnostics on exceptions — it's null. + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + // Diagnostics may or may not be null — just document the behavior + _ = ex.Diagnostics; + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1164,55 +1164,55 @@ public async Task CosmosException_HasDiagnostics_Divergent_DiagnosticsIsNull() public class FeedResponseHeadersDeepDiveTests { - [Fact] - public async Task FeedResponse_Headers_ContainRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task FeedResponse_Headers_ContainActivityId() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task FeedResponse_ContinuationToken_NullOnLastPage() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task FeedResponse_EmptyResults_StillHasMetadata() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'nobody'"); - var page = await iter.ReadNextAsync(); - - page.StatusCode.Should().Be(HttpStatusCode.OK); - page.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } + [Fact] + public async Task FeedResponse_Headers_ContainRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task FeedResponse_Headers_ContainActivityId() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task FeedResponse_ContinuationToken_NullOnLastPage() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task FeedResponse_EmptyResults_StillHasMetadata() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'nobody'"); + var page = await iter.ReadNextAsync(); + + page.StatusCode.Should().Be(HttpStatusCode.OK); + page.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1221,44 +1221,44 @@ public async Task FeedResponse_EmptyResults_StillHasMetadata() public class StreamFeedIteratorMetadataTests { - [Fact] - public async Task StreamFeedIterator_HasActivityId_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iter.ReadNextAsync(); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamFeedIterator_HasRequestCharge_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iter.ReadNextAsync(); - - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamFeedIterator_HasSessionToken_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iter.ReadNextAsync(); - - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task StreamFeedIterator_HasActivityId_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iter.ReadNextAsync(); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamFeedIterator_HasRequestCharge_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iter.ReadNextAsync(); + + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamFeedIterator_HasSessionToken_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iter.ReadNextAsync(); + + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1267,35 +1267,35 @@ await container.CreateItemAsync( public class TypedResponseHeadersDeepDiveTests { - [Fact] - public async Task TypedResponse_Headers_ContainActivityId() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task TypedResponse_Headers_ContainRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task TypedResponse_Headers_ContainSessionToken() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task TypedResponse_Headers_ContainActivityId() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TypedResponse_Headers_ContainRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TypedResponse_Headers_ContainSessionToken() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1304,61 +1304,61 @@ public async Task TypedResponse_Headers_ContainSessionToken() public class ReadManyMetadataDeepDiveTests { - [Fact] - public async Task ReadManyItemsAsync_Returns_FeedResponse_WithMetadata() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - var response = await container.ReadManyItemsAsync( - [("1", new PartitionKey("pk")), ("2", new PartitionKey("pk"))]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(2); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); - } - - [Fact] - public async Task ReadManyItemsStreamAsync_Returns_OK_WithHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.ReadManyItemsStreamAsync( - [("1", new PartitionKey("pk"))]); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - } - - [Fact] - public async Task ReadManyItemsAsync_EmptyList_Returns_EmptyResponse() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var response = await container.ReadManyItemsAsync([]); - - response.Count.Should().Be(0); - } - - [Fact] - public async Task ReadManyItemsAsync_PartialMatch_Returns_OnlyFound() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - var response = await container.ReadManyItemsAsync( - [("1", new PartitionKey("pk")), ("2", new PartitionKey("pk")), ("3", new PartitionKey("pk"))]); - - response.Count.Should().Be(2); - } + [Fact] + public async Task ReadManyItemsAsync_Returns_FeedResponse_WithMetadata() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + var response = await container.ReadManyItemsAsync( + [("1", new PartitionKey("pk")), ("2", new PartitionKey("pk"))]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(2); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public async Task ReadManyItemsStreamAsync_Returns_OK_WithHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.ReadManyItemsStreamAsync( + [("1", new PartitionKey("pk"))]); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + } + + [Fact] + public async Task ReadManyItemsAsync_EmptyList_Returns_EmptyResponse() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var response = await container.ReadManyItemsAsync([]); + + response.Count.Should().Be(0); + } + + [Fact] + public async Task ReadManyItemsAsync_PartialMatch_Returns_OnlyFound() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + var response = await container.ReadManyItemsAsync( + [("1", new PartitionKey("pk")), ("2", new PartitionKey("pk")), ("3", new PartitionKey("pk"))]); + + response.Count.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1367,92 +1367,92 @@ await container.CreateItemAsync( public class DatabaseContainerLifecycleDeepDiveTests { - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_NewDB_Returns_Created() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateDatabaseIfNotExistsAsync_ExistingDB_Returns_OK() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("testdb"); - var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReadDatabaseAsync_Returns_OK() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.ReadAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } - - [Fact] - public async Task DeleteDatabaseAsync_Returns_NoContent() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.DeleteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_NewContainer_Returns_Created() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.CreateContainerIfNotExistsAsync("mycontainer", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CreateContainerIfNotExistsAsync_ExistingContainer_Returns_OK() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - await db.CreateContainerAsync("mycontainer", "/pk"); - var response = await db.CreateContainerIfNotExistsAsync("mycontainer", "/pk"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReadContainerAsync_Returns_OK() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var c = (await db.CreateContainerAsync("mycontainer", "/pk")).Container; - var response = await c.ReadContainerAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().NotBeNull(); - } - - [Fact] - public async Task DeleteContainerAsync_Returns_NoContent() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var c = (await db.CreateContainerAsync("mycontainer", "/pk")).Container; - var response = await c.DeleteContainerAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task CreateDatabaseAsync_Duplicate_Throws_Conflict() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseAsync("testdb"); - - var act = () => client.CreateDatabaseAsync("testdb"); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_NewDB_Returns_Created() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateDatabaseIfNotExistsAsync_ExistingDB_Returns_OK() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("testdb"); + var response = await client.CreateDatabaseIfNotExistsAsync("testdb"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReadDatabaseAsync_Returns_OK() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.ReadAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteDatabaseAsync_Returns_NoContent() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.DeleteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_NewContainer_Returns_Created() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.CreateContainerIfNotExistsAsync("mycontainer", "/pk"); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CreateContainerIfNotExistsAsync_ExistingContainer_Returns_OK() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + await db.CreateContainerAsync("mycontainer", "/pk"); + var response = await db.CreateContainerIfNotExistsAsync("mycontainer", "/pk"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReadContainerAsync_Returns_OK() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var c = (await db.CreateContainerAsync("mycontainer", "/pk")).Container; + var response = await c.ReadContainerAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteContainerAsync_Returns_NoContent() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var c = (await db.CreateContainerAsync("mycontainer", "/pk")).Container; + var response = await c.DeleteContainerAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task CreateDatabaseAsync_Duplicate_Throws_Conflict() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseAsync("testdb"); + + var act = () => client.CreateDatabaseAsync("testdb"); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1461,67 +1461,67 @@ await act.Should().ThrowAsync() public class DatabaseContainerStreamDeepDiveTests { - [Fact] - public async Task CreateDatabaseStreamAsync_Returns_Created_WithHeaders() - { - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateDatabaseStreamAsync_Duplicate_Returns_Conflict_WithHeaders() - { - var client = new InMemoryCosmosClient(); - await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); - var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateContainerStreamAsync_Returns_Created_WithHeaders() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateContainerStreamAsync_Duplicate_Returns_Conflict_WithHeaders() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); - var response = await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadDatabaseStreamAsync_Returns_OK_WithHeaders() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.ReadStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task DeleteDatabaseStreamAsync_Returns_NoContent_WithHeaders() - { - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.DeleteStreamAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task CreateDatabaseStreamAsync_Returns_Created_WithHeaders() + { + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateDatabaseStreamAsync_Duplicate_Returns_Conflict_WithHeaders() + { + var client = new InMemoryCosmosClient(); + await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); + var response = await client.CreateDatabaseStreamAsync(new DatabaseProperties("testdb")); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateContainerStreamAsync_Returns_Created_WithHeaders() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateContainerStreamAsync_Duplicate_Returns_Conflict_WithHeaders() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); + var response = await db.CreateContainerStreamAsync(new ContainerProperties("mycontainer", "/pk")); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadDatabaseStreamAsync_Returns_OK_WithHeaders() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.ReadStreamAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task DeleteDatabaseStreamAsync_Returns_NoContent_WithHeaders() + { + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.DeleteStreamAsync(); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1530,42 +1530,42 @@ public async Task DeleteDatabaseStreamAsync_Returns_NoContent_WithHeaders() public class DatabaseContainerMetadataSkipTests { - [Fact(Skip = "DatabaseResponse from NSubstitute mock only has StatusCode and Resource. Would require significant refactoring to populate Headers, RequestCharge, ActivityId, Diagnostics.")] - public async Task DatabaseResponse_HasRequestCharge_ActivityId_Diagnostics() - { - await Task.CompletedTask; - } - - [Fact] - public async Task DatabaseResponse_EmulatorBehavior_OnlyHasStatusCodeAndResource() - { - // DIVERGENT BEHAVIOUR: Real Cosmos DB DatabaseResponse includes - // RequestCharge, ActivityId, Diagnostics. Emulator NSubstitute mock - // only sets StatusCode and Resource. - var client = new InMemoryCosmosClient(); - var response = await client.CreateDatabaseAsync("testdb"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - } - - [Fact(Skip = "ContainerResponse from NSubstitute mock only has StatusCode, Resource, Container. Would require significant refactoring.")] - public async Task ContainerResponse_HasRequestCharge_ActivityId_Diagnostics() - { - await Task.CompletedTask; - } - - [Fact] - public async Task ContainerResponse_EmulatorBehavior_OnlyHasStatusCodeAndResource() - { - // DIVERGENT BEHAVIOUR: Same as DatabaseResponse — limited mock. - var client = new InMemoryCosmosClient(); - var db = (await client.CreateDatabaseAsync("testdb")).Database; - var response = await db.CreateContainerAsync("mycontainer", "/pk"); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Should().NotBeNull(); - } + [Fact(Skip = "DatabaseResponse from NSubstitute mock only has StatusCode and Resource. Would require significant refactoring to populate Headers, RequestCharge, ActivityId, Diagnostics.")] + public async Task DatabaseResponse_HasRequestCharge_ActivityId_Diagnostics() + { + await Task.CompletedTask; + } + + [Fact] + public async Task DatabaseResponse_EmulatorBehavior_OnlyHasStatusCodeAndResource() + { + // DIVERGENT BEHAVIOUR: Real Cosmos DB DatabaseResponse includes + // RequestCharge, ActivityId, Diagnostics. Emulator NSubstitute mock + // only sets StatusCode and Resource. + var client = new InMemoryCosmosClient(); + var response = await client.CreateDatabaseAsync("testdb"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + } + + [Fact(Skip = "ContainerResponse from NSubstitute mock only has StatusCode, Resource, Container. Would require significant refactoring.")] + public async Task ContainerResponse_HasRequestCharge_ActivityId_Diagnostics() + { + await Task.CompletedTask; + } + + [Fact] + public async Task ContainerResponse_EmulatorBehavior_OnlyHasStatusCodeAndResource() + { + // DIVERGENT BEHAVIOUR: Same as DatabaseResponse — limited mock. + var client = new InMemoryCosmosClient(); + var db = (await client.CreateDatabaseAsync("testdb")).Database; + var response = await db.CreateContainerAsync("mycontainer", "/pk"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1574,32 +1574,32 @@ public async Task ContainerResponse_EmulatorBehavior_OnlyHasStatusCodeAndResourc public class BatchResponseAdditionalTests { - [Fact] - public async Task BatchResponse_Failure_HasConflictStatus() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }); - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task BatchResponse_PerOperation_WriteOps_HaveETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); - - using var response = await batch.ExecuteAsync(); - response[0].ETag.Should().NotBeNullOrEmpty(); - response[1].ETag.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task BatchResponse_Failure_HasConflictStatus() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "Dup" }); + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task BatchResponse_PerOperation_WriteOps_HaveETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }); + + using var response = await batch.ExecuteAsync(); + response[0].ETag.Should().NotBeNullOrEmpty(); + response[1].ETag.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1608,30 +1608,30 @@ public async Task BatchResponse_PerOperation_WriteOps_HaveETag() public class SessionTokenFormatTests { - [Fact] - public async Task TypedResponse_SessionToken_HasCosmosFormat() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var token = response.Headers["x-ms-session-token"]; - token.Should().NotBeNullOrEmpty(); - token.Should().StartWith("0:"); - } - - [Fact] - public async Task StreamResponse_SessionToken_HasCosmosFormat() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), - new PartitionKey("pk")); - - var token = response.Headers["x-ms-session-token"]; - token.Should().NotBeNullOrEmpty(); - token.Should().Contain("0:"); - } + [Fact] + public async Task TypedResponse_SessionToken_HasCosmosFormat() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var token = response.Headers["x-ms-session-token"]; + token.Should().NotBeNullOrEmpty(); + token.Should().StartWith("0:"); + } + + [Fact] + public async Task StreamResponse_SessionToken_HasCosmosFormat() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), + new PartitionKey("pk")); + + var token = response.Headers["x-ms-session-token"]; + token.Should().NotBeNullOrEmpty(); + token.Should().Contain("0:"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1640,33 +1640,33 @@ public async Task StreamResponse_SessionToken_HasCosmosFormat() public class TypedIfNoneMatchTests { - [Fact] - public async Task ReadItemAsync_IfNoneMatch_Matching_Throws_NotModified_304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk"), - new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotModified); - } - - [Fact] - public async Task ReadItemAsync_IfNoneMatch_Wildcard_Throws_NotModified_304() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotModified); - } + [Fact] + public async Task ReadItemAsync_IfNoneMatch_Matching_Throws_NotModified_304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var create = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfNoneMatchEtag = create.ETag }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotModified); + } + + [Fact] + public async Task ReadItemAsync_IfNoneMatch_Wildcard_Throws_NotModified_304() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotModified); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1675,94 +1675,94 @@ await act.Should().ThrowAsync() public class ResponseMetadataDivergentBehaviorDeepDiveTests { - [Fact] - public async Task ETag_IsMonotonicallyIncreasingHexCounter() - { - // ETags are monotonically increasing hex counters, providing ordering. - var container = new InMemoryContainer("test", "/partitionKey"); - var r1 = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var r2 = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - r1.ETag.Should().NotBe(r2.ETag); - r1.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); - r2.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); - - // Verify monotonic ordering - var v1 = long.Parse(r1.ETag!.Trim('"'), System.Globalization.NumberStyles.HexNumber); - var v2 = long.Parse(r2.ETag!.Trim('"'), System.Globalization.NumberStyles.HexNumber); - v2.Should().BeGreaterThan(v1); - } - - [Fact(Skip = "Real Cosmos: continuation tokens are opaque base64-encoded JSON with partition info. Emulator: plain integer offset.")] - public void ContinuationToken_ShouldBeOpaque_Base64Json() { } - - [Fact] - public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() - { - // DIVERGENT BEHAVIOUR: InMemoryFeedIterator uses integer offsets as continuation tokens. - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); - - var iter = new InMemoryFeedIterator(Enumerable.Range(1, 5).ToList(), maxItemCount: 2); - var page = await iter.ReadNextAsync(); - - page.ContinuationToken.Should().Be("2"); - } - - [Fact] - public async Task FeedResponseHeaders_ShouldContainItemCount() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Headers["x-ms-item-count"].Should().Be("2"); - } - - [Fact] - public async Task FeedResponseHeaders_EmulatorBehavior_HasActivityIdAndRequestCharge() - { - // DIVERGENT BEHAVIOUR: Real Cosmos includes x-ms-item-count in headers. - // Emulator includes x-ms-activity-id and x-ms-request-charge but not x-ms-item-count. - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - page.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task DeleteAllByPartitionKey_Returns_OK_WithHeaders() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteAllByPartitionKey_EmptyPartition_Returns_OK() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("empty")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task ETag_IsMonotonicallyIncreasingHexCounter() + { + // ETags are monotonically increasing hex counters, providing ordering. + var container = new InMemoryContainer("test", "/partitionKey"); + var r1 = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var r2 = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + r1.ETag.Should().NotBe(r2.ETag); + r1.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); + r2.ETag.Should().MatchRegex("^\"[0-9a-f]{16}\"$"); + + // Verify monotonic ordering + var v1 = long.Parse(r1.ETag!.Trim('"'), System.Globalization.NumberStyles.HexNumber); + var v2 = long.Parse(r2.ETag!.Trim('"'), System.Globalization.NumberStyles.HexNumber); + v2.Should().BeGreaterThan(v1); + } + + [Fact(Skip = "Real Cosmos: continuation tokens are opaque base64-encoded JSON with partition info. Emulator: plain integer offset.")] + public void ContinuationToken_ShouldBeOpaque_Base64Json() { } + + [Fact] + public async Task ContinuationToken_EmulatorBehavior_IsPlainIntegerOffset() + { + // DIVERGENT BEHAVIOUR: InMemoryFeedIterator uses integer offsets as continuation tokens. + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); + + var iter = new InMemoryFeedIterator(Enumerable.Range(1, 5).ToList(), maxItemCount: 2); + var page = await iter.ReadNextAsync(); + + page.ContinuationToken.Should().Be("2"); + } + + [Fact] + public async Task FeedResponseHeaders_ShouldContainItemCount() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Headers["x-ms-item-count"].Should().Be("2"); + } + + [Fact] + public async Task FeedResponseHeaders_EmulatorBehavior_HasActivityIdAndRequestCharge() + { + // DIVERGENT BEHAVIOUR: Real Cosmos includes x-ms-item-count in headers. + // Emulator includes x-ms-activity-id and x-ms-request-charge but not x-ms-item-count. + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + page.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task DeleteAllByPartitionKey_Returns_OK_WithHeaders() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteAllByPartitionKey_EmptyPartition_Returns_OK() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("empty")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1771,66 +1771,66 @@ public async Task DeleteAllByPartitionKey_EmptyPartition_Returns_OK() public class BatchMetadataGapsDeepDiveTests { - [Fact] - public async Task BatchResponse_ActivityId_IsValidGuid() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); - } - - [Fact] - public async Task BatchResponse_ActivityId_IsNotAllZeros() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.ActivityId.Should().NotBe("00000000-0000-0000-0000-000000000000"); - } - - [Fact] - public async Task BatchResponse_Headers_ContainActivityId() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task BatchResponse_Headers_ContainRequestCharge() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task BatchResponse_EmptyBatch_ReturnsMetadata() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.ActivityId.Should().NotBeNullOrEmpty(); - response.Diagnostics.Should().NotBeNull(); - } - - [Fact] - public async Task BatchResponse_Diagnostics_ToString_ReturnsJson() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Diagnostics.ToString().Should().Be("{}"); - } + [Fact] + public async Task BatchResponse_ActivityId_IsValidGuid() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); + } + + [Fact] + public async Task BatchResponse_ActivityId_IsNotAllZeros() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.ActivityId.Should().NotBe("00000000-0000-0000-0000-000000000000"); + } + + [Fact] + public async Task BatchResponse_Headers_ContainActivityId() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task BatchResponse_Headers_ContainRequestCharge() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task BatchResponse_EmptyBatch_ReturnsMetadata() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ActivityId.Should().NotBeNullOrEmpty(); + response.Diagnostics.Should().NotBeNull(); + } + + [Fact] + public async Task BatchResponse_Diagnostics_ToString_ReturnsJson() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Diagnostics.ToString().Should().Be("{}"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1839,79 +1839,79 @@ public async Task BatchResponse_Diagnostics_ToString_ReturnsJson() public class SessionTokenConsistencyDeepDiveTests { - [Fact] - public async Task SessionToken_Consistent_AcrossTypedAndStream() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var typed = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var stream = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk"}""")), new PartitionKey("pk")); - - var typedToken = typed.Headers["x-ms-session-token"]; - var streamToken = stream.Headers["x-ms-session-token"]; - - // Both should use the same format - typedToken.Should().StartWith("0:"); - streamToken.Should().StartWith("0:"); - typedToken.Should().MatchRegex(@"^0:\d+#\d+$"); - streamToken.Should().MatchRegex(@"^0:\d+#\d+$"); - } - - [Fact] - public async Task SessionToken_PresentOnAllTypedCrudOps() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var create = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Create"); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk")); - read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Read"); - - var upsert = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Upsert"); - - var replace = await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "C" }, "1", new PartitionKey("pk")); - replace.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Replace"); - - var patch = await container.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "D")]); - patch.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Patch"); - - var delete = await container.DeleteItemAsync("1", new PartitionKey("pk")); - delete.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Delete"); - } - - [Fact] - public async Task SessionToken_PresentOnAllStreamCrudOps() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var create = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"A"}""")), new PartitionKey("pk")); - create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Create"); - - var read = await container.ReadItemStreamAsync("1", new PartitionKey("pk")); - read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Read"); - - var upsert = await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"B"}""")), new PartitionKey("pk")); - upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Upsert"); - - var replace = await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"C"}""")), "1", new PartitionKey("pk")); - replace.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Replace"); - - var patch = await container.PatchItemStreamAsync("1", new PartitionKey("pk"), - [PatchOperation.Set("/name", "D")]); - patch.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Patch"); - - var delete = await container.DeleteItemStreamAsync("1", new PartitionKey("pk")); - delete.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Delete"); - } + [Fact] + public async Task SessionToken_Consistent_AcrossTypedAndStream() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var typed = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var stream = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk"}""")), new PartitionKey("pk")); + + var typedToken = typed.Headers["x-ms-session-token"]; + var streamToken = stream.Headers["x-ms-session-token"]; + + // Both should use the same format + typedToken.Should().StartWith("0:"); + streamToken.Should().StartWith("0:"); + typedToken.Should().MatchRegex(@"^0:\d+#\d+$"); + streamToken.Should().MatchRegex(@"^0:\d+#\d+$"); + } + + [Fact] + public async Task SessionToken_PresentOnAllTypedCrudOps() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var create = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Create"); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk")); + read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Read"); + + var upsert = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Upsert"); + + var replace = await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "C" }, "1", new PartitionKey("pk")); + replace.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Replace"); + + var patch = await container.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "D")]); + patch.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Patch"); + + var delete = await container.DeleteItemAsync("1", new PartitionKey("pk")); + delete.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Delete"); + } + + [Fact] + public async Task SessionToken_PresentOnAllStreamCrudOps() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var create = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"A"}""")), new PartitionKey("pk")); + create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Create"); + + var read = await container.ReadItemStreamAsync("1", new PartitionKey("pk")); + read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Read"); + + var upsert = await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"B"}""")), new PartitionKey("pk")); + upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Upsert"); + + var replace = await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk","name":"C"}""")), "1", new PartitionKey("pk")); + replace.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Replace"); + + var patch = await container.PatchItemStreamAsync("1", new PartitionKey("pk"), + [PatchOperation.Set("/name", "D")]); + patch.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Patch"); + + var delete = await container.DeleteItemStreamAsync("1", new PartitionKey("pk")); + delete.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty("Delete"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1920,118 +1920,118 @@ public async Task SessionToken_PresentOnAllStreamCrudOps() public class CosmosExceptionDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CosmosException_Message_ContainsStatusCode() - { - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - // CosmosException.Message includes "Resource Not Found" matching real SDK behavior - ex.Message.Should().Contain("Resource Not Found"); - ex.Message.Should().Contain("Entity with the specified id does not exist"); - ex.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - } - - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // 404 Not found — "The operation is attempting to act on a resource that no longer exists." - // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} - // The exception message should contain "Resource Not Found" to match the real SDK behavior, - // enabling code that checks for this string (e.g., e.Message.Contains("Resource Not Found")). - [Theory] - [InlineData("ReadItem")] - [InlineData("ReplaceItem")] - [InlineData("DeleteItem")] - [InlineData("PatchItem")] - public async Task CosmosException_NotFound_Message_ContainsResourceNotFound(string operation) - { - Func act = operation switch - { - "ReadItem" => () => _container.ReadItemAsync("nope", new PartitionKey("pk")), - "ReplaceItem" => () => _container.ReplaceItemAsync( - new TestDocument { Id = "nope", PartitionKey = "pk" }, "nope", new PartitionKey("pk")), - "DeleteItem" => () => _container.DeleteItemAsync("nope", new PartitionKey("pk")), - "PatchItem" => () => _container.PatchItemAsync("nope", new PartitionKey("pk"), - new[] { PatchOperation.Set("/name", "x") }), - _ => throw new ArgumentException(operation) - }; - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - ex.Which.Message.ToLower().Should().Contain("resource not found"); - } - - [Fact] - public async Task CosmosException_Headers_ContainActivityId() - { - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - ex.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - } - - [Fact] - public async Task CosmosException_HasDiagnostics_OrNull() - { - try - { - await _container.ReadItemAsync("nope", new PartitionKey("pk")); - throw new Exception("Expected CosmosException"); - } - catch (CosmosException ex) - { - // Document whatever the emulator returns — may be null - _ = ex.Diagnostics; - } - } - - [Fact] - public async Task CosmosException_ActivityId_IsUnique_PerError() - { - string activityId1 = null!, activityId2 = null!; - try - { - await _container.ReadItemAsync("nope1", new PartitionKey("pk")); - } - catch (CosmosException ex) - { - activityId1 = ex.ActivityId; - } - - try - { - await _container.ReadItemAsync("nope2", new PartitionKey("pk")); - } - catch (CosmosException ex) - { - activityId2 = ex.ActivityId; - } - - activityId1.Should().NotBe(activityId2); - } - - [Fact] - public async Task CosmosException_ResponseBody_Typed_vs_Stream() - { - // Typed API throws CosmosException; Stream API returns status code - var act = () => _container.ReadItemAsync("nope", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - - var streamResponse = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); - streamResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CosmosException_Message_ContainsStatusCode() + { + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + // CosmosException.Message includes "Resource Not Found" matching real SDK behavior + ex.Message.Should().Contain("Resource Not Found"); + ex.Message.Should().Contain("Entity with the specified id does not exist"); + ex.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } + + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // 404 Not found — "The operation is attempting to act on a resource that no longer exists." + // Ref: Observed Cosmos DB REST API error body: {"code":"NotFound","message":"Resource Not Found. ..."} + // The exception message should contain "Resource Not Found" to match the real SDK behavior, + // enabling code that checks for this string (e.g., e.Message.Contains("Resource Not Found")). + [Theory] + [InlineData("ReadItem")] + [InlineData("ReplaceItem")] + [InlineData("DeleteItem")] + [InlineData("PatchItem")] + public async Task CosmosException_NotFound_Message_ContainsResourceNotFound(string operation) + { + Func act = operation switch + { + "ReadItem" => () => _container.ReadItemAsync("nope", new PartitionKey("pk")), + "ReplaceItem" => () => _container.ReplaceItemAsync( + new TestDocument { Id = "nope", PartitionKey = "pk" }, "nope", new PartitionKey("pk")), + "DeleteItem" => () => _container.DeleteItemAsync("nope", new PartitionKey("pk")), + "PatchItem" => () => _container.PatchItemAsync("nope", new PartitionKey("pk"), + new[] { PatchOperation.Set("/name", "x") }), + _ => throw new ArgumentException(operation) + }; + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + ex.Which.Message.ToLower().Should().Contain("resource not found"); + } + + [Fact] + public async Task CosmosException_Headers_ContainActivityId() + { + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + ex.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + } + + [Fact] + public async Task CosmosException_HasDiagnostics_OrNull() + { + try + { + await _container.ReadItemAsync("nope", new PartitionKey("pk")); + throw new Exception("Expected CosmosException"); + } + catch (CosmosException ex) + { + // Document whatever the emulator returns — may be null + _ = ex.Diagnostics; + } + } + + [Fact] + public async Task CosmosException_ActivityId_IsUnique_PerError() + { + string activityId1 = null!, activityId2 = null!; + try + { + await _container.ReadItemAsync("nope1", new PartitionKey("pk")); + } + catch (CosmosException ex) + { + activityId1 = ex.ActivityId; + } + + try + { + await _container.ReadItemAsync("nope2", new PartitionKey("pk")); + } + catch (CosmosException ex) + { + activityId2 = ex.ActivityId; + } + + activityId1.Should().NotBe(activityId2); + } + + [Fact] + public async Task CosmosException_ResponseBody_Typed_vs_Stream() + { + // Typed API throws CosmosException; Stream API returns status code + var act = () => _container.ReadItemAsync("nope", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + + var streamResponse = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); + streamResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2040,50 +2040,50 @@ await act.Should().ThrowAsync() public class StreamErrorBodyContentDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task StreamError_404_Content_ContainsErrorBody() - { - var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb - // Real Cosmos DB returns a JSON error body for 404 responses. - response.Content.Should().NotBeNull(); - using var reader = new StreamReader(response.Content); - var body = reader.ReadToEnd(); - body.Should().Contain("Resource Not Found"); - } - - [Fact] - public async Task StreamError_409_Content_ContainsErrorBody() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.Content.Should().NotBeNull(); - using var reader = new StreamReader(response.Content); - var body = reader.ReadToEnd(); - body.Should().Contain("Conflict"); - } - - [Fact] - public async Task StreamError_412_Content_ContainsErrorBody() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - response.Content.Should().NotBeNull(); - using var reader = new StreamReader(response.Content); - var body = reader.ReadToEnd(); - body.Should().Contain("PreconditionFailed"); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task StreamError_404_Content_ContainsErrorBody() + { + var response = await _container.ReadItemStreamAsync("nope", new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + // Ref: https://learn.microsoft.com/en-us/rest/api/cosmos-db/http-status-codes-for-cosmosdb + // Real Cosmos DB returns a JSON error body for 404 responses. + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content); + var body = reader.ReadToEnd(); + body.Should().Contain("Resource Not Found"); + } + + [Fact] + public async Task StreamError_409_Content_ContainsErrorBody() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content); + var body = reader.ReadToEnd(); + body.Should().Contain("Conflict"); + } + + [Fact] + public async Task StreamError_412_Content_ContainsErrorBody() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + response.Content.Should().NotBeNull(); + using var reader = new StreamReader(response.Content); + var body = reader.ReadToEnd(); + body.Should().Contain("PreconditionFailed"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2092,34 +2092,34 @@ await _container.CreateItemStreamAsync( public class DeleteMetadataEdgeCaseDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task DeleteItemAsync_ETag_IsNull_OnResponse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); - response.ETag.Should().BeNull(); - } - - [Fact] - public async Task DeleteItemStreamAsync_NoETag_InHeaders() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); - response.Headers.ETag.Should().BeNullOrEmpty(); - } - - [Fact] - public async Task DeleteItemStreamAsync_Content_IsNull() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); - response.Content.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task DeleteItemAsync_ETag_IsNull_OnResponse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk")); + response.ETag.Should().BeNull(); + } + + [Fact] + public async Task DeleteItemStreamAsync_NoETag_InHeaders() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); + response.Headers.ETag.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task DeleteItemStreamAsync_Content_IsNull() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk")); + response.Content.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2128,62 +2128,62 @@ await _container.CreateItemStreamAsync( public class TypedETagEdgeCaseDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task UpsertItemAsync_StaleETag_Throws_412() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemAsync_StaleETag_Throws_412() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => _container.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "B")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task DeleteItemAsync_StaleETag_Throws_412() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => _container.DeleteItemAsync( - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task DeleteItemStreamAsync_StaleETag_Returns_412() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); - - var response = await _container.DeleteItemStreamAsync( - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task UpsertItemAsync_StaleETag_Throws_412() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemAsync_StaleETag_Throws_412() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => _container.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "B")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task DeleteItemAsync_StaleETag_Throws_412() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => _container.DeleteItemAsync( + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task DeleteItemStreamAsync_StaleETag_Returns_412() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), new PartitionKey("pk")); + + var response = await _container.DeleteItemStreamAsync( + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2192,48 +2192,48 @@ await _container.CreateItemStreamAsync( public class ContentSuppressionEdgeCaseDeepDiveTests { - private readonly InMemoryContainer _container = new("test", "/partitionKey"); - - [Fact] - public async Task CreateItemAsync_SuppressContent_StillUpdatesETag() - { - var r1 = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - r1.ETag.Should().NotBeNullOrEmpty(); - - var r2 = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - r2.ETag.Should().NotBeNullOrEmpty(); - r2.ETag.Should().NotBe(r1.ETag); - } - - [Fact] - public async Task UpsertItemAsync_SuppressContent_ExistingItem_ResourceIsNull() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await _container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.Resource.Should().BeNull(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task SuppressContent_StreamApi_HasEmptyBody() - { - var response = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), - new PartitionKey("pk"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test", "/partitionKey"); + + [Fact] + public async Task CreateItemAsync_SuppressContent_StillUpdatesETag() + { + var r1 = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + r1.ETag.Should().NotBeNullOrEmpty(); + + var r2 = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + r2.ETag.Should().NotBeNullOrEmpty(); + r2.ETag.Should().NotBe(r1.ETag); + } + + [Fact] + public async Task UpsertItemAsync_SuppressContent_ExistingItem_ResourceIsNull() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await _container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.Resource.Should().BeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SuppressContent_StreamApi_HasEmptyBody() + { + var response = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk"}""")), + new PartitionKey("pk"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2242,67 +2242,67 @@ public async Task SuppressContent_StreamApi_HasEmptyBody() public class FeedResponsePaginationDeepDiveTests { - [Fact] - public async Task FeedResponse_Count_MatchesItemsInPage() - { - var container = new InMemoryContainer("test", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Count.Should().Be(5); - } - - [Fact] - public async Task FeedResponse_MultiPage_ContinuationToken_Format() - { - var items = Enumerable.Range(0, 5).Select(i => - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }).ToList(); - var iter = new InMemoryFeedIterator(items, maxItemCount: 2); - - var page1 = await iter.ReadNextAsync(); - page1.Count.Should().Be(2); - page1.ContinuationToken.Should().NotBeNullOrEmpty(); - - var page2 = await iter.ReadNextAsync(); - page2.Count.Should().Be(2); - page2.ContinuationToken.Should().NotBeNullOrEmpty(); - - var page3 = await iter.ReadNextAsync(); - page3.Count.Should().Be(1); - page3.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task FeedResponse_MultiPage_EachPage_HasUniqueActivityId() - { - var items = Enumerable.Range(0, 4).Select(i => - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }).ToList(); - var iter = new InMemoryFeedIterator(items, maxItemCount: 2); - - var page1 = await iter.ReadNextAsync(); - var page2 = await iter.ReadNextAsync(); - - page1.ActivityId.Should().NotBe(page2.ActivityId); - } - - [Fact] - public async Task StreamFeedIterator_ItemCount_MatchesItems() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var response = await iter.ReadNextAsync(); - - response.Headers["x-ms-item-count"].Should().Be("2"); - } + [Fact] + public async Task FeedResponse_Count_MatchesItemsInPage() + { + var container = new InMemoryContainer("test", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Count.Should().Be(5); + } + + [Fact] + public async Task FeedResponse_MultiPage_ContinuationToken_Format() + { + var items = Enumerable.Range(0, 5).Select(i => + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }).ToList(); + var iter = new InMemoryFeedIterator(items, maxItemCount: 2); + + var page1 = await iter.ReadNextAsync(); + page1.Count.Should().Be(2); + page1.ContinuationToken.Should().NotBeNullOrEmpty(); + + var page2 = await iter.ReadNextAsync(); + page2.Count.Should().Be(2); + page2.ContinuationToken.Should().NotBeNullOrEmpty(); + + var page3 = await iter.ReadNextAsync(); + page3.Count.Should().Be(1); + page3.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task FeedResponse_MultiPage_EachPage_HasUniqueActivityId() + { + var items = Enumerable.Range(0, 4).Select(i => + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }).ToList(); + var iter = new InMemoryFeedIterator(items, maxItemCount: 2); + + var page1 = await iter.ReadNextAsync(); + var page2 = await iter.ReadNextAsync(); + + page1.ActivityId.Should().NotBe(page2.ActivityId); + } + + [Fact] + public async Task StreamFeedIterator_ItemCount_MatchesItems() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var response = await iter.ReadNextAsync(); + + response.Headers["x-ms-item-count"].Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2311,36 +2311,36 @@ await container.CreateItemAsync( public class DiagnosticsDeepDiveTests { - [Fact] - public async Task Diagnostics_ToString_ReturnsEmptyJson() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.Diagnostics.ToString().Should().Be("{}"); - } - - [Fact] - public async Task Diagnostics_GetContactedRegions_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.Diagnostics.GetContactedRegions().Should().BeEmpty(); - } - - [Fact] - public async Task FeedResponse_Diagnostics_GetClientElapsedTime_IsZero() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } + [Fact] + public async Task Diagnostics_ToString_ReturnsEmptyJson() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.Diagnostics.ToString().Should().Be("{}"); + } + + [Fact] + public async Task Diagnostics_GetContactedRegions_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.Diagnostics.GetContactedRegions().Should().BeEmpty(); + } + + [Fact] + public async Task FeedResponse_Diagnostics_GetClientElapsedTime_IsZero() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2349,27 +2349,27 @@ await container.CreateItemAsync( public class DeleteAllByPartitionKeyHeadersDeepDiveTests { - [Fact] - public async Task DeleteAllByPartitionKey_HasActivityId_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task DeleteAllByPartitionKey_HasRequestCharge_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task DeleteAllByPartitionKey_HasActivityId_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task DeleteAllByPartitionKey_HasRequestCharge_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk")); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2378,30 +2378,30 @@ await container.CreateItemAsync( public class ReadManyEdgeCaseDeepDiveTests { - [Fact] - public async Task ReadManyItemsAsync_HasActivityId() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.ReadManyItemsAsync( - [("1", new PartitionKey("pk"))]); - response.ActivityId.Should().NotBeNullOrEmpty(); - Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); - } - - [Fact] - public async Task ReadManyItemsStreamAsync_HasActivityId_Header() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await container.ReadManyItemsStreamAsync( - [("1", new PartitionKey("pk"))]); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task ReadManyItemsAsync_HasActivityId() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.ReadManyItemsAsync( + [("1", new PartitionKey("pk"))]); + response.ActivityId.Should().NotBeNullOrEmpty(); + Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); + } + + [Fact] + public async Task ReadManyItemsStreamAsync_HasActivityId_Header() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await container.ReadManyItemsStreamAsync( + [("1", new PartitionKey("pk"))]); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2410,57 +2410,57 @@ await container.CreateItemAsync( public class CrossApiConsistencyDeepDiveTests { - [Fact] - public async Task TypedAndStream_SameItem_SameETag() - { - var container = new InMemoryContainer("test", "/partitionKey"); - var typed = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var stream = await container.ReadItemStreamAsync("1", new PartitionKey("pk")); - - stream.Headers.ETag.Should().Be(typed.ETag); - } - - [Fact] - public async Task TypedAndStream_StatusCodes_Match_ForAllOps() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - // Create - var typedCreate = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var streamCreate = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk"}""")), new PartitionKey("pk")); - typedCreate.StatusCode.Should().Be((HttpStatusCode)streamCreate.StatusCode); - - // Read - var typedRead = await container.ReadItemAsync("1", new PartitionKey("pk")); - var streamRead = await container.ReadItemStreamAsync("2", new PartitionKey("pk")); - typedRead.StatusCode.Should().Be((HttpStatusCode)streamRead.StatusCode); - - // Delete - var typedDelete = await container.DeleteItemAsync("1", new PartitionKey("pk")); - var streamDelete = await container.DeleteItemStreamAsync("2", new PartitionKey("pk")); - typedDelete.StatusCode.Should().Be((HttpStatusCode)streamDelete.StatusCode); - } - - [Fact] - public async Task FeedIterator_TypedAndStream_SameActivityIdFormat() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var typedIter = container.GetItemQueryIterator("SELECT * FROM c"); - var typedPage = await typedIter.ReadNextAsync(); - - var streamIter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var streamResponse = await streamIter.ReadNextAsync(); - - Guid.TryParse(typedPage.ActivityId, out _).Should().BeTrue(); - Guid.TryParse(streamResponse.Headers["x-ms-activity-id"], out _).Should().BeTrue(); - } + [Fact] + public async Task TypedAndStream_SameItem_SameETag() + { + var container = new InMemoryContainer("test", "/partitionKey"); + var typed = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var stream = await container.ReadItemStreamAsync("1", new PartitionKey("pk")); + + stream.Headers.ETag.Should().Be(typed.ETag); + } + + [Fact] + public async Task TypedAndStream_StatusCodes_Match_ForAllOps() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + // Create + var typedCreate = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var streamCreate = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","partitionKey":"pk"}""")), new PartitionKey("pk")); + typedCreate.StatusCode.Should().Be((HttpStatusCode)streamCreate.StatusCode); + + // Read + var typedRead = await container.ReadItemAsync("1", new PartitionKey("pk")); + var streamRead = await container.ReadItemStreamAsync("2", new PartitionKey("pk")); + typedRead.StatusCode.Should().Be((HttpStatusCode)streamRead.StatusCode); + + // Delete + var typedDelete = await container.DeleteItemAsync("1", new PartitionKey("pk")); + var streamDelete = await container.DeleteItemStreamAsync("2", new PartitionKey("pk")); + typedDelete.StatusCode.Should().Be((HttpStatusCode)streamDelete.StatusCode); + } + + [Fact] + public async Task FeedIterator_TypedAndStream_SameActivityIdFormat() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var typedIter = container.GetItemQueryIterator("SELECT * FROM c"); + var typedPage = await typedIter.ReadNextAsync(); + + var streamIter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var streamResponse = await streamIter.ReadNextAsync(); + + Guid.TryParse(typedPage.ActivityId, out _).Should().BeTrue(); + Guid.TryParse(streamResponse.Headers["x-ms-activity-id"], out _).Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2469,50 +2469,50 @@ await container.CreateItemAsync( public class FakeCosmosHandlerResponseMetadataDeepDiveTests : IDisposable { - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _typedContainer; - - public FakeCosmosHandlerResponseMetadataDeepDiveTests() - { - _handler = new FakeCosmosHandler(new InMemoryContainer("test", "/partitionKey")); - - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = () => new HttpClient(_handler), - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0 - }); - - _typedContainer = _client.GetContainer("db", "test"); - } - - public void Dispose() - { - _client?.Dispose(); - _handler?.Dispose(); - } - - [Fact] - public async Task FakeCosmosHandler_PreservesActivityId_FromContainer() - { - await _typedContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var response = await _typedContainer.ReadItemAsync("1", new PartitionKey("pk")); - response.ActivityId.Should().NotBeNullOrEmpty(); - Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); - } - - [Fact] - public async Task FakeCosmosHandler_HasSessionToken_OnResponse() - { - var response = await _typedContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _typedContainer; + + public FakeCosmosHandlerResponseMetadataDeepDiveTests() + { + _handler = new FakeCosmosHandler(new InMemoryContainer("test", "/partitionKey")); + + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(_handler), + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0 + }); + + _typedContainer = _client.GetContainer("db", "test"); + } + + public void Dispose() + { + _client?.Dispose(); + _handler?.Dispose(); + } + + [Fact] + public async Task FakeCosmosHandler_PreservesActivityId_FromContainer() + { + await _typedContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var response = await _typedContainer.ReadItemAsync("1", new PartitionKey("pk")); + response.ActivityId.Should().NotBeNullOrEmpty(); + Guid.TryParse(response.ActivityId, out _).Should().BeTrue(); + } + + [Fact] + public async Task FakeCosmosHandler_HasSessionToken_OnResponse() + { + var response = await _typedContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityCanaryDeepDiveTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityCanaryDeepDiveTests.cs index 5b96d1a..609ee83 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityCanaryDeepDiveTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityCanaryDeepDiveTests.cs @@ -15,35 +15,35 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class ItemIdExtractionReflectionTests { - [Fact] - public void ItemIdExtraction_TypeWithUpperCaseId_FindsProperty() - { - var type = typeof(TestDocument); - var idProp = type.GetProperty("Id") ?? type.GetProperty("id"); - idProp.Should().NotBeNull("InMemoryChangeFeedProcessor.ExtractId relies on finding Id/id property"); - } - - [Fact] - public void ItemIdExtraction_TypeWithLowerCaseId_FindsProperty() - { - // JObject items have string indexers — verify the "id" key convention - var jObj = JObject.Parse("""{"id":"test-123","partitionKey":"pk"}"""); - jObj["id"]?.ToString().Should().Be("test-123"); - } - - [Fact] - public void ItemIdExtraction_TypeWithNoIdProperty_ReturnsNull() - { - // Anonymous-like type without id property - var type = typeof(NoIdDocument); - var idProp = type.GetProperty("Id") ?? type.GetProperty("id"); - idProp.Should().BeNull("types without id property should return null from reflection"); - } - - private class NoIdDocument - { - public string Name { get; set; } = default!; - } + [Fact] + public void ItemIdExtraction_TypeWithUpperCaseId_FindsProperty() + { + var type = typeof(TestDocument); + var idProp = type.GetProperty("Id") ?? type.GetProperty("id"); + idProp.Should().NotBeNull("InMemoryChangeFeedProcessor.ExtractId relies on finding Id/id property"); + } + + [Fact] + public void ItemIdExtraction_TypeWithLowerCaseId_FindsProperty() + { + // JObject items have string indexers — verify the "id" key convention + var jObj = JObject.Parse("""{"id":"test-123","partitionKey":"pk"}"""); + jObj["id"]?.ToString().Should().Be("test-123"); + } + + [Fact] + public void ItemIdExtraction_TypeWithNoIdProperty_ReturnsNull() + { + // Anonymous-like type without id property + var type = typeof(NoIdDocument); + var idProp = type.GetProperty("Id") ?? type.GetProperty("id"); + idProp.Should().BeNull("types without id property should return null from reflection"); + } + + private class NoIdDocument + { + public string Name { get; set; } = default!; + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -52,27 +52,27 @@ private class NoIdDocument public class FeedIteratorGenericReflectionTests { - [Fact] - public void FeedIterator_Generic_HasVirtual_HasMoreResults() - { - var prop = typeof(FeedIterator).GetProperty(nameof(FeedIterator.HasMoreResults)); - prop.Should().NotBeNull(); - prop!.GetMethod!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); - } - - [Fact] - public void FeedIterator_Generic_HasVirtual_ReadNextAsync() - { - var method = typeof(FeedIterator).GetMethod(nameof(FeedIterator.ReadNextAsync)); - method.Should().NotBeNull(); - method!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); - } - - [Fact] - public void FeedIterator_Generic_IsNotSealed() - { - typeof(FeedIterator).IsSealed.Should().BeFalse("InMemoryFeedIterator inherits from FeedIterator"); - } + [Fact] + public void FeedIterator_Generic_HasVirtual_HasMoreResults() + { + var prop = typeof(FeedIterator).GetProperty(nameof(FeedIterator.HasMoreResults)); + prop.Should().NotBeNull(); + prop!.GetMethod!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); + } + + [Fact] + public void FeedIterator_Generic_HasVirtual_ReadNextAsync() + { + var method = typeof(FeedIterator).GetMethod(nameof(FeedIterator.ReadNextAsync)); + method.Should().NotBeNull(); + method!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); + } + + [Fact] + public void FeedIterator_Generic_IsNotSealed() + { + typeof(FeedIterator).IsSealed.Should().BeFalse("InMemoryFeedIterator inherits from FeedIterator"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -81,24 +81,24 @@ public void FeedIterator_Generic_IsNotSealed() public class QueryDefinitionParameterTupleTests { - [Fact] - public void QueryDefinition_ParameterTuple_HasNameProperty() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", "1"); - var parameters = qd.GetQueryParameters().ToList(); - parameters.Should().HaveCount(1); - parameters[0].Name.Should().Be("@id"); - } - - [Fact] - public void QueryDefinition_ParameterTuple_HasValueProperty() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", "test-value"); - var parameters = qd.GetQueryParameters().ToList(); - parameters[0].Value.Should().Be("test-value"); - } + [Fact] + public void QueryDefinition_ParameterTuple_HasNameProperty() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", "1"); + var parameters = qd.GetQueryParameters().ToList(); + parameters.Should().HaveCount(1); + parameters[0].Name.Should().Be("@id"); + } + + [Fact] + public void QueryDefinition_ParameterTuple_HasValueProperty() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", "test-value"); + var parameters = qd.GetQueryParameters().ToList(); + parameters[0].Value.Should().Be("test-value"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -107,20 +107,20 @@ public void QueryDefinition_ParameterTuple_HasValueProperty() public class ChangeFeedStartFromSubtypeTests { - [Fact] - public void ChangeFeedStartFrom_AllSubtypes_HaveExpectedNames() - { - var subtypes = typeof(ChangeFeedStartFrom).Assembly.GetTypes() - .Where(t => t.IsSubclassOf(typeof(ChangeFeedStartFrom))) - .Select(t => t.Name) - .ToList(); - - subtypes.Should().HaveCountGreaterThanOrEqualTo(3); - // Verify expected keywords appear across subtype names - subtypes.Should().Contain(n => n.Contains("Beginning", StringComparison.OrdinalIgnoreCase) - || n.Contains("Now", StringComparison.OrdinalIgnoreCase) - || n.Contains("Time", StringComparison.OrdinalIgnoreCase)); - } + [Fact] + public void ChangeFeedStartFrom_AllSubtypes_HaveExpectedNames() + { + var subtypes = typeof(ChangeFeedStartFrom).Assembly.GetTypes() + .Where(t => t.IsSubclassOf(typeof(ChangeFeedStartFrom))) + .Select(t => t.Name) + .ToList(); + + subtypes.Should().HaveCountGreaterThanOrEqualTo(3); + // Verify expected keywords appear across subtype names + subtypes.Should().Contain(n => n.Contains("Beginning", StringComparison.OrdinalIgnoreCase) + || n.Contains("Now", StringComparison.OrdinalIgnoreCase) + || n.Contains("Time", StringComparison.OrdinalIgnoreCase)); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -129,55 +129,55 @@ public void ChangeFeedStartFrom_AllSubtypes_HaveExpectedNames() public class SdkPipelineQueryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelineQueryTests() - { - _container = new InMemoryContainer("query-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "query-pipe"); - - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, new PartitionKey("pk")).Wait(); - _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, new PartitionKey("pk")).Wait(); - _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, new PartitionKey("pk")).Wait(); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_ParameterizedQuery_ReturnsFilteredResults() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Alice"); - var iter = _cosmosContainer.GetItemQueryIterator(qd); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - results.First().Name.Should().Be("Alice"); - } - - [Fact] - public async Task SdkPipeline_ParameterizedQuery_MultipleParams_ReturnsCorrectResults() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c[\"value\"] = @val") - .WithParameter("@name", "Bob") - .WithParameter("@val", 20); - var iter = _cosmosContainer.GetItemQueryIterator(qd); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - results.First().Id.Should().Be("2"); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelineQueryTests() + { + _container = new InMemoryContainer("query-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "query-pipe"); + + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, new PartitionKey("pk")).Wait(); + _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, new PartitionKey("pk")).Wait(); + _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, new PartitionKey("pk")).Wait(); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_ParameterizedQuery_ReturnsFilteredResults() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Alice"); + var iter = _cosmosContainer.GetItemQueryIterator(qd); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + results.First().Name.Should().Be("Alice"); + } + + [Fact] + public async Task SdkPipeline_ParameterizedQuery_MultipleParams_ReturnsCorrectResults() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c[\"value\"] = @val") + .WithParameter("@name", "Bob") + .WithParameter("@val", 20); + var iter = _cosmosContainer.GetItemQueryIterator(qd); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + results.First().Id.Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -186,60 +186,60 @@ public async Task SdkPipeline_ParameterizedQuery_MultipleParams_ReturnsCorrectRe public class SdkPipelineETagTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelineETagTests() - { - _container = new InMemoryContainer("etag-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "etag-pipe"); - - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).Wait(); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_IfMatchEtag_StaleEtag_Returns412() - { - var act = () => _cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task SdkPipeline_IfMatchEtag_CurrentEtag_Succeeds() - { - var read = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - var response = await _cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - "1", new PartitionKey("pk"), - new ItemRequestOptions { IfMatchEtag = read.ETag }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task SdkPipeline_IfNoneMatchEtag_SameEtag_Returns304() - { - var read = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - var act = () => _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk"), - new ItemRequestOptions { IfNoneMatchEtag = read.ETag }); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotModified); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelineETagTests() + { + _container = new InMemoryContainer("etag-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "etag-pipe"); + + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).Wait(); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_IfMatchEtag_StaleEtag_Returns412() + { + var act = () => _cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task SdkPipeline_IfMatchEtag_CurrentEtag_Succeeds() + { + var read = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + var response = await _cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + "1", new PartitionKey("pk"), + new ItemRequestOptions { IfMatchEtag = read.ETag }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SdkPipeline_IfNoneMatchEtag_SameEtag_Returns304() + { + var read = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + var act = () => _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk"), + new ItemRequestOptions { IfNoneMatchEtag = read.ETag }); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotModified); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -248,52 +248,52 @@ await act.Should().ThrowAsync() public class SdkPipelineErrorResponseTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelineErrorResponseTests() - { - _container = new InMemoryContainer("error-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "error-pipe"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_ReadNonExistent_ThrowsCosmosException404() - { - var act = () => _cosmosContainer.ReadItemAsync("nope", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task SdkPipeline_CreateDuplicate_ThrowsCosmosException409() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "dup", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var act = () => _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "dup", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task SdkPipeline_DeleteNonExistent_ThrowsCosmosException404() - { - var act = () => _cosmosContainer.DeleteItemAsync("nope", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelineErrorResponseTests() + { + _container = new InMemoryContainer("error-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "error-pipe"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_ReadNonExistent_ThrowsCosmosException404() + { + var act = () => _cosmosContainer.ReadItemAsync("nope", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task SdkPipeline_CreateDuplicate_ThrowsCosmosException409() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "dup", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var act = () => _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "dup", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task SdkPipeline_DeleteNonExistent_ThrowsCosmosException404() + { + var act = () => _cosmosContainer.DeleteItemAsync("nope", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -302,66 +302,66 @@ await act.Should().ThrowAsync() public class SdkPipelineExpansionTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelineExpansionTests() - { - _container = new InMemoryContainer("expand-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "expand-pipe"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_ReadFeed_WithPartitionKey_ReturnsOnlyMatchingItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - results.First().Name.Should().Be("A"); - } - - [Fact] - public async Task SdkPipeline_DeleteThenRecreate_SameId_Succeeds() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "cycle", PartitionKey = "pk", Name = "V1" }, new PartitionKey("pk")); - await _cosmosContainer.DeleteItemAsync("cycle", new PartitionKey("pk")); - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "cycle", PartitionKey = "pk", Name = "V2" }, new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await _cosmosContainer.ReadItemAsync("cycle", new PartitionKey("pk")); - read.Resource.Name.Should().Be("V2"); - } - - [Fact] - public async Task SdkPipeline_PatchItemStreamAsync_ReturnsOk() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "patch-s", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); - var response = await _cosmosContainer.PatchItemStreamAsync( - "patch-s", new PartitionKey("pk"), - [PatchOperation.Set("/name", "After")]); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelineExpansionTests() + { + _container = new InMemoryContainer("expand-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "expand-pipe"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_ReadFeed_WithPartitionKey_ReturnsOnlyMatchingItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk1") }); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + results.First().Name.Should().Be("A"); + } + + [Fact] + public async Task SdkPipeline_DeleteThenRecreate_SameId_Succeeds() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "cycle", PartitionKey = "pk", Name = "V1" }, new PartitionKey("pk")); + await _cosmosContainer.DeleteItemAsync("cycle", new PartitionKey("pk")); + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "cycle", PartitionKey = "pk", Name = "V2" }, new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await _cosmosContainer.ReadItemAsync("cycle", new PartitionKey("pk")); + read.Resource.Name.Should().Be("V2"); + } + + [Fact] + public async Task SdkPipeline_PatchItemStreamAsync_ReturnsOk() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "patch-s", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); + var response = await _cosmosContainer.PatchItemStreamAsync( + "patch-s", new PartitionKey("pk"), + [PatchOperation.Set("/name", "After")]); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -370,84 +370,84 @@ await _cosmosContainer.CreateItemAsync( public class FaultInjectorCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _cosmosContainer; - - public FaultInjectorCanaryTests() - { - _container = new InMemoryContainer("fault-canary", "/partitionKey"); - _handler = new FakeCosmosHandler(_container); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _cosmosContainer = _client.GetContainer("fakeDb", "fault-canary"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - [Fact] - public async Task FaultInjector_Returns429_SdkReceivesCosmosException() - { - _handler.FaultInjector = req => - { - // Skip query plan requests - if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) - return null; - return new HttpResponseMessage((HttpStatusCode)429); - }; - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var act = () => _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => (int)e.StatusCode == 429); - } - - [Fact] - public async Task FaultInjector_ReturnsNull_RequestCompletesNormally() - { - _handler.FaultInjector = _ => null; // null = no fault - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Resource.Name.Should().Be("A"); - } - - [Fact] - public async Task FaultInjector_MetadataRequests_BypassedByDefault() - { - var faultCount = 0; - _handler.FaultInjector = req => - { - // Skip query plan and metadata requests - if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) - return null; - if (req.RequestUri?.AbsolutePath == "/" || req.RequestUri?.AbsolutePath.Contains("/pkranges") == true) - return null; - faultCount++; - return null; // Don't actually fault, just count - }; - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - faultCount.Should().BeGreaterThan(0, "actual CRUD requests should reach the fault injector"); - } + private readonly InMemoryContainer _container; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _cosmosContainer; + + public FaultInjectorCanaryTests() + { + _container = new InMemoryContainer("fault-canary", "/partitionKey"); + _handler = new FakeCosmosHandler(_container); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _cosmosContainer = _client.GetContainer("fakeDb", "fault-canary"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + [Fact] + public async Task FaultInjector_Returns429_SdkReceivesCosmosException() + { + _handler.FaultInjector = req => + { + // Skip query plan requests + if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) + return null; + return new HttpResponseMessage((HttpStatusCode)429); + }; + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var act = () => _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => (int)e.StatusCode == 429); + } + + [Fact] + public async Task FaultInjector_ReturnsNull_RequestCompletesNormally() + { + _handler.FaultInjector = _ => null; // null = no fault + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Resource.Name.Should().Be("A"); + } + + [Fact] + public async Task FaultInjector_MetadataRequests_BypassedByDefault() + { + var faultCount = 0; + _handler.FaultInjector = req => + { + // Skip query plan and metadata requests + if (req.Headers.TryGetValues("x-ms-cosmos-is-query-plan-request", out _)) + return null; + if (req.RequestUri?.AbsolutePath == "/" || req.RequestUri?.AbsolutePath.Contains("/pkranges") == true) + return null; + faultCount++; + return null; // Don't actually fault, just count + }; + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + faultCount.Should().BeGreaterThan(0, "actual CRUD requests should reach the fault injector"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -456,82 +456,82 @@ await _container.CreateItemAsync( public class QueryPlanExpansionCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public QueryPlanExpansionCanaryTests() - { - _container = new InMemoryContainer("qp-expand", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "qp-expand"); - - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")).Wait(); - _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")).Wait(); - _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, new PartitionKey("pk2")).Wait(); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task QueryPlan_GroupBy_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT c.partitionKey, COUNT(1) as cnt FROM c GROUP BY c.partitionKey"); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact(Skip = "SDK ServiceInterop query plan parser does not support HAVING clause — returns SC1001 syntax error. The emulator supports HAVING but it cannot pass through the SDK pipeline.")] - public async Task QueryPlan_GroupBy_WithHaving_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT c.partitionKey, COUNT(1) as cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) >= 2"); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task QueryPlan_TopN_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator("SELECT TOP 1 * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task QueryPlan_OffsetLimit_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c OFFSET 0 LIMIT 1"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task QueryPlan_MultipleOrderBy_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC, c[\"value\"] DESC"); - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - all.Count.Should().Be(3); - all[0].Name.Should().Be("Alice"); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public QueryPlanExpansionCanaryTests() + { + _container = new InMemoryContainer("qp-expand", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "qp-expand"); + + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")).Wait(); + _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, new PartitionKey("pk1")).Wait(); + _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk2", Name = "Charlie", Value = 30 }, new PartitionKey("pk2")).Wait(); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task QueryPlan_GroupBy_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT c.partitionKey, COUNT(1) as cnt FROM c GROUP BY c.partitionKey"); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact(Skip = "SDK ServiceInterop query plan parser does not support HAVING clause — returns SC1001 syntax error. The emulator supports HAVING but it cannot pass through the SDK pipeline.")] + public async Task QueryPlan_GroupBy_WithHaving_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT c.partitionKey, COUNT(1) as cnt FROM c GROUP BY c.partitionKey HAVING COUNT(1) >= 2"); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task QueryPlan_TopN_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator("SELECT TOP 1 * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task QueryPlan_OffsetLimit_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c OFFSET 0 LIMIT 1"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task QueryPlan_MultipleOrderBy_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC, c[\"value\"] DESC"); + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + all.Count.Should().Be(3); + all[0].Name.Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -540,60 +540,60 @@ public async Task QueryPlan_MultipleOrderBy_AcceptedBySdk() public class SdkPipelineEdgeCaseTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelineEdgeCaseTests() - { - _container = new InMemoryContainer("edge-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "edge-pipe"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_UnicodeId_RoundTrips() - { - var id = "café-☕"; - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = "pk", Name = "Unicode" }, new PartitionKey("pk")); - var result = await _cosmosContainer.ReadItemAsync(id, new PartitionKey("pk")); - result.Resource.Id.Should().Be(id); - } - - [Fact] - public async Task SdkPipeline_SpecialCharId_RoundTrips() - { - var id = "item with spaces"; - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = id, PartitionKey = "pk", Name = "Special" }, new PartitionKey("pk")); - var result = await _cosmosContainer.ReadItemAsync(id, new PartitionKey("pk")); - result.Resource.Id.Should().Be(id); - } - - [Fact] - public async Task SdkPipeline_QueryEmptyContainer_ReturnsEmptyResults() - { - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(0); - } - - [Fact] - public async Task SdkPipeline_CountEmptyContainer_ReturnsZero() - { - var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); - count.Resource.Should().Be(0); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelineEdgeCaseTests() + { + _container = new InMemoryContainer("edge-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "edge-pipe"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_UnicodeId_RoundTrips() + { + var id = "café-☕"; + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk", Name = "Unicode" }, new PartitionKey("pk")); + var result = await _cosmosContainer.ReadItemAsync(id, new PartitionKey("pk")); + result.Resource.Id.Should().Be(id); + } + + [Fact] + public async Task SdkPipeline_SpecialCharId_RoundTrips() + { + var id = "item with spaces"; + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk", Name = "Special" }, new PartitionKey("pk")); + var result = await _cosmosContainer.ReadItemAsync(id, new PartitionKey("pk")); + result.Resource.Id.Should().Be(id); + } + + [Fact] + public async Task SdkPipeline_QueryEmptyContainer_ReturnsEmptyResults() + { + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(0); + } + + [Fact] + public async Task SdkPipeline_CountEmptyContainer_ReturnsZero() + { + var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); + count.Resource.Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -602,65 +602,65 @@ public async Task SdkPipeline_CountEmptyContainer_ReturnsZero() public class SdkPipelinePaginationTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkPipelinePaginationTests() - { - _container = new InMemoryContainer("page-pipe", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "page-pipe"); - - for (var i = 0; i < 10; i++) - _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i:D2}", Value = i }, - new PartitionKey("pk")).Wait(); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task SdkPipeline_Pagination_ManyPages_AllItemsReturned() - { - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - all.Count.Should().Be(10); - } - - [Fact] - public async Task SdkPipeline_OrderByPagination_MaintainsOrder() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name) - .Take(10) - .ToFeedIterator(); - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - all.Count.Should().Be(10); - // Items should be in alphabetical order - for (var i = 1; i < all.Count; i++) - string.Compare(all[i - 1].Name, all[i].Name, StringComparison.Ordinal) - .Should().BeLessThanOrEqualTo(0); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkPipelinePaginationTests() + { + _container = new InMemoryContainer("page-pipe", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "page-pipe"); + + for (var i = 0; i < 10; i++) + _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i:D2}", Value = i }, + new PartitionKey("pk")).Wait(); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task SdkPipeline_Pagination_ManyPages_AllItemsReturned() + { + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + all.Count.Should().Be(10); + } + + [Fact] + public async Task SdkPipeline_OrderByPagination_MaintainsOrder() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name) + .Take(10) + .ToFeedIterator(); + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + all.Count.Should().Be(10); + // Items should be in alphabetical order + for (var i = 1; i < all.Count; i++) + string.Compare(all[i - 1].Name, all[i].Name, StringComparison.Ordinal) + .Should().BeLessThanOrEqualTo(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -669,73 +669,73 @@ public async Task SdkPipeline_OrderByPagination_MaintainsOrder() public class MultiContainerRoutingCanaryTests : IDisposable { - private readonly InMemoryContainer _container1; - private readonly InMemoryContainer _container2; - private readonly FakeCosmosHandler _h1; - private readonly FakeCosmosHandler _h2; - private readonly HttpMessageHandler _router; - private readonly CosmosClient _client; - - public MultiContainerRoutingCanaryTests() - { - _container1 = new InMemoryContainer("users", "/partitionKey"); - _container2 = new InMemoryContainer("orders", "/partitionKey"); - _h1 = new FakeCosmosHandler(_container1); - _h2 = new FakeCosmosHandler(_container2); - _router = FakeCosmosHandler.CreateRouter(new Dictionary - { - ["users"] = _h1, - ["orders"] = _h2 - }); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_router) - }); - } - - public void Dispose() - { - _client.Dispose(); - _h1.Dispose(); - _h2.Dispose(); - } - - [Fact] - public async Task CreateRouter_MultipleContainers_RoutesToCorrectContainer() - { - var users = _client.GetContainer("db", "users"); - var orders = _client.GetContainer("db", "orders"); - - await users.CreateItemAsync( - new TestDocument { Id = "u1", PartitionKey = "pk", Name = "UserA" }, new PartitionKey("pk")); - await orders.CreateItemAsync( - new TestDocument { Id = "o1", PartitionKey = "pk", Name = "OrderA" }, new PartitionKey("pk")); - - var userRead = await users.ReadItemAsync("u1", new PartitionKey("pk")); - userRead.Resource.Name.Should().Be("UserA"); - - var orderRead = await orders.ReadItemAsync("o1", new PartitionKey("pk")); - orderRead.Resource.Name.Should().Be("OrderA"); - - // Users container shouldn't have orders - var act = () => users.ReadItemAsync("o1", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task CreateRouter_UnknownContainer_ThrowsInvalidOperationException() - { - var unknown = _client.GetContainer("db", "unknown"); - var act = () => unknown.ReadItemAsync("1", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == System.Net.HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container1; + private readonly InMemoryContainer _container2; + private readonly FakeCosmosHandler _h1; + private readonly FakeCosmosHandler _h2; + private readonly HttpMessageHandler _router; + private readonly CosmosClient _client; + + public MultiContainerRoutingCanaryTests() + { + _container1 = new InMemoryContainer("users", "/partitionKey"); + _container2 = new InMemoryContainer("orders", "/partitionKey"); + _h1 = new FakeCosmosHandler(_container1); + _h2 = new FakeCosmosHandler(_container2); + _router = FakeCosmosHandler.CreateRouter(new Dictionary + { + ["users"] = _h1, + ["orders"] = _h2 + }); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_router) + }); + } + + public void Dispose() + { + _client.Dispose(); + _h1.Dispose(); + _h2.Dispose(); + } + + [Fact] + public async Task CreateRouter_MultipleContainers_RoutesToCorrectContainer() + { + var users = _client.GetContainer("db", "users"); + var orders = _client.GetContainer("db", "orders"); + + await users.CreateItemAsync( + new TestDocument { Id = "u1", PartitionKey = "pk", Name = "UserA" }, new PartitionKey("pk")); + await orders.CreateItemAsync( + new TestDocument { Id = "o1", PartitionKey = "pk", Name = "OrderA" }, new PartitionKey("pk")); + + var userRead = await users.ReadItemAsync("u1", new PartitionKey("pk")); + userRead.Resource.Name.Should().Be("UserA"); + + var orderRead = await orders.ReadItemAsync("o1", new PartitionKey("pk")); + orderRead.Resource.Name.Should().Be("OrderA"); + + // Users container shouldn't have orders + var act = () => users.ReadItemAsync("o1", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateRouter_UnknownContainer_ThrowsInvalidOperationException() + { + var unknown = _client.GetContainer("db", "unknown"); + var act = () => unknown.ReadItemAsync("1", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == System.Net.HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -744,47 +744,47 @@ await act.Should().ThrowAsync() public class SdkCompatibilityCanaryDivergentTests { - [Fact(Skip = "Transactional batch operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] - public void TransactionalBatch_NotSupportedViaFakeCosmosHandlerHttpRoute() { } - - [Fact] - public async Task TransactionalBatch_WorksDirectlyOnInMemoryContainer_Divergence() - { - var container = new InMemoryContainer("batch-div", "/partitionKey"); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact(Skip = "Change feed operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] - public void ChangeFeed_NotSupportedViaFakeCosmosHandlerHttpRoute() { } - - [Fact] - public async Task ChangeFeed_WorksDirectlyOnInMemoryContainer_Divergence() - { - var container = new InMemoryContainer("cf-div", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var page = await iter.ReadNextAsync(); - page.Count.Should().Be(1); - } - - [Fact(Skip = "ReadMany operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] - public void ReadMany_NotSupportedViaFakeCosmosHandlerHttpRoute() { } - - [Fact] - public async Task ReadMany_WorksDirectlyOnInMemoryContainer_Divergence() - { - var container = new InMemoryContainer("rm-div", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var response = await container.ReadManyItemsAsync( - [("1", new PartitionKey("pk"))]); - response.Count.Should().Be(1); - } + [Fact(Skip = "Transactional batch operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] + public void TransactionalBatch_NotSupportedViaFakeCosmosHandlerHttpRoute() { } + + [Fact] + public async Task TransactionalBatch_WorksDirectlyOnInMemoryContainer_Divergence() + { + var container = new InMemoryContainer("batch-div", "/partitionKey"); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact(Skip = "Change feed operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] + public void ChangeFeed_NotSupportedViaFakeCosmosHandlerHttpRoute() { } + + [Fact] + public async Task ChangeFeed_WorksDirectlyOnInMemoryContainer_Divergence() + { + var container = new InMemoryContainer("cf-div", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var page = await iter.ReadNextAsync(); + page.Count.Should().Be(1); + } + + [Fact(Skip = "ReadMany operations bypass the FakeCosmosHandler HTTP route and execute directly on InMemoryContainer.")] + public void ReadMany_NotSupportedViaFakeCosmosHandlerHttpRoute() { } + + [Fact] + public async Task ReadMany_WorksDirectlyOnInMemoryContainer_Divergence() + { + var container = new InMemoryContainer("rm-div", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var response = await container.ReadManyItemsAsync( + [("1", new PartitionKey("pk"))]); + response.Count.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -793,55 +793,55 @@ await container.CreateItemAsync( public class SdkLoggingCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _cosmosContainer; - - public SdkLoggingCanaryTests() - { - _container = new InMemoryContainer("log-canary", "/partitionKey"); - _handler = new FakeCosmosHandler(_container); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) - }); - _cosmosContainer = _client.GetContainer("fakeDb", "log-canary"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - [Fact] - public async Task QueryLog_RecordsExecutedQueries() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var beforeCount = _handler.QueryLog.Count; - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - await iter.ReadNextAsync(); - - _handler.QueryLog.Count.Should().BeGreaterThan(beforeCount); - } - - [Fact] - public async Task RequestLog_RecordsAllRequests() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var beforeCount = _handler.RequestLog.Count; - await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - - _handler.RequestLog.Count.Should().BeGreaterThan(beforeCount); - } + private readonly InMemoryContainer _container; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _cosmosContainer; + + public SdkLoggingCanaryTests() + { + _container = new InMemoryContainer("log-canary", "/partitionKey"); + _handler = new FakeCosmosHandler(_container); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) + }); + _cosmosContainer = _client.GetContainer("fakeDb", "log-canary"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + [Fact] + public async Task QueryLog_RecordsExecutedQueries() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var beforeCount = _handler.QueryLog.Count; + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + await iter.ReadNextAsync(); + + _handler.QueryLog.Count.Should().BeGreaterThan(beforeCount); + } + + [Fact] + public async Task RequestLog_RecordsAllRequests() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var beforeCount = _handler.RequestLog.Count; + await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + + _handler.RequestLog.Count.Should().BeGreaterThan(beforeCount); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityTests.cs index 2b7931c..dcd99ae 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkCompatibilityTests.cs @@ -19,117 +19,117 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class SdkReflectionCompatibilityTests { - private static readonly Assembly _cosmosAssembly = typeof(Container).Assembly; - - // ── ChangeFeedProcessorBuilder reflection assumptions ── - - [Theory] - [InlineData("changeFeedProcessor")] - [InlineData("isBuilt")] - [InlineData("changeFeedLeaseOptions")] - [InlineData("changeFeedProcessorOptions")] - [InlineData("monitoredContainer")] - [InlineData("applyBuilderConfiguration")] - public void ChangeFeedProcessorBuilder_HasExpectedPrivateField(string fieldName) - { - typeof(ChangeFeedProcessorBuilder) - .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) - .Should().NotBeNull( - $"InMemoryChangeFeedProcessor relies on private field '{fieldName}'. " + - "If this field was renamed or removed, the reflection-based builder will " + - "fall back to an NSubstitute stub that does not poll the change feed."); - } - - [Theory] - [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions")] - [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions")] - [InlineData("Microsoft.Azure.Cosmos.ContainerInlineCore")] - [InlineData("Microsoft.Azure.Cosmos.ContainerInternal")] - [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager")] - public void CosmosAssembly_ContainsExpectedInternalType(string typeName) - { - _cosmosAssembly.GetType(typeName) - .Should().NotBeNull( - $"InMemoryChangeFeedProcessor relies on internal type '{typeName}'. " + - "If removed, the builder factory will fall back to an NSubstitute stub."); - } - - [Fact] - public void ChangeFeedProcessorBuilderFactory_IsReflectionCompatible() - { - ChangeFeedProcessorBuilderFactory.IsReflectionCompatible() - .Should().BeTrue( - "All reflection assumptions for ChangeFeedProcessorBuilder are met. " + - "If this fails, the builder will fall back to a stub but change feed " + - "processor tests relying on handler invocation may not work."); - } - - // ── PatchOperation public API assumptions ── - - [Fact] - public void PatchOperation_HasPublicPathProperty() - { - var operation = PatchOperation.Set("/test", "value"); - - operation.Path.Should().Be("/test", - "InMemoryContainer uses PatchOperation.Path directly. " + - "If this property is removed, patch operations will break."); - } - - [Fact] - public void PatchOperation_ConcreteType_HasPublicValueProperty() - { - var operation = PatchOperation.Set("/name", "Alice"); - var valueProp = operation.GetType() - .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); - - valueProp.Should().NotBeNull( - "InMemoryContainer reads PatchOperation.Value via reflection on the " + - "concrete type. If this property is renamed or made non-public, patch " + - "Set/Add/Replace/Increment operations will fail."); - } - - // ── NSubstitute proxy-ability (types must remain non-sealed) ── - - [Theory] - [InlineData(typeof(ItemResponse), "CRUD responses")] - [InlineData(typeof(CosmosDiagnostics), "all response diagnostics")] - [InlineData(typeof(Scripts), "stored procedures, triggers, UDFs")] - [InlineData(typeof(StoredProcedureResponse), "sproc creation")] - [InlineData(typeof(StoredProcedureExecuteResponse), "sproc execution")] - [InlineData(typeof(TriggerResponse), "trigger creation")] - [InlineData(typeof(UserDefinedFunctionResponse), "UDF creation")] - [InlineData(typeof(ContainerResponse), "container management")] - [InlineData(typeof(ThroughputResponse), "throughput operations")] - [InlineData(typeof(Database), "Database property")] - [InlineData(typeof(Conflicts), "Conflicts property")] - [InlineData(typeof(FeedIterator), "stream feed iterators")] - [InlineData(typeof(FeedRange), "feed ranges")] - [InlineData(typeof(ChangeFeedEstimator), "change feed estimation")] - [InlineData(typeof(ChangeFeedProcessorBuilder), "change feed processor fallback")] - public void SdkType_IsNotSealed_ForNSubstituteProxying(Type sdkType, string usedFor) - { - sdkType.IsSealed.Should().BeFalse( - $"InMemoryContainer uses NSubstitute.For<{sdkType.Name}>() for {usedFor}. " + - "If this type becomes sealed, NSubstitute cannot create a proxy and the " + - "feature will need a concrete implementation or alternative approach."); - } - - // ── QueryDefinition parameter extraction ── - - [Fact] - public void QueryDefinition_GetQueryParameters_IsAvailable() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", "test"); - - var parameters = query.GetQueryParameters(); - - parameters.Should().ContainSingle() - .Which.Should().Be(("@id", (object)"test"), - "InMemoryContainer uses GetQueryParameters() as the primary path for " + - "extracting parameterised query values. Reflection is only a fallback."); - } + private static readonly Assembly _cosmosAssembly = typeof(Container).Assembly; + + // ── ChangeFeedProcessorBuilder reflection assumptions ── + + [Theory] + [InlineData("changeFeedProcessor")] + [InlineData("isBuilt")] + [InlineData("changeFeedLeaseOptions")] + [InlineData("changeFeedProcessorOptions")] + [InlineData("monitoredContainer")] + [InlineData("applyBuilderConfiguration")] + public void ChangeFeedProcessorBuilder_HasExpectedPrivateField(string fieldName) + { + typeof(ChangeFeedProcessorBuilder) + .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) + .Should().NotBeNull( + $"InMemoryChangeFeedProcessor relies on private field '{fieldName}'. " + + "If this field was renamed or removed, the reflection-based builder will " + + "fall back to an NSubstitute stub that does not poll the change feed."); + } + + [Theory] + [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions")] + [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions")] + [InlineData("Microsoft.Azure.Cosmos.ContainerInlineCore")] + [InlineData("Microsoft.Azure.Cosmos.ContainerInternal")] + [InlineData("Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager")] + public void CosmosAssembly_ContainsExpectedInternalType(string typeName) + { + _cosmosAssembly.GetType(typeName) + .Should().NotBeNull( + $"InMemoryChangeFeedProcessor relies on internal type '{typeName}'. " + + "If removed, the builder factory will fall back to an NSubstitute stub."); + } + + [Fact] + public void ChangeFeedProcessorBuilderFactory_IsReflectionCompatible() + { + ChangeFeedProcessorBuilderFactory.IsReflectionCompatible() + .Should().BeTrue( + "All reflection assumptions for ChangeFeedProcessorBuilder are met. " + + "If this fails, the builder will fall back to a stub but change feed " + + "processor tests relying on handler invocation may not work."); + } + + // ── PatchOperation public API assumptions ── + + [Fact] + public void PatchOperation_HasPublicPathProperty() + { + var operation = PatchOperation.Set("/test", "value"); + + operation.Path.Should().Be("/test", + "InMemoryContainer uses PatchOperation.Path directly. " + + "If this property is removed, patch operations will break."); + } + + [Fact] + public void PatchOperation_ConcreteType_HasPublicValueProperty() + { + var operation = PatchOperation.Set("/name", "Alice"); + var valueProp = operation.GetType() + .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + + valueProp.Should().NotBeNull( + "InMemoryContainer reads PatchOperation.Value via reflection on the " + + "concrete type. If this property is renamed or made non-public, patch " + + "Set/Add/Replace/Increment operations will fail."); + } + + // ── NSubstitute proxy-ability (types must remain non-sealed) ── + + [Theory] + [InlineData(typeof(ItemResponse), "CRUD responses")] + [InlineData(typeof(CosmosDiagnostics), "all response diagnostics")] + [InlineData(typeof(Scripts), "stored procedures, triggers, UDFs")] + [InlineData(typeof(StoredProcedureResponse), "sproc creation")] + [InlineData(typeof(StoredProcedureExecuteResponse), "sproc execution")] + [InlineData(typeof(TriggerResponse), "trigger creation")] + [InlineData(typeof(UserDefinedFunctionResponse), "UDF creation")] + [InlineData(typeof(ContainerResponse), "container management")] + [InlineData(typeof(ThroughputResponse), "throughput operations")] + [InlineData(typeof(Database), "Database property")] + [InlineData(typeof(Conflicts), "Conflicts property")] + [InlineData(typeof(FeedIterator), "stream feed iterators")] + [InlineData(typeof(FeedRange), "feed ranges")] + [InlineData(typeof(ChangeFeedEstimator), "change feed estimation")] + [InlineData(typeof(ChangeFeedProcessorBuilder), "change feed processor fallback")] + public void SdkType_IsNotSealed_ForNSubstituteProxying(Type sdkType, string usedFor) + { + sdkType.IsSealed.Should().BeFalse( + $"InMemoryContainer uses NSubstitute.For<{sdkType.Name}>() for {usedFor}. " + + "If this type becomes sealed, NSubstitute cannot create a proxy and the " + + "feature will need a concrete implementation or alternative approach."); + } + + // ── QueryDefinition parameter extraction ── + + [Fact] + public void QueryDefinition_GetQueryParameters_IsAvailable() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", "test"); + + var parameters = query.GetQueryParameters(); + + parameters.Should().ContainSingle() + .Which.Should().Be(("@id", (object)"test"), + "InMemoryContainer uses GetQueryParameters() as the primary path for " + + "extracting parameterised query values. Reflection is only a fallback."); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -138,272 +138,272 @@ public void QueryDefinition_GetQueryParameters_IsAvailable() public class PatchOperationReflectionTests { - [Fact] - public void PatchOperation_Move_ConcreteType_HasFromProperty() - { - var operation = PatchOperation.Move("/source", "/destination"); - var fromProp = operation.GetType() - .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - - fromProp.Should().NotBeNull( - "InMemoryContainer uses reflection to read PatchOperation.From. " + - "If this property is removed, Move operations will silently do nothing."); - fromProp!.GetValue(operation).Should().Be("/source"); - } - - [Fact] - public void PatchOperation_Set_GetPatchValue_ReturnsExpectedValue() - { - var operation = PatchOperation.Set("/name", "Alice"); - var valueProp = operation.GetType() - .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); - - valueProp.Should().NotBeNull(); - valueProp!.GetValue(operation).Should().Be("Alice", - "GetPatchValue() uses this reflection path to extract the value."); - } - - [Fact] - public void PatchOperation_Move_GetPatchSourcePath_ReturnsFromPath() - { - var operation = PatchOperation.Move("/source", "/destination"); - var fromProp = operation.GetType() - .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - - fromProp.Should().NotBeNull(); - fromProp!.GetValue(operation).Should().Be("/source", - "GetPatchSourcePath() uses this reflection path for Move operations."); - } - - [Fact] - public void PatchOperation_Remove_DoesNotHavePublicValueProperty() - { - var operation = PatchOperation.Remove("/test"); - var valueProp = operation.GetType() - .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); - - valueProp.Should().BeNull( - "PatchOperation.Remove should not have a Value property. " + - "GetPatchValue() will correctly return null for Remove operations."); - } + [Fact] + public void PatchOperation_Move_ConcreteType_HasFromProperty() + { + var operation = PatchOperation.Move("/source", "/destination"); + var fromProp = operation.GetType() + .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + fromProp.Should().NotBeNull( + "InMemoryContainer uses reflection to read PatchOperation.From. " + + "If this property is removed, Move operations will silently do nothing."); + fromProp!.GetValue(operation).Should().Be("/source"); + } + + [Fact] + public void PatchOperation_Set_GetPatchValue_ReturnsExpectedValue() + { + var operation = PatchOperation.Set("/name", "Alice"); + var valueProp = operation.GetType() + .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + + valueProp.Should().NotBeNull(); + valueProp!.GetValue(operation).Should().Be("Alice", + "GetPatchValue() uses this reflection path to extract the value."); + } + + [Fact] + public void PatchOperation_Move_GetPatchSourcePath_ReturnsFromPath() + { + var operation = PatchOperation.Move("/source", "/destination"); + var fromProp = operation.GetType() + .GetProperty("From", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + fromProp.Should().NotBeNull(); + fromProp!.GetValue(operation).Should().Be("/source", + "GetPatchSourcePath() uses this reflection path for Move operations."); + } + + [Fact] + public void PatchOperation_Remove_DoesNotHavePublicValueProperty() + { + var operation = PatchOperation.Remove("/test"); + var valueProp = operation.GetType() + .GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + + valueProp.Should().BeNull( + "PatchOperation.Remove should not have a Value property. " + + "GetPatchValue() will correctly return null for Remove operations."); + } } public class ChangeFeedStartFromReflectionTests { - [Theory] - [InlineData("Beginning")] - [InlineData("Now")] - [InlineData("Time")] - public void ChangeFeedStartFrom_SubtypeName_ContainsExpectedKeyword(string expectedKeyword) - { - var startFrom = expectedKeyword switch - { - "Beginning" => ChangeFeedStartFrom.Beginning(), - "Now" => ChangeFeedStartFrom.Now(), - "Time" => ChangeFeedStartFrom.Time(DateTime.UtcNow), - _ => throw new ArgumentException(expectedKeyword) - }; - - startFrom.GetType().Name.Should().Contain(expectedKeyword, - $"InMemoryContainer dispatches change feed behaviour based on " + - $"GetType().Name containing '{expectedKeyword}'. If the SDK renames " + - "this subtype, change feed iterators will misroute."); - } - - [Fact] - public void ChangeFeedStartFrom_Time_HasDateTimePropertyOrField() - { - var startFrom = ChangeFeedStartFrom.Time(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - var type = startFrom.GetType(); - - var dateTimeMembers = type - .GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.PropertyType == typeof(DateTime)) - .Cast() - .Concat(type - .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .Where(f => f.FieldType == typeof(DateTime))) - .ToList(); - - dateTimeMembers.Should().NotBeEmpty( - "InMemoryContainer.ExtractStartTime() uses reflection to find a DateTime " + - "property or field on ChangeFeedStartFrom.Time subtypes. If none exist, " + - "time-based change feed filtering will not work."); - } - - [Theory] - [InlineData("Beginning")] - [InlineData("Now")] - [InlineData("Time")] - public async Task ChangeFeedStartFrom_HasFeedRangePropertyOrField(string startType) - { - var container = new InMemoryContainer("feed-range-test", "/pk"); - var ranges = await container.GetFeedRangesAsync(); - var feedRange = ranges.First(); - var startFrom = startType switch - { - "Beginning" => ChangeFeedStartFrom.Beginning(feedRange), - "Now" => ChangeFeedStartFrom.Now(feedRange), - "Time" => ChangeFeedStartFrom.Time(DateTime.UtcNow, feedRange), - _ => throw new ArgumentException(startType) - }; - - var type = startFrom.GetType(); - var feedRangeMembers = type - .GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) - .Where(p => typeof(FeedRange).IsAssignableFrom(p.PropertyType)) - .Cast() - .Concat(type - .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .Where(f => typeof(FeedRange).IsAssignableFrom(f.FieldType))) - .ToList(); - - feedRangeMembers.Should().NotBeEmpty( - $"InMemoryContainer.ExtractFeedRangeFromStartFrom() uses reflection to find a " + - $"FeedRange property/field on ChangeFeedStartFrom.{startType} subtypes. " + - "If none exist, feed-range-scoped change feeds will ignore the range filter."); - } + [Theory] + [InlineData("Beginning")] + [InlineData("Now")] + [InlineData("Time")] + public void ChangeFeedStartFrom_SubtypeName_ContainsExpectedKeyword(string expectedKeyword) + { + var startFrom = expectedKeyword switch + { + "Beginning" => ChangeFeedStartFrom.Beginning(), + "Now" => ChangeFeedStartFrom.Now(), + "Time" => ChangeFeedStartFrom.Time(DateTime.UtcNow), + _ => throw new ArgumentException(expectedKeyword) + }; + + startFrom.GetType().Name.Should().Contain(expectedKeyword, + $"InMemoryContainer dispatches change feed behaviour based on " + + $"GetType().Name containing '{expectedKeyword}'. If the SDK renames " + + "this subtype, change feed iterators will misroute."); + } + + [Fact] + public void ChangeFeedStartFrom_Time_HasDateTimePropertyOrField() + { + var startFrom = ChangeFeedStartFrom.Time(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var type = startFrom.GetType(); + + var dateTimeMembers = type + .GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType == typeof(DateTime)) + .Cast() + .Concat(type + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.FieldType == typeof(DateTime))) + .ToList(); + + dateTimeMembers.Should().NotBeEmpty( + "InMemoryContainer.ExtractStartTime() uses reflection to find a DateTime " + + "property or field on ChangeFeedStartFrom.Time subtypes. If none exist, " + + "time-based change feed filtering will not work."); + } + + [Theory] + [InlineData("Beginning")] + [InlineData("Now")] + [InlineData("Time")] + public async Task ChangeFeedStartFrom_HasFeedRangePropertyOrField(string startType) + { + var container = new InMemoryContainer("feed-range-test", "/pk"); + var ranges = await container.GetFeedRangesAsync(); + var feedRange = ranges.First(); + var startFrom = startType switch + { + "Beginning" => ChangeFeedStartFrom.Beginning(feedRange), + "Now" => ChangeFeedStartFrom.Now(feedRange), + "Time" => ChangeFeedStartFrom.Time(DateTime.UtcNow, feedRange), + _ => throw new ArgumentException(startType) + }; + + var type = startFrom.GetType(); + var feedRangeMembers = type + .GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) + .Where(p => typeof(FeedRange).IsAssignableFrom(p.PropertyType)) + .Cast() + .Concat(type + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => typeof(FeedRange).IsAssignableFrom(f.FieldType))) + .ToList(); + + feedRangeMembers.Should().NotBeEmpty( + $"InMemoryContainer.ExtractFeedRangeFromStartFrom() uses reflection to find a " + + $"FeedRange property/field on ChangeFeedStartFrom.{startType} subtypes. " + + "If none exist, feed-range-scoped change feeds will ignore the range filter."); + } } public class QueryDefinitionReflectionTests { - [Fact] - public void QueryDefinition_HasInternalFieldContainingParameterInName() - { - var field = typeof(QueryDefinition) - .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(f => f.Name.Contains("parameter", StringComparison.OrdinalIgnoreCase)); - - field.Should().NotBeNull( - "InMemoryContainer uses a reflection fallback that looks for an internal field " + - "with 'parameter' in its name on QueryDefinition. This is secondary to " + - "GetQueryParameters() but guards against older SDK versions."); - } - - [Fact] - public void QueryDefinition_GetQueryParameters_MultipleParams_AllExtracted() - { - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.age = @age") - .WithParameter("@name", "Alice") - .WithParameter("@age", 30); - - var parameters = query.GetQueryParameters(); - - parameters.Should().HaveCount(2); - parameters.Should().Contain(("@name", (object)"Alice")); - parameters.Should().Contain(("@age", (object)30)); - } + [Fact] + public void QueryDefinition_HasInternalFieldContainingParameterInName() + { + var field = typeof(QueryDefinition) + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(f => f.Name.Contains("parameter", StringComparison.OrdinalIgnoreCase)); + + field.Should().NotBeNull( + "InMemoryContainer uses a reflection fallback that looks for an internal field " + + "with 'parameter' in its name on QueryDefinition. This is secondary to " + + "GetQueryParameters() but guards against older SDK versions."); + } + + [Fact] + public void QueryDefinition_GetQueryParameters_MultipleParams_AllExtracted() + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name AND c.age = @age") + .WithParameter("@name", "Alice") + .WithParameter("@age", 30); + + var parameters = query.GetQueryParameters(); + + parameters.Should().HaveCount(2); + parameters.Should().Contain(("@name", (object)"Alice")); + parameters.Should().Contain(("@age", (object)30)); + } } public class AccountPropertiesReflectionTests { - [Fact] - public void AccountProperties_HasNonPublicParameterlessConstructor() - { - var ctor = typeof(AccountProperties).GetConstructor( - BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null); - - ctor.Should().NotBeNull( - "InMemoryCosmosClient.ReadAccountAsync() creates AccountProperties via " + - "Activator.CreateInstance(nonPublic: true). If the constructor is removed, " + - "it falls back to NSubstitute."); - } - - [Fact] - public void AccountProperties_Id_HasPublicSettableProperty() - { - var idProp = typeof(AccountProperties).GetProperty(nameof(AccountProperties.Id)); - - idProp.Should().NotBeNull(); - idProp!.SetMethod.Should().NotBeNull( - "InMemoryCosmosClient.ReadAccountAsync() sets AccountProperties.Id via " + - "reflection. If the setter is removed, the account ID will be null."); - } + [Fact] + public void AccountProperties_HasNonPublicParameterlessConstructor() + { + var ctor = typeof(AccountProperties).GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null); + + ctor.Should().NotBeNull( + "InMemoryCosmosClient.ReadAccountAsync() creates AccountProperties via " + + "Activator.CreateInstance(nonPublic: true). If the constructor is removed, " + + "it falls back to NSubstitute."); + } + + [Fact] + public void AccountProperties_Id_HasPublicSettableProperty() + { + var idProp = typeof(AccountProperties).GetProperty(nameof(AccountProperties.Id)); + + idProp.Should().NotBeNull(); + idProp!.SetMethod.Should().NotBeNull( + "InMemoryCosmosClient.ReadAccountAsync() sets AccountProperties.Id via " + + "reflection. If the setter is removed, the account ID will be null."); + } } public class ChangeFeedInternalTypeReflectionTests { - private static readonly Assembly _cosmosAssembly = typeof(Container).Assembly; - - [Fact] - public void ChangeFeedLeaseOptions_HasLeasePrefixProperty() - { - var type = _cosmosAssembly.GetType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); - type.Should().NotBeNull(); - - var leasePrefixProp = type!.GetProperty("LeasePrefix"); - leasePrefixProp.Should().NotBeNull( - "ChangeFeedProcessorBuilderFactory sets LeasePrefix on ChangeFeedLeaseOptions. " + - "If renamed, the change feed processor will use a default prefix."); - } - - [Fact] - public void ChangeFeedProcessorOptions_HasNonPublicConstructor() - { - var type = _cosmosAssembly.GetType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); - type.Should().NotBeNull(); - - var ctor = type!.GetConstructor( - BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null) - ?? type.GetConstructor( - BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); - - ctor.Should().NotBeNull( - "ChangeFeedProcessorBuilderFactory creates ChangeFeedProcessorOptions via " + - "Activator.CreateInstance(nonPublic: true). If this constructor is removed, " + - "the builder factory falls back to NSubstitute."); - } - - [Fact] - public void ChangeFeedProcessorBuilderFactory_CanConstructApplyConfigDelegate() - { - var leaseStoreManagerType = _cosmosAssembly.GetType( - "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager"); - var containerInternalType = _cosmosAssembly.GetType("Microsoft.Azure.Cosmos.ContainerInternal"); - var leaseOptionsType = _cosmosAssembly.GetType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); - var processorOptionsType = _cosmosAssembly.GetType( - "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); - - var allTypes = new[] { leaseStoreManagerType, containerInternalType, leaseOptionsType, processorOptionsType }; - allTypes.Should().NotContainNulls(); - - var actionType = typeof(Action<,,,,,>).MakeGenericType( - leaseStoreManagerType!, - containerInternalType!, - typeof(string), - leaseOptionsType!, - processorOptionsType!, - containerInternalType!); - - actionType.Should().NotBeNull( - "ChangeFeedProcessorBuilderFactory constructs a 6-parameter Action delegate " + - "for the applyBuilderConfiguration field. If any of these internal types change, " + - "the delegate cannot be built."); - - actionType.GetMethod("Invoke")!.GetParameters().Should().HaveCount(6); - } + private static readonly Assembly _cosmosAssembly = typeof(Container).Assembly; + + [Fact] + public void ChangeFeedLeaseOptions_HasLeasePrefixProperty() + { + var type = _cosmosAssembly.GetType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); + type.Should().NotBeNull(); + + var leasePrefixProp = type!.GetProperty("LeasePrefix"); + leasePrefixProp.Should().NotBeNull( + "ChangeFeedProcessorBuilderFactory sets LeasePrefix on ChangeFeedLeaseOptions. " + + "If renamed, the change feed processor will use a default prefix."); + } + + [Fact] + public void ChangeFeedProcessorOptions_HasNonPublicConstructor() + { + var type = _cosmosAssembly.GetType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); + type.Should().NotBeNull(); + + var ctor = type!.GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null) + ?? type.GetConstructor( + BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); + + ctor.Should().NotBeNull( + "ChangeFeedProcessorBuilderFactory creates ChangeFeedProcessorOptions via " + + "Activator.CreateInstance(nonPublic: true). If this constructor is removed, " + + "the builder factory falls back to NSubstitute."); + } + + [Fact] + public void ChangeFeedProcessorBuilderFactory_CanConstructApplyConfigDelegate() + { + var leaseStoreManagerType = _cosmosAssembly.GetType( + "Microsoft.Azure.Cosmos.ChangeFeed.LeaseManagement.DocumentServiceLeaseStoreManager"); + var containerInternalType = _cosmosAssembly.GetType("Microsoft.Azure.Cosmos.ContainerInternal"); + var leaseOptionsType = _cosmosAssembly.GetType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedLeaseOptions"); + var processorOptionsType = _cosmosAssembly.GetType( + "Microsoft.Azure.Cosmos.ChangeFeed.Configuration.ChangeFeedProcessorOptions"); + + var allTypes = new[] { leaseStoreManagerType, containerInternalType, leaseOptionsType, processorOptionsType }; + allTypes.Should().NotContainNulls(); + + var actionType = typeof(Action<,,,,,>).MakeGenericType( + leaseStoreManagerType!, + containerInternalType!, + typeof(string), + leaseOptionsType!, + processorOptionsType!, + containerInternalType!); + + actionType.Should().NotBeNull( + "ChangeFeedProcessorBuilderFactory constructs a 6-parameter Action delegate " + + "for the applyBuilderConfiguration field. If any of these internal types change, " + + "the delegate cannot be built."); + + actionType.GetMethod("Invoke")!.GetParameters().Should().HaveCount(6); + } } public class FeedIteratorSetupReflectionTests { - [Fact] - public void InMemoryFeedIteratorSetup_CreateMethod_IsResolvable() - { - var method = typeof(InMemoryFeedIteratorSetup) - .GetMethod("CreateInMemoryFeedIterator", BindingFlags.NonPublic | BindingFlags.Static); - - method.Should().NotBeNull( - "InMemoryFeedIteratorSetup.Register() reflects on its own " + - "CreateInMemoryFeedIterator method to build a generic factory. " + - "If renamed, LINQ ToFeedIteratorOverridable() calls will throw."); - - method!.IsGenericMethodDefinition.Should().BeTrue( - "CreateInMemoryFeedIterator should be generic so it can be " + - "MakeGenericMethod'd for any element type T."); - } + [Fact] + public void InMemoryFeedIteratorSetup_CreateMethod_IsResolvable() + { + var method = typeof(InMemoryFeedIteratorSetup) + .GetMethod("CreateInMemoryFeedIterator", BindingFlags.NonPublic | BindingFlags.Static); + + method.Should().NotBeNull( + "InMemoryFeedIteratorSetup.Register() reflects on its own " + + "CreateInMemoryFeedIterator method to build a generic factory. " + + "If renamed, LINQ ToFeedIteratorOverridable() calls will throw."); + + method!.IsGenericMethodDefinition.Should().BeTrue( + "CreateInMemoryFeedIterator should be generic so it can be " + + "MakeGenericMethod'd for any element type T."); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -412,21 +412,21 @@ public void InMemoryFeedIteratorSetup_CreateMethod_IsResolvable() public class SdkSealedTypeCanaryTests { - [Theory] - [InlineData(typeof(DatabaseResponse), "database CRUD responses")] - [InlineData(typeof(UserResponse), "user management")] - [InlineData(typeof(PermissionResponse), "permission management")] - [InlineData(typeof(TransactionalBatchResponse), "batch execution")] - [InlineData(typeof(TransactionalBatchOperationResult), "batch operation results")] - [InlineData(typeof(TransactionalBatchOperationResult), "typed batch results")] - [InlineData(typeof(AccountProperties), "ReadAccountAsync fallback")] - public void SdkType_IsNotSealed_ForNSubstituteProxying(Type sdkType, string usedFor) - { - sdkType.IsSealed.Should().BeFalse( - $"InMemoryContainer uses NSubstitute.For<{sdkType.Name}>() for {usedFor}. " + - "If this type becomes sealed, NSubstitute cannot create a proxy and the " + - "feature will need a concrete implementation or alternative approach."); - } + [Theory] + [InlineData(typeof(DatabaseResponse), "database CRUD responses")] + [InlineData(typeof(UserResponse), "user management")] + [InlineData(typeof(PermissionResponse), "permission management")] + [InlineData(typeof(TransactionalBatchResponse), "batch execution")] + [InlineData(typeof(TransactionalBatchOperationResult), "batch operation results")] + [InlineData(typeof(TransactionalBatchOperationResult), "typed batch results")] + [InlineData(typeof(AccountProperties), "ReadAccountAsync fallback")] + public void SdkType_IsNotSealed_ForNSubstituteProxying(Type sdkType, string usedFor) + { + sdkType.IsSealed.Should().BeFalse( + $"InMemoryContainer uses NSubstitute.For<{sdkType.Name}>() for {usedFor}. " + + "If this type becomes sealed, NSubstitute cannot create a proxy and the " + + "feature will need a concrete implementation or alternative approach."); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -435,24 +435,24 @@ public void SdkType_IsNotSealed_ForNSubstituteProxying(Type sdkType, string used public class SdkCompatibilityIntegrationTests { - [Fact] - public async Task FakeCosmosHandler_VerifySdkCompatibilityAsync_Passes() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public void ChangeFeedProcessorBuilderFactory_Create_ReturnsWorkingBuilder() - { - var processor = Substitute.For(); - var builder = ChangeFeedProcessorBuilderFactory.Create("test-processor", processor); - - builder.Should().NotBeNull(); - - // Validate builder supports fluent API — WithInstanceName doesn't need ContainerInternal - var configured = builder.WithInstanceName("instance-1"); - configured.Should().NotBeNull(); - } + [Fact] + public async Task FakeCosmosHandler_VerifySdkCompatibilityAsync_Passes() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public void ChangeFeedProcessorBuilderFactory_Create_ReturnsWorkingBuilder() + { + var processor = Substitute.For(); + var builder = ChangeFeedProcessorBuilderFactory.Create("test-processor", processor); + + builder.Should().NotBeNull(); + + // Validate builder supports fluent API — WithInstanceName doesn't need ContainerInternal + var configured = builder.WithInstanceName("instance-1"); + configured.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -461,38 +461,38 @@ public void ChangeFeedProcessorBuilderFactory_Create_ReturnsWorkingBuilder() public class AdditionalSealedTypeCanaryTests { - [Fact] - public void CosmosResponseFactory_IsNotSealed() - { - var type = typeof(CosmosClient).Assembly.GetTypes() - .FirstOrDefault(t => t.Name == "CosmosResponseFactory"); - type.Should().NotBeNull("CosmosResponseFactory should exist in the SDK assembly"); - type!.IsSealed.Should().BeFalse("InMemoryCosmosClient uses NSubstitute.For()"); - } - - [Fact] - public void ResponseMessage_HasPublicConstructor_TakingHttpStatusCode() - { - var ctor = typeof(ResponseMessage).GetConstructors() - .FirstOrDefault(c => c.GetParameters().Any(p => p.ParameterType == typeof(HttpStatusCode))); - ctor.Should().NotBeNull("FakeCosmosHandler creates ResponseMessage via new ResponseMessage(statusCode)"); - } - - [Fact] - public void ResponseMessage_HasPublicContentSetter() - { - var prop = typeof(ResponseMessage).GetProperty(nameof(ResponseMessage.Content)); - prop.Should().NotBeNull(); - prop!.CanWrite.Should().BeTrue("FakeCosmosHandler sets ResponseMessage.Content in stream responses"); - } - - [Fact] - public void ResponseMessage_Headers_SupportsIndexerSet() - { - var response = new ResponseMessage(HttpStatusCode.OK); - response.Headers["x-ms-test"] = "value"; - response.Headers["x-ms-test"].Should().Be("value"); - } + [Fact] + public void CosmosResponseFactory_IsNotSealed() + { + var type = typeof(CosmosClient).Assembly.GetTypes() + .FirstOrDefault(t => t.Name == "CosmosResponseFactory"); + type.Should().NotBeNull("CosmosResponseFactory should exist in the SDK assembly"); + type!.IsSealed.Should().BeFalse("InMemoryCosmosClient uses NSubstitute.For()"); + } + + [Fact] + public void ResponseMessage_HasPublicConstructor_TakingHttpStatusCode() + { + var ctor = typeof(ResponseMessage).GetConstructors() + .FirstOrDefault(c => c.GetParameters().Any(p => p.ParameterType == typeof(HttpStatusCode))); + ctor.Should().NotBeNull("FakeCosmosHandler creates ResponseMessage via new ResponseMessage(statusCode)"); + } + + [Fact] + public void ResponseMessage_HasPublicContentSetter() + { + var prop = typeof(ResponseMessage).GetProperty(nameof(ResponseMessage.Content)); + prop.Should().NotBeNull(); + prop!.CanWrite.Should().BeTrue("FakeCosmosHandler sets ResponseMessage.Content in stream responses"); + } + + [Fact] + public void ResponseMessage_Headers_SupportsIndexerSet() + { + var response = new ResponseMessage(HttpStatusCode.OK); + response.Headers["x-ms-test"] = "value"; + response.Headers["x-ms-test"].Should().Be("value"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -504,373 +504,373 @@ public void ResponseMessage_Headers_SupportsIndexerSet() /// internal class HeaderCapturingHandler : DelegatingHandler { - public List CapturedRequests { get; } = new(); + public List CapturedRequests { get; } = new(); - public HeaderCapturingHandler(HttpMessageHandler inner) : base(inner) { } + public HeaderCapturingHandler(HttpMessageHandler inner) : base(inner) { } - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - CapturedRequests.Add(request); - return await base.SendAsync(request, cancellationToken); - } + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequests.Add(request); + return await base.SendAsync(request, cancellationToken); + } } internal static class SdkTestHelper { - public static (CosmosClient Client, HeaderCapturingHandler Capturer, FakeCosmosHandler Handler) - CreateCapturingClient(InMemoryContainer container) - { - var handler = new FakeCosmosHandler(container); - var capturer = new HeaderCapturingHandler(handler); - var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(capturer) { Timeout = TimeSpan.FromSeconds(10) } - }); - return (client, capturer, handler); - } + public static (CosmosClient Client, HeaderCapturingHandler Capturer, FakeCosmosHandler Handler) + CreateCapturingClient(InMemoryContainer container) + { + var handler = new FakeCosmosHandler(container); + var capturer = new HeaderCapturingHandler(handler); + var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(capturer) { Timeout = TimeSpan.FromSeconds(10) } + }); + return (client, capturer, handler); + } } public class SdkRequestHeaderCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkRequestHeaderCanaryTests() - { - _container = new InMemoryContainer("header-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "header-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task Sdk_SendsPartitionKeyHeader_ForPointOperations() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - - _capturer.CapturedRequests - .Any(r => r.Headers.Contains("x-ms-documentdb-partitionkey")) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsUpsertHeader_ForUpsertOperation() - { - await _cosmosContainer.UpsertItemAsync( - new TestDocument { Id = "up1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - _capturer.CapturedRequests - .Any(r => r.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var v) && - v.Any(h => h.Equals("True", StringComparison.OrdinalIgnoreCase))) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsIsQueryHeader_ForParameterisedQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = @n").WithParameter("@n", "A")); - await iter.ReadNextAsync(); - - _capturer.CapturedRequests - .Any(r => r.Headers.Contains("x-ms-documentdb-isquery") || - (r.Content?.Headers.ContentType?.MediaType?.Contains("query+json") ?? false)) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsMaxItemCountHeader_WhenSetInOptions() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); - await iter.ReadNextAsync(); - - _capturer.CapturedRequests - .Any(r => r.Headers.TryGetValues("x-ms-max-item-count", out var v) && v.Contains("1")) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_UsesQueryJsonContentType_ForQueryRequests() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - await iter.ReadNextAsync(); - - _capturer.CapturedRequests - .Any(r => r.Content?.Headers.ContentType?.MediaType?.Contains("query+json") ?? false) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsContinuationHeader_ForSubsequentPages() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); - await iter.ReadNextAsync(); - if (iter.HasMoreResults) - await iter.ReadNextAsync(); - - _capturer.CapturedRequests - .Any(r => r.Headers.Contains("x-ms-continuation")) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsQueryPlanHeader_WhenOrderByQueryExecuted() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name).ToFeedIterator(); - await iter.ReadNextAsync(); - - // The SDK should send either a query plan header or an isquery header - _capturer.CapturedRequests - .Any(r => r.Headers.Contains("x-ms-cosmos-is-query-plan-request") || - r.Headers.Contains("x-ms-documentdb-isquery")) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsPkRangesRequest_OnFirstQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - await iter.ReadNextAsync(); - - _capturer.CapturedRequests - .Any(r => r.RequestUri?.AbsolutePath.Contains("pkranges") ?? false) - .Should().BeTrue(); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkRequestHeaderCanaryTests() + { + _container = new InMemoryContainer("header-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "header-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task Sdk_SendsPartitionKeyHeader_ForPointOperations() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + + _capturer.CapturedRequests + .Any(r => r.Headers.Contains("x-ms-documentdb-partitionkey")) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsUpsertHeader_ForUpsertOperation() + { + await _cosmosContainer.UpsertItemAsync( + new TestDocument { Id = "up1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + _capturer.CapturedRequests + .Any(r => r.Headers.TryGetValues("x-ms-documentdb-is-upsert", out var v) && + v.Any(h => h.Equals("True", StringComparison.OrdinalIgnoreCase))) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsIsQueryHeader_ForParameterisedQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = @n").WithParameter("@n", "A")); + await iter.ReadNextAsync(); + + _capturer.CapturedRequests + .Any(r => r.Headers.Contains("x-ms-documentdb-isquery") || + (r.Content?.Headers.ContentType?.MediaType?.Contains("query+json") ?? false)) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsMaxItemCountHeader_WhenSetInOptions() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); + await iter.ReadNextAsync(); + + _capturer.CapturedRequests + .Any(r => r.Headers.TryGetValues("x-ms-max-item-count", out var v) && v.Contains("1")) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_UsesQueryJsonContentType_ForQueryRequests() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + await iter.ReadNextAsync(); + + _capturer.CapturedRequests + .Any(r => r.Content?.Headers.ContentType?.MediaType?.Contains("query+json") ?? false) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsContinuationHeader_ForSubsequentPages() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); + await iter.ReadNextAsync(); + if (iter.HasMoreResults) + await iter.ReadNextAsync(); + + _capturer.CapturedRequests + .Any(r => r.Headers.Contains("x-ms-continuation")) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsQueryPlanHeader_WhenOrderByQueryExecuted() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name).ToFeedIterator(); + await iter.ReadNextAsync(); + + // The SDK should send either a query plan header or an isquery header + _capturer.CapturedRequests + .Any(r => r.Headers.Contains("x-ms-cosmos-is-query-plan-request") || + r.Headers.Contains("x-ms-documentdb-isquery")) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsPkRangesRequest_OnFirstQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + await iter.ReadNextAsync(); + + _capturer.CapturedRequests + .Any(r => r.RequestUri?.AbsolutePath.Contains("pkranges") ?? false) + .Should().BeTrue(); + } } public class SdkResponseHeaderContractTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkResponseHeaderContractTests() - { - _container = new InMemoryContainer("resp-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "resp-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task Sdk_ReadsRequestCharge_FromResponseHeader() - { - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task Sdk_ReadsActivityId_FromResponseHeader() - { - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.ActivityId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Sdk_ReadsSessionToken_FromResponseHeader() - { - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Sdk_ReadsContinuation_FromFeedResponse() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemLinqQueryable( - requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); - var page = await iter.ReadNextAsync(); - - // If there are more items, continuation should be present - if (iter.HasMoreResults) - page.ContinuationToken.Should().NotBeNull(); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkResponseHeaderContractTests() + { + _container = new InMemoryContainer("resp-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "resp-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task Sdk_ReadsRequestCharge_FromResponseHeader() + { + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Sdk_ReadsActivityId_FromResponseHeader() + { + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.ActivityId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Sdk_ReadsSessionToken_FromResponseHeader() + { + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Sdk_ReadsContinuation_FromFeedResponse() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"Item{i}" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemLinqQueryable( + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }).ToFeedIterator(); + var page = await iter.ReadNextAsync(); + + // If there are more items, continuation should be present + if (iter.HasMoreResults) + page.ContinuationToken.Should().NotBeNull(); + } } public class SdkUrlPatternCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkUrlPatternCanaryTests() - { - _container = new InMemoryContainer("url-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "url-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task Sdk_SendsCollectionReadRequest_ContainingColls() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - // Any point operation triggers collection metadata request - await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - - _capturer.CapturedRequests - .Any(r => r.RequestUri?.AbsolutePath.Contains("/colls/") ?? false) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_SendsDocumentRequests_ContainingDocs() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - _capturer.CapturedRequests - .Any(r => r.RequestUri?.AbsolutePath.Contains("/docs") ?? false) - .Should().BeTrue(); - } - - [Fact] - public async Task Sdk_PointRead_UrlContainsDocumentId() - { - await _container.CreateItemAsync( - new TestDocument { Id = "testid", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - await _cosmosContainer.ReadItemAsync("testid", new PartitionKey("pk")); - - _capturer.CapturedRequests - .Any(r => r.RequestUri?.AbsolutePath.Contains("testid") ?? false) - .Should().BeTrue(); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkUrlPatternCanaryTests() + { + _container = new InMemoryContainer("url-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "url-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task Sdk_SendsCollectionReadRequest_ContainingColls() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + // Any point operation triggers collection metadata request + await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + + _capturer.CapturedRequests + .Any(r => r.RequestUri?.AbsolutePath.Contains("/colls/") ?? false) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_SendsDocumentRequests_ContainingDocs() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + _capturer.CapturedRequests + .Any(r => r.RequestUri?.AbsolutePath.Contains("/docs") ?? false) + .Should().BeTrue(); + } + + [Fact] + public async Task Sdk_PointRead_UrlContainsDocumentId() + { + await _container.CreateItemAsync( + new TestDocument { Id = "testid", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + await _cosmosContainer.ReadItemAsync("testid", new PartitionKey("pk")); + + _capturer.CapturedRequests + .Any(r => r.RequestUri?.AbsolutePath.Contains("testid") ?? false) + .Should().BeTrue(); + } } public class SdkEnvelopeFormatCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkEnvelopeFormatCanaryTests() - { - _container = new InMemoryContainer("envelope-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "envelope-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task Sdk_ParsesDocumentsEnvelope_WithDocumentsKey() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task Sdk_ParsesOrderByEnvelope_WithOrderByItemsAndPayload() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name).ToFeedIterator(); - var results = await iter.ReadNextAsync(); - results.First().Name.Should().Be("A"); - } - - [Fact] - public async Task Sdk_ParsesAggregateEnvelope_WithItemWrapper() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); - count.Resource.Should().Be(1); - } - - [Fact] - public async Task Sdk_ParsesPkRangesEnvelope_WithPartitionKeyRangesKey() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - // The SDK must successfully parse pkranges to execute a query - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkEnvelopeFormatCanaryTests() + { + _container = new InMemoryContainer("envelope-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "envelope-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task Sdk_ParsesDocumentsEnvelope_WithDocumentsKey() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Sdk_ParsesOrderByEnvelope_WithOrderByItemsAndPayload() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name).ToFeedIterator(); + var results = await iter.ReadNextAsync(); + results.First().Name.Should().Be("A"); + } + + [Fact] + public async Task Sdk_ParsesAggregateEnvelope_WithItemWrapper() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); + count.Resource.Should().Be(1); + } + + [Fact] + public async Task Sdk_ParsesPkRangesEnvelope_WithPartitionKeyRangesKey() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + // The SDK must successfully parse pkranges to execute a query + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -879,83 +879,83 @@ await _container.CreateItemAsync( public class PatchOperationTypeStringTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public PatchOperationTypeStringTests() - { - _container = new InMemoryContainer("patch-test", "/partitionKey"); - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).GetAwaiter().GetResult(); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "patch-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task Sdk_SerializesPatchSet_AsSetString() - { - var response = await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "Patched")]); - response.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Sdk_SerializesPatchAdd_AsAddString() - { - var response = await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Add("/extra", "val")]); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Sdk_SerializesPatchRemove_AsRemoveString() - { - await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/extra", "temp")]); - var response = await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Remove("/extra")]); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Sdk_SerializesPatchReplace_AsReplaceString() - { - var response = await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Replace("/name", "Replaced")]); - response.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task Sdk_SerializesPatchIncrement_AsIncrString() - { - await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/value", 10)]); - var response = await _cosmosContainer.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Increment("/value", 5)]); - response.Resource["value"]!.Value().Should().Be(15); - } - - [Fact] - public async Task Sdk_SerializesPatchMove_AsMoveString() - { - // Test through InMemoryContainer directly — FakeCosmosHandler may not - // route Move ops through the same SDK pipeline - await _container.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Set("/source", "moveMe")]); - var response = await _container.PatchItemAsync( - "1", new PartitionKey("pk"), [PatchOperation.Move("/source", "/destination")]); - response.Resource["destination"]?.ToString().Should().Be("moveMe"); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public PatchOperationTypeStringTests() + { + _container = new InMemoryContainer("patch-test", "/partitionKey"); + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).GetAwaiter().GetResult(); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "patch-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task Sdk_SerializesPatchSet_AsSetString() + { + var response = await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/name", "Patched")]); + response.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Sdk_SerializesPatchAdd_AsAddString() + { + var response = await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Add("/extra", "val")]); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Sdk_SerializesPatchRemove_AsRemoveString() + { + await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/extra", "temp")]); + var response = await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Remove("/extra")]); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Sdk_SerializesPatchReplace_AsReplaceString() + { + var response = await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Replace("/name", "Replaced")]); + response.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task Sdk_SerializesPatchIncrement_AsIncrString() + { + await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/value", 10)]); + var response = await _cosmosContainer.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Increment("/value", 5)]); + response.Resource["value"]!.Value().Should().Be(15); + } + + [Fact] + public async Task Sdk_SerializesPatchMove_AsMoveString() + { + // Test through InMemoryContainer directly — FakeCosmosHandler may not + // route Move ops through the same SDK pipeline + await _container.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Set("/source", "moveMe")]); + var response = await _container.PatchItemAsync( + "1", new PartitionKey("pk"), [PatchOperation.Move("/source", "/destination")]); + response.Resource["destination"]?.ToString().Should().Be("moveMe"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -964,115 +964,115 @@ await _container.PatchItemAsync( public class SdkCompatExpansionTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkCompatExpansionTests() - { - _container = new InMemoryContainer("expand-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "expand-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task VerifySdkCompat_Upsert_RoundTrips() - { - var doc = new TestDocument { Id = "u1", PartitionKey = "pk", Name = "Original" }; - await _cosmosContainer.UpsertItemAsync(doc, new PartitionKey("pk")); - - doc.Name = "Updated"; - var resp = await _cosmosContainer.UpsertItemAsync(doc, new PartitionKey("pk")); - resp.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task VerifySdkCompat_Replace_RoundTrips() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "r1", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); - - var replaced = await _cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "r1", PartitionKey = "pk", Name = "After" }, - "r1", new PartitionKey("pk")); - replaced.Resource.Name.Should().Be("After"); - } - - [Fact] - public async Task VerifySdkCompat_Patch_RoundTrips() - { - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "p1", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); - - var patched = await _cosmosContainer.PatchItemAsync( - "p1", new PartitionKey("pk"), [PatchOperation.Set("/name", "Patched")]); - patched.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task VerifySdkCompat_ReadFeed_ReturnsItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "f1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = _cosmosContainer.GetItemQueryIterator((string?)null); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task VerifySdkCompat_TransactionalBatch_Executes() - { - // Batch is tested through InMemoryContainer (not FakeCosmosHandler SDK route) - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk")); - batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk", Name = "B" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task VerifySdkCompat_ChangeFeed_ReturnsChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "cf1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - // Change feed is tested through InMemoryContainer (not FakeCosmosHandler SDK route) - var changes = new List(); - var iter = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - changes.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task VerifySdkCompat_StreamOperations_RoundTrip() - { - var createResp = await _cosmosContainer.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"s1","partitionKey":"pk","name":"A"}""")), - new PartitionKey("pk")); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResp = await _cosmosContainer.ReadItemStreamAsync("s1", new PartitionKey("pk")); - readResp.StatusCode.Should().Be(HttpStatusCode.OK); - - var deleteResp = await _cosmosContainer.DeleteItemStreamAsync("s1", new PartitionKey("pk")); - deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkCompatExpansionTests() + { + _container = new InMemoryContainer("expand-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "expand-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task VerifySdkCompat_Upsert_RoundTrips() + { + var doc = new TestDocument { Id = "u1", PartitionKey = "pk", Name = "Original" }; + await _cosmosContainer.UpsertItemAsync(doc, new PartitionKey("pk")); + + doc.Name = "Updated"; + var resp = await _cosmosContainer.UpsertItemAsync(doc, new PartitionKey("pk")); + resp.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task VerifySdkCompat_Replace_RoundTrips() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "r1", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); + + var replaced = await _cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "r1", PartitionKey = "pk", Name = "After" }, + "r1", new PartitionKey("pk")); + replaced.Resource.Name.Should().Be("After"); + } + + [Fact] + public async Task VerifySdkCompat_Patch_RoundTrips() + { + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "p1", PartitionKey = "pk", Name = "Before" }, new PartitionKey("pk")); + + var patched = await _cosmosContainer.PatchItemAsync( + "p1", new PartitionKey("pk"), [PatchOperation.Set("/name", "Patched")]); + patched.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task VerifySdkCompat_ReadFeed_ReturnsItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "f1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = _cosmosContainer.GetItemQueryIterator((string?)null); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task VerifySdkCompat_TransactionalBatch_Executes() + { + // Batch is tested through InMemoryContainer (not FakeCosmosHandler SDK route) + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk")); + batch.CreateItem(new TestDocument { Id = "b1", PartitionKey = "pk", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "b2", PartitionKey = "pk", Name = "B" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task VerifySdkCompat_ChangeFeed_ReturnsChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "cf1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + // Change feed is tested through InMemoryContainer (not FakeCosmosHandler SDK route) + var changes = new List(); + var iter = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + changes.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task VerifySdkCompat_StreamOperations_RoundTrip() + { + var createResp = await _cosmosContainer.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"s1","partitionKey":"pk","name":"A"}""")), + new PartitionKey("pk")); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResp = await _cosmosContainer.ReadItemStreamAsync("s1", new PartitionKey("pk")); + readResp.StatusCode.Should().Be(HttpStatusCode.OK); + + var deleteResp = await _cosmosContainer.DeleteItemStreamAsync("s1", new PartitionKey("pk")); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1081,104 +1081,104 @@ public async Task VerifySdkCompat_StreamOperations_RoundTrip() public class QueryPlanResponseCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public QueryPlanResponseCanaryTests() - { - _container = new InMemoryContainer("qp-test", "/partitionKey"); - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).GetAwaiter().GetResult(); - _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")).GetAwaiter().GetResult(); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "qp-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task QueryPlan_DistinctType_None_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(2); - } - - [Fact] - public async Task QueryPlan_DistinctType_Ordered_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.partitionKey FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact] - public async Task QueryPlan_OrderBy_Ascending_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderBy(d => d.Name).ToFeedIterator(); - var results = await iter.ReadNextAsync(); - results.First().Name.Should().Be("A"); - } - - [Fact] - public async Task QueryPlan_OrderBy_Descending_AcceptedBySdk() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderByDescending(d => d.Name).ToFeedIterator(); - var results = await iter.ReadNextAsync(); - results.First().Name.Should().Be("B"); - } - - [Fact] - public async Task QueryPlan_Aggregates_CountAcceptedBySdk() - { - var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); - count.Resource.Should().Be(2); - } - - [Fact] - public async Task QueryPlan_Aggregates_SumMinMaxAcceptedBySdk() - { - // Test SUM, MIN, MAX aggregates through the SDK query plan pipeline - // VALUE queries return scalars, so use matching scalar types (not JObject) - // Seeded items have Value=0 (default), so SUM(1) gives the count - var sumIter = _cosmosContainer.GetItemQueryIterator( - "SELECT VALUE SUM(1) FROM c"); - var sumResult = await sumIter.ReadNextAsync(); - sumResult.First().Should().Be(2); - - var minIter = _cosmosContainer.GetItemQueryIterator( - "SELECT VALUE MIN(c.name) FROM c"); - var minResult = await minIter.ReadNextAsync(); - minResult.First().Should().Be("A"); - - var maxIter = _cosmosContainer.GetItemQueryIterator( - "SELECT VALUE MAX(c.name) FROM c"); - var maxResult = await maxIter.ReadNextAsync(); - maxResult.First().Should().Be("B"); - } - - [Fact] - public async Task QueryPlan_RewrittenQuery_ParsedCorrectlyBySdk() - { - // A WHERE + ORDER BY query exercises rewrittenQuery in the query plan - var iter = _cosmosContainer.GetItemLinqQueryable() - .Where(d => d.Name != "Z") - .OrderBy(d => d.Name) - .ToFeedIterator(); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(2); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public QueryPlanResponseCanaryTests() + { + _container = new InMemoryContainer("qp-test", "/partitionKey"); + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")).GetAwaiter().GetResult(); + _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")).GetAwaiter().GetResult(); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "qp-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task QueryPlan_DistinctType_None_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(2); + } + + [Fact] + public async Task QueryPlan_DistinctType_Ordered_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemQueryIterator("SELECT DISTINCT c.partitionKey FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task QueryPlan_OrderBy_Ascending_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderBy(d => d.Name).ToFeedIterator(); + var results = await iter.ReadNextAsync(); + results.First().Name.Should().Be("A"); + } + + [Fact] + public async Task QueryPlan_OrderBy_Descending_AcceptedBySdk() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderByDescending(d => d.Name).ToFeedIterator(); + var results = await iter.ReadNextAsync(); + results.First().Name.Should().Be("B"); + } + + [Fact] + public async Task QueryPlan_Aggregates_CountAcceptedBySdk() + { + var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); + count.Resource.Should().Be(2); + } + + [Fact] + public async Task QueryPlan_Aggregates_SumMinMaxAcceptedBySdk() + { + // Test SUM, MIN, MAX aggregates through the SDK query plan pipeline + // VALUE queries return scalars, so use matching scalar types (not JObject) + // Seeded items have Value=0 (default), so SUM(1) gives the count + var sumIter = _cosmosContainer.GetItemQueryIterator( + "SELECT VALUE SUM(1) FROM c"); + var sumResult = await sumIter.ReadNextAsync(); + sumResult.First().Should().Be(2); + + var minIter = _cosmosContainer.GetItemQueryIterator( + "SELECT VALUE MIN(c.name) FROM c"); + var minResult = await minIter.ReadNextAsync(); + minResult.First().Should().Be("A"); + + var maxIter = _cosmosContainer.GetItemQueryIterator( + "SELECT VALUE MAX(c.name) FROM c"); + var maxResult = await maxIter.ReadNextAsync(); + maxResult.First().Should().Be("B"); + } + + [Fact] + public async Task QueryPlan_RewrittenQuery_ParsedCorrectlyBySdk() + { + // A WHERE + ORDER BY query exercises rewrittenQuery in the query plan + var iter = _cosmosContainer.GetItemLinqQueryable() + .Where(d => d.Name != "Z") + .OrderBy(d => d.Name) + .ToFeedIterator(); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1187,68 +1187,68 @@ public async Task QueryPlan_RewrittenQuery_ParsedCorrectlyBySdk() public class AccountCollectionMetadataCanaryTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public AccountCollectionMetadataCanaryTests() - { - _container = new InMemoryContainer("meta-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "meta-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task AccountMetadata_AcceptedBySdk_OnInitialization() - { - // The SDK requests account info during initialization — if our response - // was rejected, subsequent operations would fail - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - // No exception = SDK accepted our account metadata - } - - [Fact] - public async Task CollectionMetadata_PartitionKeyKind_Hash_AcceptedBySdk() - { - // SDK reads collection metadata to discover partition key; if kind/version - // was unrecognized, point operations would fail - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Resource.Should().NotBeNull(); - } - - [Fact] - public async Task CollectionMetadata_PartitionKeyVersion_2_AcceptedBySdk() - { - // Partition key version 2 supports hash V2; SDK uses this for PK routing - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task AccountMetadata_ConsistencyLevel_Session_AcceptedBySdk() - { - // The SDK uses the consistency level from account metadata for session consistency - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); - result.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public AccountCollectionMetadataCanaryTests() + { + _container = new InMemoryContainer("meta-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "meta-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task AccountMetadata_AcceptedBySdk_OnInitialization() + { + // The SDK requests account info during initialization — if our response + // was rejected, subsequent operations would fail + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + // No exception = SDK accepted our account metadata + } + + [Fact] + public async Task CollectionMetadata_PartitionKeyKind_Hash_AcceptedBySdk() + { + // SDK reads collection metadata to discover partition key; if kind/version + // was unrecognized, point operations would fail + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Resource.Should().NotBeNull(); + } + + [Fact] + public async Task CollectionMetadata_PartitionKeyVersion_2_AcceptedBySdk() + { + // Partition key version 2 supports hash V2; SDK uses this for PK routing + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task AccountMetadata_ConsistencyLevel_Session_AcceptedBySdk() + { + // The SDK uses the consistency level from account metadata for session consistency + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var result = await _cosmosContainer.ReadItemAsync("1", new PartitionKey("pk")); + result.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1257,87 +1257,87 @@ await _container.CreateItemAsync( public class SdkEdgeCaseReflectionTests { - [Fact] - public void PatchOperation_Increment_HasPublicValueProperty() - { - var op = PatchOperation.Increment("/path", 5); - var concreteType = op.GetType(); - var valueProp = concreteType.GetProperty("Value"); - valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for increment ops"); - } - - [Fact] - public void PatchOperation_Add_HasPublicValueProperty() - { - var op = PatchOperation.Add("/path", "val"); - var concreteType = op.GetType(); - var valueProp = concreteType.GetProperty("Value"); - valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for add ops"); - } - - [Fact] - public void PatchOperation_Replace_HasPublicValueProperty() - { - var op = PatchOperation.Replace("/path", "val"); - var concreteType = op.GetType(); - var valueProp = concreteType.GetProperty("Value"); - valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for replace ops"); - } - - [Fact] - public void ChangeFeedStartFrom_ContinuationToken_SubtypeName() - { - // Verify continuation token start type still follows naming convention - var type = typeof(ChangeFeedStartFrom).Assembly.GetTypes() - .Where(t => t.IsSubclassOf(typeof(ChangeFeedStartFrom))) - .ToList(); - type.Count.Should().BeGreaterThanOrEqualTo(3, "Expected at least Beginning, Now, and Time subtypes"); - } - - [Fact] - public void QueryDefinition_ParameterField_IsEnumerable() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", "1"); - var parameters = qd.GetQueryParameters().ToList(); - parameters.Should().HaveCount(1); - } - - [Fact] - public void QueryDefinition_ParameterItems_HaveNameAndValue() - { - var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id AND c.pk = @pk") - .WithParameter("@id", "1") - .WithParameter("@pk", "pk"); - var parameters = qd.GetQueryParameters().ToList(); - parameters.Should().HaveCount(2); - parameters.Should().Contain(p => p.Name == "@id" && (string)p.Value == "1"); - parameters.Should().Contain(p => p.Name == "@pk" && (string)p.Value == "pk"); - } - - [Fact] - public void RuntimeHelpers_GetUninitializedObject_WorksForChangeFeedProcessorBuilder() - { - var obj = RuntimeHelpers.GetUninitializedObject(typeof(ChangeFeedProcessorBuilder)); - obj.Should().NotBeNull(); - obj.Should().BeOfType(); - } - - [Fact] - public void FeedIterator_HasVirtual_HasMoreResults() - { - var prop = typeof(FeedIterator).GetProperty(nameof(FeedIterator.HasMoreResults)); - prop.Should().NotBeNull(); - prop!.GetMethod!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); - } - - [Fact] - public void FeedIterator_HasVirtual_ReadNextAsync() - { - var method = typeof(FeedIterator).GetMethod(nameof(FeedIterator.ReadNextAsync)); - method.Should().NotBeNull(); - method!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); - } + [Fact] + public void PatchOperation_Increment_HasPublicValueProperty() + { + var op = PatchOperation.Increment("/path", 5); + var concreteType = op.GetType(); + var valueProp = concreteType.GetProperty("Value"); + valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for increment ops"); + } + + [Fact] + public void PatchOperation_Add_HasPublicValueProperty() + { + var op = PatchOperation.Add("/path", "val"); + var concreteType = op.GetType(); + var valueProp = concreteType.GetProperty("Value"); + valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for add ops"); + } + + [Fact] + public void PatchOperation_Replace_HasPublicValueProperty() + { + var op = PatchOperation.Replace("/path", "val"); + var concreteType = op.GetType(); + var valueProp = concreteType.GetProperty("Value"); + valueProp.Should().NotBeNull("FakeCosmosHandler reads Value via reflection for replace ops"); + } + + [Fact] + public void ChangeFeedStartFrom_ContinuationToken_SubtypeName() + { + // Verify continuation token start type still follows naming convention + var type = typeof(ChangeFeedStartFrom).Assembly.GetTypes() + .Where(t => t.IsSubclassOf(typeof(ChangeFeedStartFrom))) + .ToList(); + type.Count.Should().BeGreaterThanOrEqualTo(3, "Expected at least Beginning, Now, and Time subtypes"); + } + + [Fact] + public void QueryDefinition_ParameterField_IsEnumerable() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", "1"); + var parameters = qd.GetQueryParameters().ToList(); + parameters.Should().HaveCount(1); + } + + [Fact] + public void QueryDefinition_ParameterItems_HaveNameAndValue() + { + var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id AND c.pk = @pk") + .WithParameter("@id", "1") + .WithParameter("@pk", "pk"); + var parameters = qd.GetQueryParameters().ToList(); + parameters.Should().HaveCount(2); + parameters.Should().Contain(p => p.Name == "@id" && (string)p.Value == "1"); + parameters.Should().Contain(p => p.Name == "@pk" && (string)p.Value == "pk"); + } + + [Fact] + public void RuntimeHelpers_GetUninitializedObject_WorksForChangeFeedProcessorBuilder() + { + var obj = RuntimeHelpers.GetUninitializedObject(typeof(ChangeFeedProcessorBuilder)); + obj.Should().NotBeNull(); + obj.Should().BeOfType(); + } + + [Fact] + public void FeedIterator_HasVirtual_HasMoreResults() + { + var prop = typeof(FeedIterator).GetProperty(nameof(FeedIterator.HasMoreResults)); + prop.Should().NotBeNull(); + prop!.GetMethod!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); + } + + [Fact] + public void FeedIterator_HasVirtual_ReadNextAsync() + { + var method = typeof(FeedIterator).GetMethod(nameof(FeedIterator.ReadNextAsync)); + method.Should().NotBeNull(); + method!.IsVirtual.Should().BeTrue("NSubstitute requires virtual/abstract members"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1346,41 +1346,41 @@ public void FeedIterator_HasVirtual_ReadNextAsync() public class FakeCosmosHandlerBugFixTests { - [Fact] - public void ParsePatchBody_MissingOperationsKey_ThrowsInvalidOperation() - { - // The ParsePatchBody method throws InvalidOperationException when "operations" key is missing. - // We verify indirectly by confirming the fixed code path exists. - // Direct testing requires reflection since ParsePatchBody is private. - var method = typeof(FakeCosmosHandler).GetMethod("ParsePatchBody", - BindingFlags.Static | BindingFlags.NonPublic); - method.Should().NotBeNull(); - - var act = () => method!.Invoke(null, ["{\"noOps\": true}"]); - act.Should().Throw() - .WithInnerException(); - } - - [Fact] - public void FilterDocumentsByRange_NonIntegerRangeId_DoesNotThrow() - { - // The method is private, so we test indirectly by confirming the handler - // doesn't crash on a query with a bad range header. - var container = new InMemoryContainer("range-test", "/partitionKey"); - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 3 }); - // If the method has been fixed, a non-integer rangeId will be handled gracefully. - // We verify by confirming the handler initializes without error. - handler.Should().NotBeNull(); - } - - [Fact] - public async Task ReadAccountAsync_ReflectionSucceeds_ReturnsPopulatedAccount() - { - var client = new InMemoryCosmosClient(); - var account = await client.ReadAccountAsync(); - account.Should().NotBeNull(); - account.Id.Should().Be("in-memory-emulator"); - } + [Fact] + public void ParsePatchBody_MissingOperationsKey_ThrowsInvalidOperation() + { + // The ParsePatchBody method throws InvalidOperationException when "operations" key is missing. + // We verify indirectly by confirming the fixed code path exists. + // Direct testing requires reflection since ParsePatchBody is private. + var method = typeof(FakeCosmosHandler).GetMethod("ParsePatchBody", + BindingFlags.Static | BindingFlags.NonPublic); + method.Should().NotBeNull(); + + var act = () => method!.Invoke(null, ["{\"noOps\": true}"]); + act.Should().Throw() + .WithInnerException(); + } + + [Fact] + public void FilterDocumentsByRange_NonIntegerRangeId_DoesNotThrow() + { + // The method is private, so we test indirectly by confirming the handler + // doesn't crash on a query with a bad range header. + var container = new InMemoryContainer("range-test", "/partitionKey"); + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 3 }); + // If the method has been fixed, a non-integer rangeId will be handled gracefully. + // We verify by confirming the handler initializes without error. + handler.Should().NotBeNull(); + } + + [Fact] + public async Task ReadAccountAsync_ReflectionSucceeds_ReturnsPopulatedAccount() + { + var client = new InMemoryCosmosClient(); + var account = await client.ReadAccountAsync(); + account.Should().NotBeNull(); + account.Id.Should().Be("in-memory-emulator"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1389,111 +1389,111 @@ public async Task ReadAccountAsync_ReflectionSucceeds_ReturnsPopulatedAccount() public class SdkCompatibilityDivergentBehaviorTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkCompatibilityDivergentBehaviorTests() - { - _container = new InMemoryContainer("diverge-test", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "diverge-test"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact(Skip = "Session token progression is not implemented. The emulator always returns static '0:0#1'. Real Cosmos DB returns monotonically increasing session tokens.")] - public void SessionToken_ShouldProgress_AcrossWrites() { } - - [Fact] - public async Task SessionToken_AlwaysReturnsStaticValue_Divergence() - { - // DIVERGENT BEHAVIOUR: Real Cosmos increments session tokens. - // Emulator always returns "0:0#1" as session token. - await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var r1 = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); - - r1.Headers["x-ms-session-token"].Should().Contain("0:"); - } - - [Fact(Skip = "Multi-partition fan-out uses synthetic partition key ranges. Range assignment uses hash modulo rather than real Cosmos range partitioning.")] - public void MultiPartition_FanOut_QueryExecutesAcrossAllRanges() { } - - [Fact] - public async Task MultiPartition_FanOut_UsesSimplifiedHashModulo_Divergence() - { - // DIVERGENT BEHAVIOUR: Emulator assigns partition key ranges - // via hash modulo. Real Cosmos uses actual range partitioning. - var container = new InMemoryContainer("multipart", "/partitionKey"); - using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 3 }); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - var c = client.GetContainer("fakeDb", "multipart"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - all.Count.Should().Be(2); - } - - [Fact(Skip = "The queryEngineConfiguration is a hardcoded permissive approximation. Real Cosmos may return different engine limits.")] - public void AccountMetadata_QueryEngineConfiguration_MatchesRealCosmos() { } - - [Fact] - public async Task AccountMetadata_QueryEngineConfiguration_IsPermissive_Divergence() - { - // DIVERGENT BEHAVIOUR: Hardcoded permissive config allows all query features. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - // Verify SDK can execute complex queries with our permissive config - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); - var results = await iter.ReadNextAsync(); - results.Count.Should().BeGreaterThan(0); - } - - [Fact(Skip = "The indexing policy in collection metadata is a simplified permissive default, not a real Cosmos indexing policy.")] - public void CollectionMetadata_IndexingPolicy_MatchesRealCosmos() { } - - [Fact] - public async Task CollectionMetadata_IndexingPolicy_ReturnsPermissiveDefault_Divergence() - { - // DIVERGENT BEHAVIOUR: Simplified indexing policy allows all query patterns. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - // Range queries work despite simplified indexing policy - var iter = _cosmosContainer.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name >= 'A'"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkCompatibilityDivergentBehaviorTests() + { + _container = new InMemoryContainer("diverge-test", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "diverge-test"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact(Skip = "Session token progression is not implemented. The emulator always returns static '0:0#1'. Real Cosmos DB returns monotonically increasing session tokens.")] + public void SessionToken_ShouldProgress_AcrossWrites() { } + + [Fact] + public async Task SessionToken_AlwaysReturnsStaticValue_Divergence() + { + // DIVERGENT BEHAVIOUR: Real Cosmos increments session tokens. + // Emulator always returns "0:0#1" as session token. + await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var r1 = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "B" }, new PartitionKey("pk")); + + r1.Headers["x-ms-session-token"].Should().Contain("0:"); + } + + [Fact(Skip = "Multi-partition fan-out uses synthetic partition key ranges. Range assignment uses hash modulo rather than real Cosmos range partitioning.")] + public void MultiPartition_FanOut_QueryExecutesAcrossAllRanges() { } + + [Fact] + public async Task MultiPartition_FanOut_UsesSimplifiedHashModulo_Divergence() + { + // DIVERGENT BEHAVIOUR: Emulator assigns partition key ranges + // via hash modulo. Real Cosmos uses actual range partitioning. + var container = new InMemoryContainer("multipart", "/partitionKey"); + using var handler = new FakeCosmosHandler(container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 3 }); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + var c = client.GetContainer("fakeDb", "multipart"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, new PartitionKey("pk2")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + all.Count.Should().Be(2); + } + + [Fact(Skip = "The queryEngineConfiguration is a hardcoded permissive approximation. Real Cosmos may return different engine limits.")] + public void AccountMetadata_QueryEngineConfiguration_MatchesRealCosmos() { } + + [Fact] + public async Task AccountMetadata_QueryEngineConfiguration_IsPermissive_Divergence() + { + // DIVERGENT BEHAVIOUR: Hardcoded permissive config allows all query features. + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + // Verify SDK can execute complex queries with our permissive config + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT DISTINCT VALUE c.name FROM c ORDER BY c.name"); + var results = await iter.ReadNextAsync(); + results.Count.Should().BeGreaterThan(0); + } + + [Fact(Skip = "The indexing policy in collection metadata is a simplified permissive default, not a real Cosmos indexing policy.")] + public void CollectionMetadata_IndexingPolicy_MatchesRealCosmos() { } + + [Fact] + public async Task CollectionMetadata_IndexingPolicy_ReturnsPermissiveDefault_Divergence() + { + // DIVERGENT BEHAVIOUR: Simplified indexing policy allows all query patterns. + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + // Range queries work despite simplified indexing policy + var iter = _cosmosContainer.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name >= 'A'"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1503,78 +1503,78 @@ await _container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class FeedIteratorSetupIntegrationTests { - [Fact] - public void FeedIteratorSetup_Register_SetsFactory() - { - InMemoryFeedIteratorSetup.Register(); - try - { - CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions - .FeedIteratorFactory.Should().NotBeNull( - "Register() should wire up the FeedIteratorFactory delegate"); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } - - [Fact] - public void FeedIteratorSetup_Register_ThenDeregister_ClearsFactory() - { - InMemoryFeedIteratorSetup.Register(); - InMemoryFeedIteratorSetup.Deregister(); - - CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions - .FeedIteratorFactory.Should().BeNull( - "Deregister() should clear the FeedIteratorFactory delegate"); - } - - [Fact] - public void FeedIteratorSetup_Register_FactoryCreatesInMemoryFeedIterator() - { - InMemoryFeedIteratorSetup.Register(); - try - { - var queryable = new[] { "a", "b", "c" }.AsQueryable(); - var factory = CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions - .FeedIteratorFactory; - factory.Should().NotBeNull(); - var result = factory!(queryable); - result.Should().BeOfType>(); - } - finally - { - InMemoryFeedIteratorSetup.Deregister(); - } - } + [Fact] + public void FeedIteratorSetup_Register_SetsFactory() + { + InMemoryFeedIteratorSetup.Register(); + try + { + CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions + .FeedIteratorFactory.Should().NotBeNull( + "Register() should wire up the FeedIteratorFactory delegate"); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } + + [Fact] + public void FeedIteratorSetup_Register_ThenDeregister_ClearsFactory() + { + InMemoryFeedIteratorSetup.Register(); + InMemoryFeedIteratorSetup.Deregister(); + + CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions + .FeedIteratorFactory.Should().BeNull( + "Deregister() should clear the FeedIteratorFactory delegate"); + } + + [Fact] + public void FeedIteratorSetup_Register_FactoryCreatesInMemoryFeedIterator() + { + InMemoryFeedIteratorSetup.Register(); + try + { + var queryable = new[] { "a", "b", "c" }.AsQueryable(); + var factory = CosmosDB.InMemoryEmulator.ProductionExtensions.CosmosOverridableFeedIteratorExtensions + .FeedIteratorFactory; + factory.Should().NotBeNull(); + var result = factory!(queryable); + result.Should().BeOfType>(); + } + finally + { + InMemoryFeedIteratorSetup.Deregister(); + } + } } public class StoredProcedurePropertiesReflectionTests { - [Fact] - public void StoredProcedureProperties_HasSettableId() - { - var props = new Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties(); - var idProp = typeof(Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties) - .GetProperty("Id"); - idProp.Should().NotBeNull(); - idProp!.CanWrite.Should().BeTrue("stored procedure ID should be settable"); - props.Id = "test-sproc"; - props.Id.Should().Be("test-sproc"); - } - - [Fact] - public void StoredProcedureProperties_HasSettableBody() - { - var props = new Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties(); - var bodyProp = typeof(Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties) - .GetProperty("Body"); - bodyProp.Should().NotBeNull(); - bodyProp!.CanWrite.Should().BeTrue("stored procedure Body should be settable"); - props.Body = "function() { return true; }"; - props.Body.Should().Be("function() { return true; }"); - } + [Fact] + public void StoredProcedureProperties_HasSettableId() + { + var props = new Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties(); + var idProp = typeof(Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties) + .GetProperty("Id"); + idProp.Should().NotBeNull(); + idProp!.CanWrite.Should().BeTrue("stored procedure ID should be settable"); + props.Id = "test-sproc"; + props.Id.Should().Be("test-sproc"); + } + + [Fact] + public void StoredProcedureProperties_HasSettableBody() + { + var props = new Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties(); + var bodyProp = typeof(Microsoft.Azure.Cosmos.Scripts.StoredProcedureProperties) + .GetProperty("Body"); + bodyProp.Should().NotBeNull(); + bodyProp!.CanWrite.Should().BeTrue("stored procedure Body should be settable"); + props.Body = "function() { return true; }"; + props.Body.Should().Be("function() { return true; }"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1583,73 +1583,73 @@ public void StoredProcedureProperties_HasSettableBody() public class ParsePatchBodyErrorPathTests { - private static readonly MethodInfo ParsePatchBodyMethod = - typeof(FakeCosmosHandler).GetMethod("ParsePatchBody", - BindingFlags.Static | BindingFlags.NonPublic)!; - - private void InvokeParsePatchBody(string json) - { - try { ParsePatchBodyMethod.Invoke(null, [json]); } - catch (TargetInvocationException ex) { throw ex.InnerException!; } - } - - [Fact] - public void ParsePatchBody_NonArrayOperations_Throws() - { - var act = () => InvokeParsePatchBody("""{"operations": "not-an-array"}"""); - act.Should().Throw("operations must be a JArray, not a string"); - } - - [Fact] - public void ParsePatchBody_MissingPathInOp_Throws() - { - var act = () => InvokeParsePatchBody( - """{"operations": [{"op": "set", "value": "hello"}]}"""); - act.Should().Throw(); - } - - [Fact] - public void ParsePatchBody_UnknownOpType_ThrowsInvalidOperation() - { - var act = () => InvokeParsePatchBody( - """{"operations": [{"op": "unknown", "path": "/name"}]}"""); - act.Should().Throw() - .WithMessage("*Unknown patch operation*"); - } - - [Fact] - public void ParsePatchBody_EmptyArray_ReturnsEmptyList() - { - var result = ParsePatchBodyMethod.Invoke(null, ["""{"operations": []}"""]); - var tuple = result as System.Runtime.CompilerServices.ITuple; - tuple.Should().NotBeNull(); - var ops = tuple![0] as System.Collections.IList; - ops.Should().NotBeNull(); - ops!.Count.Should().Be(0); - } - - [Fact] - public void ParsePatchBody_ValidSetOp_ReturnsOperation() - { - var result = ParsePatchBodyMethod.Invoke(null, - ["""{"operations": [{"op": "set", "path": "/name", "value": "test"}]}"""]); - var tuple = result as System.Runtime.CompilerServices.ITuple; - tuple.Should().NotBeNull(); - var ops = tuple![0] as System.Collections.IList; - ops.Should().NotBeNull(); - ops!.Count.Should().Be(1); - } - - [Fact] - public void ParsePatchBody_WithCondition_ReturnsCondition() - { - var result = ParsePatchBodyMethod.Invoke(null, - ["""{"operations": [{"op": "set", "path": "/name", "value": "test"}], "condition": "FROM c WHERE c.id = '1'"}"""]); - var tuple = result as System.Runtime.CompilerServices.ITuple; - tuple.Should().NotBeNull(); - var condition = tuple![1] as string; - condition.Should().Be("FROM c WHERE c.id = '1'"); - } + private static readonly MethodInfo ParsePatchBodyMethod = + typeof(FakeCosmosHandler).GetMethod("ParsePatchBody", + BindingFlags.Static | BindingFlags.NonPublic)!; + + private void InvokeParsePatchBody(string json) + { + try { ParsePatchBodyMethod.Invoke(null, [json]); } + catch (TargetInvocationException ex) { throw ex.InnerException!; } + } + + [Fact] + public void ParsePatchBody_NonArrayOperations_Throws() + { + var act = () => InvokeParsePatchBody("""{"operations": "not-an-array"}"""); + act.Should().Throw("operations must be a JArray, not a string"); + } + + [Fact] + public void ParsePatchBody_MissingPathInOp_Throws() + { + var act = () => InvokeParsePatchBody( + """{"operations": [{"op": "set", "value": "hello"}]}"""); + act.Should().Throw(); + } + + [Fact] + public void ParsePatchBody_UnknownOpType_ThrowsInvalidOperation() + { + var act = () => InvokeParsePatchBody( + """{"operations": [{"op": "unknown", "path": "/name"}]}"""); + act.Should().Throw() + .WithMessage("*Unknown patch operation*"); + } + + [Fact] + public void ParsePatchBody_EmptyArray_ReturnsEmptyList() + { + var result = ParsePatchBodyMethod.Invoke(null, ["""{"operations": []}"""]); + var tuple = result as System.Runtime.CompilerServices.ITuple; + tuple.Should().NotBeNull(); + var ops = tuple![0] as System.Collections.IList; + ops.Should().NotBeNull(); + ops!.Count.Should().Be(0); + } + + [Fact] + public void ParsePatchBody_ValidSetOp_ReturnsOperation() + { + var result = ParsePatchBodyMethod.Invoke(null, + ["""{"operations": [{"op": "set", "path": "/name", "value": "test"}]}"""]); + var tuple = result as System.Runtime.CompilerServices.ITuple; + tuple.Should().NotBeNull(); + var ops = tuple![0] as System.Collections.IList; + ops.Should().NotBeNull(); + ops!.Count.Should().Be(1); + } + + [Fact] + public void ParsePatchBody_WithCondition_ReturnsCondition() + { + var result = ParsePatchBodyMethod.Invoke(null, + ["""{"operations": [{"op": "set", "path": "/name", "value": "test"}], "condition": "FROM c WHERE c.id = '1'"}"""]); + var tuple = result as System.Runtime.CompilerServices.ITuple; + tuple.Should().NotBeNull(); + var condition = tuple![1] as string; + condition.Should().Be("FROM c WHERE c.id = '1'"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1658,47 +1658,47 @@ public void ParsePatchBody_WithCondition_ReturnsCondition() public class SdkETagHeaderRoundTripTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkETagHeaderRoundTripTests() - { - _container = new InMemoryContainer("etag-rt", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "etag-rt"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task CreateItem_ReturnsETagInResponse() - { - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.ETag.Should().NotBeNullOrWhiteSpace("create response should include an ETag"); - } - - [Fact] - public async Task ReplaceItem_ETagChanges() - { - var create = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - var firstEtag = create.ETag; - - var replace = await _cosmosContainer.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, "1", new PartitionKey("pk")); - var secondEtag = replace.ETag; - - secondEtag.Should().NotBe(firstEtag, "ETag should change after replace"); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkETagHeaderRoundTripTests() + { + _container = new InMemoryContainer("etag-rt", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "etag-rt"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task CreateItem_ReturnsETagInResponse() + { + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.ETag.Should().NotBeNullOrWhiteSpace("create response should include an ETag"); + } + + [Fact] + public async Task ReplaceItem_ETagChanges() + { + var create = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + var firstEtag = create.ETag; + + var replace = await _cosmosContainer.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "B" }, "1", new PartitionKey("pk")); + var secondEtag = replace.ETag; + + secondEtag.Should().NotBe(firstEtag, "ETag should change after replace"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1707,77 +1707,77 @@ public async Task ReplaceItem_ETagChanges() public class PartitionRangeDistributionTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly FakeCosmosHandler _handler; - private readonly CosmosClient _client; - private readonly Container _cosmosContainer; - - public PartitionRangeDistributionTests() - { - _container = new InMemoryContainer("range-dist", "/partitionKey"); - _handler = new FakeCosmosHandler(_container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); - _client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - _cosmosContainer = _client.GetContainer("fakeDb", "range-dist"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - } - - [Fact] - public async Task MultiRange_CrossPartitionQuery_ReturnsAllItems() - { - for (int i = 0; i < 10; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = $"pk-{i}", Name = $"Name{i}" }, - new PartitionKey($"pk-{i}")); - } - - var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - all.Count.Should().Be(10, "cross-partition query should return all items from all ranges"); - } - - [Fact] - public async Task MultiRange_PartitionKeyQuery_ReturnsOnlyMatchingItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-alpha", Name = "A" }, new PartitionKey("pk-alpha")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk-beta", Name = "B" }, new PartitionKey("pk-beta")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk-alpha", Name = "C" }, new PartitionKey("pk-alpha")); - - var iter = _cosmosContainer.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") - .WithParameter("@pk", "pk-alpha"), - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-alpha") }); - - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - results.Count.Should().Be(2); - results.Should().OnlyContain(d => d.PartitionKey == "pk-alpha"); - } + private readonly InMemoryContainer _container; + private readonly FakeCosmosHandler _handler; + private readonly CosmosClient _client; + private readonly Container _cosmosContainer; + + public PartitionRangeDistributionTests() + { + _container = new InMemoryContainer("range-dist", "/partitionKey"); + _handler = new FakeCosmosHandler(_container, new FakeCosmosHandlerOptions { PartitionKeyRangeCount = 4 }); + _client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(_handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + _cosmosContainer = _client.GetContainer("fakeDb", "range-dist"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + } + + [Fact] + public async Task MultiRange_CrossPartitionQuery_ReturnsAllItems() + { + for (int i = 0; i < 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = $"pk-{i}", Name = $"Name{i}" }, + new PartitionKey($"pk-{i}")); + } + + var iter = _cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + all.Count.Should().Be(10, "cross-partition query should return all items from all ranges"); + } + + [Fact] + public async Task MultiRange_PartitionKeyQuery_ReturnsOnlyMatchingItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-alpha", Name = "A" }, new PartitionKey("pk-alpha")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk-beta", Name = "B" }, new PartitionKey("pk-beta")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk-alpha", Name = "C" }, new PartitionKey("pk-alpha")); + + var iter = _cosmosContainer.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @pk") + .WithParameter("@pk", "pk-alpha"), + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("pk-alpha") }); + + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + results.Count.Should().Be(2); + results.Should().OnlyContain(d => d.PartitionKey == "pk-alpha"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1786,90 +1786,90 @@ await _container.CreateItemAsync( public class SdkLinqPipelineTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkLinqPipelineTests() - { - _container = new InMemoryContainer("linq-sdk", "/partitionKey"); - _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, - new PartitionKey("pk")).GetAwaiter().GetResult(); - _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, - new PartitionKey("pk")).GetAwaiter().GetResult(); - _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, - new PartitionKey("pk")).GetAwaiter().GetResult(); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "linq-sdk"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact] - public async Task LinqWhere_ThroughSdk_FiltersCorrectly() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIterator(); - - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task LinqProjection_ThroughSdk_SelectsFields() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .Select(d => new { d.Name }) - .ToFeedIterator(); - - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - foreach (var item in page) results.Add(item); - } - results.Count.Should().Be(3); - } - - [Fact] - public async Task LinqOrderBy_ThroughSdk_SortsCorrectly() - { - var iter = _cosmosContainer.GetItemLinqQueryable() - .OrderByDescending(d => d.Name) - .ToFeedIterator(); - - var results = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - results.AddRange(page); - } - results.First().Name.Should().Be("Charlie"); - results.Last().Name.Should().Be("Alice"); - } - - [Fact] - public async Task LinqCount_ThroughSdk_ReturnsCorrectCount() - { - var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); - count.Resource.Should().Be(3); - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkLinqPipelineTests() + { + _container = new InMemoryContainer("linq-sdk", "/partitionKey"); + _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice", Value = 10 }, + new PartitionKey("pk")).GetAwaiter().GetResult(); + _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Bob", Value = 20 }, + new PartitionKey("pk")).GetAwaiter().GetResult(); + _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk", Name = "Charlie", Value = 30 }, + new PartitionKey("pk")).GetAwaiter().GetResult(); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "linq-sdk"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact] + public async Task LinqWhere_ThroughSdk_FiltersCorrectly() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIterator(); + + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task LinqProjection_ThroughSdk_SelectsFields() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .Select(d => new { d.Name }) + .ToFeedIterator(); + + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + foreach (var item in page) results.Add(item); + } + results.Count.Should().Be(3); + } + + [Fact] + public async Task LinqOrderBy_ThroughSdk_SortsCorrectly() + { + var iter = _cosmosContainer.GetItemLinqQueryable() + .OrderByDescending(d => d.Name) + .ToFeedIterator(); + + var results = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + results.AddRange(page); + } + results.First().Name.Should().Be("Charlie"); + results.Last().Name.Should().Be("Alice"); + } + + [Fact] + public async Task LinqCount_ThroughSdk_ReturnsCorrectCount() + { + var count = await _cosmosContainer.GetItemLinqQueryable().CountAsync(); + count.Resource.Should().Be(3); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1878,82 +1878,82 @@ public async Task LinqCount_ThroughSdk_ReturnsCorrectCount() public class SdkCompatibilityPlan35DivergentTests : IDisposable { - private readonly InMemoryContainer _container; - private readonly CosmosClient _client; - private readonly HeaderCapturingHandler _capturer; - private readonly FakeCosmosHandler _handler; - private readonly Container _cosmosContainer; - - public SdkCompatibilityPlan35DivergentTests() - { - _container = new InMemoryContainer("div35", "/partitionKey"); - (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); - _cosmosContainer = _client.GetContainer("fakeDb", "div35"); - } - - public void Dispose() - { - _client.Dispose(); - _handler.Dispose(); - _capturer.Dispose(); - } - - [Fact(Skip = "Real Cosmos DB returns varying request charges based on operation type, document size, and index usage. The emulator always returns 1.0 RU for all operations.")] - public void RequestCharge_ShouldReflectOperationCost() { } - - [Fact] - public async Task RequestCharge_AlwaysReturns1RU_Divergence() - { - var response = await _cosmosContainer.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.RequestCharge.Should().Be(1.0); - } - - [Fact(Skip = "Real Cosmos DB returns CosmosDiagnostics with real timing, retry, and routing details. The emulator returns a stub diagnostics with no real data.")] - public void Diagnostics_ShouldContainRealDetails() { } - - [Fact] - public async Task Diagnostics_ReturnsStubDiagnostics_Divergence() - { - // DIVERGENT BEHAVIOUR: Real Cosmos includes detailed diagnostics. - // Through FakeCosmosHandler, the SDK wraps with its own timing, - // so we test the raw InMemoryContainer response. - var response = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.Diagnostics.Should().NotBeNull(); - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero, - "emulator diagnostics return zero elapsed time"); - } - - [Fact(Skip = "Real Cosmos DB returns opaque base64-encoded continuation tokens with partition range info. The emulator returns simple integer offsets.")] - public void ContinuationToken_ShouldBeOpaqueJson() { } - - [Fact] - public async Task ContinuationToken_IsPlainInteger_Divergence() - { - // DIVERGENT BEHAVIOUR: Real Cosmos uses opaque tokens. - // Through FakeCosmosHandler, the SDK wraps the continuation token, - // so we test the raw InMemoryContainer query iterator. - for (int i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"N{i}" }, - new PartitionKey("pk")); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - var page1 = await iter.ReadNextAsync(); - page1.Count.Should().Be(2); - - if (iter.HasMoreResults) - { - var token = page1.ContinuationToken; - if (token != null) - { - int.TryParse(token, out _).Should().BeTrue( - "emulator continuation tokens should be plain integers"); - } - } - } + private readonly InMemoryContainer _container; + private readonly CosmosClient _client; + private readonly HeaderCapturingHandler _capturer; + private readonly FakeCosmosHandler _handler; + private readonly Container _cosmosContainer; + + public SdkCompatibilityPlan35DivergentTests() + { + _container = new InMemoryContainer("div35", "/partitionKey"); + (_client, _capturer, _handler) = SdkTestHelper.CreateCapturingClient(_container); + _cosmosContainer = _client.GetContainer("fakeDb", "div35"); + } + + public void Dispose() + { + _client.Dispose(); + _handler.Dispose(); + _capturer.Dispose(); + } + + [Fact(Skip = "Real Cosmos DB returns varying request charges based on operation type, document size, and index usage. The emulator always returns 1.0 RU for all operations.")] + public void RequestCharge_ShouldReflectOperationCost() { } + + [Fact] + public async Task RequestCharge_AlwaysReturns1RU_Divergence() + { + var response = await _cosmosContainer.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.RequestCharge.Should().Be(1.0); + } + + [Fact(Skip = "Real Cosmos DB returns CosmosDiagnostics with real timing, retry, and routing details. The emulator returns a stub diagnostics with no real data.")] + public void Diagnostics_ShouldContainRealDetails() { } + + [Fact] + public async Task Diagnostics_ReturnsStubDiagnostics_Divergence() + { + // DIVERGENT BEHAVIOUR: Real Cosmos includes detailed diagnostics. + // Through FakeCosmosHandler, the SDK wraps with its own timing, + // so we test the raw InMemoryContainer response. + var response = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.Diagnostics.Should().NotBeNull(); + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero, + "emulator diagnostics return zero elapsed time"); + } + + [Fact(Skip = "Real Cosmos DB returns opaque base64-encoded continuation tokens with partition range info. The emulator returns simple integer offsets.")] + public void ContinuationToken_ShouldBeOpaqueJson() { } + + [Fact] + public async Task ContinuationToken_IsPlainInteger_Divergence() + { + // DIVERGENT BEHAVIOUR: Real Cosmos uses opaque tokens. + // Through FakeCosmosHandler, the SDK wraps the continuation token, + // so we test the raw InMemoryContainer query iterator. + for (int i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk", Name = $"N{i}" }, + new PartitionKey("pk")); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + + var page1 = await iter.ReadNextAsync(); + page1.Count.Should().Be(2); + + if (iter.HasMoreResults) + { + var token = page1.ContinuationToken; + if (token != null) + { + int.TryParse(token, out _).Should().BeTrue( + "emulator continuation tokens should be plain integers"); + } + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkRobustnessTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkRobustnessTests.cs index 339684b..6b540fc 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkRobustnessTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkRobustnessTests.cs @@ -15,79 +15,79 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class UnrecognisedHeaderTests { - private static CosmosClient CreateClient(FakeCosmosHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - RequestTimeout = TimeSpan.FromSeconds(10), - HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } - }); - - [Fact] - public void UnrecognisedHeaders_IsEmptyByDefault() - { - var container = new InMemoryContainer("test", "/pk"); - using var handler = new FakeCosmosHandler(container); - - handler.UnrecognisedHeaders.Should().BeEmpty(); - } - - [Fact] - public async Task UnrecognisedHeaders_DoesNotRecordKnownSdkHeaders() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - new { id = "1", pk = "a" }, new PartitionKey("a")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - var cosmosContainer = client.GetContainer("db", "test"); - - // Normal CRUD goes through known headers - await cosmosContainer.ReadItemAsync("1", new PartitionKey("a")); - - handler.UnrecognisedHeaders.Should().BeEmpty(); - } - - [Fact] - public async Task UnrecognisedHeaders_RecordsUnknownXMsHeaders() - { - var container = new InMemoryContainer("test", "/pk"); - using var handler = new FakeCosmosHandler(container); - - // Send a raw request with an unknown x-ms header - var invoker = new HttpMessageInvoker(handler); - var request = new HttpRequestMessage(HttpMethod.Get, - "https://localhost:9999/dbs/db/colls/test/docs/fake-id"); - request.Headers.Add("x-ms-documentdb-partitionkey", "[\"a\"]"); - request.Headers.Add("x-ms-some-future-header", "value"); - request.Headers.Add("x-ms-another-new-header", "value2"); - - await invoker.SendAsync(request, CancellationToken.None); - - handler.UnrecognisedHeaders.Should().Contain("x-ms-some-future-header"); - handler.UnrecognisedHeaders.Should().Contain("x-ms-another-new-header"); - } - - [Fact] - public async Task UnrecognisedHeaders_IgnoresNonXMsHeaders() - { - var container = new InMemoryContainer("test", "/pk"); - using var handler = new FakeCosmosHandler(container); - - var invoker = new HttpMessageInvoker(handler); - var request = new HttpRequestMessage(HttpMethod.Get, - "https://localhost:9999/dbs/db/colls/test/docs/fake-id"); - request.Headers.Add("x-ms-documentdb-partitionkey", "[\"a\"]"); - request.Headers.Add("x-custom-header", "value"); - - await invoker.SendAsync(request, CancellationToken.None); - - handler.UnrecognisedHeaders.Should().BeEmpty(); - } + private static CosmosClient CreateClient(FakeCosmosHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + RequestTimeout = TimeSpan.FromSeconds(10), + HttpClientFactory = () => new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) } + }); + + [Fact] + public void UnrecognisedHeaders_IsEmptyByDefault() + { + var container = new InMemoryContainer("test", "/pk"); + using var handler = new FakeCosmosHandler(container); + + handler.UnrecognisedHeaders.Should().BeEmpty(); + } + + [Fact] + public async Task UnrecognisedHeaders_DoesNotRecordKnownSdkHeaders() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + new { id = "1", pk = "a" }, new PartitionKey("a")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + var cosmosContainer = client.GetContainer("db", "test"); + + // Normal CRUD goes through known headers + await cosmosContainer.ReadItemAsync("1", new PartitionKey("a")); + + handler.UnrecognisedHeaders.Should().BeEmpty(); + } + + [Fact] + public async Task UnrecognisedHeaders_RecordsUnknownXMsHeaders() + { + var container = new InMemoryContainer("test", "/pk"); + using var handler = new FakeCosmosHandler(container); + + // Send a raw request with an unknown x-ms header + var invoker = new HttpMessageInvoker(handler); + var request = new HttpRequestMessage(HttpMethod.Get, + "https://localhost:9999/dbs/db/colls/test/docs/fake-id"); + request.Headers.Add("x-ms-documentdb-partitionkey", "[\"a\"]"); + request.Headers.Add("x-ms-some-future-header", "value"); + request.Headers.Add("x-ms-another-new-header", "value2"); + + await invoker.SendAsync(request, CancellationToken.None); + + handler.UnrecognisedHeaders.Should().Contain("x-ms-some-future-header"); + handler.UnrecognisedHeaders.Should().Contain("x-ms-another-new-header"); + } + + [Fact] + public async Task UnrecognisedHeaders_IgnoresNonXMsHeaders() + { + var container = new InMemoryContainer("test", "/pk"); + using var handler = new FakeCosmosHandler(container); + + var invoker = new HttpMessageInvoker(handler); + var request = new HttpRequestMessage(HttpMethod.Get, + "https://localhost:9999/dbs/db/colls/test/docs/fake-id"); + request.Headers.Add("x-ms-documentdb-partitionkey", "[\"a\"]"); + request.Headers.Add("x-custom-header", "value"); + + await invoker.SendAsync(request, CancellationToken.None); + + handler.UnrecognisedHeaders.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -96,54 +96,54 @@ public async Task UnrecognisedHeaders_IgnoresNonXMsHeaders() public class ExpandedSdkCompatibilityTests { - [Fact] - public async Task VerifySdkCompatibilityAsync_CoversUpsertRoundTrip() - { - // The expanded check should not throw — if it does, the SDK contract changed - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_CoversPatchRoundTrip() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_CoversDistinctQuery() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_CoversOffsetLimitQuery() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_IncludesBatchCheck() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_IncludesReadManyCheck() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_IncludesChangeFeedCheck() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } - - [Fact] - public async Task VerifySdkCompatibilityAsync_IncludesGroupByCheck() - { - await FakeCosmosHandler.VerifySdkCompatibilityAsync(); - } + [Fact] + public async Task VerifySdkCompatibilityAsync_CoversUpsertRoundTrip() + { + // The expanded check should not throw — if it does, the SDK contract changed + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_CoversPatchRoundTrip() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_CoversDistinctQuery() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_CoversOffsetLimitQuery() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_IncludesBatchCheck() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_IncludesReadManyCheck() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_IncludesChangeFeedCheck() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } + + [Fact] + public async Task VerifySdkCompatibilityAsync_IncludesGroupByCheck() + { + await FakeCosmosHandler.VerifySdkCompatibilityAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -152,13 +152,13 @@ public async Task VerifySdkCompatibilityAsync_IncludesGroupByCheck() public class QueryPlanVersionTests { - [Fact] - public void QueryPlanVersion_MatchesExpected() - { - FakeCosmosHandler.QueryPlanVersion.Should().Be(2, - "FakeCosmosHandler returns PartitionedQueryExecutionInfo version 2; " + - "if the SDK expects a different version, queries may fail silently."); - } + [Fact] + public void QueryPlanVersion_MatchesExpected() + { + FakeCosmosHandler.QueryPlanVersion.Should().Be(2, + "FakeCosmosHandler returns PartitionedQueryExecutionInfo version 2; " + + "if the SDK expects a different version, queries may fail silently."); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -167,39 +167,39 @@ public void QueryPlanVersion_MatchesExpected() public class SdkVersionPinningTests { - [Fact] - public void MinTestedSdkVersion_IsSet() - { - FakeCosmosHandler.MinTestedSdkVersion.Should().NotBeNull(); - } - - [Fact] - public void MaxTestedSdkVersion_IsSet() - { - FakeCosmosHandler.MaxTestedSdkVersion.Should().NotBeNull(); - } - - [Fact] - public void CurrentSdkVersion_IsWithinTestedRange() - { - var current = typeof(CosmosClient).Assembly.GetName().Version; - current.Should().NotBeNull(); - - current.Should().BeGreaterThanOrEqualTo(FakeCosmosHandler.MinTestedSdkVersion, - "the current Cosmos SDK version should be at or above the minimum tested version"); - current.Should().BeLessThanOrEqualTo(FakeCosmosHandler.MaxTestedSdkVersion, - "the current Cosmos SDK version should be at or below the maximum tested version"); - } - - [Fact] - public void SdkVersionWarnings_IsEmptyWhenWithinRange() - { - var container = new InMemoryContainer("test", "/pk"); - using var handler = new FakeCosmosHandler(container); - - // Since tests run with a supported SDK, there should be no warnings - handler.SdkVersionWarnings.Should().BeEmpty(); - } + [Fact] + public void MinTestedSdkVersion_IsSet() + { + FakeCosmosHandler.MinTestedSdkVersion.Should().NotBeNull(); + } + + [Fact] + public void MaxTestedSdkVersion_IsSet() + { + FakeCosmosHandler.MaxTestedSdkVersion.Should().NotBeNull(); + } + + [Fact] + public void CurrentSdkVersion_IsWithinTestedRange() + { + var current = typeof(CosmosClient).Assembly.GetName().Version; + current.Should().NotBeNull(); + + current.Should().BeGreaterThanOrEqualTo(FakeCosmosHandler.MinTestedSdkVersion, + "the current Cosmos SDK version should be at or above the minimum tested version"); + current.Should().BeLessThanOrEqualTo(FakeCosmosHandler.MaxTestedSdkVersion, + "the current Cosmos SDK version should be at or below the maximum tested version"); + } + + [Fact] + public void SdkVersionWarnings_IsEmptyWhenWithinRange() + { + var container = new InMemoryContainer("test", "/pk"); + using var handler = new FakeCosmosHandler(container); + + // Since tests run with a supported SDK, there should be no warnings + handler.SdkVersionWarnings.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -208,72 +208,72 @@ public void SdkVersionWarnings_IsEmptyWhenWithinRange() public class WireFormatStrategyTests { - [Fact] - public void DefaultOptions_HasDefaultQueryPlanStrategy() - { - var options = new FakeCosmosHandlerOptions(); - options.QueryPlanStrategy.Should().NotBeNull(); - } - - [Fact] - public void DefaultOptions_HasDefaultBatchSchemaStrategy() - { - var options = new FakeCosmosHandlerOptions(); - options.BatchSchemaStrategy.Should().NotBeNull(); - } - - [Fact] - public async Task CustomQueryPlanStrategy_IsUsed() - { - // On Windows, the SDK uses ServiceInterop for query plans rather than the HTTP - // endpoint, so the custom strategy won't be invoked for basic queries. - if (OperatingSystem.IsWindows()) - return; - - var called = false; - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - new { id = "1", pk = "a" }, new PartitionKey("a")); - - var customStrategy = new DelegatingQueryPlanStrategy(() => called = true); - var options = new FakeCosmosHandlerOptions - { - QueryPlanStrategy = customStrategy - }; - - using var handler = new FakeCosmosHandler(container, options); - using var client = new CosmosClient( - "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - var cosmosContainer = client.GetContainer("db", "test"); - var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - called.Should().BeTrue("the custom query plan strategy should have been invoked"); - } - - /// - /// Test helper that delegates to the default strategy but records that it was called. - /// - private sealed class DelegatingQueryPlanStrategy : IQueryPlanStrategy - { - private readonly Action _onCalled; - private readonly IQueryPlanStrategy _inner = new DefaultQueryPlanStrategy(); - - public DelegatingQueryPlanStrategy(Action onCalled) => _onCalled = onCalled; - - public JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid) - { - _onCalled(); - return _inner.BuildQueryPlan(sqlQuery, parsed, collectionRid); - } - } + [Fact] + public void DefaultOptions_HasDefaultQueryPlanStrategy() + { + var options = new FakeCosmosHandlerOptions(); + options.QueryPlanStrategy.Should().NotBeNull(); + } + + [Fact] + public void DefaultOptions_HasDefaultBatchSchemaStrategy() + { + var options = new FakeCosmosHandlerOptions(); + options.BatchSchemaStrategy.Should().NotBeNull(); + } + + [Fact] + public async Task CustomQueryPlanStrategy_IsUsed() + { + // On Windows, the SDK uses ServiceInterop for query plans rather than the HTTP + // endpoint, so the custom strategy won't be invoked for basic queries. + if (OperatingSystem.IsWindows()) + return; + + var called = false; + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + new { id = "1", pk = "a" }, new PartitionKey("a")); + + var customStrategy = new DelegatingQueryPlanStrategy(() => called = true); + var options = new FakeCosmosHandlerOptions + { + QueryPlanStrategy = customStrategy + }; + + using var handler = new FakeCosmosHandler(container, options); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + var cosmosContainer = client.GetContainer("db", "test"); + var iterator = cosmosContainer.GetItemQueryIterator("SELECT * FROM c"); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + called.Should().BeTrue("the custom query plan strategy should have been invoked"); + } + + /// + /// Test helper that delegates to the default strategy but records that it was called. + /// + private sealed class DelegatingQueryPlanStrategy : IQueryPlanStrategy + { + private readonly Action _onCalled; + private readonly IQueryPlanStrategy _inner = new DefaultQueryPlanStrategy(); + + public DelegatingQueryPlanStrategy(Action onCalled) => _onCalled = onCalled; + + public JObject BuildQueryPlan(string sqlQuery, CosmosSqlQuery? parsed, string collectionRid) + { + _onCalled(); + return _inner.BuildQueryPlan(sqlQuery, parsed, collectionRid); + } + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkVersionDriftDetectorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkVersionDriftDetectorTests.cs index 2e7580a..bb990c3 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkVersionDriftDetectorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SdkVersionDriftDetectorTests.cs @@ -6,41 +6,41 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class SdkVersionDriftDetectorTests { - [Fact] - public async Task DriftDetector_ProducesStructuredReport() - { - var report = await SdkVersionDriftDetector.RunAsync(); + [Fact] + public async Task DriftDetector_ProducesStructuredReport() + { + var report = await SdkVersionDriftDetector.RunAsync(); - report.SdkVersion.Should().NotBeNullOrEmpty(); - report.CompatibilityPassed.Should().BeTrue(); - report.UnrecognisedHeaders.Should().BeEmpty(); - report.TestSuiteVersion.Should().NotBeNullOrEmpty(); - report.MinTestedVersion.Should().NotBeNullOrEmpty(); - report.MaxTestedVersion.Should().NotBeNullOrEmpty(); - report.IsWithinTestedRange.Should().BeTrue(); - report.CompatibilityError.Should().BeNull(); - } + report.SdkVersion.Should().NotBeNullOrEmpty(); + report.CompatibilityPassed.Should().BeTrue(); + report.UnrecognisedHeaders.Should().BeEmpty(); + report.TestSuiteVersion.Should().NotBeNullOrEmpty(); + report.MinTestedVersion.Should().NotBeNullOrEmpty(); + report.MaxTestedVersion.Should().NotBeNullOrEmpty(); + report.IsWithinTestedRange.Should().BeTrue(); + report.CompatibilityError.Should().BeNull(); + } - [Fact] - public async Task DriftDetector_ReportIsSerializableToJson() - { - var report = await SdkVersionDriftDetector.RunAsync(); - var json = System.Text.Json.JsonSerializer.Serialize(report); + [Fact] + public async Task DriftDetector_ReportIsSerializableToJson() + { + var report = await SdkVersionDriftDetector.RunAsync(); + var json = System.Text.Json.JsonSerializer.Serialize(report); - json.Should().Contain("sdkVersion"); - json.Should().Contain("compatibilityPassed"); - json.Should().Contain("unrecognisedHeaders"); - json.Should().Contain("isWithinTestedRange"); - } + json.Should().Contain("sdkVersion"); + json.Should().Contain("compatibilityPassed"); + json.Should().Contain("unrecognisedHeaders"); + json.Should().Contain("isWithinTestedRange"); + } - [Fact] - public async Task DriftDetector_ExercisesMultipleCodePaths() - { - var report = await SdkVersionDriftDetector.RunAsync(); + [Fact] + public async Task DriftDetector_ExercisesMultipleCodePaths() + { + var report = await SdkVersionDriftDetector.RunAsync(); - // The detector should exercise read, query, and CRUD paths - // and still report success - report.CompatibilityPassed.Should().BeTrue(); - report.UnrecognisedHeaders.Should().BeEmpty(); - } + // The detector should exercise read, query, and CRUD paths + // and still report success + report.CompatibilityPassed.Should().BeTrue(); + report.UnrecognisedHeaders.Should().BeEmpty(); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ServiceCollectionExtensionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ServiceCollectionExtensionTests.cs index 5b959fa..ff18a11 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ServiceCollectionExtensionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ServiceCollectionExtensionTests.cs @@ -16,294 +16,294 @@ namespace CosmosDB.InMemoryEmulator.Tests; [Collection("FeedIteratorSetup")] public class UseInMemoryCosmosContainersTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void Default_RegistersSingleContainer() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("in-memory-container"); - } - - [Fact] - public void Container_IsRealSdkContainer_NotInMemoryContainer() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - // 4.0: UseInMemoryCosmosContainers now returns real SDK Container backed by FakeCosmosHandler - container.Should().NotBeOfType(); - container.Id.Should().Be("orders"); - } - - [Fact] - public async Task Container_SupportsCrud_ThroughSdkPipeline() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Real SDK pipeline — create and read back - await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - var response = await container.ReadItemAsync("1", new PartitionKey("a")); - ((string)response.Resource.id).Should().Be("1"); - } - - [Fact] - public async Task Container_ToFeedIterator_WorksNatively() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - - // .ToFeedIterator() works via FakeCosmosHandler — no ProductionExtensions needed - var query = container.GetItemLinqQueryable().ToFeedIterator(); - var results = new List(); - while (query.HasMoreResults) - { - var page = await query.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle(); - } - - [Fact] - public void RemovesExistingContainerRegistration() - { - var services = new ServiceCollection(); - services.AddSingleton(new InMemoryContainer("old-container", "/old")); - - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Id.Should().Be("in-memory-container"); - } - - [Fact] - public void DoesNotRemoveCosmosClient() - { - var services = new ServiceCollection(); - var originalClient = new InMemoryCosmosClient(); - services.AddSingleton(originalClient); - - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().BeSameAs(originalClient); - } - - [Fact] - public void CustomContainerName() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => o.AddContainer("orders")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Id.Should().Be("orders"); - } - - [Fact] - public void OnHandlerCreatedCallback() - { - var services = new ServiceCollection(); - FakeCosmosHandler? captured = null; - - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (_, h) => captured = h; - }); - - captured.Should().NotBeNull(); - captured!.BackingContainer.Id.Should().Be("orders"); - } - - [Fact] - public void OnHandlerCreated_FiresForEachContainer() - { - var services = new ServiceCollection(); - var captured = new Dictionary(); - - services.UseInMemoryCosmosContainers(o => - { - o.OnHandlerCreated = (name, h) => captured[name] = h; - o.AddContainer("orders", "/pk"); - o.AddContainer("events", "/partitionKey"); - o.AddContainer("logs", "/category"); - }); - - captured.Should().HaveCount(3); - captured.Keys.Should().BeEquivalentTo(["orders", "events", "logs"]); - } - - [Fact] - public async Task FaultInjection_ViaOnHandlerCreated() - { - var services = new ServiceCollection(); - FakeCosmosHandler? handler = null; - - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (_, h) => handler = h; - }); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - handler!.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); - } - - [Fact] - public void WithHttpMessageHandlerWrapper() - { - var services = new ServiceCollection(); - var wrapperInvoked = false; - - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/pk"); - o.WithHttpMessageHandlerWrapper(h => - { - wrapperInvoked = true; - return h; // pass-through - }); - }); - - wrapperInvoked.Should().BeTrue(); - } - - [Fact] - public void OnContainerCreatedCallback_StillWorks() - { - var services = new ServiceCollection(); - InMemoryContainer? captured = null; - - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/pk"); - o.OnContainerCreated = c => captured = (InMemoryContainer)c; - }); - - captured.Should().NotBeNull(); - captured!.Id.Should().Be("orders"); - } - - [Fact] - public void MatchesExistingLifetime_Scoped() - { - var services = new ServiceCollection(); - services.AddScoped(_ => new InMemoryContainer("old", "/id")); - - services.UseInMemoryCosmosContainers(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); - } - - [Fact] - public void MultipleContainers() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/pk"); - o.AddContainer("events", "/partitionKey"); - o.AddContainer("logs", "/category"); - }); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().HaveCount(3); - containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "events", "logs"]); - } - - [Fact] - public void Idempotent_CalledTwice() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(); - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().ContainSingle(); - } - - [Fact] - public void MatchesExistingLifetime_Transient() - { - var services = new ServiceCollection(); - services.AddTransient(_ => new InMemoryContainer("old", "/id")); - - services.UseInMemoryCosmosContainers(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); - } - - [Fact] - public void MatchesExistingLifetime_DefaultsSingleton() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); - } - - [Fact] - public void ReturnsSameServiceCollection_Fluent() - { - var services = new ServiceCollection(); - - var result = services.UseInMemoryCosmosContainers(); - - result.Should().BeSameAs(services); - } - - [Fact] - public void CustomDatabaseName() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => - { - o.DatabaseName = "TestDb"; - o.AddContainer("orders", "/pk"); - }); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void Default_RegistersSingleContainer() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("in-memory-container"); + } + + [Fact] + public void Container_IsRealSdkContainer_NotInMemoryContainer() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + // 4.0: UseInMemoryCosmosContainers now returns real SDK Container backed by FakeCosmosHandler + container.Should().NotBeOfType(); + container.Id.Should().Be("orders"); + } + + [Fact] + public async Task Container_SupportsCrud_ThroughSdkPipeline() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Real SDK pipeline — create and read back + await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + var response = await container.ReadItemAsync("1", new PartitionKey("a")); + ((string)response.Resource.id).Should().Be("1"); + } + + [Fact] + public async Task Container_ToFeedIterator_WorksNatively() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + await container.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + + // .ToFeedIterator() works via FakeCosmosHandler — no ProductionExtensions needed + var query = container.GetItemLinqQueryable().ToFeedIterator(); + var results = new List(); + while (query.HasMoreResults) + { + var page = await query.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle(); + } + + [Fact] + public void RemovesExistingContainerRegistration() + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryContainer("old-container", "/old")); + + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Id.Should().Be("in-memory-container"); + } + + [Fact] + public void DoesNotRemoveCosmosClient() + { + var services = new ServiceCollection(); + var originalClient = new InMemoryCosmosClient(); + services.AddSingleton(originalClient); + + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().BeSameAs(originalClient); + } + + [Fact] + public void CustomContainerName() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => o.AddContainer("orders")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Id.Should().Be("orders"); + } + + [Fact] + public void OnHandlerCreatedCallback() + { + var services = new ServiceCollection(); + FakeCosmosHandler? captured = null; + + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (_, h) => captured = h; + }); + + captured.Should().NotBeNull(); + captured!.BackingContainer.Id.Should().Be("orders"); + } + + [Fact] + public void OnHandlerCreated_FiresForEachContainer() + { + var services = new ServiceCollection(); + var captured = new Dictionary(); + + services.UseInMemoryCosmosContainers(o => + { + o.OnHandlerCreated = (name, h) => captured[name] = h; + o.AddContainer("orders", "/pk"); + o.AddContainer("events", "/partitionKey"); + o.AddContainer("logs", "/category"); + }); + + captured.Should().HaveCount(3); + captured.Keys.Should().BeEquivalentTo(["orders", "events", "logs"]); + } + + [Fact] + public async Task FaultInjection_ViaOnHandlerCreated() + { + var services = new ServiceCollection(); + FakeCosmosHandler? handler = null; + + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (_, h) => handler = h; + }); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + handler!.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public void WithHttpMessageHandlerWrapper() + { + var services = new ServiceCollection(); + var wrapperInvoked = false; + + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/pk"); + o.WithHttpMessageHandlerWrapper(h => + { + wrapperInvoked = true; + return h; // pass-through + }); + }); + + wrapperInvoked.Should().BeTrue(); + } + + [Fact] + public void OnContainerCreatedCallback_StillWorks() + { + var services = new ServiceCollection(); + InMemoryContainer? captured = null; + + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/pk"); + o.OnContainerCreated = c => captured = (InMemoryContainer)c; + }); + + captured.Should().NotBeNull(); + captured!.Id.Should().Be("orders"); + } + + [Fact] + public void MatchesExistingLifetime_Scoped() + { + var services = new ServiceCollection(); + services.AddScoped(_ => new InMemoryContainer("old", "/id")); + + services.UseInMemoryCosmosContainers(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void MultipleContainers() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/pk"); + o.AddContainer("events", "/partitionKey"); + o.AddContainer("logs", "/category"); + }); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().HaveCount(3); + containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "events", "logs"]); + } + + [Fact] + public void Idempotent_CalledTwice() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(); + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().ContainSingle(); + } + + [Fact] + public void MatchesExistingLifetime_Transient() + { + var services = new ServiceCollection(); + services.AddTransient(_ => new InMemoryContainer("old", "/id")); + + services.UseInMemoryCosmosContainers(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); + } + + [Fact] + public void MatchesExistingLifetime_DefaultsSingleton() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void ReturnsSameServiceCollection_Fluent() + { + var services = new ServiceCollection(); + + var result = services.UseInMemoryCosmosContainers(); + + result.Should().BeSameAs(services); + } + + [Fact] + public void CustomDatabaseName() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => + { + o.DatabaseName = "TestDb"; + o.AddContainer("orders", "/pk"); + }); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -313,338 +313,338 @@ public void CustomDatabaseName() [Collection("FeedIteratorSetup")] public class UseInMemoryCosmosDBTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void Default_RegistersCosmosClient() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - } - - [Fact] - public void Default_RegistersContainer() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("in-memory-container"); - } - - [Fact] - public void RemovesExistingCosmosClient() - { - var services = new ServiceCollection(); - // Register a factory that would fail if ever invoked - services.AddSingleton(_ => - throw new InvalidOperationException("Should have been removed")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - } - - [Fact] - public void RemovesExistingContainer() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should have been removed")); - - // Explicit AddContainer removes existing registrations - services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("orders"); - } - - [Fact] - public void ContainerIsFromClient() - { - var services = new ServiceCollection(); - FakeCosmosHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "mydb"; - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (_, h) => capturedHandler = h; - }); - - var provider = services.BuildServiceProvider(); - var diContainer = provider.GetRequiredService(); - - // DI container and client both route through the same FakeCosmosHandler - diContainer.Id.Should().Be("orders"); - capturedHandler.Should().NotBeNull(); - capturedHandler!.BackingContainer.Id.Should().Be("orders"); - } - - [Fact] - public void CustomDatabaseName() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => o.DatabaseName = "TestDb"); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var db = client.GetDatabase("TestDb"); - db.Id.Should().Be("TestDb"); - } - - [Fact] - public void DefaultDatabaseName_IsInMemoryDb() - { - var services = new ServiceCollection(); - FakeCosmosHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.OnHandlerCreated = (_, h) => capturedHandler = h; - }); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // The default database and container names are used - container.Id.Should().Be("in-memory-container"); - capturedHandler.Should().NotBeNull(); - capturedHandler!.BackingContainer.Id.Should().Be("in-memory-container"); - } - - [Fact] - public void MultipleContainers() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.AddContainer("events", "/partitionKey"); - }); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().HaveCount(2); - containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "events"]); - } - - [Fact] - public void DoesNotNeedFeedIteratorSetup() - { - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - // FakeCosmosHandler handles .ToFeedIterator() natively — no setup needed - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); - } - - [Fact] - public void OnClientCreatedCallback() - { - var services = new ServiceCollection(); - CosmosClient? captured = null; - - services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); - - captured.Should().NotBeNull(); - } - - [Fact] - public void IsSuperset_ContainerBehavior() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should have been removed")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "items")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - // Auto-detect: existing factory resolves against the in-memory client - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void ContainerPerDatabaseOverride() - { - var services = new ServiceCollection(); - var capturedHandlers = new Dictionary(); - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("c1", "/pk", databaseName: "db1"); - o.AddContainer("c2", "/pk", databaseName: "db2"); - o.OnHandlerCreated = (name, h) => capturedHandlers[name] = h; - }); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - - containers.Should().HaveCount(2); - containers.Select(c => c.Id).Should().BeEquivalentTo(["c1", "c2"]); - - // Each container backed by its own FakeCosmosHandler - capturedHandlers.Should().HaveCount(2); - capturedHandlers["c1"].BackingContainer.Id.Should().Be("c1"); - capturedHandlers["c2"].BackingContainer.Id.Should().Be("c2"); - } - - [Fact] - public void MatchesExistingContainerLifetime() - { - var services = new ServiceCollection(); - services.AddScoped(_ => new InMemoryContainer("old", "/id")); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); - } - - [Fact] - public void MatchesExistingLifetime_Transient() - { - var services = new ServiceCollection(); - services.AddTransient(_ => new InMemoryContainer("old", "/id")); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); - } - - [Fact] - public void MatchesExistingLifetime_DefaultsSingleton() - { - var services = new ServiceCollection(); - // No existing Container registration — defaults to Singleton - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); - } - - [Fact] - public void Idempotent_CalledTwice() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().ContainSingle(); - var clients = provider.GetServices().ToList(); - clients.Should().ContainSingle(); - } - - [Fact] - public void OnHandlerCreatedCallback_SingleContainer() - { - var services = new ServiceCollection(); - string? capturedName = null; - FakeCosmosHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (name, handler) => - { - capturedName = name; - capturedHandler = handler; - }; - }); - - capturedName.Should().Be("orders"); - capturedHandler.Should().NotBeNull(); - capturedHandler!.BackingContainer.Id.Should().Be("orders"); - } - - [Fact] - public void DuplicateContainerNames_LastHandlerWins() - { - // When duplicate container names are used with UseInMemoryCosmosDB, - // the handlers dictionary uses the container name as key — the last one wins. - // Both are still registered in DI as separate Container instances, but - // the FakeCosmosHandler routing only sees the last one. - var services = new ServiceCollection(); - var capturedHandlers = new List<(string Name, FakeCosmosHandler Handler)>(); - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.AddContainer("orders", "/pk2"); - o.OnHandlerCreated = (name, h) => capturedHandlers.Add((name, h)); - }); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - // Both containers are registered in DI - containers.Should().HaveCount(2); - - // OnHandlerCreated fires for each, but second overwrites first in the handler dictionary - capturedHandlers.Should().HaveCount(2); - } - - [Fact] - public void CosmosClientAlwaysSingleton_EvenIfExistingWasScoped() - { - var services = new ServiceCollection(); - services.AddScoped(_ => - throw new InvalidOperationException("Should have been removed")); - - services.UseInMemoryCosmosDB(); - - // CosmsosClient is always registered as Singleton regardless of existing registration - var descriptor = services.First(d => d.ServiceType == typeof(CosmosClient)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); - } - - [Fact] - public void ReturnsSameServiceCollection_Fluent() - { - var services = new ServiceCollection(); - - var result = services.UseInMemoryCosmosDB(); - - result.Should().BeSameAs(services); - } - - [Fact] - public void OnClientCreated_ReturnsInstanceDIResolves() - { - var services = new ServiceCollection(); - CosmosClient? captured = null; - - services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); - - var provider = services.BuildServiceProvider(); - var resolved = provider.GetRequiredService(); - - // The callback receives the exact same instance that DI will resolve - captured.Should().BeSameAs(resolved); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void Default_RegistersCosmosClient() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + } + + [Fact] + public void Default_RegistersContainer() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("in-memory-container"); + } + + [Fact] + public void RemovesExistingCosmosClient() + { + var services = new ServiceCollection(); + // Register a factory that would fail if ever invoked + services.AddSingleton(_ => + throw new InvalidOperationException("Should have been removed")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + } + + [Fact] + public void RemovesExistingContainer() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should have been removed")); + + // Explicit AddContainer removes existing registrations + services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("orders"); + } + + [Fact] + public void ContainerIsFromClient() + { + var services = new ServiceCollection(); + FakeCosmosHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "mydb"; + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (_, h) => capturedHandler = h; + }); + + var provider = services.BuildServiceProvider(); + var diContainer = provider.GetRequiredService(); + + // DI container and client both route through the same FakeCosmosHandler + diContainer.Id.Should().Be("orders"); + capturedHandler.Should().NotBeNull(); + capturedHandler!.BackingContainer.Id.Should().Be("orders"); + } + + [Fact] + public void CustomDatabaseName() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => o.DatabaseName = "TestDb"); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var db = client.GetDatabase("TestDb"); + db.Id.Should().Be("TestDb"); + } + + [Fact] + public void DefaultDatabaseName_IsInMemoryDb() + { + var services = new ServiceCollection(); + FakeCosmosHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.OnHandlerCreated = (_, h) => capturedHandler = h; + }); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // The default database and container names are used + container.Id.Should().Be("in-memory-container"); + capturedHandler.Should().NotBeNull(); + capturedHandler!.BackingContainer.Id.Should().Be("in-memory-container"); + } + + [Fact] + public void MultipleContainers() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.AddContainer("events", "/partitionKey"); + }); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().HaveCount(2); + containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "events"]); + } + + [Fact] + public void DoesNotNeedFeedIteratorSetup() + { + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + // FakeCosmosHandler handles .ToFeedIterator() natively — no setup needed + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); + } + + [Fact] + public void OnClientCreatedCallback() + { + var services = new ServiceCollection(); + CosmosClient? captured = null; + + services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); + + captured.Should().NotBeNull(); + } + + [Fact] + public void IsSuperset_ContainerBehavior() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should have been removed")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "items")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + // Auto-detect: existing factory resolves against the in-memory client + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void ContainerPerDatabaseOverride() + { + var services = new ServiceCollection(); + var capturedHandlers = new Dictionary(); + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("c1", "/pk", databaseName: "db1"); + o.AddContainer("c2", "/pk", databaseName: "db2"); + o.OnHandlerCreated = (name, h) => capturedHandlers[name] = h; + }); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + + containers.Should().HaveCount(2); + containers.Select(c => c.Id).Should().BeEquivalentTo(["c1", "c2"]); + + // Each container backed by its own FakeCosmosHandler + capturedHandlers.Should().HaveCount(2); + capturedHandlers["c1"].BackingContainer.Id.Should().Be("c1"); + capturedHandlers["c2"].BackingContainer.Id.Should().Be("c2"); + } + + [Fact] + public void MatchesExistingContainerLifetime() + { + var services = new ServiceCollection(); + services.AddScoped(_ => new InMemoryContainer("old", "/id")); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void MatchesExistingLifetime_Transient() + { + var services = new ServiceCollection(); + services.AddTransient(_ => new InMemoryContainer("old", "/id")); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); + } + + [Fact] + public void MatchesExistingLifetime_DefaultsSingleton() + { + var services = new ServiceCollection(); + // No existing Container registration — defaults to Singleton + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void Idempotent_CalledTwice() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().ContainSingle(); + var clients = provider.GetServices().ToList(); + clients.Should().ContainSingle(); + } + + [Fact] + public void OnHandlerCreatedCallback_SingleContainer() + { + var services = new ServiceCollection(); + string? capturedName = null; + FakeCosmosHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (name, handler) => + { + capturedName = name; + capturedHandler = handler; + }; + }); + + capturedName.Should().Be("orders"); + capturedHandler.Should().NotBeNull(); + capturedHandler!.BackingContainer.Id.Should().Be("orders"); + } + + [Fact] + public void DuplicateContainerNames_LastHandlerWins() + { + // When duplicate container names are used with UseInMemoryCosmosDB, + // the handlers dictionary uses the container name as key — the last one wins. + // Both are still registered in DI as separate Container instances, but + // the FakeCosmosHandler routing only sees the last one. + var services = new ServiceCollection(); + var capturedHandlers = new List<(string Name, FakeCosmosHandler Handler)>(); + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.AddContainer("orders", "/pk2"); + o.OnHandlerCreated = (name, h) => capturedHandlers.Add((name, h)); + }); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + // Both containers are registered in DI + containers.Should().HaveCount(2); + + // OnHandlerCreated fires for each, but second overwrites first in the handler dictionary + capturedHandlers.Should().HaveCount(2); + } + + [Fact] + public void CosmosClientAlwaysSingleton_EvenIfExistingWasScoped() + { + var services = new ServiceCollection(); + services.AddScoped(_ => + throw new InvalidOperationException("Should have been removed")); + + services.UseInMemoryCosmosDB(); + + // CosmsosClient is always registered as Singleton regardless of existing registration + var descriptor = services.First(d => d.ServiceType == typeof(CosmosClient)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void ReturnsSameServiceCollection_Fluent() + { + var services = new ServiceCollection(); + + var result = services.UseInMemoryCosmosDB(); + + result.Should().BeSameAs(services); + } + + [Fact] + public void OnClientCreated_ReturnsInstanceDIResolves() + { + var services = new ServiceCollection(); + CosmosClient? captured = null; + + services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); + + var provider = services.BuildServiceProvider(); + var resolved = provider.GetRequiredService(); + + // The callback receives the exact same instance that DI will resolve + captured.Should().BeSameAs(resolved); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -654,261 +654,261 @@ public void OnClientCreated_ReturnsInstanceDIResolves() [Collection("FeedIteratorSetup")] public class ServiceCollectionExtensionEdgeCaseTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void UseInMemoryCosmosDB_NoExistingRegistrations_StillWorks() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosContainers_NoExistingRegistrations_StillWorks() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public async Task UseInMemoryCosmosDB_ContainerUsableForCrud() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Create - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Read - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task UseInMemoryCosmosContainers_ContainerUsableForCrud() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => o.AddContainer("test", "/partitionKey")); - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Create - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; - var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - // Read - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task UseInMemoryCosmosDB_ContainerUsableForLinqQuery() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Seed - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - // Query via LINQ + ToFeedIteratorOverridable - var iterator = container.GetItemLinqQueryable(true) - .Where(d => d.PartitionKey == "pk1") - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - } - - [Fact] - public void UseInMemoryCosmosDB_ClientGetContainerReturnsRegisteredContainer() - { - var services = new ServiceCollection(); - FakeCosmosHandler? capturedHandler = null; - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "db"; - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (_, h) => capturedHandler = h; - }); - var provider = services.BuildServiceProvider(); - - var client = provider.GetRequiredService(); - var diContainer = provider.GetRequiredService(); - var clientContainer = client.GetContainer("db", "orders"); - - // Both route through the same FakeCosmosHandler backing - diContainer.Id.Should().Be("orders"); - clientContainer.Id.Should().Be("orders"); - capturedHandler.Should().NotBeNull(); - capturedHandler!.BackingContainer.Id.Should().Be("orders"); - } - - [Fact] - public void BothMethodsCalled_UseInMemoryCosmosDB_ThenContainers() - { - // When UseInMemoryCosmosDB is called first and UseInMemoryCosmosContainers second, - // the Container registration from UseInMemoryCosmosDB gets removed and replaced. - // CosmosClient from UseInMemoryCosmosDB remains. - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => o.AddContainer("fromDB", "/pk")); - services.UseInMemoryCosmosContainers(o => o.AddContainer("fromContainers", "/pk")); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().ContainSingle(); - containers[0].Id.Should().Be("fromContainers"); - // CosmosClient from UseInMemoryCosmosDB should still be present - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void BothMethodsCalled_UseInMemoryCosmosContainers_ThenCosmosDB() - { - // When UseInMemoryCosmosContainers is called first and UseInMemoryCosmosDB second, - // Container from Containers gets removed and replaced by DB's container. - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(o => o.AddContainer("fromContainers", "/pk")); - services.UseInMemoryCosmosDB(o => o.AddContainer("fromDB", "/pk")); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().ContainSingle(); - containers[0].Id.Should().Be("fromDB"); - } - - [Fact] - public async Task ScopedLifetime_DisposalWorks() - { - var services = new ServiceCollection(); - services.AddScoped(_ => new InMemoryContainer("old", "/id")); - - services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); - - var provider = services.BuildServiceProvider(); - - Container container; - using (var scope = provider.CreateScope()) - { - container = scope.ServiceProvider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("test"); - - // Container is usable within the scope - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Scoped" }, - new PartitionKey("pk1")); - } - - // After scope disposal, a new scope should get a fresh factory invocation - using var scope2 = provider.CreateScope(); - var container2 = scope2.ServiceProvider.GetRequiredService(); - container2.Should().NotBeNull(); - } - - [Fact] - public void FeedIteratorSetup_Asymmetry_UseInMemoryCosmosDB_DoesNotRegister() - { - // UseInMemoryCosmosDB does NOT register FeedIteratorSetup (FakeCosmosHandler handles it natively) - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( - "UseInMemoryCosmosDB uses FakeCosmosHandler which handles .ToFeedIterator() natively"); - } - - [Fact] - public void FeedIteratorSetup_Asymmetry_UseInMemoryCosmosContainers_DoesNotRegister() - { - // v4.0: UseInMemoryCosmosContainers now uses FakeCosmosHandler — no FeedIteratorSetup needed - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosContainers(); - - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( - "UseInMemoryCosmosContainers uses FakeCosmosHandler which handles .ToFeedIterator() natively"); - } - - [Fact] - public void FeedIteratorSetup_NotRegistered_TypedClient() - { - // UseInMemoryCosmosDB now uses FakeCosmosHandler, so FeedIteratorSetup is not needed - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( - "UseInMemoryCosmosDB uses FakeCosmosHandler which handles .ToFeedIterator() natively"); - } - - [Fact] - public void FluentChaining_InMemoryCosmosOptions_AddContainer() - { - var options = new InMemoryCosmosOptions(); - - var result = options.AddContainer("a", "/pk").AddContainer("b", "/pk2"); - - result.Should().BeSameAs(options); - options.Containers.Should().HaveCount(2); - options.Containers[0].ContainerName.Should().Be("a"); - options.Containers[1].ContainerName.Should().Be("b"); - } - - [Fact] - public void FluentChaining_InMemoryContainerOptions_AddContainer() - { - var options = new InMemoryContainerOptions(); - - var result = options.AddContainer("a", "/pk").AddContainer("b", "/pk2"); - - result.Should().BeSameAs(options); - options.Containers.Should().HaveCount(2); - options.Containers[0].ContainerName.Should().Be("a"); - options.Containers[1].ContainerName.Should().Be("b"); - } - - [Fact] - public void ContainerConfig_RecordEquality() - { - var a = new ContainerConfig("orders", "/pk", "db1"); - var b = new ContainerConfig("orders", "/pk", "db1"); - var c = new ContainerConfig("events", "/pk", "db1"); - - a.Should().Be(b); - a.Should().NotBe(c); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void UseInMemoryCosmosDB_NoExistingRegistrations_StillWorks() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosContainers_NoExistingRegistrations_StillWorks() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public async Task UseInMemoryCosmosDB_ContainerUsableForCrud() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Create + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Read + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task UseInMemoryCosmosContainers_ContainerUsableForCrud() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => o.AddContainer("test", "/partitionKey")); + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Create + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }; + var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Read + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task UseInMemoryCosmosDB_ContainerUsableForLinqQuery() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Seed + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + // Query via LINQ + ToFeedIteratorOverridable + var iterator = container.GetItemLinqQueryable(true) + .Where(d => d.PartitionKey == "pk1") + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + } + + [Fact] + public void UseInMemoryCosmosDB_ClientGetContainerReturnsRegisteredContainer() + { + var services = new ServiceCollection(); + FakeCosmosHandler? capturedHandler = null; + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "db"; + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (_, h) => capturedHandler = h; + }); + var provider = services.BuildServiceProvider(); + + var client = provider.GetRequiredService(); + var diContainer = provider.GetRequiredService(); + var clientContainer = client.GetContainer("db", "orders"); + + // Both route through the same FakeCosmosHandler backing + diContainer.Id.Should().Be("orders"); + clientContainer.Id.Should().Be("orders"); + capturedHandler.Should().NotBeNull(); + capturedHandler!.BackingContainer.Id.Should().Be("orders"); + } + + [Fact] + public void BothMethodsCalled_UseInMemoryCosmosDB_ThenContainers() + { + // When UseInMemoryCosmosDB is called first and UseInMemoryCosmosContainers second, + // the Container registration from UseInMemoryCosmosDB gets removed and replaced. + // CosmosClient from UseInMemoryCosmosDB remains. + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => o.AddContainer("fromDB", "/pk")); + services.UseInMemoryCosmosContainers(o => o.AddContainer("fromContainers", "/pk")); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().ContainSingle(); + containers[0].Id.Should().Be("fromContainers"); + // CosmosClient from UseInMemoryCosmosDB should still be present + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void BothMethodsCalled_UseInMemoryCosmosContainers_ThenCosmosDB() + { + // When UseInMemoryCosmosContainers is called first and UseInMemoryCosmosDB second, + // Container from Containers gets removed and replaced by DB's container. + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(o => o.AddContainer("fromContainers", "/pk")); + services.UseInMemoryCosmosDB(o => o.AddContainer("fromDB", "/pk")); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().ContainSingle(); + containers[0].Id.Should().Be("fromDB"); + } + + [Fact] + public async Task ScopedLifetime_DisposalWorks() + { + var services = new ServiceCollection(); + services.AddScoped(_ => new InMemoryContainer("old", "/id")); + + services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); + + var provider = services.BuildServiceProvider(); + + Container container; + using (var scope = provider.CreateScope()) + { + container = scope.ServiceProvider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("test"); + + // Container is usable within the scope + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Scoped" }, + new PartitionKey("pk1")); + } + + // After scope disposal, a new scope should get a fresh factory invocation + using var scope2 = provider.CreateScope(); + var container2 = scope2.ServiceProvider.GetRequiredService(); + container2.Should().NotBeNull(); + } + + [Fact] + public void FeedIteratorSetup_Asymmetry_UseInMemoryCosmosDB_DoesNotRegister() + { + // UseInMemoryCosmosDB does NOT register FeedIteratorSetup (FakeCosmosHandler handles it natively) + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( + "UseInMemoryCosmosDB uses FakeCosmosHandler which handles .ToFeedIterator() natively"); + } + + [Fact] + public void FeedIteratorSetup_Asymmetry_UseInMemoryCosmosContainers_DoesNotRegister() + { + // v4.0: UseInMemoryCosmosContainers now uses FakeCosmosHandler — no FeedIteratorSetup needed + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosContainers(); + + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( + "UseInMemoryCosmosContainers uses FakeCosmosHandler which handles .ToFeedIterator() natively"); + } + + [Fact] + public void FeedIteratorSetup_NotRegistered_TypedClient() + { + // UseInMemoryCosmosDB now uses FakeCosmosHandler, so FeedIteratorSetup is not needed + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( + "UseInMemoryCosmosDB uses FakeCosmosHandler which handles .ToFeedIterator() natively"); + } + + [Fact] + public void FluentChaining_InMemoryCosmosOptions_AddContainer() + { + var options = new InMemoryCosmosOptions(); + + var result = options.AddContainer("a", "/pk").AddContainer("b", "/pk2"); + + result.Should().BeSameAs(options); + options.Containers.Should().HaveCount(2); + options.Containers[0].ContainerName.Should().Be("a"); + options.Containers[1].ContainerName.Should().Be("b"); + } + + [Fact] + public void FluentChaining_InMemoryContainerOptions_AddContainer() + { + var options = new InMemoryContainerOptions(); + + var result = options.AddContainer("a", "/pk").AddContainer("b", "/pk2"); + + result.Should().BeSameAs(options); + options.Containers.Should().HaveCount(2); + options.Containers[0].ContainerName.Should().Be("a"); + options.Containers[1].ContainerName.Should().Be("b"); + } + + [Fact] + public void ContainerConfig_RecordEquality() + { + var a = new ContainerConfig("orders", "/pk", "db1"); + var b = new ContainerConfig("orders", "/pk", "db1"); + var c = new ContainerConfig("events", "/pk", "db1"); + + a.Should().Be(b); + a.Should().NotBe(c); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -921,594 +921,594 @@ public void ContainerConfig_RecordEquality() // — no shadow types needed. public class EmployeeCosmosClient : CosmosClient { - public EmployeeCosmosClient(string connectionString, CosmosClientOptions? options = null) - : base(connectionString, options) { } + public EmployeeCosmosClient(string connectionString, CosmosClientOptions? options = null) + : base(connectionString, options) { } } public class CustomerCosmosClient : CosmosClient { - public CustomerCosmosClient(string connectionString, CosmosClientOptions? options = null) - : base(connectionString, options) { } + public CustomerCosmosClient(string connectionString, CosmosClientOptions? options = null) + : base(connectionString, options) { } } // Simulated repos that take typed clients — exactly as in production code. public class BiometricRepository { - public Container Container { get; } + public Container Container { get; } - public BiometricRepository(EmployeeCosmosClient client) - { - Container = client.GetContainer("BiometricDb", "biometrics"); - } + public BiometricRepository(EmployeeCosmosClient client) + { + Container = client.GetContainer("BiometricDb", "biometrics"); + } } public class OOBRepository { - public Container Container { get; } + public Container Container { get; } - public OOBRepository(CustomerCosmosClient client) - { - Container = client.GetContainer("OOBDb", "oob-events"); - } + public OOBRepository(CustomerCosmosClient client) + { + Container = client.GetContainer("OOBDb", "oob-events"); + } } [Collection("FeedIteratorSetup")] public class UseInMemoryTypedCosmosDBTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void RegistersTypedClient_ResolvableFromDI() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - o.AddContainer("biometrics", "/partitionKey")); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().BeAssignableTo(); - } - - [Fact] - public void RemovesExistingTypedRegistration() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should have been removed")); - - services.UseInMemoryCosmosDB(o => - o.AddContainer("biometrics", "/partitionKey")); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - } - - [Fact] - public async Task DefaultDatabase_IsInMemoryDb() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "in-memory-container"); - container.Should().NotBeNull(); - - // Verify it's functional (backed by FakeCosmosHandler) - var item = new TestDocument { Id = "1", PartitionKey = "1", Name = "Test" }; - var response = await container.CreateItemAsync(item, new PartitionKey("1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task CustomDatabaseName() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/pk"); - }); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("BiometricDb", "biometrics"); - container.Should().NotBeNull(); - - // Verify it's functional - var response = await container.CreateItemAsync( - new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public void MultipleTypedClients_Independent() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/pk"); - }); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "OOBDb"; - o.AddContainer("oob-events", "/pk"); - }); - - var provider = services.BuildServiceProvider(); - var bioClient = provider.GetRequiredService(); - var oobClient = provider.GetRequiredService(); - - bioClient.Should().NotBeSameAs(oobClient); - } - - [Fact] - public async Task TypedClient_ContainerUsableForCrud() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/partitionKey"); - }); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("BiometricDb", "biometrics"); - - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fingerprint" }; - var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Fingerprint"); - } - - [Fact] - public void RepositoryPattern_Constructor_ResolvesTypedClient() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/partitionKey"); - }); - services.AddTransient(); - - var provider = services.BuildServiceProvider(); - var repo = provider.GetRequiredService(); - - repo.Container.Should().NotBeNull(); - repo.Container.Id.Should().Be("biometrics"); - } - - [Fact] - public void MultipleTypedClients_WithRepos_IsolatedData() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/partitionKey"); - }); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "OOBDb"; - o.AddContainer("oob-events", "/partitionKey"); - }); - services.AddTransient(); - services.AddTransient(); - - var provider = services.BuildServiceProvider(); - var bioRepo = provider.GetRequiredService(); - var oobRepo = provider.GetRequiredService(); - - // Each repo's container should be independent - bioRepo.Container.Should().NotBeSameAs(oobRepo.Container); - bioRepo.Container.Id.Should().Be("biometrics"); - oobRepo.Container.Id.Should().Be("oob-events"); - } - - [Fact] - public void DoesNotRegisterAsBaseCosmosClient() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - // Should NOT be resolvable as base CosmosClient unless explicitly registered - var baseClient = provider.GetService(); - baseClient.Should().BeNull(); - } - - [Fact] - public void DoesNotRegisterContainer_InDI() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => o.AddContainer("bio", "/pk")); - - var provider = services.BuildServiceProvider(); - // Pattern 2 never has Container in DI — repos call client.GetContainer() - var container = provider.GetService(); - container.Should().BeNull(); - } - - [Fact] - public void FeedIteratorSetup_NotNeeded_TypedClient() - { - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - // FakeCosmosHandler handles .ToFeedIterator() natively — no FeedIteratorSetup needed - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( - "UseInMemoryCosmosDB now uses FakeCosmosHandler which handles .ToFeedIterator() natively"); - } - - [Fact] - public void RegisterFeedIteratorSetupOption_Ignored_TypedClient() - { - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => o.RegisterFeedIteratorSetup = true); - - // Even with RegisterFeedIteratorSetup = true, it's ignored — FakeCosmosHandler handles natively - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); - } - - [Fact] - public void OnClientCreatedCallback() - { - var services = new ServiceCollection(); - CosmosClient? captured = null; - - services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); - - // The callback receives a Castle proxy assignable to the typed client - captured.Should().NotBeNull(); - captured.Should().BeAssignableTo(); - } - - [Fact] - public void MatchesExistingLifetime_Scoped() - { - var services = new ServiceCollection(); - services.AddScoped(_ => new EmployeeCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); - } - - [Fact] - public void MatchesExistingLifetime_DefaultsSingleton() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); - } - - [Fact] - public async Task FullEndToEnd_MultipleTypedClients() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/partitionKey"); - }); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "OOBDb"; - o.AddContainer("oob-events", "/partitionKey"); - }); - services.AddTransient(); - services.AddTransient(); - - var provider = services.BuildServiceProvider(); - - // Write via biometric repo - var bioClient = provider.GetRequiredService(); - var bioContainer = bioClient.GetContainer("BiometricDb", "biometrics"); - await bioContainer.CreateItemAsync( - new TestDocument { Id = "bio-1", PartitionKey = "pk1", Name = "Fingerprint" }, - new PartitionKey("pk1")); - - // Write via OOB repo - var oobClient = provider.GetRequiredService(); - var oobContainer = oobClient.GetContainer("OOBDb", "oob-events"); - await oobContainer.CreateItemAsync( - new TestDocument { Id = "oob-1", PartitionKey = "pk1", Name = "SMS" }, - new PartitionKey("pk1")); - - // Read back — each client's data is isolated - var bioRead = await bioContainer.ReadItemAsync("bio-1", new PartitionKey("pk1")); - bioRead.Resource.Name.Should().Be("Fingerprint"); - - var oobRead = await oobContainer.ReadItemAsync("oob-1", new PartitionKey("pk1")); - oobRead.Resource.Name.Should().Be("SMS"); - - // Cross-contamination check: bio container should not have OOB data - var act = () => bioContainer.ReadItemAsync("oob-1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public void Idempotent_CalledTwice() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(); - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var clients = provider.GetServices().ToList(); - clients.Should().ContainSingle(); - } - - [Fact] - public void MatchesExistingLifetime_Transient() - { - var services = new ServiceCollection(); - services.AddTransient(_ => new EmployeeCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); - } - - [Fact] - public async Task MultipleContainersOnSameTypedClient() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "BiometricDb"; - o.AddContainer("biometrics", "/pk"); - o.AddContainer("audit-logs", "/pk"); - }); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - - // Both containers should be accessible via GetContainer - var bioContainer = client.GetContainer("BiometricDb", "biometrics"); - bioContainer.Should().NotBeNull(); - bioContainer.Id.Should().Be("biometrics"); - - var auditContainer = client.GetContainer("BiometricDb", "audit-logs"); - auditContainer.Should().NotBeNull(); - auditContainer.Id.Should().Be("audit-logs"); - - // They should be independent — writing to one doesn't affect the other - await bioContainer.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - var act = () => auditContainer.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PerContainerDatabaseOverride_TypedClient() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("c1", "/pk", databaseName: "db1"); - o.AddContainer("c2", "/pk", databaseName: "db2"); - }); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - - var c1 = client.GetContainer("db1", "c1"); - c1.Should().NotBeNull(); - c1.Id.Should().Be("c1"); - - var c2 = client.GetContainer("db2", "c2"); - c2.Should().NotBeNull(); - c2.Id.Should().Be("c2"); - - // Verify both are functional - await c1.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - await c2.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); - } - - [Fact] - public void OnHandlerCreated_Fires_TypedClient() - { - var services = new ServiceCollection(); - var callbackInvoked = false; - string? callbackContainerName = null; - - services.UseInMemoryCosmosDB(o => - { - o.OnHandlerCreated = (name, handler) => - { - callbackInvoked = true; - callbackContainerName = name; - }; - o.AddContainer("bio", "/pk"); - }); - - callbackInvoked.Should().BeTrue( - "UseInMemoryCosmosDB now uses FakeCosmosHandler, so OnHandlerCreated should fire"); - callbackContainerName.Should().Be("bio"); - } - - [Fact] - public void HttpMessageHandlerWrapper_Invoked_TypedClient() - { - var services = new ServiceCollection(); - var wrapperInvoked = false; - - services.UseInMemoryCosmosDB(o => - { - o.HttpMessageHandlerWrapper = handler => - { - wrapperInvoked = true; - return handler; - }; - o.AddContainer("bio", "/pk"); - }); - - wrapperInvoked.Should().BeTrue( - "UseInMemoryCosmosDB now uses FakeCosmosHandler, so HttpMessageHandlerWrapper should fire"); - - // Client should still work fine - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - } - - [Fact] - public void ReturnsSameServiceCollection_Fluent() - { - var services = new ServiceCollection(); - - var result = services.UseInMemoryCosmosDB(); - - result.Should().BeSameAs(services); - } - - [Fact] - public async Task TypedClient_FaultInjection_Works() - { - var services = new ServiceCollection(); - FakeCosmosHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("bio", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => - { - capturedHandler = handler; - handler.FaultInjector = req => - { - if (req.Method == HttpMethod.Post) - return new HttpResponseMessage(HttpStatusCode.TooManyRequests); - return null; - }; - }; - }); - - capturedHandler.Should().NotBeNull(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "bio"); - - var act = () => container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - await act.Should().ThrowAsync() - .Where(e => (int)e.StatusCode == 429); - } - - [Fact] - public async Task TypedClient_QueryLogging_Works() - { - var services = new ServiceCollection(); - FakeCosmosHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("bio", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => capturedHandler = handler; - }); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "bio"); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'Test'")); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - capturedHandler!.QueryLog.Should().Contain("SELECT * FROM c WHERE c.name = 'Test'"); - } - - [Fact] - public async Task TypedClient_LinqToFeedIterator_WorksNatively() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - o.AddContainer("bio", "/partitionKey")); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "bio"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - // LINQ .ToFeedIterator() should work without FeedIteratorSetup - var iterator = container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIterator(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task TypedClient_HierarchicalPkPrefix_ScopesCorrectly() - { - var services = new ServiceCollection(); - - services.UseInMemoryCosmosDB(o => - o.AddContainer(new ContainerProperties("events", new List { "/tenantId", "/category", "/region" }))); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "events"); - - await container.CreateItemAsync( - new { id = "1", tenantId = "t1", category = "A", region = "eu" }, - new PartitionKeyBuilder().Add("t1").Add("A").Add("eu").Build()); - await container.CreateItemAsync( - new { id = "2", tenantId = "t2", category = "B", region = "us" }, - new PartitionKeyBuilder().Add("t2").Add("B").Add("us").Build()); - - // Query with 1-component prefix PK should scope correctly - var prefixPk = new PartitionKeyBuilder().Add("t1").Build(); - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c"), - requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void RegistersTypedClient_ResolvableFromDI() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + o.AddContainer("biometrics", "/partitionKey")); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().BeAssignableTo(); + } + + [Fact] + public void RemovesExistingTypedRegistration() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should have been removed")); + + services.UseInMemoryCosmosDB(o => + o.AddContainer("biometrics", "/partitionKey")); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + } + + [Fact] + public async Task DefaultDatabase_IsInMemoryDb() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "in-memory-container"); + container.Should().NotBeNull(); + + // Verify it's functional (backed by FakeCosmosHandler) + var item = new TestDocument { Id = "1", PartitionKey = "1", Name = "Test" }; + var response = await container.CreateItemAsync(item, new PartitionKey("1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CustomDatabaseName() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/pk"); + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("BiometricDb", "biometrics"); + container.Should().NotBeNull(); + + // Verify it's functional + var response = await container.CreateItemAsync( + new { id = "1", pk = "pk1" }, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public void MultipleTypedClients_Independent() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/pk"); + }); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "OOBDb"; + o.AddContainer("oob-events", "/pk"); + }); + + var provider = services.BuildServiceProvider(); + var bioClient = provider.GetRequiredService(); + var oobClient = provider.GetRequiredService(); + + bioClient.Should().NotBeSameAs(oobClient); + } + + [Fact] + public async Task TypedClient_ContainerUsableForCrud() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/partitionKey"); + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("BiometricDb", "biometrics"); + + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Fingerprint" }; + var createResponse = await container.CreateItemAsync(item, new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Fingerprint"); + } + + [Fact] + public void RepositoryPattern_Constructor_ResolvesTypedClient() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/partitionKey"); + }); + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + var repo = provider.GetRequiredService(); + + repo.Container.Should().NotBeNull(); + repo.Container.Id.Should().Be("biometrics"); + } + + [Fact] + public void MultipleTypedClients_WithRepos_IsolatedData() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/partitionKey"); + }); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "OOBDb"; + o.AddContainer("oob-events", "/partitionKey"); + }); + services.AddTransient(); + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + var bioRepo = provider.GetRequiredService(); + var oobRepo = provider.GetRequiredService(); + + // Each repo's container should be independent + bioRepo.Container.Should().NotBeSameAs(oobRepo.Container); + bioRepo.Container.Id.Should().Be("biometrics"); + oobRepo.Container.Id.Should().Be("oob-events"); + } + + [Fact] + public void DoesNotRegisterAsBaseCosmosClient() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + // Should NOT be resolvable as base CosmosClient unless explicitly registered + var baseClient = provider.GetService(); + baseClient.Should().BeNull(); + } + + [Fact] + public void DoesNotRegisterContainer_InDI() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => o.AddContainer("bio", "/pk")); + + var provider = services.BuildServiceProvider(); + // Pattern 2 never has Container in DI — repos call client.GetContainer() + var container = provider.GetService(); + container.Should().BeNull(); + } + + [Fact] + public void FeedIteratorSetup_NotNeeded_TypedClient() + { + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + // FakeCosmosHandler handles .ToFeedIterator() natively — no FeedIteratorSetup needed + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull( + "UseInMemoryCosmosDB now uses FakeCosmosHandler which handles .ToFeedIterator() natively"); + } + + [Fact] + public void RegisterFeedIteratorSetupOption_Ignored_TypedClient() + { + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => o.RegisterFeedIteratorSetup = true); + + // Even with RegisterFeedIteratorSetup = true, it's ignored — FakeCosmosHandler handles natively + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); + } + + [Fact] + public void OnClientCreatedCallback() + { + var services = new ServiceCollection(); + CosmosClient? captured = null; + + services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); + + // The callback receives a Castle proxy assignable to the typed client + captured.Should().NotBeNull(); + captured.Should().BeAssignableTo(); + } + + [Fact] + public void MatchesExistingLifetime_Scoped() + { + var services = new ServiceCollection(); + services.AddScoped(_ => new EmployeeCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void MatchesExistingLifetime_DefaultsSingleton() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public async Task FullEndToEnd_MultipleTypedClients() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/partitionKey"); + }); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "OOBDb"; + o.AddContainer("oob-events", "/partitionKey"); + }); + services.AddTransient(); + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + + // Write via biometric repo + var bioClient = provider.GetRequiredService(); + var bioContainer = bioClient.GetContainer("BiometricDb", "biometrics"); + await bioContainer.CreateItemAsync( + new TestDocument { Id = "bio-1", PartitionKey = "pk1", Name = "Fingerprint" }, + new PartitionKey("pk1")); + + // Write via OOB repo + var oobClient = provider.GetRequiredService(); + var oobContainer = oobClient.GetContainer("OOBDb", "oob-events"); + await oobContainer.CreateItemAsync( + new TestDocument { Id = "oob-1", PartitionKey = "pk1", Name = "SMS" }, + new PartitionKey("pk1")); + + // Read back — each client's data is isolated + var bioRead = await bioContainer.ReadItemAsync("bio-1", new PartitionKey("pk1")); + bioRead.Resource.Name.Should().Be("Fingerprint"); + + var oobRead = await oobContainer.ReadItemAsync("oob-1", new PartitionKey("pk1")); + oobRead.Resource.Name.Should().Be("SMS"); + + // Cross-contamination check: bio container should not have OOB data + var act = () => bioContainer.ReadItemAsync("oob-1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Idempotent_CalledTwice() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(); + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var clients = provider.GetServices().ToList(); + clients.Should().ContainSingle(); + } + + [Fact] + public void MatchesExistingLifetime_Transient() + { + var services = new ServiceCollection(); + services.AddTransient(_ => new EmployeeCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(EmployeeCosmosClient)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Transient); + } + + [Fact] + public async Task MultipleContainersOnSameTypedClient() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "BiometricDb"; + o.AddContainer("biometrics", "/pk"); + o.AddContainer("audit-logs", "/pk"); + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + // Both containers should be accessible via GetContainer + var bioContainer = client.GetContainer("BiometricDb", "biometrics"); + bioContainer.Should().NotBeNull(); + bioContainer.Id.Should().Be("biometrics"); + + var auditContainer = client.GetContainer("BiometricDb", "audit-logs"); + auditContainer.Should().NotBeNull(); + auditContainer.Id.Should().Be("audit-logs"); + + // They should be independent — writing to one doesn't affect the other + await bioContainer.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + var act = () => auditContainer.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PerContainerDatabaseOverride_TypedClient() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("c1", "/pk", databaseName: "db1"); + o.AddContainer("c2", "/pk", databaseName: "db2"); + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var c1 = client.GetContainer("db1", "c1"); + c1.Should().NotBeNull(); + c1.Id.Should().Be("c1"); + + var c2 = client.GetContainer("db2", "c2"); + c2.Should().NotBeNull(); + c2.Id.Should().Be("c2"); + + // Verify both are functional + await c1.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + await c2.CreateItemAsync(new { id = "1", pk = "a" }, new PartitionKey("a")); + } + + [Fact] + public void OnHandlerCreated_Fires_TypedClient() + { + var services = new ServiceCollection(); + var callbackInvoked = false; + string? callbackContainerName = null; + + services.UseInMemoryCosmosDB(o => + { + o.OnHandlerCreated = (name, handler) => + { + callbackInvoked = true; + callbackContainerName = name; + }; + o.AddContainer("bio", "/pk"); + }); + + callbackInvoked.Should().BeTrue( + "UseInMemoryCosmosDB now uses FakeCosmosHandler, so OnHandlerCreated should fire"); + callbackContainerName.Should().Be("bio"); + } + + [Fact] + public void HttpMessageHandlerWrapper_Invoked_TypedClient() + { + var services = new ServiceCollection(); + var wrapperInvoked = false; + + services.UseInMemoryCosmosDB(o => + { + o.HttpMessageHandlerWrapper = handler => + { + wrapperInvoked = true; + return handler; + }; + o.AddContainer("bio", "/pk"); + }); + + wrapperInvoked.Should().BeTrue( + "UseInMemoryCosmosDB now uses FakeCosmosHandler, so HttpMessageHandlerWrapper should fire"); + + // Client should still work fine + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + } + + [Fact] + public void ReturnsSameServiceCollection_Fluent() + { + var services = new ServiceCollection(); + + var result = services.UseInMemoryCosmosDB(); + + result.Should().BeSameAs(services); + } + + [Fact] + public async Task TypedClient_FaultInjection_Works() + { + var services = new ServiceCollection(); + FakeCosmosHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("bio", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => + { + capturedHandler = handler; + handler.FaultInjector = req => + { + if (req.Method == HttpMethod.Post) + return new HttpResponseMessage(HttpStatusCode.TooManyRequests); + return null; + }; + }; + }); + + capturedHandler.Should().NotBeNull(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "bio"); + + var act = () => container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + await act.Should().ThrowAsync() + .Where(e => (int)e.StatusCode == 429); + } + + [Fact] + public async Task TypedClient_QueryLogging_Works() + { + var services = new ServiceCollection(); + FakeCosmosHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("bio", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => capturedHandler = handler; + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "bio"); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'Test'")); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + capturedHandler!.QueryLog.Should().Contain("SELECT * FROM c WHERE c.name = 'Test'"); + } + + [Fact] + public async Task TypedClient_LinqToFeedIterator_WorksNatively() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + o.AddContainer("bio", "/partitionKey")); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "bio"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + // LINQ .ToFeedIterator() should work without FeedIteratorSetup + var iterator = container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIterator(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task TypedClient_HierarchicalPkPrefix_ScopesCorrectly() + { + var services = new ServiceCollection(); + + services.UseInMemoryCosmosDB(o => + o.AddContainer(new ContainerProperties("events", new List { "/tenantId", "/category", "/region" }))); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "events"); + + await container.CreateItemAsync( + new { id = "1", tenantId = "t1", category = "A", region = "eu" }, + new PartitionKeyBuilder().Add("t1").Add("A").Add("eu").Build()); + await container.CreateItemAsync( + new { id = "2", tenantId = "t2", category = "B", region = "us" }, + new PartitionKeyBuilder().Add("t2").Add("B").Add("us").Build()); + + // Query with 1-component prefix PK should scope correctly + var prefixPk = new PartitionKeyBuilder().Add("t1").Build(); + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c"), + requestOptions: new QueryRequestOptions { PartitionKey = prefixPk }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1524,199 +1524,199 @@ await container.CreateItemAsync( public class AutoDetectContainerTests { - [Fact] - public void AutoDetect_PreservesExistingContainerFactory() - { - var services = new ServiceCollection(); - - // Simulate production: register CosmosClient + Container factory - services.AddSingleton(_ => - throw new InvalidOperationException("Production client should be replaced")); - services.AddSingleton(sp => - { - var client = sp.GetRequiredService(); - return client.GetContainer("ProductionDb", "orders"); - }); - - // Zero-config swap — no AddContainer needed - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - - // Container resolves via the existing factory, which calls GetContainer on the real client - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("orders"); - } - - [Fact] - public async Task AutoDetect_ContainerIsFullyFunctional() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("MyDb", "items")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // CRUD works — the auto-created InMemoryContainer is fully usable - var item = new TestDocument { Id = "1", PartitionKey = "1", Name = "AutoDetected" }; - var response = await container.CreateItemAsync(item, new PartitionKey("1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await container.ReadItemAsync("1", new PartitionKey("1")); - read.Resource.Name.Should().Be("AutoDetected"); - } - - [Fact] - public void AutoDetect_MultipleContainerFactories_AllPreserved() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "orders")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "customers")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - containers.Should().HaveCount(2); - containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "customers"]); - } - - [Fact] - public void AutoDetect_ClientGetContainer_SharesSameBackingStore() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("MyDb", "orders")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - var diContainer = provider.GetRequiredService(); - var clientContainer = provider.GetRequiredService().GetContainer("MyDb", "orders"); - - // Both route through the same FakeCosmosHandler backing - diContainer.Id.Should().Be("orders"); - clientContainer.Id.Should().Be("orders"); - } - - [Fact] - public void AutoDetect_NoExistingContainers_CreatesDefault() - { - var services = new ServiceCollection(); - // No Container registered — only CosmosClient - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - // Default container should still be created when no existing registrations - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("in-memory-container"); - } - - [Fact] - public void AutoDetect_PreservesExistingLifetime() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - // Register as Scoped — should be preserved - services.AddScoped(sp => - sp.GetRequiredService().GetContainer("db", "orders")); - - services.UseInMemoryCosmosDB(); - - var descriptor = services.First(d => d.ServiceType == typeof(Container)); - descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); - } - - [Fact] - public void ExplicitContainers_StillRemovesExistingRegistrations() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(_ => - throw new InvalidOperationException("Should have been removed by explicit AddContainer")); - - // Explicit AddContainer — should remove existing and replace - services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - container.Id.Should().Be("orders"); - } - - [Fact] - public void AutoDetect_DoesNotNeedFeedIteratorSetup() - { - InMemoryFeedIteratorSetup.Deregister(); - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "items")); - - services.UseInMemoryCosmosDB(); - - // FakeCosmosHandler handles .ToFeedIterator() natively — no setup needed - CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); - } - - [Fact] - public void AutoDetect_OnClientCreatedCallback_StillWorks() - { - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "items")); - CosmosClient? captured = null; - - services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); - - captured.Should().NotBeNull(); - } - - [Fact] - public void AutoDetect_MixedExistingLifetimes_UsesFirst() - { - // When existing Container registrations have mixed lifetimes, - // the code uses FirstOrDefault()?.Lifetime — so the first registration's lifetime wins. - var services = new ServiceCollection(); - services.AddSingleton(_ => - throw new InvalidOperationException("Should be replaced")); - services.AddScoped(sp => - sp.GetRequiredService().GetContainer("db", "orders")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("db", "customers")); - - services.UseInMemoryCosmosDB(); - - // In auto-detect mode, existing registrations are preserved as-is. - // The first descriptor's lifetime (Scoped) was captured for informational purposes, - // but since auto-detect doesn't replace, both registrations keep their original lifetimes. - var descriptors = services.Where(d => d.ServiceType == typeof(Container)).ToList(); - descriptors[0].Lifetime.Should().Be(ServiceLifetime.Scoped); - descriptors[1].Lifetime.Should().Be(ServiceLifetime.Singleton); - } + [Fact] + public void AutoDetect_PreservesExistingContainerFactory() + { + var services = new ServiceCollection(); + + // Simulate production: register CosmosClient + Container factory + services.AddSingleton(_ => + throw new InvalidOperationException("Production client should be replaced")); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return client.GetContainer("ProductionDb", "orders"); + }); + + // Zero-config swap — no AddContainer needed + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + + // Container resolves via the existing factory, which calls GetContainer on the real client + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("orders"); + } + + [Fact] + public async Task AutoDetect_ContainerIsFullyFunctional() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("MyDb", "items")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // CRUD works — the auto-created InMemoryContainer is fully usable + var item = new TestDocument { Id = "1", PartitionKey = "1", Name = "AutoDetected" }; + var response = await container.CreateItemAsync(item, new PartitionKey("1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await container.ReadItemAsync("1", new PartitionKey("1")); + read.Resource.Name.Should().Be("AutoDetected"); + } + + [Fact] + public void AutoDetect_MultipleContainerFactories_AllPreserved() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "orders")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "customers")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + containers.Should().HaveCount(2); + containers.Select(c => c.Id).Should().BeEquivalentTo(["orders", "customers"]); + } + + [Fact] + public void AutoDetect_ClientGetContainer_SharesSameBackingStore() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("MyDb", "orders")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + var diContainer = provider.GetRequiredService(); + var clientContainer = provider.GetRequiredService().GetContainer("MyDb", "orders"); + + // Both route through the same FakeCosmosHandler backing + diContainer.Id.Should().Be("orders"); + clientContainer.Id.Should().Be("orders"); + } + + [Fact] + public void AutoDetect_NoExistingContainers_CreatesDefault() + { + var services = new ServiceCollection(); + // No Container registered — only CosmosClient + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + // Default container should still be created when no existing registrations + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("in-memory-container"); + } + + [Fact] + public void AutoDetect_PreservesExistingLifetime() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + // Register as Scoped — should be preserved + services.AddScoped(sp => + sp.GetRequiredService().GetContainer("db", "orders")); + + services.UseInMemoryCosmosDB(); + + var descriptor = services.First(d => d.ServiceType == typeof(Container)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void ExplicitContainers_StillRemovesExistingRegistrations() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(_ => + throw new InvalidOperationException("Should have been removed by explicit AddContainer")); + + // Explicit AddContainer — should remove existing and replace + services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + container.Id.Should().Be("orders"); + } + + [Fact] + public void AutoDetect_DoesNotNeedFeedIteratorSetup() + { + InMemoryFeedIteratorSetup.Deregister(); + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "items")); + + services.UseInMemoryCosmosDB(); + + // FakeCosmosHandler handles .ToFeedIterator() natively — no setup needed + CosmosOverridableFeedIteratorExtensions.StaticFallbackFactory.Should().BeNull(); + } + + [Fact] + public void AutoDetect_OnClientCreatedCallback_StillWorks() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "items")); + CosmosClient? captured = null; + + services.UseInMemoryCosmosDB(o => o.OnClientCreated = c => captured = c); + + captured.Should().NotBeNull(); + } + + [Fact] + public void AutoDetect_MixedExistingLifetimes_UsesFirst() + { + // When existing Container registrations have mixed lifetimes, + // the code uses FirstOrDefault()?.Lifetime — so the first registration's lifetime wins. + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("Should be replaced")); + services.AddScoped(sp => + sp.GetRequiredService().GetContainer("db", "orders")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("db", "customers")); + + services.UseInMemoryCosmosDB(); + + // In auto-detect mode, existing registrations are preserved as-is. + // The first descriptor's lifetime (Scoped) was captured for informational purposes, + // but since auto-detect doesn't replace, both registrations keep their original lifetimes. + var descriptors = services.Where(d => d.ServiceType == typeof(Container)).ToList(); + descriptors[0].Lifetime.Should().Be(ServiceLifetime.Scoped); + descriptors[1].Lifetime.Should().Be(ServiceLifetime.Singleton); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1726,207 +1726,207 @@ public void AutoDetect_MixedExistingLifetimes_UsesFirst() [Collection("FeedIteratorSetup")] public class HttpMessageHandlerWrapperTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - /// - /// A test DelegatingHandler that counts requests passing through it. - /// - private class CountingHandler : DelegatingHandler - { - public int RequestCount { get; private set; } - - public CountingHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - RequestCount++; - return base.SendAsync(request, cancellationToken); - } - } - - [Fact] - public void Wrapper_IsInvoked_WithHandler() - { - var services = new ServiceCollection(); - HttpMessageHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.HttpMessageHandlerWrapper = handler => - { - capturedHandler = handler; - return handler; // pass through unchanged - }; - }); - - capturedHandler.Should().NotBeNull(); - capturedHandler.Should().BeOfType(); - } - - [Fact] - public async Task Wrapper_ReturnValue_IsUsed_DelegatingHandlerSendAsyncCalled() - { - var services = new ServiceCollection(); - CountingHandler? countingHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("test", "/partitionKey"); - o.HttpMessageHandlerWrapper = handler => - { - countingHandler = new CountingHandler(handler); - return countingHandler; - }; - }); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Perform a CRUD operation — this should flow through the CountingHandler - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - countingHandler.Should().NotBeNull(); - countingHandler!.RequestCount.Should().BeGreaterThan(0); - } - - [Fact] - public async Task NullWrapper_Default_ExistingBehaviourPreserved() - { - var services = new ServiceCollection(); - - // No HttpMessageHandlerWrapper set — default null - services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // CRUD should work exactly as before - var createResponse = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Test"); - } - - [Fact] - public void MultiContainer_Wrapper_ReceivesRouter_NotIndividualHandler() - { - var services = new ServiceCollection(); - HttpMessageHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.AddContainer("events", "/pk"); - o.HttpMessageHandlerWrapper = handler => - { - capturedHandler = handler; - return handler; - }; - }); - - capturedHandler.Should().NotBeNull(); - // With multiple containers, the handler is the router — NOT a FakeCosmosHandler - capturedHandler.Should().NotBeOfType(); - } - - [Fact] - public void FluentMethod_WithHttpMessageHandlerWrapper_Works() - { - var services = new ServiceCollection(); - HttpMessageHandler? capturedHandler = null; - - services.UseInMemoryCosmosDB(o => o - .AddContainer("orders", "/pk") - .WithHttpMessageHandlerWrapper(handler => - { - capturedHandler = handler; - return handler; - })); - - capturedHandler.Should().NotBeNull(); - capturedHandler.Should().BeOfType(); - } - - [Fact] - public async Task FullDelegatingHandlerChaining_CrudAndQueryThroughWrapper() - { - var services = new ServiceCollection(); - CountingHandler? countingHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("test", "/partitionKey"); - o.HttpMessageHandlerWrapper = handler => - { - countingHandler = new CountingHandler(handler); - return countingHandler; - }; - }); - - var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - // Create - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - // Read - var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Alice"); - - // Query - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - - // All requests went through the counting handler - countingHandler.Should().NotBeNull(); - countingHandler!.RequestCount.Should().BeGreaterThanOrEqualTo(3, - "Create + Read + Query should produce at least 3 HTTP requests through the wrapper"); - } - - [Fact] - public void OnHandlerCreated_And_HttpWrapper_BothFire() - { - // Both callbacks should fire: OnHandlerCreated fires per-container during - // handler creation, then HttpMessageHandlerWrapper fires once with the - // final handler (or router). - var services = new ServiceCollection(); - var handlerCreatedNames = new List(); - HttpMessageHandler? wrappedHandler = null; - - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk"); - o.OnHandlerCreated = (name, _) => handlerCreatedNames.Add(name); - o.HttpMessageHandlerWrapper = handler => - { - wrappedHandler = handler; - return handler; - }; - }); - - // OnHandlerCreated fires first, per container - handlerCreatedNames.Should().ContainSingle().Which.Should().Be("orders"); - - // HttpMessageHandlerWrapper fires after, with the handler - wrappedHandler.Should().NotBeNull(); - wrappedHandler.Should().BeOfType(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + /// + /// A test DelegatingHandler that counts requests passing through it. + /// + private class CountingHandler : DelegatingHandler + { + public int RequestCount { get; private set; } + + public CountingHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + return base.SendAsync(request, cancellationToken); + } + } + + [Fact] + public void Wrapper_IsInvoked_WithHandler() + { + var services = new ServiceCollection(); + HttpMessageHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.HttpMessageHandlerWrapper = handler => + { + capturedHandler = handler; + return handler; // pass through unchanged + }; + }); + + capturedHandler.Should().NotBeNull(); + capturedHandler.Should().BeOfType(); + } + + [Fact] + public async Task Wrapper_ReturnValue_IsUsed_DelegatingHandlerSendAsyncCalled() + { + var services = new ServiceCollection(); + CountingHandler? countingHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("test", "/partitionKey"); + o.HttpMessageHandlerWrapper = handler => + { + countingHandler = new CountingHandler(handler); + return countingHandler; + }; + }); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Perform a CRUD operation — this should flow through the CountingHandler + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + countingHandler.Should().NotBeNull(); + countingHandler!.RequestCount.Should().BeGreaterThan(0); + } + + [Fact] + public async Task NullWrapper_Default_ExistingBehaviourPreserved() + { + var services = new ServiceCollection(); + + // No HttpMessageHandlerWrapper set — default null + services.UseInMemoryCosmosDB(o => o.AddContainer("test", "/partitionKey")); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // CRUD should work exactly as before + var createResponse = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Test"); + } + + [Fact] + public void MultiContainer_Wrapper_ReceivesRouter_NotIndividualHandler() + { + var services = new ServiceCollection(); + HttpMessageHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.AddContainer("events", "/pk"); + o.HttpMessageHandlerWrapper = handler => + { + capturedHandler = handler; + return handler; + }; + }); + + capturedHandler.Should().NotBeNull(); + // With multiple containers, the handler is the router — NOT a FakeCosmosHandler + capturedHandler.Should().NotBeOfType(); + } + + [Fact] + public void FluentMethod_WithHttpMessageHandlerWrapper_Works() + { + var services = new ServiceCollection(); + HttpMessageHandler? capturedHandler = null; + + services.UseInMemoryCosmosDB(o => o + .AddContainer("orders", "/pk") + .WithHttpMessageHandlerWrapper(handler => + { + capturedHandler = handler; + return handler; + })); + + capturedHandler.Should().NotBeNull(); + capturedHandler.Should().BeOfType(); + } + + [Fact] + public async Task FullDelegatingHandlerChaining_CrudAndQueryThroughWrapper() + { + var services = new ServiceCollection(); + CountingHandler? countingHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("test", "/partitionKey"); + o.HttpMessageHandlerWrapper = handler => + { + countingHandler = new CountingHandler(handler); + return countingHandler; + }; + }); + + var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + // Create + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + // Read + var readResponse = await container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Alice"); + + // Query + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'Alice'")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + + // All requests went through the counting handler + countingHandler.Should().NotBeNull(); + countingHandler!.RequestCount.Should().BeGreaterThanOrEqualTo(3, + "Create + Read + Query should produce at least 3 HTTP requests through the wrapper"); + } + + [Fact] + public void OnHandlerCreated_And_HttpWrapper_BothFire() + { + // Both callbacks should fire: OnHandlerCreated fires per-container during + // handler creation, then HttpMessageHandlerWrapper fires once with the + // final handler (or router). + var services = new ServiceCollection(); + var handlerCreatedNames = new List(); + HttpMessageHandler? wrappedHandler = null; + + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk"); + o.OnHandlerCreated = (name, _) => handlerCreatedNames.Add(name); + o.HttpMessageHandlerWrapper = handler => + { + wrappedHandler = handler; + return handler; + }; + }); + + // OnHandlerCreated fires first, per container + handlerCreatedNames.Should().ContainSingle().Which.Should().Be("orders"); + + // HttpMessageHandlerWrapper fires after, with the handler + wrappedHandler.Should().NotBeNull(); + wrappedHandler.Should().BeOfType(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1936,35 +1936,35 @@ public void OnHandlerCreated_And_HttpWrapper_BothFire() [Collection("FeedIteratorSetup")] public class ServiceCollectionBugInvestigationTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void Idempotent_CalledTwice_NoExistingRegistrations_VerifyBehavior() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var clients = provider.GetServices().ToList(); - clients.Should().ContainSingle(); - - var containers = provider.GetServices().ToList(); - containers.Should().ContainSingle(); - } - - [Fact] - public void UseInMemoryCosmosDB_RegisterFeedIteratorSetup_HasNoEffect() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.RegisterFeedIteratorSetup = true); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - // FeedIteratorSetup is NOT registered by UseInMemoryCosmosDB — it's only - // relevant for UseInMemoryCosmosDB and UseInMemoryCosmosContainers - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void Idempotent_CalledTwice_NoExistingRegistrations_VerifyBehavior() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var clients = provider.GetServices().ToList(); + clients.Should().ContainSingle(); + + var containers = provider.GetServices().ToList(); + containers.Should().ContainSingle(); + } + + [Fact] + public void UseInMemoryCosmosDB_RegisterFeedIteratorSetup_HasNoEffect() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.RegisterFeedIteratorSetup = true); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + // FeedIteratorSetup is NOT registered by UseInMemoryCosmosDB — it's only + // relevant for UseInMemoryCosmosDB and UseInMemoryCosmosContainers + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1974,98 +1974,98 @@ public void UseInMemoryCosmosDB_RegisterFeedIteratorSetup_HasNoEffect() [Collection("FeedIteratorSetup")] public class ServiceCollectionCallbackTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void OnContainerCreated_DefaultContainer_StillFires() - { - InMemoryContainer? captured = null; - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => - { - o.OnContainerCreated = c => captured = (InMemoryContainer)c; - }); - - using var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - - captured.Should().NotBeNull(); - } - - [Fact] - public void OnHandlerCreated_DefaultContainer_StillFires() - { - FakeCosmosHandler? captured = null; - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.OnHandlerCreated = (name, handler) => captured = handler; - }); - - using var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - - captured.Should().NotBeNull(); - } - - [Fact] - public void OnHandlerCreated_AutoDetectMode_StillFires() - { - FakeCosmosHandler? captured = null; - var services = new ServiceCollection(); - services.AddSingleton(sp => - { - var client = sp.GetRequiredService(); - return client.GetContainer("db", "existing"); - }); - services.UseInMemoryCosmosDB(o => - { - o.OnHandlerCreated = (name, handler) => captured = handler; - }); - - using var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - - captured.Should().NotBeNull(); - } - - [Fact] - public void OnHandlerCreated_MultipleContainers_FiresForEach() - { - var names = new List(); - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/pk") - .AddContainer("events", "/pk") - .AddContainer("users", "/pk"); - o.OnHandlerCreated = (name, handler) => names.Add(name); - }); - - using var provider = services.BuildServiceProvider(); - _ = provider.GetServices().ToList(); - - names.Should().HaveCount(3); - names.Should().Contain("orders"); - names.Should().Contain("events"); - names.Should().Contain("users"); - } - - [Fact] - public void TypedClient_OnClientCreated_ReturnsDIInstance() - { - EmployeeCosmosClient? captured = null; - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.OnClientCreated = c => captured = (EmployeeCosmosClient)c; - }); - - using var provider = services.BuildServiceProvider(); - var resolved = provider.GetRequiredService(); - - captured.Should().BeSameAs(resolved); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void OnContainerCreated_DefaultContainer_StillFires() + { + InMemoryContainer? captured = null; + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => + { + o.OnContainerCreated = c => captured = (InMemoryContainer)c; + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + captured.Should().NotBeNull(); + } + + [Fact] + public void OnHandlerCreated_DefaultContainer_StillFires() + { + FakeCosmosHandler? captured = null; + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.OnHandlerCreated = (name, handler) => captured = handler; + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + captured.Should().NotBeNull(); + } + + [Fact] + public void OnHandlerCreated_AutoDetectMode_StillFires() + { + FakeCosmosHandler? captured = null; + var services = new ServiceCollection(); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return client.GetContainer("db", "existing"); + }); + services.UseInMemoryCosmosDB(o => + { + o.OnHandlerCreated = (name, handler) => captured = handler; + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + captured.Should().NotBeNull(); + } + + [Fact] + public void OnHandlerCreated_MultipleContainers_FiresForEach() + { + var names = new List(); + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/pk") + .AddContainer("events", "/pk") + .AddContainer("users", "/pk"); + o.OnHandlerCreated = (name, handler) => names.Add(name); + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetServices().ToList(); + + names.Should().HaveCount(3); + names.Should().Contain("orders"); + names.Should().Contain("events"); + names.Should().Contain("users"); + } + + [Fact] + public void TypedClient_OnClientCreated_ReturnsDIInstance() + { + EmployeeCosmosClient? captured = null; + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.OnClientCreated = c => captured = (EmployeeCosmosClient)c; + }); + + using var provider = services.BuildServiceProvider(); + var resolved = provider.GetRequiredService(); + + captured.Should().BeSameAs(resolved); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2075,71 +2075,71 @@ public void TypedClient_OnClientCreated_ReturnsDIInstance() [Collection("FeedIteratorSetup")] public class ServiceCollectionIdempotencyTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void Idempotent_CalledTwice_AutoDetectMode() - { - var services = new ServiceCollection(); - services.AddSingleton(sp => - { - var client = sp.GetRequiredService(); - return client.GetContainer("db", "existing"); - }); - services.UseInMemoryCosmosDB(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var clients = provider.GetServices().ToList(); - clients.Should().ContainSingle(); - } - - [Fact] - public void CalledTwice_DifferentContainers_SecondWins() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); - services.UseInMemoryCosmosDB(o => o.AddContainer("events", "/pk")); - - using var provider = services.BuildServiceProvider(); - var containers = provider.GetServices().ToList(); - // Second call removes all containers and adds its own - containers.Should().ContainSingle(); - } - - [Fact] - public void CalledTwice_DifferentDatabaseNames() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "db1"; - o.AddContainer("orders", "/pk"); - }); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "db2"; - o.AddContainer("events", "/pk"); - }); - - using var provider = services.BuildServiceProvider(); - // Second call wins — client is replaced - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosContainers_CalledTwice_DifferentContainers() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); - services.UseInMemoryCosmosContainers(o => o.AddContainer("events", "/pk")); - - using var provider = services.BuildServiceProvider(); - // Both registrations add; last is resolved for "Container" - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void Idempotent_CalledTwice_AutoDetectMode() + { + var services = new ServiceCollection(); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return client.GetContainer("db", "existing"); + }); + services.UseInMemoryCosmosDB(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var clients = provider.GetServices().ToList(); + clients.Should().ContainSingle(); + } + + [Fact] + public void CalledTwice_DifferentContainers_SecondWins() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer("orders", "/pk")); + services.UseInMemoryCosmosDB(o => o.AddContainer("events", "/pk")); + + using var provider = services.BuildServiceProvider(); + var containers = provider.GetServices().ToList(); + // Second call removes all containers and adds its own + containers.Should().ContainSingle(); + } + + [Fact] + public void CalledTwice_DifferentDatabaseNames() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "db1"; + o.AddContainer("orders", "/pk"); + }); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "db2"; + o.AddContainer("events", "/pk"); + }); + + using var provider = services.BuildServiceProvider(); + // Second call wins — client is replaced + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosContainers_CalledTwice_DifferentContainers() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => o.AddContainer("orders", "/pk")); + services.UseInMemoryCosmosContainers(o => o.AddContainer("events", "/pk")); + + using var provider = services.BuildServiceProvider(); + // Both registrations add; last is resolved for "Container" + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2149,156 +2149,156 @@ public void UseInMemoryCosmosContainers_CalledTwice_DifferentContainers() [Collection("FeedIteratorSetup")] public class ContainerPropertiesOverloadTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_ContainerCreated() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - o.AddContainer(new ContainerProperties("orders", "/partitionKey"))); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - - // Verify CRUD works - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_UniqueKeyPolicyEnforced() - { - var props = new ContainerProperties("orders-uk", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer(props)); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); - - var act = () => container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_DefaultTtlHonored() - { - var props = new ContainerProperties("orders-ttl", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer(props)); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - await Task.Delay(1500); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(0, "item should expire after TTL"); - } - - [Fact] - public async Task UseInMemoryCosmosContainers_AddContainerViaContainerProperties_ContainerCreated() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => - o.AddContainer(new ContainerProperties("orders-cont", "/partitionKey"))); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UseInMemoryCosmosContainers_AddContainerViaContainerProperties_UniqueKeyPolicyEnforced() - { - var props = new ContainerProperties("orders-cont-uk", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => o.AddContainer(props)); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); - - var act = () => container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public async Task TypedClient_AddContainerViaContainerProperties_ContainerCreated() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "EmpDb"; - o.AddContainer(new ContainerProperties("employees", "/partitionKey"), "EmpDb"); - }); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("EmpDb", "employees"); - - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public void FluentChaining_InMemoryCosmosOptions_AddContainerProperties() - { - var options = new InMemoryCosmosOptions(); - var props = new ContainerProperties("chained", "/pk"); - var result = options.AddContainer(props, "myDb"); - - result.Should().BeSameAs(options, "AddContainer should support fluent chaining"); - options.Containers.Should().ContainSingle(); - options.Containers[0].ContainerName.Should().Be("chained"); - options.Containers[0].DatabaseName.Should().Be("myDb"); - options.Containers[0].ContainerProperties.Should().BeSameAs(props); - } - - [Fact] - public void FluentChaining_InMemoryContainerOptions_AddContainerProperties() - { - var options = new InMemoryContainerOptions(); - var props = new ContainerProperties("chained-cont", "/pk"); - var result = options.AddContainer(props); - - result.Should().BeSameAs(options, "AddContainer should support fluent chaining"); - options.Containers.Should().ContainSingle(); - options.Containers[0].ContainerProperties.Should().BeSameAs(props); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_ContainerCreated() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + o.AddContainer(new ContainerProperties("orders", "/partitionKey"))); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + + // Verify CRUD works + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_UniqueKeyPolicyEnforced() + { + var props = new ContainerProperties("orders-uk", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer(props)); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); + + var act = () => container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task UseInMemoryCosmosDB_AddContainerViaContainerProperties_DefaultTtlHonored() + { + var props = new ContainerProperties("orders-ttl", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer(props)); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + await Task.Delay(1500); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(0, "item should expire after TTL"); + } + + [Fact] + public async Task UseInMemoryCosmosContainers_AddContainerViaContainerProperties_ContainerCreated() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => + o.AddContainer(new ContainerProperties("orders-cont", "/partitionKey"))); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UseInMemoryCosmosContainers_AddContainerViaContainerProperties_UniqueKeyPolicyEnforced() + { + var props = new ContainerProperties("orders-cont-uk", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => o.AddContainer(props)); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); + + var act = () => container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public async Task TypedClient_AddContainerViaContainerProperties_ContainerCreated() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "EmpDb"; + o.AddContainer(new ContainerProperties("employees", "/partitionKey"), "EmpDb"); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("EmpDb", "employees"); + + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public void FluentChaining_InMemoryCosmosOptions_AddContainerProperties() + { + var options = new InMemoryCosmosOptions(); + var props = new ContainerProperties("chained", "/pk"); + var result = options.AddContainer(props, "myDb"); + + result.Should().BeSameAs(options, "AddContainer should support fluent chaining"); + options.Containers.Should().ContainSingle(); + options.Containers[0].ContainerName.Should().Be("chained"); + options.Containers[0].DatabaseName.Should().Be("myDb"); + options.Containers[0].ContainerProperties.Should().BeSameAs(props); + } + + [Fact] + public void FluentChaining_InMemoryContainerOptions_AddContainerProperties() + { + var options = new InMemoryContainerOptions(); + var props = new ContainerProperties("chained-cont", "/pk"); + var result = options.AddContainer(props); + + result.Should().BeSameAs(options, "AddContainer should support fluent chaining"); + options.Containers.Should().ContainSingle(); + options.Containers[0].ContainerProperties.Should().BeSameAs(props); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2308,122 +2308,122 @@ public void FluentChaining_InMemoryContainerOptions_AddContainerProperties() [Collection("FeedIteratorSetup")] public class StatePersistenceDirectoryTests : IDisposable { - private readonly string _tempDir; - - public StatePersistenceDirectoryTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - InMemoryFeedIteratorSetup.Deregister(); - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - [Fact] - public async Task UseInMemoryCosmosDB_StatePersistenceDirectory_NonExistentDir_StartsEmpty() - { - var emptyDir = Path.Combine(Path.GetTempPath(), $"cosmos-empty-{Guid.NewGuid():N}"); - try - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey"); - o.StatePersistenceDirectory = emptyDir; - }); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(0); - } - finally - { - if (Directory.Exists(emptyDir)) Directory.Delete(emptyDir, true); - } - } - - [Fact] - public async Task UseInMemoryCosmosDB_StatePersistenceDirectory_LoadsSavedState() - { - // Pre-create state file in expected format - var stateContainer = new InMemoryContainer("mycontainer", "/partitionKey"); - await stateContainer.CreateItemAsync( - new TestDocument { Id = "seeded", PartitionKey = "pk", Name = "Seeded" }, new PartitionKey("pk")); - var state = stateContainer.ExportState(); - - // UseInMemoryCosmosDB uses "{db}_{container}.json" naming - var stateFile = Path.Combine(_tempDir, "mydb_mycontainer.json"); - await File.WriteAllTextAsync(stateFile, state); - - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "mydb"; - o.AddContainer("mycontainer", "/partitionKey", "mydb"); - o.StatePersistenceDirectory = _tempDir; - }); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - var read = await container.ReadItemAsync("seeded", new PartitionKey("pk")); - read.Resource.Name.Should().Be("Seeded"); - } - - [Fact] - public async Task UseInMemoryCosmosContainers_StatePersistenceDirectory_NonExistentDir_StartsEmpty() - { - var emptyDir = Path.Combine(Path.GetTempPath(), $"cosmos-empty-{Guid.NewGuid():N}"); - try - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("orders", "/partitionKey"); - o.StatePersistenceDirectory = emptyDir; - }); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(0); - } - finally - { - if (Directory.Exists(emptyDir)) Directory.Delete(emptyDir, true); - } - } - - [Fact] - public async Task UseInMemoryCosmosContainers_StatePersistenceDirectory_LoadsSavedState() - { - // UseInMemoryCosmosContainers uses "{container}.json" naming (no db prefix) - var stateContainer = new InMemoryContainer("events", "/partitionKey"); - await stateContainer.CreateItemAsync( - new TestDocument { Id = "seeded", PartitionKey = "pk", Name = "Loaded" }, new PartitionKey("pk")); - var state = stateContainer.ExportState(); - - var stateFile = Path.Combine(_tempDir, "events.json"); - await File.WriteAllTextAsync(stateFile, state); - - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("events", "/partitionKey"); - o.StatePersistenceDirectory = _tempDir; - }); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - var read = await container.ReadItemAsync("seeded", new PartitionKey("pk")); - read.Resource.Name.Should().Be("Loaded"); - } + private readonly string _tempDir; + + public StatePersistenceDirectoryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + InMemoryFeedIteratorSetup.Deregister(); + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Fact] + public async Task UseInMemoryCosmosDB_StatePersistenceDirectory_NonExistentDir_StartsEmpty() + { + var emptyDir = Path.Combine(Path.GetTempPath(), $"cosmos-empty-{Guid.NewGuid():N}"); + try + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey"); + o.StatePersistenceDirectory = emptyDir; + }); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(0); + } + finally + { + if (Directory.Exists(emptyDir)) Directory.Delete(emptyDir, true); + } + } + + [Fact] + public async Task UseInMemoryCosmosDB_StatePersistenceDirectory_LoadsSavedState() + { + // Pre-create state file in expected format + var stateContainer = new InMemoryContainer("mycontainer", "/partitionKey"); + await stateContainer.CreateItemAsync( + new TestDocument { Id = "seeded", PartitionKey = "pk", Name = "Seeded" }, new PartitionKey("pk")); + var state = stateContainer.ExportState(); + + // UseInMemoryCosmosDB uses "{db}_{container}.json" naming + var stateFile = Path.Combine(_tempDir, "mydb_mycontainer.json"); + await File.WriteAllTextAsync(stateFile, state); + + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "mydb"; + o.AddContainer("mycontainer", "/partitionKey", "mydb"); + o.StatePersistenceDirectory = _tempDir; + }); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + var read = await container.ReadItemAsync("seeded", new PartitionKey("pk")); + read.Resource.Name.Should().Be("Seeded"); + } + + [Fact] + public async Task UseInMemoryCosmosContainers_StatePersistenceDirectory_NonExistentDir_StartsEmpty() + { + var emptyDir = Path.Combine(Path.GetTempPath(), $"cosmos-empty-{Guid.NewGuid():N}"); + try + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("orders", "/partitionKey"); + o.StatePersistenceDirectory = emptyDir; + }); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(0); + } + finally + { + if (Directory.Exists(emptyDir)) Directory.Delete(emptyDir, true); + } + } + + [Fact] + public async Task UseInMemoryCosmosContainers_StatePersistenceDirectory_LoadsSavedState() + { + // UseInMemoryCosmosContainers uses "{container}.json" naming (no db prefix) + var stateContainer = new InMemoryContainer("events", "/partitionKey"); + await stateContainer.CreateItemAsync( + new TestDocument { Id = "seeded", PartitionKey = "pk", Name = "Loaded" }, new PartitionKey("pk")); + var state = stateContainer.ExportState(); + + var stateFile = Path.Combine(_tempDir, "events.json"); + await File.WriteAllTextAsync(stateFile, state); + + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("events", "/partitionKey"); + o.StatePersistenceDirectory = _tempDir; + }); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + var read = await container.ReadItemAsync("seeded", new PartitionKey("pk")); + read.Resource.Name.Should().Be("Loaded"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2433,60 +2433,60 @@ await stateContainer.CreateItemAsync( [Collection("FeedIteratorSetup")] public class MultiDatabaseContainerCollisionTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task MultiDatabase_SameContainerName_DataIsolation() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey", "db1"); - o.AddContainer("orders", "/partitionKey", "db2"); - }); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - - var db1Container = client.GetContainer("db1", "orders"); - var db2Container = client.GetContainer("db2", "orders"); - - await db1Container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "DB1Item" }, new PartitionKey("pk")); - - // db2 should not see db1's item - var act = () => db2Container.ReadItemAsync("1", new PartitionKey("pk")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task MultiDatabase_DifferentContainerNames_NoCollision() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey", "db1"); - o.AddContainer("customers", "/partitionKey", "db2"); - }); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - - var orders = client.GetContainer("db1", "orders"); - var customers = client.GetContainer("db2", "customers"); - - await orders.CreateItemAsync( - new TestDocument { Id = "o1", PartitionKey = "pk", Name = "Order" }, new PartitionKey("pk")); - await customers.CreateItemAsync( - new TestDocument { Id = "c1", PartitionKey = "pk", Name = "Customer" }, new PartitionKey("pk")); - - var orderRead = await orders.ReadItemAsync("o1", new PartitionKey("pk")); - orderRead.Resource.Name.Should().Be("Order"); - - var customerRead = await customers.ReadItemAsync("c1", new PartitionKey("pk")); - customerRead.Resource.Name.Should().Be("Customer"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task MultiDatabase_SameContainerName_DataIsolation() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey", "db1"); + o.AddContainer("orders", "/partitionKey", "db2"); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var db1Container = client.GetContainer("db1", "orders"); + var db2Container = client.GetContainer("db2", "orders"); + + await db1Container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "DB1Item" }, new PartitionKey("pk")); + + // db2 should not see db1's item + var act = () => db2Container.ReadItemAsync("1", new PartitionKey("pk")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task MultiDatabase_DifferentContainerNames_NoCollision() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey", "db1"); + o.AddContainer("customers", "/partitionKey", "db2"); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var orders = client.GetContainer("db1", "orders"); + var customers = client.GetContainer("db2", "customers"); + + await orders.CreateItemAsync( + new TestDocument { Id = "o1", PartitionKey = "pk", Name = "Order" }, new PartitionKey("pk")); + await customers.CreateItemAsync( + new TestDocument { Id = "c1", PartitionKey = "pk", Name = "Customer" }, new PartitionKey("pk")); + + var orderRead = await orders.ReadItemAsync("o1", new PartitionKey("pk")); + orderRead.Resource.Name.Should().Be("Order"); + + var customerRead = await customers.ReadItemAsync("c1", new PartitionKey("pk")); + customerRead.Resource.Name.Should().Be("Customer"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2496,59 +2496,59 @@ await customers.CreateItemAsync( [Collection("FeedIteratorSetup")] public class CallbackExceptionPropagationTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void OnHandlerCreated_ThrowsException_PropagatesUnwrapped() - { - var services = new ServiceCollection(); - var act = () => services.UseInMemoryCosmosDB(o => - { - o.AddContainer("test", "/pk"); - o.OnHandlerCreated = (_, _) => throw new InvalidOperationException("Handler callback failed"); - }); - act.Should().Throw() - .WithMessage("*Handler callback failed*"); - } - - [Fact] - public void OnClientCreated_ThrowsException_PropagatesUnwrapped() - { - var services = new ServiceCollection(); - var act = () => services.UseInMemoryCosmosDB(o => - { - o.AddContainer("test", "/pk"); - o.OnClientCreated = _ => throw new InvalidOperationException("Client callback failed"); - }); - act.Should().Throw() - .WithMessage("*Client callback failed*"); - } - - [Fact] - public void OnContainerCreated_ThrowsException_PropagatesUnwrapped() - { - var services = new ServiceCollection(); - var act = () => services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("test", "/pk"); - o.OnContainerCreated = _ => throw new InvalidOperationException("Container callback failed"); - }); - act.Should().Throw() - .WithMessage("*Container callback failed*"); - } - - [Fact] - public void HttpMessageHandlerWrapper_ThrowsException_PropagatesUnwrapped() - { - var services = new ServiceCollection(); - var act = () => services.UseInMemoryCosmosDB(o => - { - o.AddContainer("test", "/pk"); - o.WithHttpMessageHandlerWrapper(_ => throw new InvalidOperationException("Wrapper failed")); - }); - act.Should().Throw() - .WithMessage("*Wrapper failed*"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void OnHandlerCreated_ThrowsException_PropagatesUnwrapped() + { + var services = new ServiceCollection(); + var act = () => services.UseInMemoryCosmosDB(o => + { + o.AddContainer("test", "/pk"); + o.OnHandlerCreated = (_, _) => throw new InvalidOperationException("Handler callback failed"); + }); + act.Should().Throw() + .WithMessage("*Handler callback failed*"); + } + + [Fact] + public void OnClientCreated_ThrowsException_PropagatesUnwrapped() + { + var services = new ServiceCollection(); + var act = () => services.UseInMemoryCosmosDB(o => + { + o.AddContainer("test", "/pk"); + o.OnClientCreated = _ => throw new InvalidOperationException("Client callback failed"); + }); + act.Should().Throw() + .WithMessage("*Client callback failed*"); + } + + [Fact] + public void OnContainerCreated_ThrowsException_PropagatesUnwrapped() + { + var services = new ServiceCollection(); + var act = () => services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("test", "/pk"); + o.OnContainerCreated = _ => throw new InvalidOperationException("Container callback failed"); + }); + act.Should().Throw() + .WithMessage("*Container callback failed*"); + } + + [Fact] + public void HttpMessageHandlerWrapper_ThrowsException_PropagatesUnwrapped() + { + var services = new ServiceCollection(); + var act = () => services.UseInMemoryCosmosDB(o => + { + o.AddContainer("test", "/pk"); + o.WithHttpMessageHandlerWrapper(_ => throw new InvalidOperationException("Wrapper failed")); + }); + act.Should().Throw() + .WithMessage("*Wrapper failed*"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2558,49 +2558,49 @@ public void HttpMessageHandlerWrapper_ThrowsException_PropagatesUnwrapped() [Collection("FeedIteratorSetup")] public class AutoDetectModeEdgeCaseTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task AutoDetect_FactoryThatUsesGetContainer_DifferentNames() - { - var services = new ServiceCollection(); - services.AddSingleton(sp => new CosmosClient("AccountEndpoint=https://localhost;AccountKey=dGVzdGtleQ==;")); - services.AddSingleton(sp => - { - var client = sp.GetRequiredService(); - return client.GetContainer("CustomDb", "custom-container"); - }); - - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - - // Verify it's functional even with custom db/container names - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task AutoDetect_NoCosmosClient_NoContainer_RegistersBoth() - { - var services = new ServiceCollection(); - // No existing registrations at all - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().NotBeNull(); - - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - - var response = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task AutoDetect_FactoryThatUsesGetContainer_DifferentNames() + { + var services = new ServiceCollection(); + services.AddSingleton(sp => new CosmosClient("AccountEndpoint=https://localhost;AccountKey=dGVzdGtleQ==;")); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return client.GetContainer("CustomDb", "custom-container"); + }); + + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + + // Verify it's functional even with custom db/container names + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task AutoDetect_NoCosmosClient_NoContainer_RegistersBoth() + { + var services = new ServiceCollection(); + // No existing registrations at all + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().NotBeNull(); + + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + + var response = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2610,86 +2610,86 @@ public async Task AutoDetect_NoCosmosClient_NoContainer_RegistersBoth() [Collection("FeedIteratorSetup")] public class ServiceCollectionEdgeCaseDeepDiveTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void UseInMemoryCosmosDB_ExplicitNullConfigure() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(null); - - using var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosContainers_ExplicitNullConfigure() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(null); - - using var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosDB_TypedClient_ExplicitNullConfigure() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(null); - - using var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void ContainerConfig_DefaultValues() - { - var config = new ContainerConfig("my-container"); - config.ContainerName.Should().Be("my-container"); - config.PartitionKeyPath.Should().Be("/id"); - config.DatabaseName.Should().BeNull(); - } - - [Fact] - public void ContainerConfig_WithDeconstruction() - { - var config = new ContainerConfig("my-container", "/pk", "mydb"); - var (name, pkPath, dbName, _) = config; - name.Should().Be("my-container"); - pkPath.Should().Be("/pk"); - dbName.Should().Be("mydb"); - } - - [Fact] - public void ContainerConfig_EmptyContainerName() - { - var config = new ContainerConfig(""); - config.ContainerName.Should().BeEmpty(); - } - - [Fact] - public void ContainerConfig_PartitionKeyPathWithoutLeadingSlash() - { - var config = new ContainerConfig("test", "id"); - config.PartitionKeyPath.Should().Be("id"); - // InMemoryContainer handles the path normalization - } - - [Fact] - public void DoesNotAffectUnrelatedServices() - { - var services = new ServiceCollection(); - services.AddSingleton("hello-world"); - services.AddSingleton(42); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Should().Be("hello-world"); - provider.GetRequiredService().Should().Be(42); - provider.GetRequiredService().Should().NotBeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void UseInMemoryCosmosDB_ExplicitNullConfigure() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(null); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosContainers_ExplicitNullConfigure() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(null); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosDB_TypedClient_ExplicitNullConfigure() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(null); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void ContainerConfig_DefaultValues() + { + var config = new ContainerConfig("my-container"); + config.ContainerName.Should().Be("my-container"); + config.PartitionKeyPath.Should().Be("/id"); + config.DatabaseName.Should().BeNull(); + } + + [Fact] + public void ContainerConfig_WithDeconstruction() + { + var config = new ContainerConfig("my-container", "/pk", "mydb"); + var (name, pkPath, dbName, _) = config; + name.Should().Be("my-container"); + pkPath.Should().Be("/pk"); + dbName.Should().Be("mydb"); + } + + [Fact] + public void ContainerConfig_EmptyContainerName() + { + var config = new ContainerConfig(""); + config.ContainerName.Should().BeEmpty(); + } + + [Fact] + public void ContainerConfig_PartitionKeyPathWithoutLeadingSlash() + { + var config = new ContainerConfig("test", "id"); + config.PartitionKeyPath.Should().Be("id"); + // InMemoryContainer handles the path normalization + } + + [Fact] + public void DoesNotAffectUnrelatedServices() + { + var services = new ServiceCollection(); + services.AddSingleton("hello-world"); + services.AddSingleton(42); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().Be("hello-world"); + provider.GetRequiredService().Should().Be(42); + provider.GetRequiredService().Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2699,141 +2699,141 @@ public void DoesNotAffectUnrelatedServices() [Collection("FeedIteratorSetup")] public class ServiceCollectionQueryIntegrationTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task UseInMemoryCosmosContainers_LinqQuery_ViaToFeedIteratorOverridable() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice" }, new PartitionKey("1")); - - var iter = container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIteratorOverridable(); - var page = await iter.ReadNextAsync(); - page.Count.Should().Be(1); - } - - [Fact] - public async Task UseInMemoryCosmosDB_SqlQuery() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task UseInMemoryCosmosContainers_SqlQuery() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task TypedClient_SqlQuery() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - o.AddContainer("biometrics", "/partitionKey", "BiometricDb")); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("BiometricDb", "biometrics"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var results = await iter.ReadNextAsync(); - results.Count.Should().Be(1); - } - - [Fact] - public async Task TypedClient_LinqQuery_ViaToFeedIteratorOverridable() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - o.AddContainer("biometrics", "/partitionKey", "BiometricDb")); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var container = client.GetContainer("BiometricDb", "biometrics"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); - - var iter = container.GetItemLinqQueryable() - .Where(d => d.Name == "Alice") - .ToFeedIteratorOverridable(); - var page = await iter.ReadNextAsync(); - page.Count.Should().Be(1); - } - - [Fact] - public async Task MultiContainer_DataIsolation_UseInMemoryCosmosDB() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey"); - o.AddContainer("events", "/partitionKey"); - }); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - var orders = client.GetContainer("in-memory-db", "orders"); - var events = client.GetContainer("in-memory-db", "events"); - - await orders.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Order1" }, new PartitionKey("pk")); - - var ordersIter = orders.GetItemQueryIterator("SELECT * FROM c"); - var orderResults = await ordersIter.ReadNextAsync(); - orderResults.Count.Should().Be(1); - - var eventsIter = events.GetItemQueryIterator("SELECT * FROM c"); - var eventResults = await eventsIter.ReadNextAsync(); - eventResults.Count.Should().Be(0); - } - - [Fact] - public async Task UseInMemoryCosmosDB_StreamCrud() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - - var createResp = await container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"1","name":"A"}""")), - new PartitionKey("1")); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResp = await container.ReadItemStreamAsync("1", new PartitionKey("1")); - readResp.StatusCode.Should().Be(HttpStatusCode.OK); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task UseInMemoryCosmosContainers_LinqQuery_ViaToFeedIteratorOverridable() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "Alice" }, new PartitionKey("1")); + + var iter = container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIteratorOverridable(); + var page = await iter.ReadNextAsync(); + page.Count.Should().Be(1); + } + + [Fact] + public async Task UseInMemoryCosmosDB_SqlQuery() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task UseInMemoryCosmosContainers_SqlQuery() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "1", Name = "A" }, new PartitionKey("1")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task TypedClient_SqlQuery() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + o.AddContainer("biometrics", "/partitionKey", "BiometricDb")); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("BiometricDb", "biometrics"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "A" }, new PartitionKey("pk")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var results = await iter.ReadNextAsync(); + results.Count.Should().Be(1); + } + + [Fact] + public async Task TypedClient_LinqQuery_ViaToFeedIteratorOverridable() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + o.AddContainer("biometrics", "/partitionKey", "BiometricDb")); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var container = client.GetContainer("BiometricDb", "biometrics"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Alice" }, new PartitionKey("pk")); + + var iter = container.GetItemLinqQueryable() + .Where(d => d.Name == "Alice") + .ToFeedIteratorOverridable(); + var page = await iter.ReadNextAsync(); + page.Count.Should().Be(1); + } + + [Fact] + public async Task MultiContainer_DataIsolation_UseInMemoryCosmosDB() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey"); + o.AddContainer("events", "/partitionKey"); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var orders = client.GetContainer("in-memory-db", "orders"); + var events = client.GetContainer("in-memory-db", "events"); + + await orders.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Order1" }, new PartitionKey("pk")); + + var ordersIter = orders.GetItemQueryIterator("SELECT * FROM c"); + var orderResults = await ordersIter.ReadNextAsync(); + orderResults.Count.Should().Be(1); + + var eventsIter = events.GetItemQueryIterator("SELECT * FROM c"); + var eventResults = await eventsIter.ReadNextAsync(); + eventResults.Count.Should().Be(0); + } + + [Fact] + public async Task UseInMemoryCosmosDB_StreamCrud() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + + var createResp = await container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"1","name":"A"}""")), + new PartitionKey("1")); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResp = await container.ReadItemStreamAsync("1", new PartitionKey("1")); + readResp.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2843,64 +2843,64 @@ public async Task UseInMemoryCosmosDB_StreamCrud() [Collection("FeedIteratorSetup")] public class AutoDetectEdgeCaseTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void AutoDetect_ExistingInstanceRegistration_IsPreserved() - { - var instance = new InMemoryContainer("standalone", "/id"); - var services = new ServiceCollection(); - services.AddSingleton(instance); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - // Auto-detect preserves existing registrations - var containers = provider.GetServices().ToList(); - containers.Should().NotBeEmpty(); - } - - [Fact] - public void AutoDetect_FactoryWithAdditionalDependency_Resolves() - { - var services = new ServiceCollection(); - services.AddSingleton("test-config-value"); - services.AddSingleton(sp => - { - var config = sp.GetRequiredService(); - var client = sp.GetRequiredService(); - return client.GetContainer("db", config); - }); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - container.Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosContainers_ExistingCosmosClientFactory_StillResolvable() - { - var services = new ServiceCollection(); - services.AddSingleton(new InMemoryCosmosClient()); - services.UseInMemoryCosmosContainers(); - - using var provider = services.BuildServiceProvider(); - // CosmosClient is NOT removed by UseInMemoryCosmosContainers - provider.GetRequiredService().Should().NotBeNull(); - provider.GetRequiredService().Should().NotBeNull(); - } - - [Fact] - public void UseInMemoryCosmosContainers_ContainerIsRealSdkType() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(); - - using var provider = services.BuildServiceProvider(); - var container = provider.GetRequiredService(); - // v4.0: Container is a real SDK Container backed by FakeCosmosHandler - container.Should().NotBeOfType(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void AutoDetect_ExistingInstanceRegistration_IsPreserved() + { + var instance = new InMemoryContainer("standalone", "/id"); + var services = new ServiceCollection(); + services.AddSingleton(instance); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + // Auto-detect preserves existing registrations + var containers = provider.GetServices().ToList(); + containers.Should().NotBeEmpty(); + } + + [Fact] + public void AutoDetect_FactoryWithAdditionalDependency_Resolves() + { + var services = new ServiceCollection(); + services.AddSingleton("test-config-value"); + services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + var client = sp.GetRequiredService(); + return client.GetContainer("db", config); + }); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + container.Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosContainers_ExistingCosmosClientFactory_StillResolvable() + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryCosmosClient()); + services.UseInMemoryCosmosContainers(); + + using var provider = services.BuildServiceProvider(); + // CosmosClient is NOT removed by UseInMemoryCosmosContainers + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void UseInMemoryCosmosContainers_ContainerIsRealSdkType() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(); + + using var provider = services.BuildServiceProvider(); + var container = provider.GetRequiredService(); + // v4.0: Container is a real SDK Container backed by FakeCosmosHandler + container.Should().NotBeOfType(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2910,73 +2910,73 @@ public void UseInMemoryCosmosContainers_ContainerIsRealSdkType() [Collection("FeedIteratorSetup")] public class ServiceCollectionLifecycleTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void ServiceProviderDisposal_DoesNotThrow() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - _ = provider.GetRequiredService(); - - var act = () => provider.Dispose(); - act.Should().NotThrow(); - } - - [Fact] - public void ServiceProviderDisposal_TypedClient_DoesNotThrow() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - - var act = () => provider.Dispose(); - act.Should().NotThrow(); - } - - [Fact] - public void ServiceProviderDisposal_ContainersOnly_DoesNotThrow() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosContainers(); - - var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); - - var act = () => provider.Dispose(); - act.Should().NotThrow(); - } - - [Fact] - public async Task ConcurrentScopeResolution_SameBackingData() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(o => o.AddContainer("shared", "/partitionKey")); - - using var provider = services.BuildServiceProvider(); - - // Write from main scope - var container = provider.GetRequiredService(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Shared" }, new PartitionKey("pk")); - - // Read from concurrent "scopes" (singletons share the same instance) - var tasks = Enumerable.Range(0, 5).Select(async _ => - { - var c = provider.GetRequiredService(); - var iter = c.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - return page.Count; - }); - - var counts = await Task.WhenAll(tasks); - counts.Should().AllSatisfy(c => c.Should().Be(1)); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void ServiceProviderDisposal_DoesNotThrow() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + _ = provider.GetRequiredService(); + + var act = () => provider.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public void ServiceProviderDisposal_TypedClient_DoesNotThrow() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + var act = () => provider.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public void ServiceProviderDisposal_ContainersOnly_DoesNotThrow() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosContainers(); + + var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + var act = () => provider.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public async Task ConcurrentScopeResolution_SameBackingData() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(o => o.AddContainer("shared", "/partitionKey")); + + using var provider = services.BuildServiceProvider(); + + // Write from main scope + var container = provider.GetRequiredService(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Shared" }, new PartitionKey("pk")); + + // Read from concurrent "scopes" (singletons share the same instance) + var tasks = Enumerable.Range(0, 5).Select(async _ => + { + var c = provider.GetRequiredService(); + var iter = c.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + return page.Count; + }); + + var counts = await Task.WhenAll(tasks); + counts.Should().AllSatisfy(c => c.Should().Be(1)); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2986,33 +2986,33 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class ServiceCollectionFluentApiTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void FluentChaining_InMemoryCosmosOptions_AddContainer_WithDatabaseName() - { - var options = new InMemoryCosmosOptions(); - options.AddContainer("orders", "/pk", "db1") - .AddContainer("events", "/pk", "db2"); - - options.Containers.Should().HaveCount(2); - options.Containers[0].DatabaseName.Should().Be("db1"); - options.Containers[1].DatabaseName.Should().Be("db2"); - } - - [Fact] - public void FluentChaining_WithHttpMessageHandlerWrapper_ChainedWithAddContainer() - { - HttpMessageHandler? captured = null; - var options = new InMemoryCosmosOptions(); - var result = options - .AddContainer("orders", "/pk") - .WithHttpMessageHandlerWrapper(h => { captured = h; return h; }) - .AddContainer("events", "/pk"); - - result.Containers.Should().HaveCount(2); - result.HttpMessageHandlerWrapper.Should().NotBeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void FluentChaining_InMemoryCosmosOptions_AddContainer_WithDatabaseName() + { + var options = new InMemoryCosmosOptions(); + options.AddContainer("orders", "/pk", "db1") + .AddContainer("events", "/pk", "db2"); + + options.Containers.Should().HaveCount(2); + options.Containers[0].DatabaseName.Should().Be("db1"); + options.Containers[1].DatabaseName.Should().Be("db2"); + } + + [Fact] + public void FluentChaining_WithHttpMessageHandlerWrapper_ChainedWithAddContainer() + { + HttpMessageHandler? captured = null; + var options = new InMemoryCosmosOptions(); + var result = options + .AddContainer("orders", "/pk") + .WithHttpMessageHandlerWrapper(h => { captured = h; return h; }) + .AddContainer("events", "/pk"); + + result.Containers.Should().HaveCount(2); + result.HttpMessageHandlerWrapper.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -3022,28 +3022,28 @@ public void FluentChaining_WithHttpMessageHandlerWrapper_ChainedWithAddContainer [Collection("FeedIteratorSetup")] public class ServiceCollectionClientPropertyTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public void CosmosClient_Endpoint() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Endpoint.Should().NotBeNull(); - } - - [Fact] - public void TypedClient_IsCastleProxy() - { - var services = new ServiceCollection(); - services.UseInMemoryCosmosDB(); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - client.Should().BeAssignableTo(); - client.GetType().Should().NotBe(typeof(EmployeeCosmosClient), "Castle proxy creates a subclass"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public void CosmosClient_Endpoint() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Endpoint.Should().NotBeNull(); + } + + [Fact] + public void TypedClient_IsCastleProxy() + { + var services = new ServiceCollection(); + services.UseInMemoryCosmosDB(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + client.Should().BeAssignableTo(); + client.GetType().Should().NotBe(typeof(EmployeeCosmosClient), "Castle proxy creates a subclass"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SkippedBehaviorTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SkippedBehaviorTests.cs index c615fa5..c4c511f 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SkippedBehaviorTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SkippedBehaviorTests.cs @@ -10,282 +10,282 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class SkippedBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task RequestCharge_ShouldBeNonZero_OnEveryResponse() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - response.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task ContinuationToken_ShouldEnablePaginatedResumption() - { - for (var i = 0; i < 10; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var requestOptions = new QueryRequestOptions { MaxItemCount = 3 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: requestOptions); - - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().Be(3); - firstPage.ContinuationToken.Should().NotBeNullOrEmpty(); - - var iterator2 = _container.GetItemQueryIterator("SELECT * FROM c", - continuationToken: firstPage.ContinuationToken, requestOptions: requestOptions); - var secondPage = await iterator2.ReadNextAsync(); - secondPage.Count.Should().Be(3); - } - - [Fact] - public async Task LargeDocument_ShouldBeRejected_Over2MB() - { - var largeValue = new string('x', 3 * 1024 * 1024); - var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = largeValue }; - - var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task TimeToLive_ShouldAutoDeleteExpiredDocuments() - { - _container.DefaultTimeToLive = 1; - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temporary" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task StoredProcedure_ShouldExecuteServerSideLogic() - { - _container.RegisterStoredProcedure("sprocId", (pk, args) => "executed"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "sprocId", new PartitionKey("pk1"), Array.Empty()); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().Be("executed"); - } - - [Fact] - public async Task PreTrigger_ShouldFireOnCreate() - { - _container.RegisterTrigger("validateInsert", TriggerType.Pre, TriggerOperation.Create, - doc => { doc["triggered"] = true; return doc; }); - - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - var options = new ItemRequestOptions { PreTriggers = new List { "validateInsert" } }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1"), options); - - var result = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - result.Resource["triggered"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task UserDefinedFunction_ShouldBeCallableInQuery() - { - _container.RegisterUdf("tax", args => (double)(long)args[0] * 0.2); - - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 100 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT udf.tax(c.value) AS taxAmount FROM c"); - var iterator = _container.GetItemQueryIterator(query); - var response = await iterator.ReadNextAsync(); - response.Should().ContainSingle(); - response.First()["taxAmount"]!.Value().Should().Be(20.0); - } - - [Fact] - public async Task IndexingPolicy_ShouldBeStoredOnContainer() - { - var updatedProperties = new ContainerProperties("test-container", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - Automatic = true, - IndexingMode = IndexingMode.Consistent - } - }; - - var replaceResponse = await _container.ReplaceContainerAsync(updatedProperties); - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResponse.Resource.IndexingPolicy.Should().NotBeNull(); - replaceResponse.Resource.IndexingPolicy.Automatic.Should().BeTrue(); - } - - [Fact] - public async Task CrossPartitionOrderBy_ShouldSortAcrossPartitions() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bravo", Value = 20 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alpha", Value = 10 }, - new PartitionKey("pk2")); - - var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC"); - var requestOptions = new QueryRequestOptions { MaxItemCount = 1 }; - var iterator = _container.GetItemQueryIterator(query, requestOptions: requestOptions); - - var allPages = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allPages.AddRange(page); - } - - allPages.Should().HaveCount(2); - allPages[0].Value.Should().Be(10); - allPages[1].Value.Should().Be(20); - } - - [Fact] - public async Task ConflictResolution_ShouldBeStoredOnContainer() - { - var readResponse = await _container.ReadContainerAsync(); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - readResponse.Resource.Should().NotBeNull(); - - var updatedProperties = new ContainerProperties("test-container", "/partitionKey") - { - ConflictResolutionPolicy = new ConflictResolutionPolicy - { - Mode = ConflictResolutionMode.LastWriterWins, - ResolutionPath = "/_ts" - } - }; - var replaceResponse = await _container.ReplaceContainerAsync(updatedProperties); - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResponse.Resource.ConflictResolutionPolicy.Mode.Should().Be(ConflictResolutionMode.LastWriterWins); - } - - [Fact] - public async Task SessionToken_ShouldBePresentOnResponses() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - response.Headers.Session.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task MaxItemCount_ShouldLimitPageSize() - { - for (var i = 0; i < 10; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - var requestOptions = new QueryRequestOptions { MaxItemCount = 3 }; - var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: requestOptions); - - var firstPage = await iterator.ReadNextAsync(); - firstPage.Count.Should().BeLessThanOrEqualTo(3); - } - - [Fact] - public async Task StreamResponseHeaders_ShouldContainMetadata() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - response.Headers["ETag"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task HierarchicalPartitionKey_ShouldSupportMultipleLevels() - { - var container = new InMemoryContainer("hierarchical-test", new[] { "/tenantId", "/userId" }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenantId = "t1", userId = "u2", name = "Bob" }), - new PartitionKeyBuilder().Add("t1").Add("u2").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", tenantId = "t2", userId = "u1", name = "Charlie" }), - new PartitionKeyBuilder().Add("t2").Add("u1").Build()); - - container.ItemCount.Should().Be(3); - - var result = await container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - result.Resource["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task TransactionalBatch_ShouldRollbackOnFailure() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); - - using var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().NotBe(HttpStatusCode.OK); - - var act = () => _container.ReadItemAsync("2", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task TransactionalBatch_ShouldRejectOver100Operations() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 101; i++) - { - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - } - - var act = () => batch.ExecuteAsync(); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Like_SingleCharWildcard_ShouldMatchSingleCharacter() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "cat" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "cut" }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "coat" }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'c_t'"); - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task RequestCharge_ShouldBeNonZero_OnEveryResponse() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task ContinuationToken_ShouldEnablePaginatedResumption() + { + for (var i = 0; i < 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var requestOptions = new QueryRequestOptions { MaxItemCount = 3 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: requestOptions); + + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().Be(3); + firstPage.ContinuationToken.Should().NotBeNullOrEmpty(); + + var iterator2 = _container.GetItemQueryIterator("SELECT * FROM c", + continuationToken: firstPage.ContinuationToken, requestOptions: requestOptions); + var secondPage = await iterator2.ReadNextAsync(); + secondPage.Count.Should().Be(3); + } + + [Fact] + public async Task LargeDocument_ShouldBeRejected_Over2MB() + { + var largeValue = new string('x', 3 * 1024 * 1024); + var doc = new TestDocument { Id = "large", PartitionKey = "pk1", Name = largeValue }; + + var act = () => _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TimeToLive_ShouldAutoDeleteExpiredDocuments() + { + _container.DefaultTimeToLive = 1; + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temporary" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task StoredProcedure_ShouldExecuteServerSideLogic() + { + _container.RegisterStoredProcedure("sprocId", (pk, args) => "executed"); + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "sprocId", new PartitionKey("pk1"), Array.Empty()); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().Be("executed"); + } + + [Fact] + public async Task PreTrigger_ShouldFireOnCreate() + { + _container.RegisterTrigger("validateInsert", TriggerType.Pre, TriggerOperation.Create, + doc => { doc["triggered"] = true; return doc; }); + + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + var options = new ItemRequestOptions { PreTriggers = new List { "validateInsert" } }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1"), options); + + var result = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + result.Resource["triggered"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task UserDefinedFunction_ShouldBeCallableInQuery() + { + _container.RegisterUdf("tax", args => (double)(long)args[0] * 0.2); + + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 100 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT udf.tax(c.value) AS taxAmount FROM c"); + var iterator = _container.GetItemQueryIterator(query); + var response = await iterator.ReadNextAsync(); + response.Should().ContainSingle(); + response.First()["taxAmount"]!.Value().Should().Be(20.0); + } + + [Fact] + public async Task IndexingPolicy_ShouldBeStoredOnContainer() + { + var updatedProperties = new ContainerProperties("test-container", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + Automatic = true, + IndexingMode = IndexingMode.Consistent + } + }; + + var replaceResponse = await _container.ReplaceContainerAsync(updatedProperties); + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResponse.Resource.IndexingPolicy.Should().NotBeNull(); + replaceResponse.Resource.IndexingPolicy.Automatic.Should().BeTrue(); + } + + [Fact] + public async Task CrossPartitionOrderBy_ShouldSortAcrossPartitions() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bravo", Value = 20 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alpha", Value = 10 }, + new PartitionKey("pk2")); + + var query = new QueryDefinition("SELECT * FROM c ORDER BY c.value ASC"); + var requestOptions = new QueryRequestOptions { MaxItemCount = 1 }; + var iterator = _container.GetItemQueryIterator(query, requestOptions: requestOptions); + + var allPages = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allPages.AddRange(page); + } + + allPages.Should().HaveCount(2); + allPages[0].Value.Should().Be(10); + allPages[1].Value.Should().Be(20); + } + + [Fact] + public async Task ConflictResolution_ShouldBeStoredOnContainer() + { + var readResponse = await _container.ReadContainerAsync(); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + readResponse.Resource.Should().NotBeNull(); + + var updatedProperties = new ContainerProperties("test-container", "/partitionKey") + { + ConflictResolutionPolicy = new ConflictResolutionPolicy + { + Mode = ConflictResolutionMode.LastWriterWins, + ResolutionPath = "/_ts" + } + }; + var replaceResponse = await _container.ReplaceContainerAsync(updatedProperties); + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResponse.Resource.ConflictResolutionPolicy.Mode.Should().Be(ConflictResolutionMode.LastWriterWins); + } + + [Fact] + public async Task SessionToken_ShouldBePresentOnResponses() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + var response = await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + response.Headers.Session.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task MaxItemCount_ShouldLimitPageSize() + { + for (var i = 0; i < 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + var requestOptions = new QueryRequestOptions { MaxItemCount = 3 }; + var iterator = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: requestOptions); + + var firstPage = await iterator.ReadNextAsync(); + firstPage.Count.Should().BeLessThanOrEqualTo(3); + } + + [Fact] + public async Task StreamResponseHeaders_ShouldContainMetadata() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + response.Headers["ETag"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task HierarchicalPartitionKey_ShouldSupportMultipleLevels() + { + var container = new InMemoryContainer("hierarchical-test", new[] { "/tenantId", "/userId" }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenantId = "t1", userId = "u2", name = "Bob" }), + new PartitionKeyBuilder().Add("t1").Add("u2").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", tenantId = "t2", userId = "u1", name = "Charlie" }), + new PartitionKeyBuilder().Add("t2").Add("u1").Build()); + + container.ItemCount.Should().Be(3); + + var result = await container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + result.Resource["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task TransactionalBatch_ShouldRollbackOnFailure() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); + + using var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().NotBe(HttpStatusCode.OK); + + var act = () => _container.ReadItemAsync("2", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TransactionalBatch_ShouldRejectOver100Operations() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 101; i++) + { + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + } + + var act = () => batch.ExecuteAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Like_SingleCharWildcard_ShouldMatchSingleCharacter() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "cat" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "cut" }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk1", Name = "coat" }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'c_t'"); + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -294,80 +294,80 @@ await _container.CreateItemAsync( public class RequestChargeEdgeCaseTests { - private readonly InMemoryContainer _container = new("rc-test", "/partitionKey"); - - [Fact] - public async Task RequestCharge_ShouldBe1_OnRead() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnReplace() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - - doc.Name = "B"; - var response = await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnDelete() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnPatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "B") }); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var iter = _container.GetItemQueryIterator("SELECT * FROM c"); - var response = await iter.ReadNextAsync(); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnUpsert() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; - var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); - response.RequestCharge.Should().Be(1.0); - } - - [Fact] - public async Task RequestCharge_ShouldBe1_OnChangeFeedRead() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var iter = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var response = await iter.ReadNextAsync(); - response.RequestCharge.Should().BeGreaterThanOrEqualTo(1.0); - } + private readonly InMemoryContainer _container = new("rc-test", "/partitionKey"); + + [Fact] + public async Task RequestCharge_ShouldBe1_OnRead() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnReplace() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + + doc.Name = "B"; + var response = await _container.ReplaceItemAsync(doc, "1", new PartitionKey("pk1")); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnDelete() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.DeleteItemAsync("1", new PartitionKey("pk1")); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnPatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await _container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "B") }); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var iter = _container.GetItemQueryIterator("SELECT * FROM c"); + var response = await iter.ReadNextAsync(); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnUpsert() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; + var response = await _container.UpsertItemAsync(doc, new PartitionKey("pk1")); + response.RequestCharge.Should().Be(1.0); + } + + [Fact] + public async Task RequestCharge_ShouldBe1_OnChangeFeedRead() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var iter = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var response = await iter.ReadNextAsync(); + response.RequestCharge.Should().BeGreaterThanOrEqualTo(1.0); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -376,127 +376,127 @@ await _container.CreateItemAsync( public class ContinuationTokenEdgeCaseTests { - private readonly InMemoryContainer _container = new("ct-test", "/partitionKey"); - - private async Task SeedItemsAsync(int count) - { - for (var i = 0; i < count; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, - new PartitionKey("pk1")); - } - - [Fact] - public async Task ContinuationToken_WithOrderBy_ShouldPreserveOrder() - { - await SeedItemsAsync(6); - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value ASC", requestOptions: opts); - - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - if (page.ContinuationToken != null) - { - iter = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value ASC", - continuationToken: page.ContinuationToken, requestOptions: opts); - } - } - - all.Select(d => d.Value).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task ContinuationToken_EmptyResults_ShouldReturnNull() - { - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'nonexistent'"); - var page = await iter.ReadNextAsync(); - - page.Should().BeEmpty(); - page.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task ContinuationToken_FullIteration_CollectsAllItems() - { - await SeedItemsAsync(7); - - var opts = new QueryRequestOptions { MaxItemCount = 3 }; - var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - - all.Should().HaveCount(7); - } - - [Fact] - public async Task ContinuationToken_LastPage_ShouldReturnNull() - { - await SeedItemsAsync(3); - - var opts = new QueryRequestOptions { MaxItemCount = 10 }; - var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(3); - page.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task ContinuationToken_WithDistinct_CollectsUniqueItems() - { - for (var i = 0; i < 6; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Name{i % 3}", Value = i }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - var iter = _container.GetItemQueryIterator( - "SELECT DISTINCT c.name FROM c", requestOptions: opts); - - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page.Select(j => j["name"]!.ToString())); - } - - all.Distinct().Should().HaveCount(3); - } - - [Fact] - public async Task ContinuationToken_WithWhere_PaginatesFilteredResults() - { - for (var i = 0; i < 10; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = i % 2 == 0 ? "Even" : "Odd", Value = i }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Even'", requestOptions: opts); - - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - - all.Should().HaveCount(5); - all.Should().OnlyContain(d => d.Name == "Even"); - } + private readonly InMemoryContainer _container = new("ct-test", "/partitionKey"); + + private async Task SeedItemsAsync(int count) + { + for (var i = 0; i < count; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}", Value = i }, + new PartitionKey("pk1")); + } + + [Fact] + public async Task ContinuationToken_WithOrderBy_ShouldPreserveOrder() + { + await SeedItemsAsync(6); + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value ASC", requestOptions: opts); + + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + if (page.ContinuationToken != null) + { + iter = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value ASC", + continuationToken: page.ContinuationToken, requestOptions: opts); + } + } + + all.Select(d => d.Value).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task ContinuationToken_EmptyResults_ShouldReturnNull() + { + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'nonexistent'"); + var page = await iter.ReadNextAsync(); + + page.Should().BeEmpty(); + page.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task ContinuationToken_FullIteration_CollectsAllItems() + { + await SeedItemsAsync(7); + + var opts = new QueryRequestOptions { MaxItemCount = 3 }; + var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + + all.Should().HaveCount(7); + } + + [Fact] + public async Task ContinuationToken_LastPage_ShouldReturnNull() + { + await SeedItemsAsync(3); + + var opts = new QueryRequestOptions { MaxItemCount = 10 }; + var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(3); + page.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task ContinuationToken_WithDistinct_CollectsUniqueItems() + { + for (var i = 0; i < 6; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Name{i % 3}", Value = i }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + var iter = _container.GetItemQueryIterator( + "SELECT DISTINCT c.name FROM c", requestOptions: opts); + + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page.Select(j => j["name"]!.ToString())); + } + + all.Distinct().Should().HaveCount(3); + } + + [Fact] + public async Task ContinuationToken_WithWhere_PaginatesFilteredResults() + { + for (var i = 0; i < 10; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = i % 2 == 0 ? "Even" : "Odd", Value = i }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Even'", requestOptions: opts); + + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + + all.Should().HaveCount(5); + all.Should().OnlyContain(d => d.Name == "Even"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -505,22 +505,22 @@ await _container.CreateItemAsync( public class TtlSkippedBehaviorEdgeCaseTests { - [Fact] - public async Task ItemTtl_MinusOne_OverridesContainerDefault_NoExpiration() - { - var container = new InMemoryContainer("ttl-minus1", "/pk"); - container.DefaultTimeToLive = 1; - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","_ttl":-1}""")), - new PartitionKey("a")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.OK, - "_ttl:-1 should override container default and prevent expiration"); - } + [Fact] + public async Task ItemTtl_MinusOne_OverridesContainerDefault_NoExpiration() + { + var container = new InMemoryContainer("ttl-minus1", "/pk"); + container.DefaultTimeToLive = 1; + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","_ttl":-1}""")), + new PartitionKey("a")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.OK, + "_ttl:-1 should override container default and prevent expiration"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -529,40 +529,40 @@ await container.CreateItemStreamAsync( public class SessionTokenEdgeCaseTests { - [Fact] - public async Task SessionToken_ShouldBePresent_OnReadResponse() - { - var container = new InMemoryContainer("st-test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); - response.Headers.Session.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task SessionToken_ShouldBePresent_OnStreamResponse() - { - var container = new InMemoryContainer("st-stream", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task SessionToken_NowPresentOnStreamResponse() - { - // Previously divergent: stream responses didn't include x-ms-session-token header. Now fixed. - var container = new InMemoryContainer("st-stream-div", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty( - "emulator now sets session token on stream responses"); - } + [Fact] + public async Task SessionToken_ShouldBePresent_OnReadResponse() + { + var container = new InMemoryContainer("st-test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + var response = await container.ReadItemAsync("1", new PartitionKey("pk1")); + response.Headers.Session.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task SessionToken_ShouldBePresent_OnStreamResponse() + { + var container = new InMemoryContainer("st-stream", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task SessionToken_NowPresentOnStreamResponse() + { + // Previously divergent: stream responses didn't include x-ms-session-token header. Now fixed. + var container = new InMemoryContainer("st-stream-div", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + response.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty( + "emulator now sets session token on stream responses"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -571,77 +571,77 @@ await container.CreateItemAsync( public class MaxItemCountEdgeCaseTests { - private readonly InMemoryContainer _container = new("mi-test", "/partitionKey"); - - [Fact] - public async Task MaxItemCount_One_ShouldReturnOnePerPage() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 1 }; - var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var page = await iter.ReadNextAsync(); - - page.Count.Should().Be(1); - } - - [Fact] - public async Task MaxItemCount_GreaterThanTotal_ShouldReturnAll() - { - for (var i = 0; i < 3; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 100 }; - var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var page = await iter.ReadNextAsync(); - - page.Count.Should().Be(3); - page.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task MaxItemCount_WithOrderBy_ShouldPaginateCorrectly() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}", Value = 5 - i }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value ASC", requestOptions: opts); - - var all = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - all.AddRange(page); - } - - all.Select(d => d.Value).Should().BeInAscendingOrder(); - all.Should().HaveCount(5); - } - - [Fact] - public async Task MaxItemCount_MinusOne_ShouldReturnAllInOnePage() - { - for (var i = 0; i < 5; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = -1 }; - var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var page = await iter.ReadNextAsync(); - - page.Count.Should().Be(5); - page.ContinuationToken.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("mi-test", "/partitionKey"); + + [Fact] + public async Task MaxItemCount_One_ShouldReturnOnePerPage() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 1 }; + var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var page = await iter.ReadNextAsync(); + + page.Count.Should().Be(1); + } + + [Fact] + public async Task MaxItemCount_GreaterThanTotal_ShouldReturnAll() + { + for (var i = 0; i < 3; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 100 }; + var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var page = await iter.ReadNextAsync(); + + page.Count.Should().Be(3); + page.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task MaxItemCount_WithOrderBy_ShouldPaginateCorrectly() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}", Value = 5 - i }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value ASC", requestOptions: opts); + + var all = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + all.AddRange(page); + } + + all.Select(d => d.Value).Should().BeInAscendingOrder(); + all.Should().HaveCount(5); + } + + [Fact] + public async Task MaxItemCount_MinusOne_ShouldReturnAllInOnePage() + { + for (var i = 0; i < 5; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = -1 }; + var iter = _container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var page = await iter.ReadNextAsync(); + + page.Count.Should().Be(5); + page.ContinuationToken.Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -650,58 +650,58 @@ await _container.CreateItemAsync( public class HierarchicalPkEdgeCaseTests { - [Fact] - public async Task HierarchicalPK_PointReadWithWrongSubKey_ShouldReturn404() - { - var container = new InMemoryContainer("hp-404", new[] { "/tenantId", "/userId" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - - var act = () => container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u999").Build()); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task HierarchicalPK_ThreeLevels_ShouldWork() - { - var container = new InMemoryContainer("hp-3level", new[] { "/a", "/b", "/c" }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z", name = "deep" }), - new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); - - var result = await container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); - result.Resource["name"]!.ToString().Should().Be("deep"); - } - - [Fact] - public async Task HierarchicalPK_QueryByFirstLevelPrefix_FiltersCorrectly() - { - var container = new InMemoryContainer("hp-prefix", new[] { "/tenantId", "/userId" }); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenantId = "t1", userId = "u2", name = "Bob" }), - new PartitionKeyBuilder().Add("t1").Add("u2").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", tenantId = "t2", userId = "u1", name = "Charlie" }), - new PartitionKeyBuilder().Add("t2").Add("u1").Build()); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.tenantId = 't1'"); - var iter = container.GetItemQueryIterator(query); - var all = new List(); - while (iter.HasMoreResults) - all.AddRange(await iter.ReadNextAsync()); - - all.Should().HaveCount(2); - all.Should().OnlyContain(j => j["tenantId"]!.ToString() == "t1"); - } + [Fact] + public async Task HierarchicalPK_PointReadWithWrongSubKey_ShouldReturn404() + { + var container = new InMemoryContainer("hp-404", new[] { "/tenantId", "/userId" }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + + var act = () => container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u999").Build()); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task HierarchicalPK_ThreeLevels_ShouldWork() + { + var container = new InMemoryContainer("hp-3level", new[] { "/a", "/b", "/c" }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z", name = "deep" }), + new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); + + var result = await container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); + result.Resource["name"]!.ToString().Should().Be("deep"); + } + + [Fact] + public async Task HierarchicalPK_QueryByFirstLevelPrefix_FiltersCorrectly() + { + var container = new InMemoryContainer("hp-prefix", new[] { "/tenantId", "/userId" }); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenantId = "t1", userId = "u2", name = "Bob" }), + new PartitionKeyBuilder().Add("t1").Add("u2").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", tenantId = "t2", userId = "u1", name = "Charlie" }), + new PartitionKeyBuilder().Add("t2").Add("u1").Build()); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.tenantId = 't1'"); + var iter = container.GetItemQueryIterator(query); + var all = new List(); + while (iter.HasMoreResults) + all.AddRange(await iter.ReadNextAsync()); + + all.Should().HaveCount(2); + all.Should().OnlyContain(j => j["tenantId"]!.ToString() == "t1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -710,43 +710,43 @@ await container.CreateItemAsync( public class StreamHeaderEdgeCaseTests { - [Fact] - public async Task StreamHeaders_OnNotFound_ShouldContainActivityId() - { - var container = new InMemoryContainer("sh-test", "/partitionKey"); - - using var response = await container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamHeaders_OnConflict_ShouldContainActivityId() - { - var container = new InMemoryContainer("sh-409", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - using var ms = new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","name":"Dup"}""")); - using var response = await container.CreateItemStreamAsync(ms, new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task StreamHeaders_OnQueryResponse_ContainMetadata() - { - var container = new InMemoryContainer("sh-query", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); - - using var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); - using var response = await iter.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task StreamHeaders_OnNotFound_ShouldContainActivityId() + { + var container = new InMemoryContainer("sh-test", "/partitionKey"); + + using var response = await container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamHeaders_OnConflict_ShouldContainActivityId() + { + var container = new InMemoryContainer("sh-409", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + using var ms = new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","name":"Dup"}""")); + using var response = await container.CreateItemStreamAsync(ms, new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task StreamHeaders_OnQueryResponse_ContainMetadata() + { + var container = new InMemoryContainer("sh-query", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, new PartitionKey("pk1")); + + using var iter = container.GetItemQueryStreamIterator("SELECT * FROM c"); + using var response = await iter.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + response.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -755,75 +755,75 @@ await container.CreateItemAsync( public class CrossPartitionOrderByEdgeCaseTests { - [Fact] - public async Task CrossPartitionOrderBy_DESC_ShouldSortCorrectly() - { - var container = new InMemoryContainer("cp-desc", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B", Value = 30 }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 20 }, - new PartitionKey("pk3")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value DESC"); - var all = new List(); - while (iter.HasMoreResults) - all.AddRange(await iter.ReadNextAsync()); - - all.Select(d => d.Value).Should().BeInDescendingOrder(); - } - - [Fact] - public async Task CrossPartitionOrderBy_Strings_SortsLexicographically() - { - var container = new InMemoryContainer("cp-str", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alpha" }, - new PartitionKey("pk2")); - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bravo" }, - new PartitionKey("pk3")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name ASC"); - var all = new List(); - while (iter.HasMoreResults) - all.AddRange(await iter.ReadNextAsync()); - - all.Select(d => d.Name).Should().BeInAscendingOrder(); - } - - [Fact] - public async Task CrossPartitionOrderBy_WithNullValues_ShouldHandleGracefully() - { - var container = new InMemoryContainer("cp-null", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B", Value = 20 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk2", name = "A" }), - new PartitionKey("pk2")); // no "value" field - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 10 }, - new PartitionKey("pk3")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value ASC"); - var all = new List(); - while (iter.HasMoreResults) - all.AddRange(await iter.ReadNextAsync()); - - // Should not throw; all 3 items returned - all.Should().HaveCount(3); - } + [Fact] + public async Task CrossPartitionOrderBy_DESC_ShouldSortCorrectly() + { + var container = new InMemoryContainer("cp-desc", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B", Value = 30 }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 20 }, + new PartitionKey("pk3")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value DESC"); + var all = new List(); + while (iter.HasMoreResults) + all.AddRange(await iter.ReadNextAsync()); + + all.Select(d => d.Value).Should().BeInDescendingOrder(); + } + + [Fact] + public async Task CrossPartitionOrderBy_Strings_SortsLexicographically() + { + var container = new InMemoryContainer("cp-str", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Charlie" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "Alpha" }, + new PartitionKey("pk2")); + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "Bravo" }, + new PartitionKey("pk3")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name ASC"); + var all = new List(); + while (iter.HasMoreResults) + all.AddRange(await iter.ReadNextAsync()); + + all.Select(d => d.Name).Should().BeInAscendingOrder(); + } + + [Fact] + public async Task CrossPartitionOrderBy_WithNullValues_ShouldHandleGracefully() + { + var container = new InMemoryContainer("cp-null", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B", Value = 20 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk2", name = "A" }), + new PartitionKey("pk2")); // no "value" field + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 10 }, + new PartitionKey("pk3")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value ASC"); + var all = new List(); + while (iter.HasMoreResults) + all.AddRange(await iter.ReadNextAsync()); + + // Should not throw; all 3 items returned + all.Should().HaveCount(3); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -832,26 +832,26 @@ await container.CreateItemAsync( public class ConflictResolutionEdgeCaseTests { - [Fact] - public async Task ConflictResolution_CustomMode_ShouldStoreSprocLink() - { - var container = new InMemoryContainer("cr-sproc", "/pk"); - var props = new ContainerProperties("cr-sproc", "/pk") - { - ConflictResolutionPolicy = new ConflictResolutionPolicy - { - Mode = ConflictResolutionMode.Custom, - ResolutionProcedure = "dbs/myDb/colls/myCol/sprocs/resolver" - } - }; - - await container.ReplaceContainerAsync(props); - var read = await container.ReadContainerAsync(); - - read.Resource.ConflictResolutionPolicy.Mode.Should().Be(ConflictResolutionMode.Custom); - read.Resource.ConflictResolutionPolicy.ResolutionProcedure.Should().Be( - "dbs/myDb/colls/myCol/sprocs/resolver"); - } + [Fact] + public async Task ConflictResolution_CustomMode_ShouldStoreSprocLink() + { + var container = new InMemoryContainer("cr-sproc", "/pk"); + var props = new ContainerProperties("cr-sproc", "/pk") + { + ConflictResolutionPolicy = new ConflictResolutionPolicy + { + Mode = ConflictResolutionMode.Custom, + ResolutionProcedure = "dbs/myDb/colls/myCol/sprocs/resolver" + } + }; + + await container.ReplaceContainerAsync(props); + var read = await container.ReadContainerAsync(); + + read.Resource.ConflictResolutionPolicy.Mode.Should().Be(ConflictResolutionMode.Custom); + read.Resource.ConflictResolutionPolicy.ResolutionProcedure.Should().Be( + "dbs/myDb/colls/myCol/sprocs/resolver"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -860,164 +860,164 @@ public async Task ConflictResolution_CustomMode_ShouldStoreSprocLink() public class SkippedBehaviorDivergentTests { - [Fact(Skip = "Conflict resolution policy is stored but not enforced. " + - "The in-memory emulator is single-instance/single-region — no write conflicts can arise. " + - "Implementing conflict resolution would require simulating multi-region replication.")] - public void ConflictResolution_ShouldResolveConflicts_AtRuntime() - { - // Real Cosmos DB applies conflict resolution policies during multi-region replication. - // The emulator stores the policy but never invokes it. - } - - [Fact] - public async Task Divergent_ConflictResolution_PolicyStoredButNotEnforced() - { - // Sister test: policy is stored on the container but has no runtime effect - var container = new InMemoryContainer("cr-div", "/pk"); - var props = new ContainerProperties("cr-div", "/pk") - { - ConflictResolutionPolicy = new ConflictResolutionPolicy - { - Mode = ConflictResolutionMode.LastWriterWins, - ResolutionPath = "/_ts" - } - }; - - await container.ReplaceContainerAsync(props); - - // Two writes with the same id — normal 409 Conflict (not LWW resolution) - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var act = () => container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact(Skip = "Request charges are always synthetic (1.0 RU). " + - "Real Cosmos DB computes RU based on document size, index utilization, and query complexity. " + - "Implementing RU estimation would require replicating the proprietary Cosmos DB cost model.")] - public void RequestCharge_ShouldReflectActualRUConsumption() - { - // Would need the actual Cosmos DB cost model which is proprietary. - } - - [Fact] - public async Task Divergent_RequestCharge_IsAlwaysSynthetic_1RU() - { - // Sister test: all ops return exactly 1.0 RU - var container = new InMemoryContainer("ru-div", "/partitionKey"); - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; - var create = await container.CreateItemAsync(doc, new PartitionKey("pk1")); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var query = await _container_GetQuery(container); - - create.RequestCharge.Should().Be(1.0); - read.RequestCharge.Should().Be(1.0); - query.RequestCharge.Should().Be(1.0); - } - - private static async Task> _container_GetQuery(InMemoryContainer container) - { - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - return await iter.ReadNextAsync(); - } - - [Fact(Skip = "Continuation tokens are simple integer offsets. " + - "Real Cosmos DB uses opaque base64-encoded JSON strings with internal cursor state. " + - "Implementing opaque tokens adds complexity without functional benefit for testing.")] - public void ContinuationToken_ShouldBeOpaqueBase64() - { - // Real Cosmos DB continuation tokens are opaque base64 JSON. - // Emulator uses plain integer offsets (e.g. "3", "10"). - } - - [Fact] - public async Task Divergent_ContinuationToken_IsPlainIntegerOffset() - { - // Sister test: token is a plain integer - var container = new InMemoryContainer("ct-div", "/partitionKey"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 2 }; - var iter = container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - var page = await iter.ReadNextAsync(); - - page.ContinuationToken.Should().NotBeNull(); - int.TryParse(page.ContinuationToken, out _).Should().BeTrue( - "emulator uses plain integer offsets as continuation tokens"); - } - - [Fact] - public async Task MaxItemCount_Zero_ShouldReturn400_InRealCosmos() - { - var container = new InMemoryContainer("mi-zero", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var opts = new QueryRequestOptions { MaxItemCount = 0 }; - var act = () => container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); - - act.Should().Throw(); - } - - [Fact] - public async Task CrossPartitionOrderBy_NullOrdering_ShouldFollowCosmosSpec() - { - var container = new InMemoryContainer("co-null-spec", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B", Value = 20 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", partitionKey = "pk2", name = "A" }), - new PartitionKey("pk2")); // no "value" field — undefined - await container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 10 }, - new PartitionKey("pk3")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.value ASC"); - var all = new List(); - while (iter.HasMoreResults) - all.AddRange(await iter.ReadNextAsync()); - - // Cosmos type ordering: undefined < null < numbers - all.Should().HaveCount(3); - all[0]["id"]!.ToString().Should().Be("2", "undefined value sorts first"); - all[1]["id"]!.ToString().Should().Be("3", "value 10 before value 20"); - all[2]["id"]!.ToString().Should().Be("1", "value 20 last"); - } - - [Fact(Skip = "TTL eviction is lazy — expired items are only removed when a read/query " + - "accesses them. Real Cosmos DB proactively evicts via a background process. " + - "Implementing proactive eviction would add a background timer, introducing " + - "non-determinism inappropriate for a unit-testing mock.")] - public void TTL_ProactiveEviction_ShouldDeleteWithoutRead() { } - - [Fact] - public async Task Divergent_TTL_LazyEviction_ItemVisibleUntilAccessed() - { - var container = new InMemoryContainer("ttl-lazy-div", "/pk"); - container.DefaultTimeToLive = 1; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "temp" }, - new PartitionKey("a")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // ItemCount still shows item (no proactive eviction) - container.ItemCount.Should().BeGreaterThanOrEqualTo(0); - - // Read triggers lazy eviction → 404 - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } + [Fact(Skip = "Conflict resolution policy is stored but not enforced. " + + "The in-memory emulator is single-instance/single-region — no write conflicts can arise. " + + "Implementing conflict resolution would require simulating multi-region replication.")] + public void ConflictResolution_ShouldResolveConflicts_AtRuntime() + { + // Real Cosmos DB applies conflict resolution policies during multi-region replication. + // The emulator stores the policy but never invokes it. + } + + [Fact] + public async Task Divergent_ConflictResolution_PolicyStoredButNotEnforced() + { + // Sister test: policy is stored on the container but has no runtime effect + var container = new InMemoryContainer("cr-div", "/pk"); + var props = new ContainerProperties("cr-div", "/pk") + { + ConflictResolutionPolicy = new ConflictResolutionPolicy + { + Mode = ConflictResolutionMode.LastWriterWins, + ResolutionPath = "/_ts" + } + }; + + await container.ReplaceContainerAsync(props); + + // Two writes with the same id — normal 409 Conflict (not LWW resolution) + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact(Skip = "Request charges are always synthetic (1.0 RU). " + + "Real Cosmos DB computes RU based on document size, index utilization, and query complexity. " + + "Implementing RU estimation would require replicating the proprietary Cosmos DB cost model.")] + public void RequestCharge_ShouldReflectActualRUConsumption() + { + // Would need the actual Cosmos DB cost model which is proprietary. + } + + [Fact] + public async Task Divergent_RequestCharge_IsAlwaysSynthetic_1RU() + { + // Sister test: all ops return exactly 1.0 RU + var container = new InMemoryContainer("ru-div", "/partitionKey"); + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }; + var create = await container.CreateItemAsync(doc, new PartitionKey("pk1")); + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var query = await _container_GetQuery(container); + + create.RequestCharge.Should().Be(1.0); + read.RequestCharge.Should().Be(1.0); + query.RequestCharge.Should().Be(1.0); + } + + private static async Task> _container_GetQuery(InMemoryContainer container) + { + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + return await iter.ReadNextAsync(); + } + + [Fact(Skip = "Continuation tokens are simple integer offsets. " + + "Real Cosmos DB uses opaque base64-encoded JSON strings with internal cursor state. " + + "Implementing opaque tokens adds complexity without functional benefit for testing.")] + public void ContinuationToken_ShouldBeOpaqueBase64() + { + // Real Cosmos DB continuation tokens are opaque base64 JSON. + // Emulator uses plain integer offsets (e.g. "3", "10"). + } + + [Fact] + public async Task Divergent_ContinuationToken_IsPlainIntegerOffset() + { + // Sister test: token is a plain integer + var container = new InMemoryContainer("ct-div", "/partitionKey"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"I{i}" }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 2 }; + var iter = container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + var page = await iter.ReadNextAsync(); + + page.ContinuationToken.Should().NotBeNull(); + int.TryParse(page.ContinuationToken, out _).Should().BeTrue( + "emulator uses plain integer offsets as continuation tokens"); + } + + [Fact] + public async Task MaxItemCount_Zero_ShouldReturn400_InRealCosmos() + { + var container = new InMemoryContainer("mi-zero", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var opts = new QueryRequestOptions { MaxItemCount = 0 }; + var act = () => container.GetItemQueryIterator("SELECT * FROM c", requestOptions: opts); + + act.Should().Throw(); + } + + [Fact] + public async Task CrossPartitionOrderBy_NullOrdering_ShouldFollowCosmosSpec() + { + var container = new InMemoryContainer("co-null-spec", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B", Value = 20 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", partitionKey = "pk2", name = "A" }), + new PartitionKey("pk2")); // no "value" field — undefined + await container.CreateItemAsync( + new TestDocument { Id = "3", PartitionKey = "pk3", Name = "C", Value = 10 }, + new PartitionKey("pk3")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.value ASC"); + var all = new List(); + while (iter.HasMoreResults) + all.AddRange(await iter.ReadNextAsync()); + + // Cosmos type ordering: undefined < null < numbers + all.Should().HaveCount(3); + all[0]["id"]!.ToString().Should().Be("2", "undefined value sorts first"); + all[1]["id"]!.ToString().Should().Be("3", "value 10 before value 20"); + all[2]["id"]!.ToString().Should().Be("1", "value 20 last"); + } + + [Fact(Skip = "TTL eviction is lazy — expired items are only removed when a read/query " + + "accesses them. Real Cosmos DB proactively evicts via a background process. " + + "Implementing proactive eviction would add a background timer, introducing " + + "non-determinism inappropriate for a unit-testing mock.")] + public void TTL_ProactiveEviction_ShouldDeleteWithoutRead() { } + + [Fact] + public async Task Divergent_TTL_LazyEviction_ItemVisibleUntilAccessed() + { + var container = new InMemoryContainer("ttl-lazy-div", "/pk"); + container.DefaultTimeToLive = 1; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "temp" }, + new PartitionKey("a")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // ItemCount still shows item (no proactive eviction) + container.ItemCount.Should().BeGreaterThanOrEqualTo(0); + + // Read triggers lazy eviction → 404 + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1026,81 +1026,81 @@ await act.Should().ThrowAsync() public class LikeOperatorEdgeCaseTests { - private readonly InMemoryContainer _container = new("like-test", "/partitionKey"); - - private async Task SeedAsync() - { - var names = new[] { "cat", "cut", "coat", "cart", "c", "", "catalog", "wildcard" }; - for (var i = 0; i < names.Length; i++) - await _container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = names[i] }, - new PartitionKey("pk1")); - } - - [Fact] - public async Task Like_CombinedPercentAndUnderscore_MatchesCorrectly() - { - await SeedAsync(); - - // c_t matches "cat", "cut" (3 chars, c + any + t) - // c%t matches "cat", "cut", "coat", "cart" (c + anything + t) - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'c%t'"); - var iter = _container.GetItemQueryIterator(query); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - - all.Should().Contain(d => d.Name == "cat"); - all.Should().Contain(d => d.Name == "cut"); - all.Should().Contain(d => d.Name == "coat"); - all.Should().Contain(d => d.Name == "cart"); - } - - [Fact] - public async Task Like_PercentOnly_MatchesAllStrings() - { - await SeedAsync(); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE '%'"); - var iter = _container.GetItemQueryIterator(query); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - - // '%' matches all strings including empty - all.Should().HaveCount(8); - } - - [Fact] - public async Task Like_NullField_ReturnsNoMatch() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1" }), - new PartitionKey("pk1")); // no "name" field - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "hello" }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE '%'"); - var iter = _container.GetItemQueryIterator(query); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - - // Null/missing name should not match LIKE - all.Should().ContainSingle(); - } - - [Fact] - public async Task Like_EmptyPattern_MatchesOnlyEmptyString() - { - await SeedAsync(); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE ''"); - var iter = _container.GetItemQueryIterator(query); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - - all.Should().ContainSingle(); - all[0].Name.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("like-test", "/partitionKey"); + + private async Task SeedAsync() + { + var names = new[] { "cat", "cut", "coat", "cart", "c", "", "catalog", "wildcard" }; + for (var i = 0; i < names.Length; i++) + await _container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = names[i] }, + new PartitionKey("pk1")); + } + + [Fact] + public async Task Like_CombinedPercentAndUnderscore_MatchesCorrectly() + { + await SeedAsync(); + + // c_t matches "cat", "cut" (3 chars, c + any + t) + // c%t matches "cat", "cut", "coat", "cart" (c + anything + t) + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE 'c%t'"); + var iter = _container.GetItemQueryIterator(query); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + + all.Should().Contain(d => d.Name == "cat"); + all.Should().Contain(d => d.Name == "cut"); + all.Should().Contain(d => d.Name == "coat"); + all.Should().Contain(d => d.Name == "cart"); + } + + [Fact] + public async Task Like_PercentOnly_MatchesAllStrings() + { + await SeedAsync(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE '%'"); + var iter = _container.GetItemQueryIterator(query); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + + // '%' matches all strings including empty + all.Should().HaveCount(8); + } + + [Fact] + public async Task Like_NullField_ReturnsNoMatch() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1" }), + new PartitionKey("pk1")); // no "name" field + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "hello" }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE '%'"); + var iter = _container.GetItemQueryIterator(query); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + + // Null/missing name should not match LIKE + all.Should().ContainSingle(); + } + + [Fact] + public async Task Like_EmptyPattern_MatchesOnlyEmptyString() + { + await SeedAsync(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE ''"); + var iter = _container.GetItemQueryIterator(query); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + + all.Should().ContainSingle(); + all[0].Name.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1109,43 +1109,43 @@ public async Task Like_EmptyPattern_MatchesOnlyEmptyString() public class UdfEdgeCaseTests { - [Fact] - public async Task Udf_MultipleUdfsInSameQuery() - { - var container = new InMemoryContainer("udf-multi", "/partitionKey"); - container.RegisterUdf("double", args => (long)args[0] * 2); - container.RegisterUdf("triple", args => (long)args[0] * 3); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, - new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT udf.double(c.value) AS doubled, udf.triple(c.value) AS tripled FROM c"); - var iter = container.GetItemQueryIterator(query); - var response = await iter.ReadNextAsync(); - - var item = response.First(); - item["doubled"]!.Value().Should().Be(20); - item["tripled"]!.Value().Should().Be(30); - } - - [Fact] - public async Task Udf_ReturningNull_ShouldProduceNullInResult() - { - var container = new InMemoryContainer("udf-null", "/partitionKey"); - container.RegisterUdf("nullify", _ => null!); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT udf.nullify(c.name) AS result FROM c"); - var iter = container.GetItemQueryIterator(query); - var response = await iter.ReadNextAsync(); - - response.First()["result"]!.Type.Should().Be(JTokenType.Null); - } + [Fact] + public async Task Udf_MultipleUdfsInSameQuery() + { + var container = new InMemoryContainer("udf-multi", "/partitionKey"); + container.RegisterUdf("double", args => (long)args[0] * 2); + container.RegisterUdf("triple", args => (long)args[0] * 3); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A", Value = 10 }, + new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT udf.double(c.value) AS doubled, udf.triple(c.value) AS tripled FROM c"); + var iter = container.GetItemQueryIterator(query); + var response = await iter.ReadNextAsync(); + + var item = response.First(); + item["doubled"]!.Value().Should().Be(20); + item["tripled"]!.Value().Should().Be(30); + } + + [Fact] + public async Task Udf_ReturningNull_ShouldProduceNullInResult() + { + var container = new InMemoryContainer("udf-null", "/partitionKey"); + container.RegisterUdf("nullify", _ => null!); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT udf.nullify(c.name) AS result FROM c"); + var iter = container.GetItemQueryIterator(query); + var response = await iter.ReadNextAsync(); + + response.First()["result"]!.Type.Should().Be(JTokenType.Null); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1154,52 +1154,52 @@ await container.CreateItemAsync( public class IndexingPolicyEdgeCaseTests { - [Fact] - public async Task IndexingPolicy_CompositeIndex_PersistsOnReplace() - { - var container = new InMemoryContainer("ix-composite", "/partitionKey"); - var props = new ContainerProperties("ix-composite", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - CompositeIndexes = - { - new System.Collections.ObjectModel.Collection - { - new() { Path = "/name", Order = CompositePathSortOrder.Ascending }, - new() { Path = "/value", Order = CompositePathSortOrder.Descending } - } - } - } - }; - - await container.ReplaceContainerAsync(props); - var read = await container.ReadContainerAsync(); - - read.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); - read.Resource.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); - read.Resource.IndexingPolicy.CompositeIndexes[0][0].Path.Should().Be("/name"); - read.Resource.IndexingPolicy.CompositeIndexes[0][1].Order.Should().Be(CompositePathSortOrder.Descending); - } - - [Fact] - public async Task IndexingPolicy_ExcludedPaths_PersistOnReplace() - { - var container = new InMemoryContainer("ix-excluded", "/partitionKey"); - var props = new ContainerProperties("ix-excluded", "/partitionKey") - { - IndexingPolicy = new IndexingPolicy - { - ExcludedPaths = { new ExcludedPath { Path = "/largeField/*" } } - } - }; - - await container.ReplaceContainerAsync(props); - var read = await container.ReadContainerAsync(); - - read.Resource.IndexingPolicy.ExcludedPaths - .Should().Contain(p => p.Path == "/largeField/*"); - } + [Fact] + public async Task IndexingPolicy_CompositeIndex_PersistsOnReplace() + { + var container = new InMemoryContainer("ix-composite", "/partitionKey"); + var props = new ContainerProperties("ix-composite", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + CompositeIndexes = + { + new System.Collections.ObjectModel.Collection + { + new() { Path = "/name", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/value", Order = CompositePathSortOrder.Descending } + } + } + } + }; + + await container.ReplaceContainerAsync(props); + var read = await container.ReadContainerAsync(); + + read.Resource.IndexingPolicy.CompositeIndexes.Should().HaveCount(1); + read.Resource.IndexingPolicy.CompositeIndexes[0].Should().HaveCount(2); + read.Resource.IndexingPolicy.CompositeIndexes[0][0].Path.Should().Be("/name"); + read.Resource.IndexingPolicy.CompositeIndexes[0][1].Order.Should().Be(CompositePathSortOrder.Descending); + } + + [Fact] + public async Task IndexingPolicy_ExcludedPaths_PersistOnReplace() + { + var container = new InMemoryContainer("ix-excluded", "/partitionKey"); + var props = new ContainerProperties("ix-excluded", "/partitionKey") + { + IndexingPolicy = new IndexingPolicy + { + ExcludedPaths = { new ExcludedPath { Path = "/largeField/*" } } + } + }; + + await container.ReplaceContainerAsync(props); + var read = await container.ReadContainerAsync(); + + read.Resource.IndexingPolicy.ExcludedPaths + .Should().Contain(p => p.Path == "/largeField/*"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1208,29 +1208,29 @@ public async Task IndexingPolicy_ExcludedPaths_PersistOnReplace() public class GeneralEdgeCaseTests { - [Fact] - public async Task EmptyContainer_Query_ReturnsEmptyWithNoToken() - { - var container = new InMemoryContainer("ge-empty", "/partitionKey"); - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var page = await iter.ReadNextAsync(); - - page.Should().BeEmpty(); - page.ContinuationToken.Should().BeNull(); - } - - [Fact] - public async Task Unicode_InDocumentId_RoundTrips() - { - var container = new InMemoryContainer("ge-unicode", "/partitionKey"); - var id = "emoji-\ud83d\ude80-\u4e16\u754c"; - - await container.CreateItemAsync( - JObject.FromObject(new { id, partitionKey = "pk1", name = "unicode-test" }), - new PartitionKey("pk1")); - - var result = await container.ReadItemAsync(id, new PartitionKey("pk1")); - result.Resource["id"]!.ToString().Should().Be(id); - result.Resource["name"]!.ToString().Should().Be("unicode-test"); - } + [Fact] + public async Task EmptyContainer_Query_ReturnsEmptyWithNoToken() + { + var container = new InMemoryContainer("ge-empty", "/partitionKey"); + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var page = await iter.ReadNextAsync(); + + page.Should().BeEmpty(); + page.ContinuationToken.Should().BeNull(); + } + + [Fact] + public async Task Unicode_InDocumentId_RoundTrips() + { + var container = new InMemoryContainer("ge-unicode", "/partitionKey"); + var id = "emoji-\ud83d\ude80-\u4e16\u754c"; + + await container.CreateItemAsync( + JObject.FromObject(new { id, partitionKey = "pk1", name = "unicode-test" }), + new PartitionKey("pk1")); + + var result = await container.ReadItemAsync(id, new PartitionKey("pk1")); + result.Resource["id"]!.ToString().Should().Be(id); + result.Resource["name"]!.ToString().Should().Be("unicode-test"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs index 0a91cdd..56dd877 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs @@ -1,1990 +1,1990 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; using Xunit; -using System.Net; -using System.Text; namespace CosmosDB.InMemoryEmulator.Tests; public class SqlFunctionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItems() - { - var items = new[] - { - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Anderson", Value = 10, IsActive = true, Tags = ["dot", "net"] }, - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob Brown", Value = 20, IsActive = false, Tags = ["java"] }, - new TestDocument - { - Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["dot"], - Nested = new NestedObject { Description = "nested value", Score = 3.14 } - }, - new TestDocument { Id = "4", PartitionKey = "pk1", Name = " diana ", Value = 0, IsActive = true, Tags = [] }, - new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = -5, IsActive = false, Tags = ["a", "b", "c"] }, - }; - foreach (var item in items) - { - await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - } - } - - [Fact] - public async Task StartsWith_MatchesPrefix() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, @prefix)") - .WithParameter("@prefix", "Ali"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice Anderson"); - } - - [Fact] - public async Task StartsWith_CaseInsensitive() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, @prefix, true)") - .WithParameter("@prefix", "ali"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - } - - [Fact] - public async Task EndsWith_MatchesSuffix() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, @suffix)") - .WithParameter("@suffix", "Anderson"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice Anderson"); - } - - [Fact] - public async Task Contains_MatchesSubstring() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, @sub)") - .WithParameter("@sub", "Brown"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Bob Brown"); - } - - [Fact] - public async Task Contains_CaseInsensitive() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, @sub, true)") - .WithParameter("@sub", "brown"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - } - - [Fact] - public async Task ArrayContains_MatchesElement() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, @tag)") - .WithParameter("@tag", "dot"); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task ArrayLength_FiltersOnLength() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_LENGTH(c.tags) > 1"); - - var results = await QueryAll(query); - - // Item 1: ["dot","net"] (2), Item 5: ["a","b","c"] (3) - results.Should().HaveCount(2); - } - - [Fact] - public async Task IsDefined_ReturnsFalseForUndefinedProperty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE IS_DEFINED(c.nonExistentProperty)"); - - var results = await QueryAll(query); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task IsNull_ReturnsTrueForNullProperty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE IS_NULL(c.nested)"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItems() + { + var items = new[] + { + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Anderson", Value = 10, IsActive = true, Tags = ["dot", "net"] }, + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob Brown", Value = 20, IsActive = false, Tags = ["java"] }, + new TestDocument + { + Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["dot"], + Nested = new NestedObject { Description = "nested value", Score = 3.14 } + }, + new TestDocument { Id = "4", PartitionKey = "pk1", Name = " diana ", Value = 0, IsActive = true, Tags = [] }, + new TestDocument { Id = "5", PartitionKey = "pk1", Name = "Eve", Value = -5, IsActive = false, Tags = ["a", "b", "c"] }, + }; + foreach (var item in items) + { + await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + } + } + + [Fact] + public async Task StartsWith_MatchesPrefix() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, @prefix)") + .WithParameter("@prefix", "Ali"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice Anderson"); + } + + [Fact] + public async Task StartsWith_CaseInsensitive() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(c.name, @prefix, true)") + .WithParameter("@prefix", "ali"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + } + + [Fact] + public async Task EndsWith_MatchesSuffix() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(c.name, @suffix)") + .WithParameter("@suffix", "Anderson"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice Anderson"); + } + + [Fact] + public async Task Contains_MatchesSubstring() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, @sub)") + .WithParameter("@sub", "Brown"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Bob Brown"); + } + + [Fact] + public async Task Contains_CaseInsensitive() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, @sub, true)") + .WithParameter("@sub", "brown"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + } + + [Fact] + public async Task ArrayContains_MatchesElement() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, @tag)") + .WithParameter("@tag", "dot"); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task ArrayLength_FiltersOnLength() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_LENGTH(c.tags) > 1"); + + var results = await QueryAll(query); + + // Item 1: ["dot","net"] (2), Item 5: ["a","b","c"] (3) + results.Should().HaveCount(2); + } + + [Fact] + public async Task IsDefined_ReturnsFalseForUndefinedProperty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE IS_DEFINED(c.nonExistentProperty)"); + + var results = await QueryAll(query); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task IsNull_ReturnsTrueForNullProperty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE IS_NULL(c.nested)"); - var results = await QueryAll(query); + var results = await QueryAll(query); - // Items 1,2,4,5 have null nested, item 3 has a nested object - results.Should().HaveCount(4); - } - - [Fact] - public async Task StringConcat_ConcatenatesStrings() - { - await SeedItems(); - var query = new QueryDefinition("SELECT CONCAT(c.name, '-', c.id) AS combined FROM c WHERE c.id = '1'"); + // Items 1,2,4,5 have null nested, item 3 has a nested object + results.Should().HaveCount(4); + } + + [Fact] + public async Task StringConcat_ConcatenatesStrings() + { + await SeedItems(); + var query = new QueryDefinition("SELECT CONCAT(c.name, '-', c.id) AS combined FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["combined"]!.ToString().Should().Be("Alice Anderson-1"); - } + results.Should().HaveCount(1); + results[0]["combined"]!.ToString().Should().Be("Alice Anderson-1"); + } - [Fact] - public async Task Lower_ConvertsToLowerCase() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LOWER(c.name) AS lowerName FROM c WHERE c.id = '1'"); + [Fact] + public async Task Lower_ConvertsToLowerCase() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LOWER(c.name) AS lowerName FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["lowerName"]!.ToString().Should().Be("alice anderson"); - } + results.Should().HaveCount(1); + results[0]["lowerName"]!.ToString().Should().Be("alice anderson"); + } - [Fact] - public async Task Upper_ConvertsToUpperCase() - { - await SeedItems(); - var query = new QueryDefinition("SELECT UPPER(c.name) AS upperName FROM c WHERE c.id = '1'"); + [Fact] + public async Task Upper_ConvertsToUpperCase() + { + await SeedItems(); + var query = new QueryDefinition("SELECT UPPER(c.name) AS upperName FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["upperName"]!.ToString().Should().Be("ALICE ANDERSON"); - } + results.Should().HaveCount(1); + results[0]["upperName"]!.ToString().Should().Be("ALICE ANDERSON"); + } - [Fact] - public async Task Trim_RemovesWhitespace() - { - await SeedItems(); - var query = new QueryDefinition("SELECT TRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); + [Fact] + public async Task Trim_RemovesWhitespace() + { + await SeedItems(); + var query = new QueryDefinition("SELECT TRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["trimmedName"]!.ToString().Should().Be("diana"); - } + results.Should().HaveCount(1); + results[0]["trimmedName"]!.ToString().Should().Be("diana"); + } - [Fact] - public async Task Ltrim_RemovesLeadingWhitespace() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LTRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); + [Fact] + public async Task Ltrim_RemovesLeadingWhitespace() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LTRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["trimmedName"]!.ToString().Should().Be("diana "); - } + results.Should().HaveCount(1); + results[0]["trimmedName"]!.ToString().Should().Be("diana "); + } - [Fact] - public async Task Rtrim_RemovesTrailingWhitespace() - { - await SeedItems(); - var query = new QueryDefinition("SELECT RTRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); + [Fact] + public async Task Rtrim_RemovesTrailingWhitespace() + { + await SeedItems(); + var query = new QueryDefinition("SELECT RTRIM(c.name) AS trimmedName FROM c WHERE c.id = '4'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["trimmedName"]!.ToString().Should().Be(" diana"); - } + results.Should().HaveCount(1); + results[0]["trimmedName"]!.ToString().Should().Be(" diana"); + } - [Fact] - public async Task Left_ReturnsLeftCharacters() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LEFT(c.name, 3) AS prefix FROM c WHERE c.id = '1'"); + [Fact] + public async Task Left_ReturnsLeftCharacters() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LEFT(c.name, 3) AS prefix FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["prefix"]!.ToString().Should().Be("Ali"); - } + results.Should().HaveCount(1); + results[0]["prefix"]!.ToString().Should().Be("Ali"); + } - [Fact] - public async Task Right_ReturnsRightCharacters() - { - await SeedItems(); - var query = new QueryDefinition("SELECT RIGHT(c.name, 5) AS suffix FROM c WHERE c.id = '2'"); + [Fact] + public async Task Right_ReturnsRightCharacters() + { + await SeedItems(); + var query = new QueryDefinition("SELECT RIGHT(c.name, 5) AS suffix FROM c WHERE c.id = '2'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["suffix"]!.ToString().Should().Be("Brown"); - } + results.Should().HaveCount(1); + results[0]["suffix"]!.ToString().Should().Be("Brown"); + } - [Fact] - public async Task Length_ReturnsStringLength() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LENGTH(c.name) AS nameLen FROM c WHERE c.id = '5'"); + [Fact] + public async Task Length_ReturnsStringLength() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LENGTH(c.name) AS nameLen FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["nameLen"]!.Value().Should().Be(3); - } + results.Should().HaveCount(1); + results[0]["nameLen"]!.Value().Should().Be(3); + } - [Fact] - public async Task Substring_ReturnsSubstring() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SUBSTRING(c.name, 0, 3) AS sub FROM c WHERE c.id = '1'"); + [Fact] + public async Task Substring_ReturnsSubstring() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SUBSTRING(c.name, 0, 3) AS sub FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["sub"]!.ToString().Should().Be("Ali"); - } + results.Should().HaveCount(1); + results[0]["sub"]!.ToString().Should().Be("Ali"); + } - [Fact] - public async Task IndexOf_ReturnsPosition() - { - await SeedItems(); - var query = new QueryDefinition("SELECT INDEX_OF(c.name, 'Brown') AS pos FROM c WHERE c.id = '2'"); + [Fact] + public async Task IndexOf_ReturnsPosition() + { + await SeedItems(); + var query = new QueryDefinition("SELECT INDEX_OF(c.name, 'Brown') AS pos FROM c WHERE c.id = '2'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["pos"]!.Value().Should().Be(4); - } + results.Should().HaveCount(1); + results[0]["pos"]!.Value().Should().Be(4); + } - [Fact] - public async Task Replace_ReplacesSubstring() - { - await SeedItems(); - var query = new QueryDefinition("SELECT REPLACE(c.name, 'Alice', 'Alicia') AS replaced FROM c WHERE c.id = '1'"); + [Fact] + public async Task Replace_ReplacesSubstring() + { + await SeedItems(); + var query = new QueryDefinition("SELECT REPLACE(c.name, 'Alice', 'Alicia') AS replaced FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["replaced"]!.ToString().Should().Be("Alicia Anderson"); - } + results.Should().HaveCount(1); + results[0]["replaced"]!.ToString().Should().Be("Alicia Anderson"); + } - [Fact] - public async Task Abs_ReturnsAbsoluteValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ABS(c.value) AS absVal FROM c WHERE c.id = '5'"); + [Fact] + public async Task Abs_ReturnsAbsoluteValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ABS(c.value) AS absVal FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["absVal"]!.Value().Should().Be(5); - } + results.Should().HaveCount(1); + results[0]["absVal"]!.Value().Should().Be(5); + } - [Fact] - public async Task Floor_ReturnsFloor() - { - await SeedItems(); - var query = new QueryDefinition("SELECT FLOOR(c.nested.score) AS floored FROM c WHERE c.id = '3'"); + [Fact] + public async Task Floor_ReturnsFloor() + { + await SeedItems(); + var query = new QueryDefinition("SELECT FLOOR(c.nested.score) AS floored FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["floored"]!.Value().Should().Be(3.0); - } + results.Should().HaveCount(1); + results[0]["floored"]!.Value().Should().Be(3.0); + } - [Fact] - public async Task Ceiling_ReturnsCeiling() - { - await SeedItems(); - var query = new QueryDefinition("SELECT CEILING(c.nested.score) AS ceiled FROM c WHERE c.id = '3'"); + [Fact] + public async Task Ceiling_ReturnsCeiling() + { + await SeedItems(); + var query = new QueryDefinition("SELECT CEILING(c.nested.score) AS ceiled FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["ceiled"]!.Value().Should().Be(4.0); - } + results.Should().HaveCount(1); + results[0]["ceiled"]!.Value().Should().Be(4.0); + } - [Fact] - public async Task Round_ReturnsRoundedValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ROUND(c.nested.score) AS rounded FROM c WHERE c.id = '3'"); + [Fact] + public async Task Round_ReturnsRoundedValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ROUND(c.nested.score) AS rounded FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["rounded"]!.Value().Should().Be(3.0); - } + results.Should().HaveCount(1); + results[0]["rounded"]!.Value().Should().Be(3.0); + } - // ── Additional Math functions ── + // ── Additional Math functions ── - [Fact] - public async Task Sqrt_ReturnsSquareRoot() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SQRT(c.value) AS sqrtVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Sqrt_ReturnsSquareRoot() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SQRT(c.value) AS sqrtVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["sqrtVal"]!.Value().Should().BeApproximately(Math.Sqrt(10), 0.0001); - } + results.Should().HaveCount(1); + results[0]["sqrtVal"]!.Value().Should().BeApproximately(Math.Sqrt(10), 0.0001); + } - [Fact] - public async Task Square_ReturnsSquared() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SQUARE(c.value) AS sq FROM c WHERE c.id = '2'"); + [Fact] + public async Task Square_ReturnsSquared() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SQUARE(c.value) AS sq FROM c WHERE c.id = '2'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["sq"]!.Value().Should().Be(400); - } + results.Should().HaveCount(1); + results[0]["sq"]!.Value().Should().Be(400); + } - [Fact] - public async Task Power_ReturnsPower() - { - await SeedItems(); - var query = new QueryDefinition("SELECT POWER(c.value, 2) AS pw FROM c WHERE c.id = '3'"); + [Fact] + public async Task Power_ReturnsPower() + { + await SeedItems(); + var query = new QueryDefinition("SELECT POWER(c.value, 2) AS pw FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["pw"]!.Value().Should().Be(900); - } + results.Should().HaveCount(1); + results[0]["pw"]!.Value().Should().Be(900); + } - [Fact] - public async Task Exp_ReturnsExponential() - { - await SeedItems(); - var query = new QueryDefinition("SELECT EXP(1) AS expVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Exp_ReturnsExponential() + { + await SeedItems(); + var query = new QueryDefinition("SELECT EXP(1) AS expVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["expVal"]!.Value().Should().BeApproximately(Math.E, 0.0001); - } + results.Should().HaveCount(1); + results[0]["expVal"]!.Value().Should().BeApproximately(Math.E, 0.0001); + } - [Fact] - public async Task Log_ReturnsNaturalLog() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LOG(c.value) AS logVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Log_ReturnsNaturalLog() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LOG(c.value) AS logVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["logVal"]!.Value().Should().BeApproximately(Math.Log(10), 0.0001); - } + results.Should().HaveCount(1); + results[0]["logVal"]!.Value().Should().BeApproximately(Math.Log(10), 0.0001); + } - [Fact] - public async Task Log10_ReturnsLog10() - { - await SeedItems(); - var query = new QueryDefinition("SELECT LOG10(c.value) AS log10Val FROM c WHERE c.id = '1'"); + [Fact] + public async Task Log10_ReturnsLog10() + { + await SeedItems(); + var query = new QueryDefinition("SELECT LOG10(c.value) AS log10Val FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["log10Val"]!.Value().Should().BeApproximately(1.0, 0.0001); - } + results.Should().HaveCount(1); + results[0]["log10Val"]!.Value().Should().BeApproximately(1.0, 0.0001); + } - [Fact] - public async Task Sign_ReturnsSign() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SIGN(c.value) AS signVal FROM c WHERE c.id = '5'"); + [Fact] + public async Task Sign_ReturnsSign() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SIGN(c.value) AS signVal FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["signVal"]!.Value().Should().Be(-1); - } + results.Should().HaveCount(1); + results[0]["signVal"]!.Value().Should().Be(-1); + } - [Fact] - public async Task Trunc_ReturnsTruncated() - { - await SeedItems(); - var query = new QueryDefinition("SELECT TRUNC(c.nested.score) AS truncVal FROM c WHERE c.id = '3'"); + [Fact] + public async Task Trunc_ReturnsTruncated() + { + await SeedItems(); + var query = new QueryDefinition("SELECT TRUNC(c.nested.score) AS truncVal FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["truncVal"]!.Value().Should().Be(3.0); - } + results.Should().HaveCount(1); + results[0]["truncVal"]!.Value().Should().Be(3.0); + } - [Fact] - public async Task Pi_ReturnsPi() - { - await SeedItems(); - var query = new QueryDefinition("SELECT PI() AS piVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Pi_ReturnsPi() + { + await SeedItems(); + var query = new QueryDefinition("SELECT PI() AS piVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["piVal"]!.Value().Should().BeApproximately(Math.PI, 0.0001); - } + results.Should().HaveCount(1); + results[0]["piVal"]!.Value().Should().BeApproximately(Math.PI, 0.0001); + } - [Fact] - public async Task Sin_ReturnsSine() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SIN(1) AS sinVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Sin_ReturnsSine() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SIN(1) AS sinVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["sinVal"]!.Value().Should().BeApproximately(Math.Sin(1), 0.0001); - } + results.Should().HaveCount(1); + results[0]["sinVal"]!.Value().Should().BeApproximately(Math.Sin(1), 0.0001); + } - [Fact] - public async Task Cos_ReturnsCosine() - { - await SeedItems(); - var query = new QueryDefinition("SELECT COS(0) AS cosVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Cos_ReturnsCosine() + { + await SeedItems(); + var query = new QueryDefinition("SELECT COS(0) AS cosVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["cosVal"]!.Value().Should().Be(1.0); - } + results.Should().HaveCount(1); + results[0]["cosVal"]!.Value().Should().Be(1.0); + } - [Fact] - public async Task Tan_ReturnsTangent() - { - await SeedItems(); - var query = new QueryDefinition("SELECT TAN(0) AS tanVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Tan_ReturnsTangent() + { + await SeedItems(); + var query = new QueryDefinition("SELECT TAN(0) AS tanVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["tanVal"]!.Value().Should().Be(0.0); - } + results.Should().HaveCount(1); + results[0]["tanVal"]!.Value().Should().Be(0.0); + } - [Fact] - public async Task Asin_ReturnsArcSine() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ASIN(1) AS asinVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Asin_ReturnsArcSine() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ASIN(1) AS asinVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["asinVal"]!.Value().Should().BeApproximately(Math.PI / 2, 0.0001); - } + results.Should().HaveCount(1); + results[0]["asinVal"]!.Value().Should().BeApproximately(Math.PI / 2, 0.0001); + } - [Fact] - public async Task Acos_ReturnsArcCosine() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ACOS(1) AS acosVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Acos_ReturnsArcCosine() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ACOS(1) AS acosVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["acosVal"]!.Value().Should().Be(0.0); - } + results.Should().HaveCount(1); + results[0]["acosVal"]!.Value().Should().Be(0.0); + } - [Fact] - public async Task Atan_ReturnsArcTangent() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ATAN(1) AS atanVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Atan_ReturnsArcTangent() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ATAN(1) AS atanVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["atanVal"]!.Value().Should().BeApproximately(Math.PI / 4, 0.0001); - } + results.Should().HaveCount(1); + results[0]["atanVal"]!.Value().Should().BeApproximately(Math.PI / 4, 0.0001); + } - [Fact] - public async Task Atn2_ReturnsArcTangent2() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ATN2(1, 1) AS atn2Val FROM c WHERE c.id = '1'"); + [Fact] + public async Task Atn2_ReturnsArcTangent2() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ATN2(1, 1) AS atn2Val FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["atn2Val"]!.Value().Should().BeApproximately(Math.PI / 4, 0.0001); - } + results.Should().HaveCount(1); + results[0]["atn2Val"]!.Value().Should().BeApproximately(Math.PI / 4, 0.0001); + } - [Fact] - public async Task Degrees_ConvertsToDegrees() - { - await SeedItems(); - var query = new QueryDefinition("SELECT DEGREES(PI()) AS degVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Degrees_ConvertsToDegrees() + { + await SeedItems(); + var query = new QueryDefinition("SELECT DEGREES(PI()) AS degVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["degVal"]!.Value().Should().BeApproximately(180.0, 0.0001); - } + results.Should().HaveCount(1); + results[0]["degVal"]!.Value().Should().BeApproximately(180.0, 0.0001); + } - [Fact] - public async Task Radians_ConvertsToRadians() - { - await SeedItems(); - var query = new QueryDefinition("SELECT RADIANS(180) AS radVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Radians_ConvertsToRadians() + { + await SeedItems(); + var query = new QueryDefinition("SELECT RADIANS(180) AS radVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["radVal"]!.Value().Should().BeApproximately(Math.PI, 0.0001); - } + results.Should().HaveCount(1); + results[0]["radVal"]!.Value().Should().BeApproximately(Math.PI, 0.0001); + } - [Fact] - public async Task Rand_ReturnsValueBetweenZeroAndOne() - { - await SeedItems(); - var query = new QueryDefinition("SELECT RAND() AS randVal FROM c WHERE c.id = '1'"); + [Fact] + public async Task Rand_ReturnsValueBetweenZeroAndOne() + { + await SeedItems(); + var query = new QueryDefinition("SELECT RAND() AS randVal FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - var randVal = results[0]["randVal"]!.Value(); - randVal.Should().BeGreaterThanOrEqualTo(0.0).And.BeLessThan(1.0); - } + results.Should().HaveCount(1); + var randVal = results[0]["randVal"]!.Value(); + randVal.Should().BeGreaterThanOrEqualTo(0.0).And.BeLessThan(1.0); + } - // ── Reverse and RegexMatch ── + // ── Reverse and RegexMatch ── - [Fact] - public async Task Reverse_ReversesString() - { - await SeedItems(); - var query = new QueryDefinition("SELECT REVERSE(c.name) AS reversed FROM c WHERE c.id = '5'"); + [Fact] + public async Task Reverse_ReversesString() + { + await SeedItems(); + var query = new QueryDefinition("SELECT REVERSE(c.name) AS reversed FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["reversed"]!.ToString().Should().Be("evE"); - } + results.Should().HaveCount(1); + results[0]["reversed"]!.ToString().Should().Be("evE"); + } - [Fact] - public async Task RegexMatch_MatchesPattern() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE RegexMatch(c.name, '^Alice')"); + [Fact] + public async Task RegexMatch_MatchesPattern() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE RegexMatch(c.name, '^Alice')"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Name.Should().Be("Alice Anderson"); - } + results.Should().HaveCount(1); + results[0].Name.Should().Be("Alice Anderson"); + } - [Fact] - public async Task RegexMatch_CaseInsensitive() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE RegexMatch(c.name, '^alice', 'i')"); + [Fact] + public async Task RegexMatch_CaseInsensitive() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE RegexMatch(c.name, '^alice', 'i')"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - } + results.Should().HaveCount(1); + } - // ── Type checking functions ── + // ── Type checking functions ── - [Fact] - public async Task IsArray_ReturnsTrueForArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_ARRAY(c.tags) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsArray_ReturnsTrueForArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_ARRAY(c.tags) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsBool_ReturnsTrueForBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_BOOL(c.isActive) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsBool_ReturnsTrueForBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_BOOL(c.isActive) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsNumber_ReturnsTrueForNumber() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_NUMBER(c.value) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsNumber_ReturnsTrueForNumber() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_NUMBER(c.value) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsString_ReturnsTrueForString() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_STRING(c.name) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsString_ReturnsTrueForString() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_STRING(c.name) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsObject_ReturnsTrueForObject() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_OBJECT(c.nested) FROM c WHERE c.id = '3'"); + [Fact] + public async Task IsObject_ReturnsTrueForObject() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_OBJECT(c.nested) FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsPrimitive_ReturnsTrueForPrimitive() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_PRIMITIVE(c.name) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsPrimitive_ReturnsTrueForPrimitive() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_PRIMITIVE(c.name) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - // ── Conversion functions ── + // ── Conversion functions ── - [Fact] - public async Task ToNumber_ConvertsStringToNumber() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ToNumber('42.5') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ToNumber_ConvertsStringToNumber() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ToNumber('42.5') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().Be(42.5); - } + results.Should().ContainSingle().Which.Should().Be(42.5); + } - [Fact] - public async Task ToBoolean_ConvertsStringToBoolean() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE ToBoolean('true') FROM c WHERE c.id = '1'"); + [Fact] + public async Task ToBoolean_ConvertsStringToBoolean() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE ToBoolean('true') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - // ── IS_FINITE_NUMBER and IS_INTEGER ── + // ── IS_FINITE_NUMBER and IS_INTEGER ── - [Fact] - public async Task IsFiniteNumber_ReturnsTrueForFiniteNumber() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_FINITE_NUMBER(c.value) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsFiniteNumber_ReturnsTrueForFiniteNumber() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_FINITE_NUMBER(c.value) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsFiniteNumber_ReturnsFalseForString() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_FINITE_NUMBER(c.name) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsFiniteNumber_ReturnsFalseForString() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_FINITE_NUMBER(c.name) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task IsInteger_ReturnsTrueForIntegerValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_INTEGER(c.value) FROM c WHERE c.id = '1'"); + [Fact] + public async Task IsInteger_ReturnsTrueForIntegerValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_INTEGER(c.value) FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + results.Should().ContainSingle().Which.Should().BeTrue(); + } - [Fact] - public async Task IsInteger_ReturnsFalseForDouble() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE IS_INTEGER(c.nested.score) FROM c WHERE c.id = '3'"); + [Fact] + public async Task IsInteger_ReturnsFalseForDouble() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE IS_INTEGER(c.nested.score) FROM c WHERE c.id = '3'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().BeFalse(); - } + results.Should().ContainSingle().Which.Should().BeFalse(); + } - [Fact] - public async Task IsFiniteNumber_InWhereClause_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE IS_FINITE_NUMBER(c.value)"); + [Fact] + public async Task IsFiniteNumber_InWhereClause_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE IS_FINITE_NUMBER(c.value)"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(5); - } + results.Should().HaveCount(5); + } - [Fact] - public async Task IsInteger_InWhereClause_FiltersCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE IS_INTEGER(c.value)"); + [Fact] + public async Task IsInteger_InWhereClause_FiltersCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE IS_INTEGER(c.value)"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(5); - } + results.Should().HaveCount(5); + } - // ── Array functions ── + // ── Array functions ── - [Fact] - public async Task ArraySlice_ReturnsSlice() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_SLICE(c.tags, 0, 1) AS sliced FROM c WHERE c.id = '5'"); + [Fact] + public async Task ArraySlice_ReturnsSlice() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_SLICE(c.tags, 0, 1) AS sliced FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - var sliced = (JArray)results[0]["sliced"]!; - sliced.Should().HaveCount(1); - sliced[0]!.ToString().Should().Be("a"); - } + results.Should().HaveCount(1); + var sliced = (JArray)results[0]["sliced"]!; + sliced.Should().HaveCount(1); + sliced[0]!.ToString().Should().Be("a"); + } - [Fact] - public async Task ArrayConcat_ConcatenatesArrays() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ARRAY_CONCAT(c.tags, c.tags) AS doubled FROM c WHERE c.id = '1'"); + [Fact] + public async Task ArrayConcat_ConcatenatesArrays() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ARRAY_CONCAT(c.tags, c.tags) AS doubled FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - var doubled = (JArray)results[0]["doubled"]!; - doubled.Should().HaveCount(4); - } + results.Should().HaveCount(1); + var doubled = (JArray)results[0]["doubled"]!; + doubled.Should().HaveCount(4); + } - // ── New string functions ── + // ── New string functions ── - [Fact] - public async Task Replicate_RepeatsString() - { - await SeedItems(); - var query = new QueryDefinition("SELECT REPLICATE(c.name, 3) AS rep FROM c WHERE c.id = '5'"); + [Fact] + public async Task Replicate_RepeatsString() + { + await SeedItems(); + var query = new QueryDefinition("SELECT REPLICATE(c.name, 3) AS rep FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["rep"]!.ToString().Should().Be("EveEveEve"); - } + results.Should().HaveCount(1); + results[0]["rep"]!.ToString().Should().Be("EveEveEve"); + } - [Fact] - public async Task Replicate_ZeroCount_ReturnsEmpty() - { - await SeedItems(); - var query = new QueryDefinition("SELECT REPLICATE(c.name, 0) AS rep FROM c WHERE c.id = '5'"); + [Fact] + public async Task Replicate_ZeroCount_ReturnsEmpty() + { + await SeedItems(); + var query = new QueryDefinition("SELECT REPLICATE(c.name, 0) AS rep FROM c WHERE c.id = '5'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["rep"]!.ToString().Should().BeEmpty(); - } + results.Should().HaveCount(1); + results[0]["rep"]!.ToString().Should().BeEmpty(); + } - [Fact] - public async Task StringEquals_MatchesExact() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Eve')"); + [Fact] + public async Task StringEquals_MatchesExact() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'Eve')"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Id.Should().Be("5"); - } + results.Should().HaveCount(1); + results[0].Id.Should().Be("5"); + } - [Fact] - public async Task StringEquals_CaseInsensitive() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'eve', true)"); + [Fact] + public async Task StringEquals_CaseInsensitive() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'eve', true)"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0].Id.Should().Be("5"); - } + results.Should().HaveCount(1); + results[0].Id.Should().Be("5"); + } - [Fact] - public async Task StringToArray_ParsesJsonArray() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE StringToArray('[1, 2, 3]') FROM c WHERE c.id = '1'"); + [Fact] + public async Task StringToArray_ParsesJsonArray() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE StringToArray('[1, 2, 3]') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0].Should().HaveCount(3); - } + results.Should().ContainSingle(); + results[0].Should().HaveCount(3); + } - [Fact] - public async Task StringToBoolean_ParsesTrueAndFalse() - { - await SeedItems(); - var query = new QueryDefinition("SELECT StringToBoolean('true') AS t, StringToBoolean('false') AS f FROM c WHERE c.id = '1'"); + [Fact] + public async Task StringToBoolean_ParsesTrueAndFalse() + { + await SeedItems(); + var query = new QueryDefinition("SELECT StringToBoolean('true') AS t, StringToBoolean('false') AS f FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["t"]!.Value().Should().BeTrue(); - results[0]["f"]!.Value().Should().BeFalse(); - } + results.Should().HaveCount(1); + results[0]["t"]!.Value().Should().BeTrue(); + results[0]["f"]!.Value().Should().BeFalse(); + } - [Fact] - public async Task StringToNumber_ParsesInteger() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE StringToNumber('42') FROM c WHERE c.id = '1'"); + [Fact] + public async Task StringToNumber_ParsesInteger() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE StringToNumber('42') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().Be(42); - } + results.Should().ContainSingle().Which.Should().Be(42); + } - [Fact] - public async Task StringToNumber_ParsesDecimal() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE StringToNumber('3.14') FROM c WHERE c.id = '1'"); + [Fact] + public async Task StringToNumber_ParsesDecimal() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE StringToNumber('3.14') FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle().Which.Should().Be(3.14); - } + results.Should().ContainSingle().Which.Should().Be(3.14); + } - [Fact] - public async Task StringToObject_ParsesJsonObject() - { - await SeedItems(); - var query = new QueryDefinition("""SELECT VALUE StringToObject('{"a": 1}') FROM c WHERE c.id = '1'"""); + [Fact] + public async Task StringToObject_ParsesJsonObject() + { + await SeedItems(); + var query = new QueryDefinition("""SELECT VALUE StringToObject('{"a": 1}') FROM c WHERE c.id = '1'"""); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().ContainSingle(); - results[0]["a"]!.Value().Should().Be(1); - } + results.Should().ContainSingle(); + results[0]["a"]!.Value().Should().Be(1); + } - // ── Integer math functions ── + // ── Integer math functions ── - [Fact] - public async Task NumberBin_RoundsDownToNearestBin() - { - await SeedItems(); - var query = new QueryDefinition("SELECT NumberBin(c.value, 7) AS binned FROM c WHERE c.id = '1'"); + [Fact] + public async Task NumberBin_RoundsDownToNearestBin() + { + await SeedItems(); + var query = new QueryDefinition("SELECT NumberBin(c.value, 7) AS binned FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["binned"]!.Value().Should().Be(7); - } + results.Should().HaveCount(1); + results[0]["binned"]!.Value().Should().Be(7); + } - [Fact] - public async Task IntAdd_AddsIntegers() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntAdd(c.value, 5) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntAdd_AddsIntegers() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntAdd(c.value, 5) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(15); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(15); + } - [Fact] - public async Task IntSub_SubtractsIntegers() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntSub(c.value, 3) AS result FROM c WHERE c.id = '2'"); + [Fact] + public async Task IntSub_SubtractsIntegers() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntSub(c.value, 3) AS result FROM c WHERE c.id = '2'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(17); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(17); + } - [Fact] - public async Task IntMul_MultipliesIntegers() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntMul(c.value, 3) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntMul_MultipliesIntegers() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntMul(c.value, 3) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(30); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(30); + } - [Fact] - public async Task IntDiv_DividesIntegers() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntDiv(c.value, 3) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntDiv_DividesIntegers() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntDiv(c.value, 3) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(3); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(3); + } - [Fact] - public async Task IntDiv_DivisionByZero_ReturnsUndefined() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntDiv(c.value, 0) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntDiv_DivisionByZero_ReturnsUndefined() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntDiv(c.value, 0) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"].Should().BeNull(); - } + results.Should().HaveCount(1); + results[0]["result"].Should().BeNull(); + } - [Fact] - public async Task IntMod_ReturnsRemainder() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntMod(c.value, 3) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntMod_ReturnsRemainder() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntMod(c.value, 3) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(1); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(1); + } - [Fact] - public async Task IntBitAnd_ReturnsBitwiseAnd() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitAnd(c.value, 6) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntBitAnd_ReturnsBitwiseAnd() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitAnd(c.value, 6) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(10 & 6); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(10 & 6); + } - [Fact] - public async Task IntBitOr_ReturnsBitwiseOr() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitOr(c.value, 5) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntBitOr_ReturnsBitwiseOr() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitOr(c.value, 5) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(10 | 5); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(10 | 5); + } - [Fact] - public async Task IntBitXor_ReturnsBitwiseXor() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitXor(c.value, 7) AS result FROM c WHERE c.id = '1'"); + [Fact] + public async Task IntBitXor_ReturnsBitwiseXor() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitXor(c.value, 7) AS result FROM c WHERE c.id = '1'"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(10 ^ 7); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(10 ^ 7); + } - [Fact] - public async Task IntBitNot_ReturnsBitwiseNot() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitNot(c.value) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); + [Fact] + public async Task IntBitNot_ReturnsBitwiseNot() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitNot(c.value) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(~10L); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(~10L); + } - [Fact] - public async Task IntBitLeftShift_ShiftsLeft() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitLeftShift(c.value, 2) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); + [Fact] + public async Task IntBitLeftShift_ShiftsLeft() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitLeftShift(c.value, 2) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(10L << 2); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(10L << 2); + } - [Fact] - public async Task IntBitRightShift_ShiftsRight() - { - await SeedItems(); - var query = new QueryDefinition("SELECT IntBitRightShift(c.value, 1) AS result FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); + [Fact] + public async Task IntBitRightShift_ShiftsRight() + { + await SeedItems(); + var query = new QueryDefinition("SELECT IntBitRightShift(c.value, 1) AS result FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["result"]!.Value().Should().Be(10L >> 1); - } + results.Should().HaveCount(1); + results[0]["result"]!.Value().Should().Be(10L >> 1); + } - [Fact] - public async Task SumAggregate_ReturnsCorrectSum() - { - await SeedItems(); - var query = new QueryDefinition("SELECT SUM(c.value) AS total FROM c"); + [Fact] + public async Task SumAggregate_ReturnsCorrectSum() + { + await SeedItems(); + var query = new QueryDefinition("SELECT SUM(c.value) AS total FROM c"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["total"]!.Value().Should().Be(55); - } + results.Should().HaveCount(1); + results[0]["total"]!.Value().Should().Be(55); + } - [Fact] - public async Task AvgAggregate_ReturnsCorrectAverage() - { - await SeedItems(); - var query = new QueryDefinition("SELECT AVG(c.value) AS average FROM c"); + [Fact] + public async Task AvgAggregate_ReturnsCorrectAverage() + { + await SeedItems(); + var query = new QueryDefinition("SELECT AVG(c.value) AS average FROM c"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["average"]!.Value().Should().Be(11.0); - } + results.Should().HaveCount(1); + results[0]["average"]!.Value().Should().Be(11.0); + } - [Fact] - public async Task MinAggregate_ReturnsMinValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT MIN(c.value) AS minVal FROM c"); + [Fact] + public async Task MinAggregate_ReturnsMinValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT MIN(c.value) AS minVal FROM c"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["minVal"]!.Value().Should().Be(-5); - } + results.Should().HaveCount(1); + results[0]["minVal"]!.Value().Should().Be(-5); + } - [Fact] - public async Task MaxAggregate_ReturnsMaxValue() - { - await SeedItems(); - var query = new QueryDefinition("SELECT MAX(c.value) AS maxVal FROM c"); + [Fact] + public async Task MaxAggregate_ReturnsMaxValue() + { + await SeedItems(); + var query = new QueryDefinition("SELECT MAX(c.value) AS maxVal FROM c"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(1); - results[0]["maxVal"]!.Value().Should().Be(30); - } + results.Should().HaveCount(1); + results[0]["maxVal"]!.Value().Should().Be(30); + } - [Fact] - public async Task GroupBy_GroupsCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive"); + [Fact] + public async Task GroupBy_GroupsCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive"); - var results = await QueryAll(query); + var results = await QueryAll(query); - results.Should().HaveCount(2); - var activeGroup = results.First(r => r["isActive"]!.Value()); - activeGroup["cnt"]!.Value().Should().Be(3); - var inactiveGroup = results.First(r => !r["isActive"]!.Value()); - inactiveGroup["cnt"]!.Value().Should().Be(2); - } - - [Fact] - public async Task GroupByHaving_FiltersGroups() - { - await SeedItems(); - var query = new QueryDefinition("SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive HAVING COUNT(1) > 2"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["isActive"]!.Value().Should().BeTrue(); - results[0]["cnt"]!.Value().Should().Be(3); - } - - [Fact] - public async Task InExpression_FiltersMultipleValues() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice Anderson', 'Bob Brown', 'NotExist')"); - - var results = await QueryAll(query); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task NotIn_FiltersExcludedValues() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name NOT IN ('Alice Anderson', 'Bob Brown')"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task Between_FiltersRange() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 10 AND 30"); - - var results = await QueryAll(query); - - results.Should().HaveCount(3); - } - - [Fact] - public async Task EmptyResult_ReturnsEmptyCollection() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = 'NonExistent'"); - - var results = await QueryAll(query); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ToString_ReturnsStringRepresentation() - { - await SeedItems(); - var query = new QueryDefinition("SELECT ToString(c.value) AS str FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0]["str"]!.ToString().Should().Be("10"); - } - - [Fact] - public async Task ValueSelect_ReturnsScalarValues() - { - await SeedItems(); - var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.id = '1'"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Should().Be("Alice Anderson"); - } - - [Fact] - public async Task NestedPropertyAccess_QueriesCorrectly() - { - await SeedItems(); - var query = new QueryDefinition("SELECT * FROM c WHERE c.nested.score > 3.0"); - - var results = await QueryAll(query); - - results.Should().HaveCount(1); - results[0].Id.Should().Be("3"); - } - - private async Task> QueryAll(QueryDefinition query) - { - var iterator = _container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Spatial functions - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task StDistance_BetweenTwoPoints_ReturnsMeters() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [-2.2426, 53.4808]}) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - results[0].Should().BeApproximately(262_000, 5_000); // London to Manchester ~262km - } - - [Fact] - public async Task StDistance_WithNonPoint_ReturnsEmpty() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Name = "not a point" - }, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [0, 0]}) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().BeNull(); - } - - [Fact] - public async Task StWithin_PointInsidePolygon_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - - // Polygon covering most of southern England - var query = new QueryDefinition(""" + results.Should().HaveCount(2); + var activeGroup = results.First(r => r["isActive"]!.Value()); + activeGroup["cnt"]!.Value().Should().Be(3); + var inactiveGroup = results.First(r => !r["isActive"]!.Value()); + inactiveGroup["cnt"]!.Value().Should().Be(2); + } + + [Fact] + public async Task GroupByHaving_FiltersGroups() + { + await SeedItems(); + var query = new QueryDefinition("SELECT c.isActive, COUNT(1) AS cnt FROM c GROUP BY c.isActive HAVING COUNT(1) > 2"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["isActive"]!.Value().Should().BeTrue(); + results[0]["cnt"]!.Value().Should().Be(3); + } + + [Fact] + public async Task InExpression_FiltersMultipleValues() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name IN ('Alice Anderson', 'Bob Brown', 'NotExist')"); + + var results = await QueryAll(query); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task NotIn_FiltersExcludedValues() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name NOT IN ('Alice Anderson', 'Bob Brown')"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task Between_FiltersRange() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.value BETWEEN 10 AND 30"); + + var results = await QueryAll(query); + + results.Should().HaveCount(3); + } + + [Fact] + public async Task EmptyResult_ReturnsEmptyCollection() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = 'NonExistent'"); + + var results = await QueryAll(query); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ToString_ReturnsStringRepresentation() + { + await SeedItems(); + var query = new QueryDefinition("SELECT ToString(c.value) AS str FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0]["str"]!.ToString().Should().Be("10"); + } + + [Fact] + public async Task ValueSelect_ReturnsScalarValues() + { + await SeedItems(); + var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.id = '1'"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Should().Be("Alice Anderson"); + } + + [Fact] + public async Task NestedPropertyAccess_QueriesCorrectly() + { + await SeedItems(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.nested.score > 3.0"); + + var results = await QueryAll(query); + + results.Should().HaveCount(1); + results[0].Id.Should().Be("3"); + } + + private async Task> QueryAll(QueryDefinition query) + { + var iterator = _container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Spatial functions + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task StDistance_BetweenTwoPoints_ReturnsMeters() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [-2.2426, 53.4808]}) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + results[0].Should().BeApproximately(262_000, 5_000); // London to Manchester ~262km + } + + [Fact] + public async Task StDistance_WithNonPoint_ReturnsEmpty() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "not a point" + }, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [0, 0]}) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().BeNull(); + } + + [Fact] + public async Task StWithin_PointInsidePolygon_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + + // Polygon covering most of southern England + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_WITHIN(c.location, { 'type': 'Polygon', 'coordinates': [[[-2.0, 50.0], [1.0, 50.0], [1.0, 52.0], [-2.0, 52.0], [-2.0, 50.0]]] }) """); - var results = await QueryAll(container, query); + var results = await QueryAll(container, query); - results.Should().ContainSingle(); - } + results.Should().ContainSingle(); + } - [Fact] - public async Task StWithin_PointOutsidePolygon_ReturnsEmpty() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); + [Fact] + public async Task StWithin_PointOutsidePolygon_ReturnsEmpty() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); - // Polygon in France - var query = new QueryDefinition(""" + // Polygon in France + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_WITHIN(c.location, { 'type': 'Polygon', 'coordinates': [[[1.0, 43.0], [3.0, 43.0], [3.0, 45.0], [1.0, 45.0], [1.0, 43.0]]] }) """); - var results = await QueryAll(container, query); + var results = await QueryAll(container, query); - results.Should().BeEmpty(); - } + results.Should().BeEmpty(); + } - [Fact] - public async Task StIntersects_PointInPolygon_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); + [Fact] + public async Task StIntersects_PointInPolygon_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); - var query = new QueryDefinition(""" + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_INTERSECTS(c.location, { 'type': 'Polygon', 'coordinates': [[[-1.0, 51.0], [0.0, 51.0], [0.0, 52.0], [-1.0, 52.0], [-1.0, 51.0]]] }) """); - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task StIsValid_ValidPoint_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.location) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StIsValid_InvalidGeoJson_ReturnsFalse() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { 999.0, 999.0 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.location) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().BeFalse(); - } - - [Fact] - public async Task StIsValidDetailed_ValidPolygon_ReturnsValidTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Area = new GeoJsonGeometry - { - Type = "Polygon", - Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 0.0, 0.0 } } } - } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - results[0]["valid"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task StIsValidDetailed_InvalidPolygon_ReturnsValidFalseWithReason() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Area = new GeoJsonGeometry - { - Type = "Polygon", - Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 } } } - } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - results[0]["valid"]!.Value().Should().BeFalse(); - results[0]["reason"]!.ToString().Should().NotBeEmpty(); - } - - // ── Additional spatial tests ── - - [Fact] - public async Task StDistance_BetweenSamePoint_ReturnsZero() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition( - "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [-0.1278, 51.5074]}) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().Be(0); - } - - [Fact] - public async Task StWithin_PointInCircle_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition(""" + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task StIsValid_ValidPoint_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.location) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StIsValid_InvalidGeoJson_ReturnsFalse() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { 999.0, 999.0 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.location) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().BeFalse(); + } + + [Fact] + public async Task StIsValidDetailed_ValidPolygon_ReturnsValidTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Area = new GeoJsonGeometry + { + Type = "Polygon", + Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 0.0, 0.0 } } } + } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + results[0]["valid"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task StIsValidDetailed_InvalidPolygon_ReturnsValidFalseWithReason() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Area = new GeoJsonGeometry + { + Type = "Polygon", + Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 0.0 } } } + } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + results[0]["valid"]!.Value().Should().BeFalse(); + results[0]["reason"]!.ToString().Should().NotBeEmpty(); + } + + // ── Additional spatial tests ── + + [Fact] + public async Task StDistance_BetweenSamePoint_ReturnsZero() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition( + "SELECT VALUE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [-0.1278, 51.5074]}) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().Be(0); + } + + [Fact] + public async Task StWithin_PointInCircle_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_WITHIN(c.location, { 'center': {'type': 'Point', 'coordinates': [-0.1278, 51.5074]}, 'radius': 1000 }) """); - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task StIntersects_TwoOverlappingPolygons_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Area = new GeoJsonGeometry - { - Type = "Polygon", - Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 2.0, 0.0 }, new[] { 2.0, 2.0 }, new[] { 0.0, 2.0 }, new[] { 0.0, 0.0 } } } - } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition(""" + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task StIntersects_TwoOverlappingPolygons_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Area = new GeoJsonGeometry + { + Type = "Polygon", + Coordinates = new[] { new[] { new[] { 0.0, 0.0 }, new[] { 2.0, 0.0 }, new[] { 2.0, 2.0 }, new[] { 0.0, 2.0 }, new[] { 0.0, 0.0 } } } + } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_INTERSECTS(c.area, { 'type': 'Polygon', 'coordinates': [[[1.0, 1.0], [3.0, 1.0], [3.0, 3.0], [1.0, 3.0], [1.0, 1.0]]] }) """); - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - } - - [Fact] - public async Task StIsValid_ValidLineString_ReturnsTrue() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Area = new GeoJsonGeometry - { - Type = "LineString", - Coordinates = new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 2.0, 0.0 } } - } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.area) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task StIsValidDetailed_LineStringTooFewPoints_ReturnsInvalid() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "1", - PartitionKey = "pk1", - Area = new GeoJsonGeometry - { - Type = "LineString", - Coordinates = new[] { new[] { 0.0, 0.0 } } - } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle(); - results[0]["valid"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task StDistance_InWhereClause_FiltersNearbyPoints() - { - var container = new InMemoryContainer("geo-test", "/partitionKey"); - await container.CreateItemAsync(new GeoDocument - { - Id = "london", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } - }, new PartitionKey("pk1")); - await container.CreateItemAsync(new GeoDocument - { - Id = "paris", - PartitionKey = "pk1", - Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { 2.3522, 48.8566 } } - }, new PartitionKey("pk1")); - - var query = new QueryDefinition(""" + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + } + + [Fact] + public async Task StIsValid_ValidLineString_ReturnsTrue() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Area = new GeoJsonGeometry + { + Type = "LineString", + Coordinates = new[] { new[] { 0.0, 0.0 }, new[] { 1.0, 1.0 }, new[] { 2.0, 0.0 } } + } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALID(c.area) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task StIsValidDetailed_LineStringTooFewPoints_ReturnsInvalid() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "1", + PartitionKey = "pk1", + Area = new GeoJsonGeometry + { + Type = "LineString", + Coordinates = new[] { new[] { 0.0, 0.0 } } + } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE ST_ISVALIDDETAILED(c.area) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle(); + results[0]["valid"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task StDistance_InWhereClause_FiltersNearbyPoints() + { + var container = new InMemoryContainer("geo-test", "/partitionKey"); + await container.CreateItemAsync(new GeoDocument + { + Id = "london", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { -0.1278, 51.5074 } } + }, new PartitionKey("pk1")); + await container.CreateItemAsync(new GeoDocument + { + Id = "paris", + PartitionKey = "pk1", + Location = new GeoJsonGeometry { Type = "Point", Coordinates = new[] { 2.3522, 48.8566 } } + }, new PartitionKey("pk1")); + + var query = new QueryDefinition(""" SELECT * FROM c WHERE ST_DISTANCE(c.location, {'type': 'Point', 'coordinates': [-0.13, 51.51]}) < 10000 """); - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Id.Should().Be("london"); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // UDF registration - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task RegisteredUdf_IsCalledDuringQuery() - { - var container = new InMemoryContainer("udf-test", "/partitionKey"); - container.RegisterUdf("doubleValue", args => - { - var val = Convert.ToDouble(args[0]); - return val * 2; - }); - - await container.CreateItemAsync(new UdfDocument - { - Id = "1", - PartitionKey = "pk1", - Value = 21 - }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE udf.doubleValue(c.value) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().Be(42); - } - - [Fact] - public async Task RegisteredUdf_InWhereClause_FiltersCorrectly() - { - var container = new InMemoryContainer("udf-test", "/partitionKey"); - container.RegisterUdf("isEven", args => - { - var val = Convert.ToInt64(args[0]); - return val % 2 == 0; - }); - - await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new UdfDocument { Id = "2", PartitionKey = "pk1", Value = 11 }, new PartitionKey("pk1")); - await container.CreateItemAsync(new UdfDocument { Id = "3", PartitionKey = "pk1", Value = 12 }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE udf.isEven(c.value)"); - - var results = await QueryAll(container, query); - - results.Should().HaveCount(2); - } - - [Fact] - public async Task UnregisteredUdf_ThrowsNotSupportedException() - { - var container = new InMemoryContainer("udf-test", "/partitionKey"); - await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE udf.missing(c.value) FROM c"); - - var act = async () => await QueryAll(container, query); - - await act.Should().ThrowAsync() - .WithMessage("*RegisterUdf*"); - } - - [Fact] - public async Task RegisteredUdf_WithMultipleArgs_ReceivesAllArgs() - { - var container = new InMemoryContainer("udf-test", "/partitionKey"); - container.RegisterUdf("add", args => - { - var a = Convert.ToDouble(args[0]); - var b = Convert.ToDouble(args[1]); - return a + b; - }); - - await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", X = 10, Y = 32 }, new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT VALUE udf.add(c.x, c.y) FROM c"); - - var results = await QueryAll(container, query); - - results.Should().ContainSingle().Which.Should().Be(42); - } - - private static async Task> QueryAll(InMemoryContainer container, QueryDefinition query) - { - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - return results; - } + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Id.Should().Be("london"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UDF registration + // ═══════════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task RegisteredUdf_IsCalledDuringQuery() + { + var container = new InMemoryContainer("udf-test", "/partitionKey"); + container.RegisterUdf("doubleValue", args => + { + var val = Convert.ToDouble(args[0]); + return val * 2; + }); + + await container.CreateItemAsync(new UdfDocument + { + Id = "1", + PartitionKey = "pk1", + Value = 21 + }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE udf.doubleValue(c.value) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().Be(42); + } + + [Fact] + public async Task RegisteredUdf_InWhereClause_FiltersCorrectly() + { + var container = new InMemoryContainer("udf-test", "/partitionKey"); + container.RegisterUdf("isEven", args => + { + var val = Convert.ToInt64(args[0]); + return val % 2 == 0; + }); + + await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new UdfDocument { Id = "2", PartitionKey = "pk1", Value = 11 }, new PartitionKey("pk1")); + await container.CreateItemAsync(new UdfDocument { Id = "3", PartitionKey = "pk1", Value = 12 }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE udf.isEven(c.value)"); + + var results = await QueryAll(container, query); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task UnregisteredUdf_ThrowsNotSupportedException() + { + var container = new InMemoryContainer("udf-test", "/partitionKey"); + await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 10 }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE udf.missing(c.value) FROM c"); + + var act = async () => await QueryAll(container, query); + + await act.Should().ThrowAsync() + .WithMessage("*RegisterUdf*"); + } + + [Fact] + public async Task RegisteredUdf_WithMultipleArgs_ReceivesAllArgs() + { + var container = new InMemoryContainer("udf-test", "/partitionKey"); + container.RegisterUdf("add", args => + { + var a = Convert.ToDouble(args[0]); + var b = Convert.ToDouble(args[1]); + return a + b; + }); + + await container.CreateItemAsync(new UdfDocument { Id = "1", PartitionKey = "pk1", X = 10, Y = 32 }, new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT VALUE udf.add(c.x, c.y) FROM c"); + + var results = await QueryAll(container, query); + + results.Should().ContainSingle().Which.Should().Be(42); + } + + private static async Task> QueryAll(InMemoryContainer container, QueryDefinition query) + { + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + return results; + } } public class SqlFunctionGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task SqlFunc_GetCurrentTimestamp_NotImplemented() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentTimestamp() FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - } - - [Fact] - public async Task SqlFunc_Substring_OutOfBounds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE SUBSTRING(c.name, 10, 5) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - // Out-of-bounds substring returns empty string - results[0].ToString().Should().BeEmpty(); - } - - [Fact] - public async Task SqlFunc_MathFunctions_WithNull_ReturnUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","val":null}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE ABS(c.val) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // ABS(null) → undefined → omitted by SELECT VALUE - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task SqlFunc_GetCurrentTimestamp_NotImplemented() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentTimestamp() FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task SqlFunc_Substring_OutOfBounds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE SUBSTRING(c.name, 10, 5) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + // Out-of-bounds substring returns empty string + results[0].ToString().Should().BeEmpty(); + } + + [Fact] + public async Task SqlFunc_MathFunctions_WithNull_ReturnUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","val":null}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE ABS(c.val) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // ABS(null) → undefined → omitted by SELECT VALUE + results.Should().BeEmpty(); + } } public class SqlFunctionGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task SqlFunc_DateTimeFunctions_NotImplemented() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE GetCurrentDateTime() FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().NotBeEmpty(); - } - - [Fact] - public async Task SqlFunc_IS_INTEGER_DistinguishesFromFloat() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","intVal":42,"floatVal":42.5}""")), - new PartitionKey("pk1")); - - var intResult = _container.GetItemQueryIterator( - "SELECT VALUE IS_INTEGER(c.intVal) FROM c"); - var intResults = new List(); - while (intResult.HasMoreResults) - { - var page = await intResult.ReadNextAsync(); - intResults.AddRange(page); - } - - intResults.Should().ContainSingle(); - } - - [Fact] - public async Task SqlFunc_NullArgs_StringFunctions_ReturnUndefined() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE UPPER(c.name) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - // Cosmos DB: UPPER(null) → undefined → excluded from SELECT VALUE results - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task SqlFunc_DateTimeFunctions_NotImplemented() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE GetCurrentDateTime() FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().NotBeEmpty(); + } + + [Fact] + public async Task SqlFunc_IS_INTEGER_DistinguishesFromFloat() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","intVal":42,"floatVal":42.5}""")), + new PartitionKey("pk1")); + + var intResult = _container.GetItemQueryIterator( + "SELECT VALUE IS_INTEGER(c.intVal) FROM c"); + var intResults = new List(); + while (intResult.HasMoreResults) + { + var page = await intResult.ReadNextAsync(); + intResults.AddRange(page); + } + + intResults.Should().ContainSingle(); + } + + [Fact] + public async Task SqlFunc_NullArgs_StringFunctions_ReturnUndefined() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","partitionKey":"pk1","name":null}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE UPPER(c.name) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + // Cosmos DB: UPPER(null) → undefined → excluded from SELECT VALUE results + results.Should().BeEmpty(); + } } public class SqlFunctionGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task SeedItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello World", Value = 42 }, - new PartitionKey("pk1")); - } - - private async Task> RunQuery(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - private async Task> RunQueryTokens(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - return results; - } - - [Fact] - public async Task Contains_CaseSensitive_Default() - { - await SeedItem(); - var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, "hello")"""); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Contains_CaseInsensitive_ThirdParam() - { - await SeedItem(); - var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, "hello", true)"""); - results.Should().HaveCount(1); - } - - [Fact] - public async Task StartsWith_CaseInsensitive() - { - await SeedItem(); - var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "hello", true)"""); - results.Should().HaveCount(1); - } - - [Fact] - public async Task ArrayContains_PartialMatch() - { - var json = """{"id":"1","partitionKey":"pk1","items":[{"name":"urgent","priority":1},{"name":"review","priority":2}]}"""; - await _container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "urgent"}, true)"""); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Is_Defined_FalseForMissingField() - { - await SeedItem(); - var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nonExistentField)"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Is_Defined_TrueForExistingField() - { - await SeedItem(); - var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.name)"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task IndexOf_NotFound_ReturnsNegative() - { - await SeedItem(); - var results = await RunQueryTokens("SELECT VALUE INDEX_OF(c.name, \"xyz\") FROM c"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task Substring_Basic() - { - await SeedItem(); - var results = await RunQueryTokens("SELECT VALUE SUBSTRING(c.name, 0, 5) FROM c"); - results.Should().HaveCount(1); - } - - [Fact] - public async Task Replace_MultipleOccurrences() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "aaa" }, - new PartitionKey("pk1")); - - var results = await RunQueryTokens("""SELECT VALUE REPLACE(c.name, "a", "bb") FROM c"""); - results.Should().HaveCount(1); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task SeedItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Hello World", Value = 42 }, + new PartitionKey("pk1")); + } + + private async Task> RunQuery(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> RunQueryTokens(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + [Fact] + public async Task Contains_CaseSensitive_Default() + { + await SeedItem(); + var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, "hello")"""); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Contains_CaseInsensitive_ThirdParam() + { + await SeedItem(); + var results = await RunQuery("""SELECT * FROM c WHERE CONTAINS(c.name, "hello", true)"""); + results.Should().HaveCount(1); + } + + [Fact] + public async Task StartsWith_CaseInsensitive() + { + await SeedItem(); + var results = await RunQuery("""SELECT * FROM c WHERE STARTSWITH(c.name, "hello", true)"""); + results.Should().HaveCount(1); + } + + [Fact] + public async Task ArrayContains_PartialMatch() + { + var json = """{"id":"1","partitionKey":"pk1","items":[{"name":"urgent","priority":1},{"name":"review","priority":2}]}"""; + await _container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, {"name": "urgent"}, true)"""); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Is_Defined_FalseForMissingField() + { + await SeedItem(); + var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.nonExistentField)"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Is_Defined_TrueForExistingField() + { + await SeedItem(); + var results = await RunQuery("SELECT * FROM c WHERE IS_DEFINED(c.name)"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task IndexOf_NotFound_ReturnsNegative() + { + await SeedItem(); + var results = await RunQueryTokens("SELECT VALUE INDEX_OF(c.name, \"xyz\") FROM c"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task Substring_Basic() + { + await SeedItem(); + var results = await RunQueryTokens("SELECT VALUE SUBSTRING(c.name, 0, 5) FROM c"); + results.Should().HaveCount(1); + } + + [Fact] + public async Task Replace_MultipleOccurrences() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "aaa" }, + new PartitionKey("pk1")); + + var results = await RunQueryTokens("""SELECT VALUE REPLACE(c.name, "a", "bb") FROM c"""); + results.Should().HaveCount(1); + } } public class SqlFunctionGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task SqlFunc_Coalesce_WithMultipleArgs() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","a":null,"b":"value"}""")), - new PartitionKey("pk1")); - - // COALESCE returns the first non-undefined value. c.a is null (defined) → returned as null. - var iterator = _container.GetItemQueryIterator( - """SELECT VALUE COALESCE(c.a, c.b) FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0].Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task SqlFunc_IS_PRIMITIVE_ReturnsFalse_ForObjectAndArray() - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","partitionKey":"pk1","arr":[1,2],"obj":{"a":1},"str":"hello"}""")), - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT IS_PRIMITIVE(c.arr) AS arrPrim, IS_PRIMITIVE(c.obj) AS objPrim, IS_PRIMITIVE(c.str) AS strPrim FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["arrPrim"]!.Value().Should().BeFalse(); - results[0]["objPrim"]!.Value().Should().BeFalse(); - results[0]["strPrim"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task SqlFunc_TypeFunctions_WithUndefined_ReturnFalse() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - """SELECT IS_STRING(c.nonExistent) AS isStr, IS_NUMBER(c.nonExistent) AS isNum FROM c"""); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["isStr"]!.Value().Should().BeFalse(); - results[0]["isNum"]!.Value().Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task SqlFunc_Coalesce_WithMultipleArgs() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","a":null,"b":"value"}""")), + new PartitionKey("pk1")); + + // COALESCE returns the first non-undefined value. c.a is null (defined) → returned as null. + var iterator = _container.GetItemQueryIterator( + """SELECT VALUE COALESCE(c.a, c.b) FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0].Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task SqlFunc_IS_PRIMITIVE_ReturnsFalse_ForObjectAndArray() + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","partitionKey":"pk1","arr":[1,2],"obj":{"a":1},"str":"hello"}""")), + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT IS_PRIMITIVE(c.arr) AS arrPrim, IS_PRIMITIVE(c.obj) AS objPrim, IS_PRIMITIVE(c.str) AS strPrim FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["arrPrim"]!.Value().Should().BeFalse(); + results[0]["objPrim"]!.Value().Should().BeFalse(); + results[0]["strPrim"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task SqlFunc_TypeFunctions_WithUndefined_ReturnFalse() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + """SELECT IS_STRING(c.nonExistent) AS isStr, IS_NUMBER(c.nonExistent) AS isNum FROM c"""); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["isStr"]!.Value().Should().BeFalse(); + results[0]["isNum"]!.Value().Should().BeFalse(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1993,90 +1993,90 @@ await _container.CreateItemAsync( public class SqlFunctionBugFixTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice Anderson", value = 10, isActive = true, tags = new[] { "dot", "net" } }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Bob Brown", value = 20, isActive = false }), new PartitionKey("pk1")); - } - - // Bug 1: LOG with base argument - [Fact] - public async Task Log_WithBase_ReturnsCustomBaseLog() - { - await Seed(); - var results = await Query("SELECT LOG(8, 2) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Value().Should().BeApproximately(3.0, 0.0001); - } - - // Bug 2: ROUND with precision - [Fact] - public async Task Round_WithPrecision_RoundsToDecimalPlaces() - { - await Seed(); - var results = await Query("SELECT ROUND(3.14159, 2) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Value().Should().BeApproximately(3.14, 0.0001); - } - - [Fact] - public async Task Round_NoPrecision_RoundsToInteger() - { - await Seed(); - var results = await Query("SELECT ROUND(3.7) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Value().Should().BeApproximately(4.0, 0.0001); - } - - // Bug 3: INDEX_OF with start position - [Fact] - public async Task IndexOf_WithStartPosition_ReturnsCorrectIndex() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", text = "hello hello" }), new PartitionKey("pk1")); - var results = await Query("SELECT INDEX_OF(c.text, 'hello', 1) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(6); - } - - // Bug 6: StringToNumber rejects NaN/Infinity - [Fact] - public async Task StringToNumber_NaN_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT StringToNumber('NaN') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); // undefined → omitted from JSON - } - - [Fact] - public async Task StringToNumber_Infinity_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT StringToNumber('Infinity') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); - } - - // Bug 7: COALESCE skips undefined - [Fact] - public async Task Coalesce_SkipsUndefined_ReturnsDefined() - { - await Seed(); - var results = await Query("SELECT COALESCE(c.nonExistent, c.name) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.ToString().Should().Be("Alice Anderson"); - } - - [Fact] - public async Task Coalesce_AllNull_ReturnsNull() - { - await Seed(); - var results = await Query("SELECT COALESCE(null, null) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Type.Should().Be(JTokenType.Null); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice Anderson", value = 10, isActive = true, tags = new[] { "dot", "net" } }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Bob Brown", value = 20, isActive = false }), new PartitionKey("pk1")); + } + + // Bug 1: LOG with base argument + [Fact] + public async Task Log_WithBase_ReturnsCustomBaseLog() + { + await Seed(); + var results = await Query("SELECT LOG(8, 2) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Value().Should().BeApproximately(3.0, 0.0001); + } + + // Bug 2: ROUND with precision + [Fact] + public async Task Round_WithPrecision_RoundsToDecimalPlaces() + { + await Seed(); + var results = await Query("SELECT ROUND(3.14159, 2) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Value().Should().BeApproximately(3.14, 0.0001); + } + + [Fact] + public async Task Round_NoPrecision_RoundsToInteger() + { + await Seed(); + var results = await Query("SELECT ROUND(3.7) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Value().Should().BeApproximately(4.0, 0.0001); + } + + // Bug 3: INDEX_OF with start position + [Fact] + public async Task IndexOf_WithStartPosition_ReturnsCorrectIndex() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", text = "hello hello" }), new PartitionKey("pk1")); + var results = await Query("SELECT INDEX_OF(c.text, 'hello', 1) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(6); + } + + // Bug 6: StringToNumber rejects NaN/Infinity + [Fact] + public async Task StringToNumber_NaN_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT StringToNumber('NaN') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); // undefined → omitted from JSON + } + + [Fact] + public async Task StringToNumber_Infinity_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT StringToNumber('Infinity') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); + } + + // Bug 7: COALESCE skips undefined + [Fact] + public async Task Coalesce_SkipsUndefined_ReturnsDefined() + { + await Seed(); + var results = await Query("SELECT COALESCE(c.nonExistent, c.name) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.ToString().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task Coalesce_AllNull_ReturnsNull() + { + await Seed(); + var results = await Query("SELECT COALESCE(null, null) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Type.Should().Be(JTokenType.Null); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2085,129 +2085,129 @@ public async Task Coalesce_AllNull_ReturnsNull() public class SqlFunctionMissingCoverageTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10, nested = new { description = "D1", score = 3.14 } }), new PartitionKey("pk1")); - } - - // COT - [Fact] - public async Task Cot_ReturnsCorrectValue() - { - await Seed(); - var results = await Query("SELECT COT(1) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Value().Should().BeApproximately(1.0 / Math.Tan(1), 0.0001); - } - - // CHOOSE - [Fact] - public async Task Choose_ReturnsCorrectElement() - { - await Seed(); - var results = await Query("SELECT CHOOSE(2, 'a', 'b', 'c') AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.ToString().Should().Be("b"); - } - - [Fact] - public async Task Choose_OutOfRange_ReturnsUndefined() - { - await Seed(); - // CHOOSE out-of-bounds returns undefined; the field is omitted from the projection - var results = await Query("SELECT CHOOSE(5, 'a', 'b') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); - } - - // OBJECTTOARRAY / ARRAYTOOBJECT - [Fact] - public async Task ObjectToArray_ReturnsKeyValuePairs() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", data = new { x = 1, y = 2 } }), new PartitionKey("pk1")); - var results = await Query("SELECT ObjectToArray(c.data) AS val FROM c"); - var arr = (JArray)results[0]["val"]!; - arr.Should().HaveCount(2); - arr[0]["k"]!.ToString().Should().Be("x"); - } - - [Fact] - public async Task ArrayToObject_ReturnsObject() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { new { k = "name", v = "Alice" } } }), new PartitionKey("pk1")); - var results = await Query("SELECT VALUE ArrayToObject(c.arr) FROM c"); - results[0]["name"]!.ToString().Should().Be("Alice"); - } - - // STRINGJOIN / STRINGSPLIT - [Fact] - public async Task StringJoin_JoinsArrayWithSeparator() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); - var results = await Query("SELECT StringJoin(c.tags, '-') AS val FROM c"); - results[0]["val"]!.ToString().Should().Be("a-b-c"); - } - - [Fact] - public async Task StringSplit_SplitsStringByDelimiter() - { - await Seed(); - var results = await Query("SELECT StringSplit('a-b-c', '-') AS val FROM c WHERE c.id = '1'"); - var arr = (JArray)results[0]["val"]!; - arr.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); - } - - // STRINGTONULL - [Fact] - public async Task StringToNull_ParsesNullLiteral() - { - await Seed(); - var results = await Query("SELECT StringToNull('null') AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task StringToNull_InvalidInput_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT StringToNull('hello') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); // undefined → omitted - } - - // DOCUMENTID - [Fact] - public async Task DocumentId_ReturnsDocumentId() - { - await Seed(); - var results = await Query("SELECT DOCUMENTID(c) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.ToString().Should().NotBeNullOrEmpty(); - } - - // ENDSWITH case-insensitive - [Fact] - public async Task EndsWith_CaseInsensitive_ReturnsTrue() - { - await Seed(); - var results = await Query("SELECT ENDSWITH(c.name, 'ALICE', true) AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.Value().Should().BeTrue(); - } - - // COUNT aggregate - [Fact] - public async Task CountAggregate_ReturnsCorrectCount() - { - await Seed(); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await Query("SELECT COUNT(1) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10, nested = new { description = "D1", score = 3.14 } }), new PartitionKey("pk1")); + } + + // COT + [Fact] + public async Task Cot_ReturnsCorrectValue() + { + await Seed(); + var results = await Query("SELECT COT(1) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Value().Should().BeApproximately(1.0 / Math.Tan(1), 0.0001); + } + + // CHOOSE + [Fact] + public async Task Choose_ReturnsCorrectElement() + { + await Seed(); + var results = await Query("SELECT CHOOSE(2, 'a', 'b', 'c') AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.ToString().Should().Be("b"); + } + + [Fact] + public async Task Choose_OutOfRange_ReturnsUndefined() + { + await Seed(); + // CHOOSE out-of-bounds returns undefined; the field is omitted from the projection + var results = await Query("SELECT CHOOSE(5, 'a', 'b') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); + } + + // OBJECTTOARRAY / ARRAYTOOBJECT + [Fact] + public async Task ObjectToArray_ReturnsKeyValuePairs() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", data = new { x = 1, y = 2 } }), new PartitionKey("pk1")); + var results = await Query("SELECT ObjectToArray(c.data) AS val FROM c"); + var arr = (JArray)results[0]["val"]!; + arr.Should().HaveCount(2); + arr[0]["k"]!.ToString().Should().Be("x"); + } + + [Fact] + public async Task ArrayToObject_ReturnsObject() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { new { k = "name", v = "Alice" } } }), new PartitionKey("pk1")); + var results = await Query("SELECT VALUE ArrayToObject(c.arr) FROM c"); + results[0]["name"]!.ToString().Should().Be("Alice"); + } + + // STRINGJOIN / STRINGSPLIT + [Fact] + public async Task StringJoin_JoinsArrayWithSeparator() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", tags = new[] { "a", "b", "c" } }), new PartitionKey("pk1")); + var results = await Query("SELECT StringJoin(c.tags, '-') AS val FROM c"); + results[0]["val"]!.ToString().Should().Be("a-b-c"); + } + + [Fact] + public async Task StringSplit_SplitsStringByDelimiter() + { + await Seed(); + var results = await Query("SELECT StringSplit('a-b-c', '-') AS val FROM c WHERE c.id = '1'"); + var arr = (JArray)results[0]["val"]!; + arr.Select(t => t.ToString()).Should().BeEquivalentTo(["a", "b", "c"]); + } + + // STRINGTONULL + [Fact] + public async Task StringToNull_ParsesNullLiteral() + { + await Seed(); + var results = await Query("SELECT StringToNull('null') AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task StringToNull_InvalidInput_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT StringToNull('hello') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); // undefined → omitted + } + + // DOCUMENTID + [Fact] + public async Task DocumentId_ReturnsDocumentId() + { + await Seed(); + var results = await Query("SELECT DOCUMENTID(c) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.ToString().Should().NotBeNullOrEmpty(); + } + + // ENDSWITH case-insensitive + [Fact] + public async Task EndsWith_CaseInsensitive_ReturnsTrue() + { + await Seed(); + var results = await Query("SELECT ENDSWITH(c.name, 'ALICE', true) AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.Value().Should().BeTrue(); + } + + // COUNT aggregate + [Fact] + public async Task CountAggregate_ReturnsCorrectCount() + { + await Seed(); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await Query("SELECT COUNT(1) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2216,111 +2216,111 @@ public async Task CountAggregate_ReturnsCorrectCount() public class SqlFunctionStringEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10 }), new PartitionKey("pk1")); - } - - [Fact] - public async Task Contains_EmptySubstring_ReturnsTrue() - { - await Seed(); - var results = await Query("SELECT CONTAINS(c.name, '') AS val FROM c"); - results[0]["val"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task StartsWith_EmptyPrefix_ReturnsTrue() - { - await Seed(); - var results = await Query("SELECT STARTSWITH(c.name, '') AS val FROM c"); - results[0]["val"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task EndsWith_EmptySuffix_ReturnsTrue() - { - await Seed(); - var results = await Query("SELECT ENDSWITH(c.name, '') AS val FROM c"); - results[0]["val"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task IndexOf_EmptySubstring_ReturnsZero() - { - await Seed(); - var results = await Query("SELECT INDEX_OF(c.name, '') AS val FROM c"); - results[0]["val"]!.Value().Should().Be(0); - } - - [Fact] - public async Task Reverse_EmptyString_ReturnsEmpty() - { - await Seed(); - var results = await Query("SELECT REVERSE('') AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.ToString().Should().BeEmpty(); - } - - [Fact] - public async Task Left_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var results = await Query("SELECT LEFT(c.name, 100) AS val FROM c"); - results[0]["val"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Right_CountExceedsLength_ReturnsFullString() - { - await Seed(); - var results = await Query("SELECT RIGHT(c.name, 100) AS val FROM c"); - results[0]["val"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task Concat_NoArgs_ReturnsEmpty() - { - await Seed(); - var results = await Query("SELECT CONCAT() AS val FROM c WHERE c.id = '1'"); - results[0]["val"]!.ToString().Should().BeEmpty(); - } - - [Fact] - public async Task StringToBoolean_MixedCase_ReturnsUndefined() - { - await Seed(); - // Cosmos DB StringToBoolean is case-sensitive: "True" != "true" - var results = await Query("SELECT StringToBoolean('True') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); // undefined → omitted - } - - [Fact] - public async Task StringToArray_InvalidJson_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT StringToArray('not json') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); - } - - [Fact] - public async Task ToString_NullInput_ReturnsUndefined() - { - await Seed(); - // Cosmos DB: TOSTRING(null) → undefined (property omitted from result) - var results = await Query("SELECT ToString(null) AS val FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["val"].Should().BeNull(); // property omitted = null in JObject access - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", value = 10 }), new PartitionKey("pk1")); + } + + [Fact] + public async Task Contains_EmptySubstring_ReturnsTrue() + { + await Seed(); + var results = await Query("SELECT CONTAINS(c.name, '') AS val FROM c"); + results[0]["val"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task StartsWith_EmptyPrefix_ReturnsTrue() + { + await Seed(); + var results = await Query("SELECT STARTSWITH(c.name, '') AS val FROM c"); + results[0]["val"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task EndsWith_EmptySuffix_ReturnsTrue() + { + await Seed(); + var results = await Query("SELECT ENDSWITH(c.name, '') AS val FROM c"); + results[0]["val"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task IndexOf_EmptySubstring_ReturnsZero() + { + await Seed(); + var results = await Query("SELECT INDEX_OF(c.name, '') AS val FROM c"); + results[0]["val"]!.Value().Should().Be(0); + } + + [Fact] + public async Task Reverse_EmptyString_ReturnsEmpty() + { + await Seed(); + var results = await Query("SELECT REVERSE('') AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.ToString().Should().BeEmpty(); + } + + [Fact] + public async Task Left_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var results = await Query("SELECT LEFT(c.name, 100) AS val FROM c"); + results[0]["val"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Right_CountExceedsLength_ReturnsFullString() + { + await Seed(); + var results = await Query("SELECT RIGHT(c.name, 100) AS val FROM c"); + results[0]["val"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task Concat_NoArgs_ReturnsEmpty() + { + await Seed(); + var results = await Query("SELECT CONCAT() AS val FROM c WHERE c.id = '1'"); + results[0]["val"]!.ToString().Should().BeEmpty(); + } + + [Fact] + public async Task StringToBoolean_MixedCase_ReturnsUndefined() + { + await Seed(); + // Cosmos DB StringToBoolean is case-sensitive: "True" != "true" + var results = await Query("SELECT StringToBoolean('True') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); // undefined → omitted + } + + [Fact] + public async Task StringToArray_InvalidJson_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT StringToArray('not json') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); + } + + [Fact] + public async Task ToString_NullInput_ReturnsUndefined() + { + await Seed(); + // Cosmos DB: TOSTRING(null) → undefined (property omitted from result) + var results = await Query("SELECT ToString(null) AS val FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["val"].Should().BeNull(); // property omitted = null in JObject access + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2329,52 +2329,52 @@ public async Task ToString_NullInput_ReturnsUndefined() public class SqlFunctionMathEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - } - - [Fact] - public async Task Power_ZeroExponent_ReturnsOne() - { - await Seed(); - var results = await Query("SELECT POWER(5, 0) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(1.0); - } - - [Fact] - public async Task Sign_Zero_ReturnsZero() - { - await Seed(); - var results = await Query("SELECT SIGN(0) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(0); - } - - [Fact] - public async Task Trunc_NegativeDecimal_TruncatesTowardZero() - { - await Seed(); - var results = await Query("SELECT TRUNC(-3.7) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(-3.0); - } - - [Fact] - public async Task Exp_Zero_ReturnsOne() - { - await Seed(); - var results = await Query("SELECT EXP(0) AS val FROM c"); - results[0]["val"]!.Value().Should().Be(1.0); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + } + + [Fact] + public async Task Power_ZeroExponent_ReturnsOne() + { + await Seed(); + var results = await Query("SELECT POWER(5, 0) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(1.0); + } + + [Fact] + public async Task Sign_Zero_ReturnsZero() + { + await Seed(); + var results = await Query("SELECT SIGN(0) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(0); + } + + [Fact] + public async Task Trunc_NegativeDecimal_TruncatesTowardZero() + { + await Seed(); + var results = await Query("SELECT TRUNC(-3.7) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(-3.0); + } + + [Fact] + public async Task Exp_Zero_ReturnsOne() + { + await Seed(); + var results = await Query("SELECT EXP(0) AS val FROM c"); + results[0]["val"]!.Value().Should().Be(1.0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2383,28 +2383,28 @@ public async Task Exp_Zero_ReturnsOne() public class SqlFunctionIntegerEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - } - - [Fact] - public async Task IntMod_ByZero_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT IntMod(10, 0) AS val FROM c"); - results[0]["val"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + } + + [Fact] + public async Task IntMod_ByZero_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT IntMod(10, 0) AS val FROM c"); + results[0]["val"].Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2413,71 +2413,71 @@ public async Task IntMod_ByZero_ReturnsUndefined() public class SqlFunctionTypeCheckEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task IsArray_ReturnsFalseForString() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = "hello" }), new PartitionKey("pk1")); - var results = await Query("SELECT IS_ARRAY(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsBool_ReturnsFalseForNumber() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = 42 }), new PartitionKey("pk1")); - var results = await Query("SELECT IS_BOOL(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsNumber_ReturnsFalseForBoolean() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = true }), new PartitionKey("pk1")); - var results = await Query("SELECT IS_NUMBER(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsString_ReturnsFalseForNumber() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = 42 }), new PartitionKey("pk1")); - var results = await Query("SELECT IS_STRING(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsObject_ReturnsFalseForArray() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = new[] { 1, 2 } }), new PartitionKey("pk1")); - var results = await Query("SELECT IS_OBJECT(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsObject_ReturnsFalseForNull() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"val\":null}"), new PartitionKey("pk1")); - var results = await Query("SELECT IS_OBJECT(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsDefined_TrueForNullProperty() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"val\":null}"), new PartitionKey("pk1")); - var results = await Query("SELECT IS_DEFINED(c.val) AS val FROM c"); - results[0]["val"]!.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task IsArray_ReturnsFalseForString() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = "hello" }), new PartitionKey("pk1")); + var results = await Query("SELECT IS_ARRAY(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsBool_ReturnsFalseForNumber() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = 42 }), new PartitionKey("pk1")); + var results = await Query("SELECT IS_BOOL(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsNumber_ReturnsFalseForBoolean() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = true }), new PartitionKey("pk1")); + var results = await Query("SELECT IS_NUMBER(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsString_ReturnsFalseForNumber() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = 42 }), new PartitionKey("pk1")); + var results = await Query("SELECT IS_STRING(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsObject_ReturnsFalseForArray() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", val = new[] { 1, 2 } }), new PartitionKey("pk1")); + var results = await Query("SELECT IS_OBJECT(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsObject_ReturnsFalseForNull() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"val\":null}"), new PartitionKey("pk1")); + var results = await Query("SELECT IS_OBJECT(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsDefined_TrueForNullProperty() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"val\":null}"), new PartitionKey("pk1")); + var results = await Query("SELECT IS_DEFINED(c.val) AS val FROM c"); + results[0]["val"]!.Value().Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2486,48 +2486,48 @@ public async Task IsDefined_TrueForNullProperty() public class SqlFunctionArrayEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task ArraySlice_NegativeStart_FromEnd() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { 1, 2, 3, 4 } }), new PartitionKey("pk1")); - var results = await Query("SELECT ARRAY_SLICE(c.arr, -2) AS val FROM c"); - var arr = (JArray)results[0]["val"]!; - arr.Select(t => t.Value()).Should().BeEquivalentTo([3, 4]); - } - - [Fact] - public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { 1, 2 } }), new PartitionKey("pk1")); - var results = await Query("SELECT ARRAY_SLICE(c.arr, 10) AS val FROM c"); - ((JArray)results[0]["val"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ArrayConcat_EmptyArrays_ReturnsEmpty() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - var results = await Query("SELECT ARRAY_CONCAT([], []) AS val FROM c"); - ((JArray)results[0]["val"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ArrayContains_NullElement_MatchesNull() - { - await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"arr\":[1,null,3]}"), new PartitionKey("pk1")); - var results = await Query("SELECT ARRAY_CONTAINS(c.arr, null) AS val FROM c"); - results[0]["val"]!.Value().Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task ArraySlice_NegativeStart_FromEnd() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { 1, 2, 3, 4 } }), new PartitionKey("pk1")); + var results = await Query("SELECT ARRAY_SLICE(c.arr, -2) AS val FROM c"); + var arr = (JArray)results[0]["val"]!; + arr.Select(t => t.Value()).Should().BeEquivalentTo([3, 4]); + } + + [Fact] + public async Task ArraySlice_StartBeyondLength_ReturnsEmpty() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", arr = new[] { 1, 2 } }), new PartitionKey("pk1")); + var results = await Query("SELECT ARRAY_SLICE(c.arr, 10) AS val FROM c"); + ((JArray)results[0]["val"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ArrayConcat_EmptyArrays_ReturnsEmpty() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + var results = await Query("SELECT ARRAY_CONCAT([], []) AS val FROM c"); + ((JArray)results[0]["val"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ArrayContains_NullElement_MatchesNull() + { + await _container.CreateItemAsync(JObject.Parse("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"arr\":[1,null,3]}"), new PartitionKey("pk1")); + var results = await Query("SELECT ARRAY_CONTAINS(c.arr, null) AS val FROM c"); + results[0]["val"]!.Value().Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2536,37 +2536,37 @@ public async Task ArrayContains_NullElement_MatchesNull() public class SqlFunctionAggregateEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task CountWithFilter_ReturnsFilteredCount() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); - - var results = await Query("SELECT COUNT(1) AS val FROM c WHERE c.active = true"); - results[0]["val"]!.Value().Should().Be(2); - } - - [Fact] - public async Task MaxWithStrings_ReturnsLexMax() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Banana" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Apple" }), new PartitionKey("pk1")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Cherry" }), new PartitionKey("pk1")); - - var results = await Query("SELECT MAX(c.name) AS val FROM c"); - results[0]["val"]!.ToString().Should().Be("Cherry"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task CountWithFilter_ReturnsFilteredCount() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", active = false }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", active = true }), new PartitionKey("pk1")); + + var results = await Query("SELECT COUNT(1) AS val FROM c WHERE c.active = true"); + results[0]["val"]!.Value().Should().Be(2); + } + + [Fact] + public async Task MaxWithStrings_ReturnsLexMax() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Banana" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", partitionKey = "pk1", name = "Apple" }), new PartitionKey("pk1")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", partitionKey = "pk1", name = "Cherry" }), new PartitionKey("pk1")); + + var results = await Query("SELECT MAX(c.name) AS val FROM c"); + results[0]["val"]!.ToString().Should().Be("Cherry"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2575,46 +2575,46 @@ public async Task MaxWithStrings_ReturnsLexMax() public class SqlFunctionConversionEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private async Task> Query(string sql) - { - var iterator = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - private async Task Seed() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); - } - - [Fact] - public async Task ToNumber_InvalidString_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT ToNumber('abc') AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull(); - } - - [Fact] - public async Task ToNumber_NullInput_ReturnsUndefined() - { - await Seed(); - // TONUMBER(null) → undefined per Cosmos semantics — field is omitted - var results = await Query("SELECT ToNumber(null) AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull("undefined values are omitted from projection"); - } - - [Fact] - public async Task ToBoolean_NullInput_ReturnsUndefined() - { - await Seed(); - // TOBOOLEAN(null) returns undefined — field is omitted from result - var results = await Query("SELECT ToBoolean(null) AS val FROM c WHERE c.id = '1'"); - results[0]["val"].Should().BeNull("undefined values are omitted from projection"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private async Task> Query(string sql) + { + var iterator = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iterator.HasMoreResults) results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + private async Task Seed() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", partitionKey = "pk1" }), new PartitionKey("pk1")); + } + + [Fact] + public async Task ToNumber_InvalidString_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT ToNumber('abc') AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull(); + } + + [Fact] + public async Task ToNumber_NullInput_ReturnsUndefined() + { + await Seed(); + // TONUMBER(null) → undefined per Cosmos semantics — field is omitted + var results = await Query("SELECT ToNumber(null) AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull("undefined values are omitted from projection"); + } + + [Fact] + public async Task ToBoolean_NullInput_ReturnsUndefined() + { + await Seed(); + // TOBOOLEAN(null) returns undefined — field is omitted from result + var results = await Query("SELECT ToBoolean(null) AS val FROM c WHERE c.id = '1'"); + results[0]["val"].Should().BeNull("undefined values are omitted from projection"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2623,434 +2623,442 @@ public async Task ToBoolean_NullInput_ReturnsUndefined() public class SqlFunctionDeepDiveTests { - private readonly InMemoryContainer _container = new("sql-dd", "/partitionKey"); - - private async Task Seed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Anderson", Value = 10, IsActive = true, Tags = ["dot", "net"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob Brown", Value = 20, IsActive = false, Tags = ["java"] }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "3", PartitionKey = "pk1", Name = "Charlie", Value = 30, IsActive = true, Tags = ["dot"], - Nested = new NestedObject { Description = "nested value", Score = 3.14 } }, - new PartitionKey("pk1")); - } - - private async Task> Query(string sql) - { - var iter = _container.GetItemQueryIterator(sql); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - return results; - } - - // ── TYPE() Function ── - - [Fact] - public async Task Type_String_ReturnsString() - { - await Seed(); - var results = await Query("SELECT TYPE(c.name) AS t FROM c WHERE c.id = '1'"); - results[0]["t"]!.Value().Should().Be("string"); - } - - [Fact] - public async Task Type_Number_ReturnsNumber() - { - await Seed(); - var results = await Query("SELECT TYPE(c[\"value\"]) AS t FROM c WHERE c.id = '1'"); - results[0]["t"]!.Value().Should().Be("number"); - } - - [Fact] - public async Task Type_Boolean_ReturnsBoolean() - { - await Seed(); - var results = await Query("SELECT TYPE(c.isActive) AS t FROM c WHERE c.id = '1'"); - results[0]["t"]!.Value().Should().Be("boolean"); - } - - [Fact] - public async Task Type_Null_ReturnsNull() - { - await Seed(); - var results = await Query("SELECT TYPE(null) AS t FROM c WHERE c.id = '1'"); - results[0]["t"]!.Value().Should().Be("null"); - } - - [Fact] - public async Task Type_Array_ReturnsArray() - { - await Seed(); - var results = await Query("SELECT TYPE(c.tags) AS t FROM c WHERE c.id = '1'"); - results[0]["t"]!.Value().Should().Be("array"); - } - - [Fact] - public async Task Type_Object_ReturnsObject() - { - await Seed(); - var results = await Query("SELECT TYPE(c.nested) AS t FROM c WHERE c.id = '3'"); - results[0]["t"]!.Value().Should().Be("object"); - } - - [Fact] - public async Task Type_UndefinedProperty_ReturnsUndefined() - { - await Seed(); - // TYPE on undefined returns undefined — field is omitted - var results = await Query("SELECT TYPE(c.nonexistent) AS t FROM c WHERE c.id = '1'"); - results[0]["t"].Should().BeNull("TYPE of undefined should be undefined (omitted)"); - } - - // ── IS_NAN ── - - [Fact] - public async Task IsNan_RegularNumber_ReturnsFalse() - { - await Seed(); - var results = await Query("SELECT IS_NAN(c[\"value\"]) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task IsNan_NonNumber_ReturnsFalse() - { - await Seed(); - var results = await Query("SELECT IS_NAN(c.name) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - // ── COALESCE Edge Cases ── - - [Fact] - public async Task Coalesce_ThreeArgs_ReturnsFirstDefined() - { - await Seed(); - var results = await Query("SELECT COALESCE(c.nonexistent, c.alsoMissing, c.name) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("Alice Anderson"); - } - - [Fact] - public async Task Coalesce_AllUndefined_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT COALESCE(c.a, c.b, c.missing) AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("all args undefined → undefined"); - } - - [Fact] - public async Task Coalesce_NullAndDefined_ReturnsNull() - { - await Seed(); - // null is defined (it's the null value), so it should be returned - var results = await Query("SELECT COALESCE(null, 'fallback') AS r FROM c WHERE c.id = '1'"); - var token = results[0]["r"]; - token.Should().NotBeNull("COALESCE should return null, not undefined"); - token!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task Coalesce_SingleArg_ReturnsThatArg() - { - await Seed(); - var results = await Query("SELECT COALESCE(c.name) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("Alice Anderson"); - } - - // ── CHOOSE Edge Cases ── - - [Fact] - public async Task Choose_FirstElement_ReturnsCorrect() - { - await Seed(); - var results = await Query("SELECT CHOOSE(1, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("a"); - } - - [Fact] - public async Task Choose_LastElement_ReturnsCorrect() - { - await Seed(); - var results = await Query("SELECT CHOOSE(3, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("c"); - } - - [Fact] - public async Task Choose_ZeroIndex_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT CHOOSE(0, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("index 0 is out of bounds → undefined"); - } - - [Fact] - public async Task Choose_OutOfBounds_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT CHOOSE(10, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("index beyond count → undefined"); - } - - // ── String Edge Cases ── - - [Fact] - public async Task Replace_EmptyFind_ReturnsOriginal() - { - await Seed(); - var results = await Query("SELECT REPLACE('hello', '', 'x') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("hello"); - } - - [Fact] - public async Task Left_ZeroCount_ReturnsEmpty() - { - await Seed(); - var results = await Query("SELECT LEFT('hello', 0) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeEmpty(); - } - - [Fact] - public async Task Right_ZeroCount_ReturnsEmpty() - { - await Seed(); - var results = await Query("SELECT RIGHT('hello', 0) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeEmpty(); - } - - [Fact] - public async Task Length_NonString_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT LENGTH(42) AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("LENGTH of non-string → undefined"); - } - - [Fact] - public async Task IndexOf_WithStartPosition_NotFound_ReturnsNegativeOne() - { - await Seed(); - var results = await Query("SELECT INDEX_OF('hello world', 'hello', 5) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(-1); - } - - [Fact] - public async Task RegexMatch_InvalidPattern_ReturnsUndefined() - { - await Seed(); - // An invalid regex returns undefined (field omitted) - var results = await Query("SELECT RegexMatch('hello', '[invalid') AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("invalid regex → undefined"); - } - - [Fact] - public async Task StringSplit_NoDelimiterFound_ReturnsSingleElement() - { - await Seed(); - var results = await Query("SELECT StringSplit('hello', ',') AS r FROM c WHERE c.id = '1'"); - var arr = results[0]["r"] as JArray; - arr.Should().NotBeNull(); - arr!.Count.Should().Be(1); - arr[0]!.Value().Should().Be("hello"); - } - - [Fact] - public async Task EndsWith_CaseSensitive_NoMatch() - { - await Seed(); - var results = await Query("SELECT ENDSWITH('Hello', 'LO') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task EndsWith_CaseInsensitive_Match() - { - await Seed(); - var results = await Query("SELECT ENDSWITH('Hello', 'LO', true) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeTrue(); - } - - // ── Math Edge Cases ── - - [Fact] - public async Task Sqrt_NegativeNumber_ReturnsNaN() - { - await Seed(); - // SQRT(-1) = NaN — should either return undefined or NaN - var results = await Query("SELECT SQRT(-1) AS r FROM c WHERE c.id = '1'"); - var token = results[0]["r"]; - // Either NaN or undefined (omitted) is acceptable - if (token != null) - { - var val = token.Value(); - double.IsNaN(val).Should().BeTrue(); - } - } - - [Fact] - public async Task Log_Zero_ReturnsNegativeInfinity() - { - await Seed(); - var results = await Query("SELECT LOG(0) AS r FROM c WHERE c.id = '1'"); - var token = results[0]["r"]; - if (token != null) - { - var val = token.Value(); - double.IsNegativeInfinity(val).Should().BeTrue(); - } - } - - [Fact] - public async Task Power_LargeExponent_ReturnsInfinity() - { - await Seed(); - var results = await Query("SELECT POWER(10, 309) AS r FROM c WHERE c.id = '1'"); - var token = results[0]["r"]; - if (token != null) - { - var val = token.Value(); - double.IsInfinity(val).Should().BeTrue(); - } - } - - [Fact] - public async Task Round_NegativePrecision_Works() - { - await Seed(); - var results = await Query("SELECT ROUND(1234, -2) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(1200); - } - - // ── Integer Math Edge Cases ── - - [Fact] - public async Task IntMod_NegativeValues_Works() - { - await Seed(); - var results = await Query("SELECT IntMod(-10, 3) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(-1); - } - - [Fact] - public async Task IntAdd_Overflow_Wraps() - { - await Seed(); - // IntAdd should handle large numbers - var results = await Query("SELECT IntAdd(9223372036854775807, 0) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(long.MaxValue); - } - - // ── Conversion Edge Cases ── - - [Fact] - public async Task ToString_BoolInput_ReturnsTrueOrFalse() - { - await Seed(); - var results = await Query("SELECT ToString(true) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("true"); - } - - [Fact] - public async Task ToString_NumberInput_ReturnsNumberString() - { - await Seed(); - var results = await Query("SELECT ToString(42) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("42"); - } - - [Fact] - public async Task ToNumber_BoolInput_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT ToNumber(true) AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("TONUMBER(bool) → undefined"); - } - - [Fact] - public async Task ObjectToArray_NonObject_ReturnsUndefined() - { - await Seed(); - var results = await Query("SELECT ObjectToArray('hello') AS r FROM c WHERE c.id = '1'"); - results[0]["r"].Should().BeNull("ObjectToArray(string) → undefined"); - } - - // ── Aggregate Edge Cases ── - - [Fact] - public async Task Count_EmptyResult_ReturnsZero() - { - await Seed(); - var iter = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); - var items = new List(); - while (iter.HasMoreResults) items.AddRange(await iter.ReadNextAsync()); - items.First().Should().Be(0); - } - - [Fact] - public async Task Avg_SingleItem_ReturnsThatValue() - { - await Seed(); - var iter = _container.GetItemQueryIterator("SELECT VALUE AVG(c[\"value\"]) FROM c WHERE c.id = '1'"); - var items = new List(); - while (iter.HasMoreResults) items.AddRange(await iter.ReadNextAsync()); - items.First().Should().Be(10); - } - - // ── Cross-function Composition ── - - [Fact] - public async Task NestedFunctions_UpperOfConcat() - { - await Seed(); - var results = await Query("SELECT UPPER(CONCAT(c.name, ' test')) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("ALICE ANDERSON TEST"); - } - - [Fact] - public async Task NestedFunctions_LengthOfReplace() - { - await Seed(); - var results = await Query("SELECT LENGTH(REPLACE(c.name, ' ', '')) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(13); // "AliceAnderson" = 13 chars - } - - [Fact] - public async Task ArithmeticInFunctionArgs() - { - await Seed(); - var results = await Query("SELECT ABS(c[\"value\"] - 15) AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be(5); - } - - // ── FullTextContainsAny + Undefined (Bug 7 validation) ── - - [Fact] - public async Task FullTextContainsAny_UndefinedProperty_ReturnsFalse() - { - await Seed(); - // Bug 7: FULLTEXTCONTAINSANY should return false for undefined, not undefined - var results = await Query("SELECT FULLTEXTCONTAINSANY(c.nonexistent, 'hello') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().BeFalse(); - } - - // ── REVERSE non-string (Bug 4 validation) ── - - [Fact] - public async Task Reverse_StringInput_Reversed() - { - await Seed(); - var results = await Query("SELECT REVERSE('hello') AS r FROM c WHERE c.id = '1'"); - results[0]["r"]!.Value().Should().Be("olleh"); - } - - [Fact] - public async Task Reverse_NonString_ReturnsUndefined() - { - await Seed(); - 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"); - } + private readonly InMemoryContainer _container = new("sql-dd", "/partitionKey"); + + private async Task Seed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice Anderson", Value = 10, IsActive = true, Tags = ["dot", "net"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob Brown", Value = 20, IsActive = false, Tags = ["java"] }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument + { + Id = "3", + PartitionKey = "pk1", + Name = "Charlie", + Value = 30, + IsActive = true, + Tags = ["dot"], + Nested = new NestedObject { Description = "nested value", Score = 3.14 } + }, + new PartitionKey("pk1")); + } + + private async Task> Query(string sql) + { + var iter = _container.GetItemQueryIterator(sql); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + return results; + } + + // ── TYPE() Function ── + + [Fact] + public async Task Type_String_ReturnsString() + { + await Seed(); + var results = await Query("SELECT TYPE(c.name) AS t FROM c WHERE c.id = '1'"); + results[0]["t"]!.Value().Should().Be("string"); + } + + [Fact] + public async Task Type_Number_ReturnsNumber() + { + await Seed(); + var results = await Query("SELECT TYPE(c[\"value\"]) AS t FROM c WHERE c.id = '1'"); + results[0]["t"]!.Value().Should().Be("number"); + } + + [Fact] + public async Task Type_Boolean_ReturnsBoolean() + { + await Seed(); + var results = await Query("SELECT TYPE(c.isActive) AS t FROM c WHERE c.id = '1'"); + results[0]["t"]!.Value().Should().Be("boolean"); + } + + [Fact] + public async Task Type_Null_ReturnsNull() + { + await Seed(); + var results = await Query("SELECT TYPE(null) AS t FROM c WHERE c.id = '1'"); + results[0]["t"]!.Value().Should().Be("null"); + } + + [Fact] + public async Task Type_Array_ReturnsArray() + { + await Seed(); + var results = await Query("SELECT TYPE(c.tags) AS t FROM c WHERE c.id = '1'"); + results[0]["t"]!.Value().Should().Be("array"); + } + + [Fact] + public async Task Type_Object_ReturnsObject() + { + await Seed(); + var results = await Query("SELECT TYPE(c.nested) AS t FROM c WHERE c.id = '3'"); + results[0]["t"]!.Value().Should().Be("object"); + } + + [Fact] + public async Task Type_UndefinedProperty_ReturnsUndefined() + { + await Seed(); + // TYPE on undefined returns undefined — field is omitted + var results = await Query("SELECT TYPE(c.nonexistent) AS t FROM c WHERE c.id = '1'"); + results[0]["t"].Should().BeNull("TYPE of undefined should be undefined (omitted)"); + } + + // ── IS_NAN ── + + [Fact] + public async Task IsNan_RegularNumber_ReturnsFalse() + { + await Seed(); + var results = await Query("SELECT IS_NAN(c[\"value\"]) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task IsNan_NonNumber_ReturnsFalse() + { + await Seed(); + var results = await Query("SELECT IS_NAN(c.name) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + // ── COALESCE Edge Cases ── + + [Fact] + public async Task Coalesce_ThreeArgs_ReturnsFirstDefined() + { + await Seed(); + var results = await Query("SELECT COALESCE(c.nonexistent, c.alsoMissing, c.name) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task Coalesce_AllUndefined_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT COALESCE(c.a, c.b, c.missing) AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("all args undefined → undefined"); + } + + [Fact] + public async Task Coalesce_NullAndDefined_ReturnsNull() + { + await Seed(); + // null is defined (it's the null value), so it should be returned + var results = await Query("SELECT COALESCE(null, 'fallback') AS r FROM c WHERE c.id = '1'"); + var token = results[0]["r"]; + token.Should().NotBeNull("COALESCE should return null, not undefined"); + token!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task Coalesce_SingleArg_ReturnsThatArg() + { + await Seed(); + var results = await Query("SELECT COALESCE(c.name) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("Alice Anderson"); + } + + // ── CHOOSE Edge Cases ── + + [Fact] + public async Task Choose_FirstElement_ReturnsCorrect() + { + await Seed(); + var results = await Query("SELECT CHOOSE(1, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("a"); + } + + [Fact] + public async Task Choose_LastElement_ReturnsCorrect() + { + await Seed(); + var results = await Query("SELECT CHOOSE(3, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("c"); + } + + [Fact] + public async Task Choose_ZeroIndex_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT CHOOSE(0, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("index 0 is out of bounds → undefined"); + } + + [Fact] + public async Task Choose_OutOfBounds_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT CHOOSE(10, 'a', 'b', 'c') AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("index beyond count → undefined"); + } + + // ── String Edge Cases ── + + [Fact] + public async Task Replace_EmptyFind_ReturnsOriginal() + { + await Seed(); + var results = await Query("SELECT REPLACE('hello', '', 'x') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("hello"); + } + + [Fact] + public async Task Left_ZeroCount_ReturnsEmpty() + { + await Seed(); + var results = await Query("SELECT LEFT('hello', 0) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeEmpty(); + } + + [Fact] + public async Task Right_ZeroCount_ReturnsEmpty() + { + await Seed(); + var results = await Query("SELECT RIGHT('hello', 0) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeEmpty(); + } + + [Fact] + public async Task Length_NonString_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT LENGTH(42) AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("LENGTH of non-string → undefined"); + } + + [Fact] + public async Task IndexOf_WithStartPosition_NotFound_ReturnsNegativeOne() + { + await Seed(); + var results = await Query("SELECT INDEX_OF('hello world', 'hello', 5) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(-1); + } + + [Fact] + public async Task RegexMatch_InvalidPattern_ReturnsUndefined() + { + await Seed(); + // An invalid regex returns undefined (field omitted) + var results = await Query("SELECT RegexMatch('hello', '[invalid') AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("invalid regex → undefined"); + } + + [Fact] + public async Task StringSplit_NoDelimiterFound_ReturnsSingleElement() + { + await Seed(); + var results = await Query("SELECT StringSplit('hello', ',') AS r FROM c WHERE c.id = '1'"); + var arr = results[0]["r"] as JArray; + arr.Should().NotBeNull(); + arr!.Count.Should().Be(1); + arr[0]!.Value().Should().Be("hello"); + } + + [Fact] + public async Task EndsWith_CaseSensitive_NoMatch() + { + await Seed(); + var results = await Query("SELECT ENDSWITH('Hello', 'LO') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task EndsWith_CaseInsensitive_Match() + { + await Seed(); + var results = await Query("SELECT ENDSWITH('Hello', 'LO', true) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeTrue(); + } + + // ── Math Edge Cases ── + + [Fact] + public async Task Sqrt_NegativeNumber_ReturnsNaN() + { + await Seed(); + // SQRT(-1) = NaN — should either return undefined or NaN + var results = await Query("SELECT SQRT(-1) AS r FROM c WHERE c.id = '1'"); + var token = results[0]["r"]; + // Either NaN or undefined (omitted) is acceptable + if (token != null) + { + var val = token.Value(); + double.IsNaN(val).Should().BeTrue(); + } + } + + [Fact] + public async Task Log_Zero_ReturnsNegativeInfinity() + { + await Seed(); + var results = await Query("SELECT LOG(0) AS r FROM c WHERE c.id = '1'"); + var token = results[0]["r"]; + if (token != null) + { + var val = token.Value(); + double.IsNegativeInfinity(val).Should().BeTrue(); + } + } + + [Fact] + public async Task Power_LargeExponent_ReturnsInfinity() + { + await Seed(); + var results = await Query("SELECT POWER(10, 309) AS r FROM c WHERE c.id = '1'"); + var token = results[0]["r"]; + if (token != null) + { + var val = token.Value(); + double.IsInfinity(val).Should().BeTrue(); + } + } + + [Fact] + public async Task Round_NegativePrecision_Works() + { + await Seed(); + var results = await Query("SELECT ROUND(1234, -2) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(1200); + } + + // ── Integer Math Edge Cases ── + + [Fact] + public async Task IntMod_NegativeValues_Works() + { + await Seed(); + var results = await Query("SELECT IntMod(-10, 3) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(-1); + } + + [Fact] + public async Task IntAdd_Overflow_Wraps() + { + await Seed(); + // IntAdd should handle large numbers + var results = await Query("SELECT IntAdd(9223372036854775807, 0) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(long.MaxValue); + } + + // ── Conversion Edge Cases ── + + [Fact] + public async Task ToString_BoolInput_ReturnsTrueOrFalse() + { + await Seed(); + var results = await Query("SELECT ToString(true) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("true"); + } + + [Fact] + public async Task ToString_NumberInput_ReturnsNumberString() + { + await Seed(); + var results = await Query("SELECT ToString(42) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("42"); + } + + [Fact] + public async Task ToNumber_BoolInput_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT ToNumber(true) AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("TONUMBER(bool) → undefined"); + } + + [Fact] + public async Task ObjectToArray_NonObject_ReturnsUndefined() + { + await Seed(); + var results = await Query("SELECT ObjectToArray('hello') AS r FROM c WHERE c.id = '1'"); + results[0]["r"].Should().BeNull("ObjectToArray(string) → undefined"); + } + + // ── Aggregate Edge Cases ── + + [Fact] + public async Task Count_EmptyResult_ReturnsZero() + { + await Seed(); + var iter = _container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c WHERE c.id = 'nonexistent'"); + var items = new List(); + while (iter.HasMoreResults) items.AddRange(await iter.ReadNextAsync()); + items.First().Should().Be(0); + } + + [Fact] + public async Task Avg_SingleItem_ReturnsThatValue() + { + await Seed(); + var iter = _container.GetItemQueryIterator("SELECT VALUE AVG(c[\"value\"]) FROM c WHERE c.id = '1'"); + var items = new List(); + while (iter.HasMoreResults) items.AddRange(await iter.ReadNextAsync()); + items.First().Should().Be(10); + } + + // ── Cross-function Composition ── + + [Fact] + public async Task NestedFunctions_UpperOfConcat() + { + await Seed(); + var results = await Query("SELECT UPPER(CONCAT(c.name, ' test')) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("ALICE ANDERSON TEST"); + } + + [Fact] + public async Task NestedFunctions_LengthOfReplace() + { + await Seed(); + var results = await Query("SELECT LENGTH(REPLACE(c.name, ' ', '')) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(13); // "AliceAnderson" = 13 chars + } + + [Fact] + public async Task ArithmeticInFunctionArgs() + { + await Seed(); + var results = await Query("SELECT ABS(c[\"value\"] - 15) AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be(5); + } + + // ── FullTextContainsAny + Undefined (Bug 7 validation) ── + + [Fact] + public async Task FullTextContainsAny_UndefinedProperty_ReturnsFalse() + { + await Seed(); + // Bug 7: FULLTEXTCONTAINSANY should return false for undefined, not undefined + var results = await Query("SELECT FULLTEXTCONTAINSANY(c.nonexistent, 'hello') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().BeFalse(); + } + + // ── REVERSE non-string (Bug 4 validation) ── + + [Fact] + public async Task Reverse_StringInput_Reversed() + { + await Seed(); + var results = await Query("SELECT REVERSE('hello') AS r FROM c WHERE c.id = '1'"); + results[0]["r"]!.Value().Should().Be("olleh"); + } + + [Fact] + public async Task Reverse_NonString_ReturnsUndefined() + { + await Seed(); + 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"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StatePersistenceTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StatePersistenceTests.cs index f7236e5..1715930 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StatePersistenceTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StatePersistenceTests.cs @@ -1,215 +1,215 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; -using System.Net; -using System.Text; namespace CosmosDB.InMemoryEmulator.Tests; public class StatePersistenceTests { - [Fact] - public void ExportState_EmptyContainer_ReturnsEmptyJson() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - var json = container.ExportState(); - - var parsed = JObject.Parse(json); - parsed["items"]!.Should().BeOfType(); - ((JArray)parsed["items"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ExportState_WithItems_SerializesAllItems() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var json = container.ExportState(); - - var parsed = JObject.Parse(json); - var items = (JArray)parsed["items"]!; - items.Should().HaveCount(2); - } - - [Fact] - public async Task ImportState_RestoresItems() - { - var source = new InMemoryContainer("source-container", "/partitionKey"); - await source.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await source.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var exportedJson = source.ExportState(); - - var target = new InMemoryContainer("target-container", "/partitionKey"); - target.ImportState(exportedJson); - - target.ItemCount.Should().Be(2); - var alice = await target.ReadItemAsync("1", new PartitionKey("pk1")); - alice.Resource.Name.Should().Be("Alice"); - } - - [Fact] - public async Task ImportState_ClearsExistingDataBeforeImporting() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Existing" }, - new PartitionKey("pk1")); - - var stateJson = """{"items":[{"id":"new","partitionKey":"pk1","name":"New","value":0,"isActive":true,"tags":[]}]}"""; - container.ImportState(stateJson); - - container.ItemCount.Should().Be(1); - var result = await container.ReadItemAsync("new", new PartitionKey("pk1")); - result.Resource.Name.Should().Be("New"); - } - - [Fact] - public async Task ExportState_ToFile_And_ImportState_FromFile_RoundTrips() - { - var tempFile = Path.GetTempFileName(); - try - { - var source = new InMemoryContainer("source", "/partitionKey"); - await source.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, Tags = ["a", "b"] }, - new PartitionKey("pk1")); - - source.ExportStateToFile(tempFile); - - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportStateFromFile(tempFile); - - target.ItemCount.Should().Be(1); - var item = await target.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("Alice"); - item.Resource.Value.Should().Be(42); - item.Resource.Tags.Should().BeEquivalentTo(["a", "b"]); - } - finally - { - File.Delete(tempFile); - } - } - - [Fact] - public async Task ExportState_PreservesPartitionKeyIsolation() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Different Alice" }, - new PartitionKey("pk2")); - - var json = container.ExportState(); - - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(json); - - target.ItemCount.Should().Be(2); - var pk1Item = await target.ReadItemAsync("1", new PartitionKey("pk1")); - pk1Item.Resource.Name.Should().Be("Alice"); - var pk2Item = await target.ReadItemAsync("1", new PartitionKey("pk2")); - pk2Item.Resource.Name.Should().Be("Different Alice"); - } - - [Fact] - public async Task ImportState_WithNestedObjects_PreservesStructure() - { - var source = new InMemoryContainer("source", "/partitionKey"); - await source.CreateItemAsync(new TestDocument - { - Id = "1", - PartitionKey = "pk1", - Name = "Test", - Nested = new NestedObject { Description = "nested value", Score = 3.14 } - }, new PartitionKey("pk1")); - - var json = source.ExportState(); - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(json); - - var item = await target.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource.Nested.Should().NotBeNull(); - item.Resource.Nested!.Description.Should().Be("nested value"); - item.Resource.Nested!.Score.Should().Be(3.14); - } - - [Fact] - public void ImportState_WithInvalidJson_ThrowsException() - { - var container = new InMemoryContainer("test-container", "/partitionKey"); - - var action = () => container.ImportState("not valid json"); - - action.Should().Throw(); - } - - [Fact] - public async Task ExportState_ItemsAreQueryableAfterImport() - { - var source = new InMemoryContainer("source", "/partitionKey"); - await source.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await source.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - var json = source.ExportState(); - var target = new InMemoryContainer("target", "/partitionKey"); - target.ImportState(json); - - var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.value > 15"); - var iterator = target.GetItemQueryIterator(queryDef); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - results.AddRange(response); - } - - results.Should().HaveCount(1); - results[0].Name.Should().Be("Bob"); - } - - [Fact] - public async Task ExportState_ImportState_RoundTrip() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, - new PartitionKey("pk1")); - - var state = container.ExportState(); - state.Should().NotBeNullOrEmpty(); - - var newContainer = new InMemoryContainer("test", "/partitionKey"); - newContainer.ImportState(state); - - var read = await newContainer.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Alice"); - - newContainer.ItemCount.Should().Be(2); - } + [Fact] + public void ExportState_EmptyContainer_ReturnsEmptyJson() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + var json = container.ExportState(); + + var parsed = JObject.Parse(json); + parsed["items"]!.Should().BeOfType(); + ((JArray)parsed["items"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ExportState_WithItems_SerializesAllItems() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var json = container.ExportState(); + + var parsed = JObject.Parse(json); + var items = (JArray)parsed["items"]!; + items.Should().HaveCount(2); + } + + [Fact] + public async Task ImportState_RestoresItems() + { + var source = new InMemoryContainer("source-container", "/partitionKey"); + await source.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await source.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var exportedJson = source.ExportState(); + + var target = new InMemoryContainer("target-container", "/partitionKey"); + target.ImportState(exportedJson); + + target.ItemCount.Should().Be(2); + var alice = await target.ReadItemAsync("1", new PartitionKey("pk1")); + alice.Resource.Name.Should().Be("Alice"); + } + + [Fact] + public async Task ImportState_ClearsExistingDataBeforeImporting() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Existing" }, + new PartitionKey("pk1")); + + var stateJson = """{"items":[{"id":"new","partitionKey":"pk1","name":"New","value":0,"isActive":true,"tags":[]}]}"""; + container.ImportState(stateJson); + + container.ItemCount.Should().Be(1); + var result = await container.ReadItemAsync("new", new PartitionKey("pk1")); + result.Resource.Name.Should().Be("New"); + } + + [Fact] + public async Task ExportState_ToFile_And_ImportState_FromFile_RoundTrips() + { + var tempFile = Path.GetTempFileName(); + try + { + var source = new InMemoryContainer("source", "/partitionKey"); + await source.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 42, Tags = ["a", "b"] }, + new PartitionKey("pk1")); + + source.ExportStateToFile(tempFile); + + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportStateFromFile(tempFile); + + target.ItemCount.Should().Be(1); + var item = await target.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("Alice"); + item.Resource.Value.Should().Be(42); + item.Resource.Tags.Should().BeEquivalentTo(["a", "b"]); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task ExportState_PreservesPartitionKeyIsolation() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk2", Name = "Different Alice" }, + new PartitionKey("pk2")); + + var json = container.ExportState(); + + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(json); + + target.ItemCount.Should().Be(2); + var pk1Item = await target.ReadItemAsync("1", new PartitionKey("pk1")); + pk1Item.Resource.Name.Should().Be("Alice"); + var pk2Item = await target.ReadItemAsync("1", new PartitionKey("pk2")); + pk2Item.Resource.Name.Should().Be("Different Alice"); + } + + [Fact] + public async Task ImportState_WithNestedObjects_PreservesStructure() + { + var source = new InMemoryContainer("source", "/partitionKey"); + await source.CreateItemAsync(new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "Test", + Nested = new NestedObject { Description = "nested value", Score = 3.14 } + }, new PartitionKey("pk1")); + + var json = source.ExportState(); + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(json); + + var item = await target.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource.Nested.Should().NotBeNull(); + item.Resource.Nested!.Description.Should().Be("nested value"); + item.Resource.Nested!.Score.Should().Be(3.14); + } + + [Fact] + public void ImportState_WithInvalidJson_ThrowsException() + { + var container = new InMemoryContainer("test-container", "/partitionKey"); + + var action = () => container.ImportState("not valid json"); + + action.Should().Throw(); + } + + [Fact] + public async Task ExportState_ItemsAreQueryableAfterImport() + { + var source = new InMemoryContainer("source", "/partitionKey"); + await source.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await source.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + var json = source.ExportState(); + var target = new InMemoryContainer("target", "/partitionKey"); + target.ImportState(json); + + var queryDef = new QueryDefinition("SELECT * FROM c WHERE c.value > 15"); + var iterator = target.GetItemQueryIterator(queryDef); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + results.AddRange(response); + } + + results.Should().HaveCount(1); + results[0].Name.Should().Be("Bob"); + } + + [Fact] + public async Task ExportState_ImportState_RoundTrip() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }, + new PartitionKey("pk1")); + + var state = container.ExportState(); + state.Should().NotBeNullOrEmpty(); + + var newContainer = new InMemoryContainer("test", "/partitionKey"); + newContainer.ImportState(state); + + var read = await newContainer.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Alice"); + + newContainer.ItemCount.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -218,258 +218,258 @@ await container.CreateItemAsync( public class ExportStateEdgeCaseTests { - [Fact] - public async Task ExportState_SingleItem_ProducesValidJson() - { - var container = new InMemoryContainer("export-single", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var json = container.ExportState(); - var parsed = JObject.Parse(json); - var items = (JArray)parsed["items"]!; - items.Should().HaveCount(1); - items[0]["id"]!.ToString().Should().Be("1"); - } - - [Fact] - public async Task ExportState_IncludesSystemProperties() - { - var container = new InMemoryContainer("export-sys", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var json = container.ExportState(); - var items = (JArray)JObject.Parse(json)["items"]!; - var item = items[0]; - - item["_etag"].Should().NotBeNull(); - item["_ts"].Should().NotBeNull(); - } - - [Fact] - public async Task ExportState_LargeNumberOfItems_AllSerialized() - { - var container = new InMemoryContainer("export-large", "/pk"); - for (var i = 0; i < 500; i++) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var items = (JArray)JObject.Parse(json)["items"]!; - items.Should().HaveCount(500); - } - - [Fact] - public async Task ExportState_WithSpecialCharacters_RoundTrips() - { - var container = new InMemoryContainer("export-special", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "quotes\"and\\backslash\nnewline\ttab" }), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["text"]!.ToString().Should().Be("quotes\"and\\backslash\nnewline\ttab"); - } - - [Fact] - public async Task ExportState_WithNullValues_PreservesNulls() - { - var container = new InMemoryContainer("export-null", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","field":null}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var items = (JArray)JObject.Parse(json)["items"]!; - items[0]["field"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task ExportState_WithBooleanValues_PreservesType() - { - var container = new InMemoryContainer("export-bool", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","active":true,"deleted":false}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var item = ((JArray)JObject.Parse(json)["items"]!)[0]; - item["active"]!.Type.Should().Be(JTokenType.Boolean); - item["active"]!.Value().Should().BeTrue(); - item["deleted"]!.Value().Should().BeFalse(); - } - - [Fact] - public async Task ExportState_WithArrays_PreservesArrays() - { - var container = new InMemoryContainer("export-arrays", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","tags":["a","b"],"empty":[],"nested":[[1,2],[3]]}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["tags"]!.Should().BeOfType(); - ((JArray)result.Resource["tags"]!).Should().HaveCount(2); - ((JArray)result.Resource["empty"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ExportState_WithDeeplyNestedObjects_PreservesAll() - { - var container = new InMemoryContainer("export-deep", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","a":{"b":{"c":{"d":{"e":"deep"}}}}}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource.SelectToken("a.b.c.d.e")!.ToString().Should().Be("deep"); - } - - [Fact] - public async Task ExportState_OutputIsIndentedJson() - { - var container = new InMemoryContainer("export-format", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - json.Should().Contain("\n", "ExportState should produce indented (pretty-printed) JSON"); - } - - [Fact] - public async Task ExportState_CalledTwice_ProducesSameOutput() - { - var container = new InMemoryContainer("export-idem", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test"}""")), - new PartitionKey("a")); - - var json1 = container.ExportState(); - var json2 = container.ExportState(); - json1.Should().Be(json2); - } - - [Fact] - public async Task ExportState_WithNumericTypes_PreservesPrecision() - { - var container = new InMemoryContainer("export-num", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","intVal":42,"longVal":9999999999,"doubleVal":3.14159265358979}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["intVal"]!.Value().Should().Be(42); - result.Resource["longVal"]!.Value().Should().Be(9999999999); - result.Resource["doubleVal"]!.Value().Should().BeApproximately(3.14159265358979, 0.000001); - } - - [Fact] - public async Task ExportState_WithDateTimeValues_PreservesAsStrings() - { - var container = new InMemoryContainer("export-dt", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","created":"2024-01-15T10:30:00Z"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["created"]!.ToString().Should().Contain("2024-01-15"); - } - - [Fact] - public async Task ExportState_WithEmptyStringValues_PreservesThem() - { - var container = new InMemoryContainer("export-emptystr", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","emptyField":"","nullField":null}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var items = (JArray)JObject.Parse(json)["items"]!; - items[0]["emptyField"]!.Type.Should().Be(JTokenType.String); - items[0]["emptyField"]!.ToString().Should().BeEmpty(); - items[0]["nullField"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task ExportState_WithUnicodeAndEmoji_RoundTrips() - { - var container = new InMemoryContainer("export-utf", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - """{"id":"1","pk":"a","text":"\u4e16\u754c\ud83d\ude80"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var target = new InMemoryContainer("target", "/pk"); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["text"]!.ToString().Should().Contain("\u4e16\u754c"); - } - - [Fact] - public async Task ExportState_WithMaxNestedDepth_DoesNotStackOverflow() - { - var container = new InMemoryContainer("export-deepnest", "/pk"); - // Build 50-level deep JSON - var deep = new JObject { ["id"] = "1", ["pk"] = "a" }; - var current = deep; - for (var i = 0; i < 50; i++) - { - var child = new JObject { ["level"] = i }; - current["nested"] = child; - current = child; - } - - await container.CreateItemAsync(deep, new PartitionKey("a")); - - var act = () => container.ExportState(); - act.Should().NotThrow(); - } - - [Fact] - public async Task ExportState_ContainerMetadataNotIncluded() - { - var container = new InMemoryContainer("export-nometa", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var parsed = JObject.Parse(json); - - parsed.Properties().Select(p => p.Name).Should().BeEquivalentTo(["items"]); - } + [Fact] + public async Task ExportState_SingleItem_ProducesValidJson() + { + var container = new InMemoryContainer("export-single", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var json = container.ExportState(); + var parsed = JObject.Parse(json); + var items = (JArray)parsed["items"]!; + items.Should().HaveCount(1); + items[0]["id"]!.ToString().Should().Be("1"); + } + + [Fact] + public async Task ExportState_IncludesSystemProperties() + { + var container = new InMemoryContainer("export-sys", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var json = container.ExportState(); + var items = (JArray)JObject.Parse(json)["items"]!; + var item = items[0]; + + item["_etag"].Should().NotBeNull(); + item["_ts"].Should().NotBeNull(); + } + + [Fact] + public async Task ExportState_LargeNumberOfItems_AllSerialized() + { + var container = new InMemoryContainer("export-large", "/pk"); + for (var i = 0; i < 500; i++) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var items = (JArray)JObject.Parse(json)["items"]!; + items.Should().HaveCount(500); + } + + [Fact] + public async Task ExportState_WithSpecialCharacters_RoundTrips() + { + var container = new InMemoryContainer("export-special", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "quotes\"and\\backslash\nnewline\ttab" }), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["text"]!.ToString().Should().Be("quotes\"and\\backslash\nnewline\ttab"); + } + + [Fact] + public async Task ExportState_WithNullValues_PreservesNulls() + { + var container = new InMemoryContainer("export-null", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","field":null}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var items = (JArray)JObject.Parse(json)["items"]!; + items[0]["field"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task ExportState_WithBooleanValues_PreservesType() + { + var container = new InMemoryContainer("export-bool", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","active":true,"deleted":false}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var item = ((JArray)JObject.Parse(json)["items"]!)[0]; + item["active"]!.Type.Should().Be(JTokenType.Boolean); + item["active"]!.Value().Should().BeTrue(); + item["deleted"]!.Value().Should().BeFalse(); + } + + [Fact] + public async Task ExportState_WithArrays_PreservesArrays() + { + var container = new InMemoryContainer("export-arrays", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","tags":["a","b"],"empty":[],"nested":[[1,2],[3]]}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["tags"]!.Should().BeOfType(); + ((JArray)result.Resource["tags"]!).Should().HaveCount(2); + ((JArray)result.Resource["empty"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ExportState_WithDeeplyNestedObjects_PreservesAll() + { + var container = new InMemoryContainer("export-deep", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","a":{"b":{"c":{"d":{"e":"deep"}}}}}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource.SelectToken("a.b.c.d.e")!.ToString().Should().Be("deep"); + } + + [Fact] + public async Task ExportState_OutputIsIndentedJson() + { + var container = new InMemoryContainer("export-format", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + json.Should().Contain("\n", "ExportState should produce indented (pretty-printed) JSON"); + } + + [Fact] + public async Task ExportState_CalledTwice_ProducesSameOutput() + { + var container = new InMemoryContainer("export-idem", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"test"}""")), + new PartitionKey("a")); + + var json1 = container.ExportState(); + var json2 = container.ExportState(); + json1.Should().Be(json2); + } + + [Fact] + public async Task ExportState_WithNumericTypes_PreservesPrecision() + { + var container = new InMemoryContainer("export-num", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","intVal":42,"longVal":9999999999,"doubleVal":3.14159265358979}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["intVal"]!.Value().Should().Be(42); + result.Resource["longVal"]!.Value().Should().Be(9999999999); + result.Resource["doubleVal"]!.Value().Should().BeApproximately(3.14159265358979, 0.000001); + } + + [Fact] + public async Task ExportState_WithDateTimeValues_PreservesAsStrings() + { + var container = new InMemoryContainer("export-dt", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","created":"2024-01-15T10:30:00Z"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["created"]!.ToString().Should().Contain("2024-01-15"); + } + + [Fact] + public async Task ExportState_WithEmptyStringValues_PreservesThem() + { + var container = new InMemoryContainer("export-emptystr", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","emptyField":"","nullField":null}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var items = (JArray)JObject.Parse(json)["items"]!; + items[0]["emptyField"]!.Type.Should().Be(JTokenType.String); + items[0]["emptyField"]!.ToString().Should().BeEmpty(); + items[0]["nullField"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task ExportState_WithUnicodeAndEmoji_RoundTrips() + { + var container = new InMemoryContainer("export-utf", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + """{"id":"1","pk":"a","text":"\u4e16\u754c\ud83d\ude80"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var target = new InMemoryContainer("target", "/pk"); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["text"]!.ToString().Should().Contain("\u4e16\u754c"); + } + + [Fact] + public async Task ExportState_WithMaxNestedDepth_DoesNotStackOverflow() + { + var container = new InMemoryContainer("export-deepnest", "/pk"); + // Build 50-level deep JSON + var deep = new JObject { ["id"] = "1", ["pk"] = "a" }; + var current = deep; + for (var i = 0; i < 50; i++) + { + var child = new JObject { ["level"] = i }; + current["nested"] = child; + current = child; + } + + await container.CreateItemAsync(deep, new PartitionKey("a")); + + var act = () => container.ExportState(); + act.Should().NotThrow(); + } + + [Fact] + public async Task ExportState_ContainerMetadataNotIncluded() + { + var container = new InMemoryContainer("export-nometa", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var parsed = JObject.Parse(json); + + parsed.Properties().Select(p => p.Name).Should().BeEquivalentTo(["items"]); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -478,225 +478,225 @@ await container.CreateItemStreamAsync( public class ImportStateEdgeCaseTests { - [Fact] - public void ImportState_EmptyItemsArray_ResultsInEmptyContainer() - { - var container = new InMemoryContainer("import-empty", "/pk"); - container.ImportState("""{"items":[]}"""); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task ImportState_MissingItemsKey_PreservesExistingData() - { - var container = new InMemoryContainer("import-nokey", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ImportState("""{"foo":"bar"}"""); - container.ItemCount.Should().Be(1, "missing 'items' key means import is a no-op"); - } - - [Fact] - public async Task ImportState_EmptyJsonObject_PreservesExistingData() - { - var container = new InMemoryContainer("import-obj", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ImportState("{}"); - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task ImportState_GeneratesNewETags() - { - var source = new InMemoryContainer("import-etag-src", "/partitionKey"); - await source.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var sourceRead = await source.ReadItemAsync("1", new PartitionKey("pk1")); - var sourceEtag = sourceRead.ETag; - - var target = new InMemoryContainer("import-etag-tgt", "/partitionKey"); - target.ImportState(source.ExportState()); - - var targetRead = await target.ReadItemAsync("1", new PartitionKey("pk1")); - targetRead.ETag.Should().NotBe(sourceEtag, "import generates new etags"); - } - - [Fact] - public void ImportState_NullJson_ThrowsException() - { - var container = new InMemoryContainer("import-null", "/pk"); - var act = () => container.ImportState(null!); - act.Should().Throw(); - } - - [Fact] - public void ImportState_EmptyString_ThrowsException() - { - var container = new InMemoryContainer("import-empty-str", "/pk"); - var act = () => container.ImportState(""); - act.Should().Throw(); - } - - [Fact] - public async Task ImportState_DuplicateIds_SamePartitionKey_LastWins() - { - var container = new InMemoryContainer("import-dup", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"first"},{"id":"1","pk":"a","name":"last"}]}"""); - - container.ItemCount.Should().Be(1); - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("last"); - } - - [Fact] - public async Task ImportState_DuplicateIds_DifferentPartitionKeys_BothStored() - { - var container = new InMemoryContainer("import-dup-pk", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"A"},{"id":"1","pk":"b","name":"B"}]}"""); - - container.ItemCount.Should().Be(2); - var a = await container.ReadItemAsync("1", new PartitionKey("a")); - a.Resource["name"]!.ToString().Should().Be("A"); - var b = await container.ReadItemAsync("1", new PartitionKey("b")); - b.Resource["name"]!.ToString().Should().Be("B"); - } - - [Fact] - public async Task ImportState_CalledMultipleTimes_OnlyLastImportSurvives() - { - var container = new InMemoryContainer("import-multi", "/pk"); - container.ImportState("""{"items":[{"id":"A","pk":"a"}]}"""); - container.ImportState("""{"items":[{"id":"B","pk":"b"}]}"""); - - container.ItemCount.Should().Be(1); - var result = await container.ReadItemAsync("B", new PartitionKey("b")); - result.Resource["id"]!.ToString().Should().Be("B"); - - var act = () => container.ReadItemAsync("A", new PartitionKey("a")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ImportState_WithExtraJsonProperties_PreservesThem() - { - var container = new InMemoryContainer("import-extra", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","customField":"preserved","nestedCustom":{"x":1}}]}"""); - - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["customField"]!.ToString().Should().Be("preserved"); - result.Resource["nestedCustom"]!["x"]!.Value().Should().Be(1); - } - - [Fact] - public void ImportState_ItemsMissingId_ThrowsInvalidOperation() - { - var container = new InMemoryContainer("import-noid", "/pk"); - var act = () => container.ImportState("""{"items":[{"pk":"a","name":"no-id-item"}]}"""); - - act.Should().Throw() - .WithMessage("*'id'*"); - } - - [Fact] - public async Task ImportState_ItemMissingPartitionKeyField_Behavior() - { - var container = new InMemoryContainer("import-nopk", "/pk"); - container.ImportState("""{"items":[{"id":"1","name":"no-pk"}]}"""); - - container.ItemCount.Should().Be(1); - // When PK field is missing, item is stored with null partition key - var result = await container.ReadItemAsync("1", PartitionKey.None); - result.Resource["name"]!.ToString().Should().Be("no-pk"); - } - - [Fact] - public async Task ImportState_GeneratesNewTimestamps() - { - var source = new InMemoryContainer("import-ts-src", "/pk"); - await source.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var export = source.ExportState(); - var sourceTs = (long)((JArray)JObject.Parse(export)["items"]!)[0]["_ts"]!; - - await Task.Delay(1100); // Wait so timestamp differs - - var target = new InMemoryContainer("import-ts-tgt", "/pk"); - target.ImportState(export); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(sourceTs); - } - - [Fact] - public async Task ImportState_WithItemsHavingSystemProperties_OverwritesThem() - { - var container = new InMemoryContainer("import-sysprop", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","_etag":"\"old-etag\"","_ts":1000}]}"""); - - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["_etag"]!.ToString().Should().NotBe("\"old-etag\"", - "import regenerates ETags"); - result.Resource["_ts"]!.Value().Should().BeGreaterThan(1000, - "import regenerates timestamps"); - } - - [Fact] - public void ImportState_ValidJsonButArray_Throws() - { - var container = new InMemoryContainer("import-arr", "/pk"); - var act = () => container.ImportState("""[{"id":"1"}]"""); - act.Should().Throw(); - } - - [Fact] - public void ImportState_ValidJsonButPrimitive_Throws() - { - var container = new InMemoryContainer("import-prim", "/pk"); - var act = () => container.ImportState("123"); - act.Should().Throw(); - } - - [Fact] - public async Task ImportState_VeryLargePayload_1000Items_Succeeds() - { - var items = new JArray(); - for (var i = 0; i < 1000; i++) - items.Add(new JObject { ["id"] = $"{i}", ["pk"] = "a", ["name"] = $"Item{i}" }); - - var state = new JObject { ["items"] = items }; - var container = new InMemoryContainer("import-large", "/pk"); - container.ImportState(state.ToString()); - - container.ItemCount.Should().Be(1000); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - all.Should().HaveCount(1000); - } - - [Fact] - public void ImportState_WithWhitespaceOnlyJson_Throws() - { - var container = new InMemoryContainer("import-ws", "/pk"); - var act = () => container.ImportState(" "); - act.Should().Throw(); - } - - [Fact] - public async Task ImportState_ItemsWithDifferentSchemas_AllPreserved() - { - var container = new InMemoryContainer("import-schema", "/pk"); - container.ImportState(""" + [Fact] + public void ImportState_EmptyItemsArray_ResultsInEmptyContainer() + { + var container = new InMemoryContainer("import-empty", "/pk"); + container.ImportState("""{"items":[]}"""); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task ImportState_MissingItemsKey_PreservesExistingData() + { + var container = new InMemoryContainer("import-nokey", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ImportState("""{"foo":"bar"}"""); + container.ItemCount.Should().Be(1, "missing 'items' key means import is a no-op"); + } + + [Fact] + public async Task ImportState_EmptyJsonObject_PreservesExistingData() + { + var container = new InMemoryContainer("import-obj", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ImportState("{}"); + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task ImportState_GeneratesNewETags() + { + var source = new InMemoryContainer("import-etag-src", "/partitionKey"); + await source.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var sourceRead = await source.ReadItemAsync("1", new PartitionKey("pk1")); + var sourceEtag = sourceRead.ETag; + + var target = new InMemoryContainer("import-etag-tgt", "/partitionKey"); + target.ImportState(source.ExportState()); + + var targetRead = await target.ReadItemAsync("1", new PartitionKey("pk1")); + targetRead.ETag.Should().NotBe(sourceEtag, "import generates new etags"); + } + + [Fact] + public void ImportState_NullJson_ThrowsException() + { + var container = new InMemoryContainer("import-null", "/pk"); + var act = () => container.ImportState(null!); + act.Should().Throw(); + } + + [Fact] + public void ImportState_EmptyString_ThrowsException() + { + var container = new InMemoryContainer("import-empty-str", "/pk"); + var act = () => container.ImportState(""); + act.Should().Throw(); + } + + [Fact] + public async Task ImportState_DuplicateIds_SamePartitionKey_LastWins() + { + var container = new InMemoryContainer("import-dup", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"first"},{"id":"1","pk":"a","name":"last"}]}"""); + + container.ItemCount.Should().Be(1); + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("last"); + } + + [Fact] + public async Task ImportState_DuplicateIds_DifferentPartitionKeys_BothStored() + { + var container = new InMemoryContainer("import-dup-pk", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"A"},{"id":"1","pk":"b","name":"B"}]}"""); + + container.ItemCount.Should().Be(2); + var a = await container.ReadItemAsync("1", new PartitionKey("a")); + a.Resource["name"]!.ToString().Should().Be("A"); + var b = await container.ReadItemAsync("1", new PartitionKey("b")); + b.Resource["name"]!.ToString().Should().Be("B"); + } + + [Fact] + public async Task ImportState_CalledMultipleTimes_OnlyLastImportSurvives() + { + var container = new InMemoryContainer("import-multi", "/pk"); + container.ImportState("""{"items":[{"id":"A","pk":"a"}]}"""); + container.ImportState("""{"items":[{"id":"B","pk":"b"}]}"""); + + container.ItemCount.Should().Be(1); + var result = await container.ReadItemAsync("B", new PartitionKey("b")); + result.Resource["id"]!.ToString().Should().Be("B"); + + var act = () => container.ReadItemAsync("A", new PartitionKey("a")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ImportState_WithExtraJsonProperties_PreservesThem() + { + var container = new InMemoryContainer("import-extra", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","customField":"preserved","nestedCustom":{"x":1}}]}"""); + + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["customField"]!.ToString().Should().Be("preserved"); + result.Resource["nestedCustom"]!["x"]!.Value().Should().Be(1); + } + + [Fact] + public void ImportState_ItemsMissingId_ThrowsInvalidOperation() + { + var container = new InMemoryContainer("import-noid", "/pk"); + var act = () => container.ImportState("""{"items":[{"pk":"a","name":"no-id-item"}]}"""); + + act.Should().Throw() + .WithMessage("*'id'*"); + } + + [Fact] + public async Task ImportState_ItemMissingPartitionKeyField_Behavior() + { + var container = new InMemoryContainer("import-nopk", "/pk"); + container.ImportState("""{"items":[{"id":"1","name":"no-pk"}]}"""); + + container.ItemCount.Should().Be(1); + // When PK field is missing, item is stored with null partition key + var result = await container.ReadItemAsync("1", PartitionKey.None); + result.Resource["name"]!.ToString().Should().Be("no-pk"); + } + + [Fact] + public async Task ImportState_GeneratesNewTimestamps() + { + var source = new InMemoryContainer("import-ts-src", "/pk"); + await source.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var export = source.ExportState(); + var sourceTs = (long)((JArray)JObject.Parse(export)["items"]!)[0]["_ts"]!; + + await Task.Delay(1100); // Wait so timestamp differs + + var target = new InMemoryContainer("import-ts-tgt", "/pk"); + target.ImportState(export); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(sourceTs); + } + + [Fact] + public async Task ImportState_WithItemsHavingSystemProperties_OverwritesThem() + { + var container = new InMemoryContainer("import-sysprop", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","_etag":"\"old-etag\"","_ts":1000}]}"""); + + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["_etag"]!.ToString().Should().NotBe("\"old-etag\"", + "import regenerates ETags"); + result.Resource["_ts"]!.Value().Should().BeGreaterThan(1000, + "import regenerates timestamps"); + } + + [Fact] + public void ImportState_ValidJsonButArray_Throws() + { + var container = new InMemoryContainer("import-arr", "/pk"); + var act = () => container.ImportState("""[{"id":"1"}]"""); + act.Should().Throw(); + } + + [Fact] + public void ImportState_ValidJsonButPrimitive_Throws() + { + var container = new InMemoryContainer("import-prim", "/pk"); + var act = () => container.ImportState("123"); + act.Should().Throw(); + } + + [Fact] + public async Task ImportState_VeryLargePayload_1000Items_Succeeds() + { + var items = new JArray(); + for (var i = 0; i < 1000; i++) + items.Add(new JObject { ["id"] = $"{i}", ["pk"] = "a", ["name"] = $"Item{i}" }); + + var state = new JObject { ["items"] = items }; + var container = new InMemoryContainer("import-large", "/pk"); + container.ImportState(state.ToString()); + + container.ItemCount.Should().Be(1000); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + all.Should().HaveCount(1000); + } + + [Fact] + public void ImportState_WithWhitespaceOnlyJson_Throws() + { + var container = new InMemoryContainer("import-ws", "/pk"); + var act = () => container.ImportState(" "); + act.Should().Throw(); + } + + [Fact] + public async Task ImportState_ItemsWithDifferentSchemas_AllPreserved() + { + var container = new InMemoryContainer("import-schema", "/pk"); + container.ImportState(""" {"items":[ {"id":"1","pk":"a","name":"Alice","age":30}, {"id":"2","pk":"a","score":99.5,"active":true}, @@ -704,14 +704,14 @@ public async Task ImportState_ItemsWithDifferentSchemas_AllPreserved() ]} """); - container.ItemCount.Should().Be(3); - var r1 = await container.ReadItemAsync("1", new PartitionKey("a")); - r1.Resource["name"]!.ToString().Should().Be("Alice"); - var r2 = await container.ReadItemAsync("2", new PartitionKey("a")); - r2.Resource["score"]!.Value().Should().Be(99.5); - var r3 = await container.ReadItemAsync("3", new PartitionKey("a")); - ((JArray)r3.Resource["tags"]!).Should().HaveCount(2); - } + container.ItemCount.Should().Be(3); + var r1 = await container.ReadItemAsync("1", new PartitionKey("a")); + r1.Resource["name"]!.ToString().Should().Be("Alice"); + var r2 = await container.ReadItemAsync("2", new PartitionKey("a")); + r2.Resource["score"]!.Value().Should().Be(99.5); + var r3 = await container.ReadItemAsync("3", new PartitionKey("a")); + ((JArray)r3.Resource["tags"]!).Should().HaveCount(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -720,98 +720,98 @@ public async Task ImportState_ItemsWithDifferentSchemas_AllPreserved() public class StatePersistenceChangeFeedTests { - [Fact] - public void ImportState_DoesNotPopulateChangeFeed() - { - var container = new InMemoryContainer("cf-import", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"}]}"""); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - - // Change feed should be empty — import bypasses change feed recording - // HasMoreResults is false when change feed has no entries - iter.HasMoreResults.Should().BeFalse( - "ImportState does not record change feed entries — this is by design"); - } - - [Fact] - public async Task ImportState_SubsequentWritesAppearInChangeFeed() - { - var container = new InMemoryContainer("cf-writes", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), - new PartitionKey("a")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var page = await iter.ReadNextAsync(); - - page.Should().Contain(j => j["id"]!.ToString() == "2"); - } - - [Fact] - public async Task ExportState_DoesNotIncludeChangeFeedHistory() - { - var container = new InMemoryContainer("cf-export", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var parsed = JObject.Parse(json); - - // Export only has "items", no change feed data - parsed.Properties().Select(p => p.Name).Should().BeEquivalentTo(["items"]); - } - - [Fact] - public void ClearItems_ClearsChangeFeed_VerifiedViaIterator() - { - var container = new InMemoryContainer("cf-clear", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - container.ClearItems(); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - iter.HasMoreResults.Should().BeFalse("change feed should be empty after ClearItems"); - } - - [Fact] - public void ImportState_ThenExport_ThenReimport_ChangeFeedStillEmpty() - { - var container = new InMemoryContainer("cf-chain", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - var exported = container.ExportState(); - container.ImportState(exported); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - iter.HasMoreResults.Should().BeFalse("chained import/export/reimport should leave change feed empty"); - } - - [Fact] - public async Task ImportState_ThenModifyItems_ChangeFeedOnlyHasModifications() - { - var container = new InMemoryContainer("cf-mod", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"a"}]}"""); - - // Modify one item - await container.UpsertItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "modified" }), - new PartitionKey("a")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var page = await iter.ReadNextAsync(); - - page.Should().ContainSingle(); - page.First()["id"]!.ToString().Should().Be("2"); - } + [Fact] + public void ImportState_DoesNotPopulateChangeFeed() + { + var container = new InMemoryContainer("cf-import", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"}]}"""); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + + // Change feed should be empty — import bypasses change feed recording + // HasMoreResults is false when change feed has no entries + iter.HasMoreResults.Should().BeFalse( + "ImportState does not record change feed entries — this is by design"); + } + + [Fact] + public async Task ImportState_SubsequentWritesAppearInChangeFeed() + { + var container = new InMemoryContainer("cf-writes", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a"}""")), + new PartitionKey("a")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var page = await iter.ReadNextAsync(); + + page.Should().Contain(j => j["id"]!.ToString() == "2"); + } + + [Fact] + public async Task ExportState_DoesNotIncludeChangeFeedHistory() + { + var container = new InMemoryContainer("cf-export", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var parsed = JObject.Parse(json); + + // Export only has "items", no change feed data + parsed.Properties().Select(p => p.Name).Should().BeEquivalentTo(["items"]); + } + + [Fact] + public void ClearItems_ClearsChangeFeed_VerifiedViaIterator() + { + var container = new InMemoryContainer("cf-clear", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + container.ClearItems(); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + iter.HasMoreResults.Should().BeFalse("change feed should be empty after ClearItems"); + } + + [Fact] + public void ImportState_ThenExport_ThenReimport_ChangeFeedStillEmpty() + { + var container = new InMemoryContainer("cf-chain", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + var exported = container.ExportState(); + container.ImportState(exported); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + iter.HasMoreResults.Should().BeFalse("chained import/export/reimport should leave change feed empty"); + } + + [Fact] + public async Task ImportState_ThenModifyItems_ChangeFeedOnlyHasModifications() + { + var container = new InMemoryContainer("cf-mod", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"a"}]}"""); + + // Modify one item + await container.UpsertItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "modified" }), + new PartitionKey("a")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var page = await iter.ReadNextAsync(); + + page.Should().ContainSingle(); + page.First()["id"]!.ToString().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -820,61 +820,61 @@ await container.UpsertItemAsync( public class StatePersistenceTtlTests { - [Fact] - public async Task ImportState_WithDefaultTTL_ImportedItemsRespectTTL() - { - var container = new InMemoryContainer("ttl-import", "/pk"); - container.DefaultTimeToLive = 1; - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task ImportState_WithPerItemTTL_ItemsExpireCorrectly() - { - var container = new InMemoryContainer("ttl-peritem", "/pk"); - container.DefaultTimeToLive = 60; // container TTL = 60s - container.ImportState("""{"items":[{"id":"1","pk":"a","_ttl":1}]}"""); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task ExportState_WithTTLItems_IncludesTtlField() - { - var container = new InMemoryContainer("ttl-export", "/pk"); - container.DefaultTimeToLive = 60; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","ttl":300}""")), - new PartitionKey("a")); - - var json = container.ExportState(); - var items = (JArray)JObject.Parse(json)["items"]!; - items[0]["ttl"]!.Value().Should().Be(300); - } - - [Fact] - public async Task ImportState_IntoContainerWithNoTTL_TtlFieldIgnored() - { - var container = new InMemoryContainer("ttl-none", "/pk"); - // No DefaultTimeToLive set - container.ImportState("""{"items":[{"id":"1","pk":"a","ttl":1}]}"""); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should still exist — container has no TTL configured - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.StatusCode.Should().Be(HttpStatusCode.OK); - } + [Fact] + public async Task ImportState_WithDefaultTTL_ImportedItemsRespectTTL() + { + var container = new InMemoryContainer("ttl-import", "/pk"); + container.DefaultTimeToLive = 1; + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task ImportState_WithPerItemTTL_ItemsExpireCorrectly() + { + var container = new InMemoryContainer("ttl-peritem", "/pk"); + container.DefaultTimeToLive = 60; // container TTL = 60s + container.ImportState("""{"items":[{"id":"1","pk":"a","_ttl":1}]}"""); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task ExportState_WithTTLItems_IncludesTtlField() + { + var container = new InMemoryContainer("ttl-export", "/pk"); + container.DefaultTimeToLive = 60; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","ttl":300}""")), + new PartitionKey("a")); + + var json = container.ExportState(); + var items = (JArray)JObject.Parse(json)["items"]!; + items[0]["ttl"]!.Value().Should().Be(300); + } + + [Fact] + public async Task ImportState_IntoContainerWithNoTTL_TtlFieldIgnored() + { + var container = new InMemoryContainer("ttl-none", "/pk"); + // No DefaultTimeToLive set + container.ImportState("""{"items":[{"id":"1","pk":"a","ttl":1}]}"""); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should still exist — container has no TTL configured + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -883,63 +883,63 @@ public async Task ImportState_IntoContainerWithNoTTL_TtlFieldIgnored() public class StatePersistenceHierarchicalPkTests { - [Fact] - public async Task ExportImport_HierarchicalPartitionKey_RoundTrips() - { - var source = new InMemoryContainer("hk-src", new[] { "/tenantId", "/userId" }); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - - var json = source.ExportState(); - var target = new InMemoryContainer("hk-tgt", new[] { "/tenantId", "/userId" }); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - result.Resource["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task ExportImport_ThreeLevelHierarchicalPK_RoundTrips() - { - var source = new InMemoryContainer("hk-3level", new[] { "/a", "/b", "/c" }); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z", name = "deep" }), - new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); - - var json = source.ExportState(); - var target = new InMemoryContainer("hk-3tgt", new[] { "/a", "/b", "/c" }); - target.ImportState(json); - - var result = await target.ReadItemAsync("1", - new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); - result.Resource["name"]!.ToString().Should().Be("deep"); - } - - [Fact] - public async Task ExportImport_HierarchicalPK_SameIdDifferentPKValues_BothPreserved() - { - var source = new InMemoryContainer("hk-sameid", new[] { "/tenantId", "/userId" }); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u2", name = "Bob" }), - new PartitionKeyBuilder().Add("t1").Add("u2").Build()); - - var json = source.ExportState(); - var target = new InMemoryContainer("hk-sameid-tgt", new[] { "/tenantId", "/userId" }); - target.ImportState(json); - - target.ItemCount.Should().Be(2); - var r1 = await target.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - r1.Resource["name"]!.ToString().Should().Be("Alice"); - var r2 = await target.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u2").Build()); - r2.Resource["name"]!.ToString().Should().Be("Bob"); - } + [Fact] + public async Task ExportImport_HierarchicalPartitionKey_RoundTrips() + { + var source = new InMemoryContainer("hk-src", new[] { "/tenantId", "/userId" }); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + + var json = source.ExportState(); + var target = new InMemoryContainer("hk-tgt", new[] { "/tenantId", "/userId" }); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + result.Resource["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task ExportImport_ThreeLevelHierarchicalPK_RoundTrips() + { + var source = new InMemoryContainer("hk-3level", new[] { "/a", "/b", "/c" }); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", a = "x", b = "y", c = "z", name = "deep" }), + new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); + + var json = source.ExportState(); + var target = new InMemoryContainer("hk-3tgt", new[] { "/a", "/b", "/c" }); + target.ImportState(json); + + var result = await target.ReadItemAsync("1", + new PartitionKeyBuilder().Add("x").Add("y").Add("z").Build()); + result.Resource["name"]!.ToString().Should().Be("deep"); + } + + [Fact] + public async Task ExportImport_HierarchicalPK_SameIdDifferentPKValues_BothPreserved() + { + var source = new InMemoryContainer("hk-sameid", new[] { "/tenantId", "/userId" }); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u1", name = "Alice" }), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", userId = "u2", name = "Bob" }), + new PartitionKeyBuilder().Add("t1").Add("u2").Build()); + + var json = source.ExportState(); + var target = new InMemoryContainer("hk-sameid-tgt", new[] { "/tenantId", "/userId" }); + target.ImportState(json); + + target.ItemCount.Should().Be(2); + var r1 = await target.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + r1.Resource["name"]!.ToString().Should().Be("Alice"); + var r2 = await target.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u2").Build()); + r2.Resource["name"]!.ToString().Should().Be("Bob"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -948,102 +948,102 @@ await source.CreateItemAsync( public class StatePersistenceFileTests { - [Fact] - public async Task ExportStateToFile_CreatesFile() - { - var tempFile = Path.GetTempFileName(); - try - { - var container = new InMemoryContainer("file-create", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ExportStateToFile(tempFile); - File.Exists(tempFile).Should().BeTrue(); - File.ReadAllText(tempFile).Should().Contain("\"id\": \"1\""); - } - finally { File.Delete(tempFile); } - } - - [Fact] - public async Task ExportStateToFile_OverwritesExistingFile() - { - var tempFile = Path.GetTempFileName(); - try - { - File.WriteAllText(tempFile, "old content"); - - var container = new InMemoryContainer("file-overwrite", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ExportStateToFile(tempFile); - File.ReadAllText(tempFile).Should().NotContain("old content"); - } - finally { File.Delete(tempFile); } - } - - [Fact] - public void ImportStateFromFile_FileNotFound_Throws() - { - var container = new InMemoryContainer("file-404", "/pk"); - var act = () => container.ImportStateFromFile("/nonexistent/path.json"); - act.Should().Throw(); - } - - [Fact] - public void ImportStateFromFile_EmptyFile_Throws() - { - var tempFile = Path.GetTempFileName(); - try - { - File.WriteAllText(tempFile, ""); - var container = new InMemoryContainer("file-empty", "/pk"); - var act = () => container.ImportStateFromFile(tempFile); - act.Should().Throw(); - } - finally { File.Delete(tempFile); } - } - - [Fact] - public async Task ExportStateToFile_And_ImportStateFromFile_LargeDataset() - { - var tempFile = Path.GetTempFileName(); - try - { - var source = new InMemoryContainer("file-large", "/pk"); - for (var i = 0; i < 100; i++) - await source.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a","name":"Item{{{i}}}"}""")), - new PartitionKey("a")); - - source.ExportStateToFile(tempFile); - - var target = new InMemoryContainer("file-large-tgt", "/pk"); - target.ImportStateFromFile(tempFile); - - target.ItemCount.Should().Be(100); - } - finally { File.Delete(tempFile); } - } - - [Fact] - public void ExportStateToFile_NullPath_Throws() - { - var container = new InMemoryContainer("file-null", "/pk"); - var act = () => container.ExportStateToFile(null!); - act.Should().Throw(); - } - - [Fact] - public void ImportStateFromFile_NullPath_Throws() - { - var container = new InMemoryContainer("file-null-import", "/pk"); - var act = () => container.ImportStateFromFile(null!); - act.Should().Throw(); - } + [Fact] + public async Task ExportStateToFile_CreatesFile() + { + var tempFile = Path.GetTempFileName(); + try + { + var container = new InMemoryContainer("file-create", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ExportStateToFile(tempFile); + File.Exists(tempFile).Should().BeTrue(); + File.ReadAllText(tempFile).Should().Contain("\"id\": \"1\""); + } + finally { File.Delete(tempFile); } + } + + [Fact] + public async Task ExportStateToFile_OverwritesExistingFile() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "old content"); + + var container = new InMemoryContainer("file-overwrite", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ExportStateToFile(tempFile); + File.ReadAllText(tempFile).Should().NotContain("old content"); + } + finally { File.Delete(tempFile); } + } + + [Fact] + public void ImportStateFromFile_FileNotFound_Throws() + { + var container = new InMemoryContainer("file-404", "/pk"); + var act = () => container.ImportStateFromFile("/nonexistent/path.json"); + act.Should().Throw(); + } + + [Fact] + public void ImportStateFromFile_EmptyFile_Throws() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, ""); + var container = new InMemoryContainer("file-empty", "/pk"); + var act = () => container.ImportStateFromFile(tempFile); + act.Should().Throw(); + } + finally { File.Delete(tempFile); } + } + + [Fact] + public async Task ExportStateToFile_And_ImportStateFromFile_LargeDataset() + { + var tempFile = Path.GetTempFileName(); + try + { + var source = new InMemoryContainer("file-large", "/pk"); + for (var i = 0; i < 100; i++) + await source.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a","name":"Item{{{i}}}"}""")), + new PartitionKey("a")); + + source.ExportStateToFile(tempFile); + + var target = new InMemoryContainer("file-large-tgt", "/pk"); + target.ImportStateFromFile(tempFile); + + target.ItemCount.Should().Be(100); + } + finally { File.Delete(tempFile); } + } + + [Fact] + public void ExportStateToFile_NullPath_Throws() + { + var container = new InMemoryContainer("file-null", "/pk"); + var act = () => container.ExportStateToFile(null!); + act.Should().Throw(); + } + + [Fact] + public void ImportStateFromFile_NullPath_Throws() + { + var container = new InMemoryContainer("file-null-import", "/pk"); + var act = () => container.ImportStateFromFile(null!); + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1052,88 +1052,88 @@ public void ImportStateFromFile_NullPath_Throws() public class ClearItemsTests { - [Fact] - public async Task ClearItems_EmptiesAllStorage() - { - var container = new InMemoryContainer("clear-all", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ClearItems(); - container.ItemCount.Should().Be(0); - } - - [Fact] - public void ClearItems_OnEmptyContainer_DoesNotThrow() - { - var container = new InMemoryContainer("clear-empty", "/pk"); - var act = () => container.ClearItems(); - act.Should().NotThrow(); - } - - [Fact] - public async Task ClearItems_ThenCreateItem_WorksNormally() - { - var container = new InMemoryContainer("clear-then-create", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), - new PartitionKey("a")); - - container.ClearItems(); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "new" }), - new PartitionKey("a")); - - container.ItemCount.Should().Be(1); - var result = await container.ReadItemAsync("2", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("new"); - } - - [Fact] - public async Task ClearItems_ClearsETags() - { - var container = new InMemoryContainer("clear-etag", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var etag = (await container.ReadItemAsync("1", new PartitionKey("a"))).ETag; - etag.Should().NotBeNullOrEmpty(); - - container.ClearItems(); - - var act = () => container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public void ClearItems_CalledMultipleTimes_NoError() - { - var container = new InMemoryContainer("clear-multi", "/pk"); - var act = () => - { - container.ClearItems(); - container.ClearItems(); - container.ClearItems(); - }; - act.Should().NotThrow(); - } - - [Fact] - public async Task ClearItems_DoesNotAffectContainerConfig() - { - var container = new InMemoryContainer("clear-config", "/pk"); - container.DefaultTimeToLive = 300; - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - container.ClearItems(); - container.ItemCount.Should().Be(0); - container.DefaultTimeToLive.Should().Be(300, "TTL config survives ClearItems"); - } + [Fact] + public async Task ClearItems_EmptiesAllStorage() + { + var container = new InMemoryContainer("clear-all", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ClearItems(); + container.ItemCount.Should().Be(0); + } + + [Fact] + public void ClearItems_OnEmptyContainer_DoesNotThrow() + { + var container = new InMemoryContainer("clear-empty", "/pk"); + var act = () => container.ClearItems(); + act.Should().NotThrow(); + } + + [Fact] + public async Task ClearItems_ThenCreateItem_WorksNormally() + { + var container = new InMemoryContainer("clear-then-create", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a"}""")), + new PartitionKey("a")); + + container.ClearItems(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "new" }), + new PartitionKey("a")); + + container.ItemCount.Should().Be(1); + var result = await container.ReadItemAsync("2", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("new"); + } + + [Fact] + public async Task ClearItems_ClearsETags() + { + var container = new InMemoryContainer("clear-etag", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var etag = (await container.ReadItemAsync("1", new PartitionKey("a"))).ETag; + etag.Should().NotBeNullOrEmpty(); + + container.ClearItems(); + + var act = () => container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public void ClearItems_CalledMultipleTimes_NoError() + { + var container = new InMemoryContainer("clear-multi", "/pk"); + var act = () => + { + container.ClearItems(); + container.ClearItems(); + container.ClearItems(); + }; + act.Should().NotThrow(); + } + + [Fact] + public async Task ClearItems_DoesNotAffectContainerConfig() + { + var container = new InMemoryContainer("clear-config", "/pk"); + container.DefaultTimeToLive = 300; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + container.ClearItems(); + container.ItemCount.Should().Be(0); + container.DefaultTimeToLive.Should().Be(300, "TTL config survives ClearItems"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1142,54 +1142,54 @@ await container.CreateItemAsync( public class StatePersistenceConcurrencyTests { - [Fact] - public async Task ExportState_WhileWritesHappening_DoesNotThrow() - { - var container = new InMemoryContainer("conc-export", "/pk"); - for (var i = 0; i < 50; i++) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), - new PartitionKey("a")); - - var tasks = Enumerable.Range(50, 50).Select(async i => - { - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), - new PartitionKey("a")); - }).ToList(); - - var exportTask = Task.Run(() => container.ExportState()); - - var act = async () => await Task.WhenAll(tasks.Append(exportTask)); - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task ExportState_DuringConcurrentWrites_MayNotBeAtomicSnapshot() - { - // Documents that ConcurrentDictionary enumeration may include some concurrent writes - var container = new InMemoryContainer("conc-snapshot", "/pk"); - for (var i = 0; i < 20; i++) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), - new PartitionKey("a")); - - // Start writes concurrently with export - var writeTasks = Enumerable.Range(20, 30).Select(async i => - { - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), - new PartitionKey("a")); - }); - - var exportResult = ""; - var exportTask = Task.Run(() => exportResult = container.ExportState()); - await Task.WhenAll(writeTasks.Append(exportTask)); - - // Export should have at least the 20 original items, possibly more - var items = (JArray)JObject.Parse(exportResult)["items"]!; - items.Count.Should().BeGreaterThanOrEqualTo(20); - } + [Fact] + public async Task ExportState_WhileWritesHappening_DoesNotThrow() + { + var container = new InMemoryContainer("conc-export", "/pk"); + for (var i = 0; i < 50; i++) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), + new PartitionKey("a")); + + var tasks = Enumerable.Range(50, 50).Select(async i => + { + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), + new PartitionKey("a")); + }).ToList(); + + var exportTask = Task.Run(() => container.ExportState()); + + var act = async () => await Task.WhenAll(tasks.Append(exportTask)); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task ExportState_DuringConcurrentWrites_MayNotBeAtomicSnapshot() + { + // Documents that ConcurrentDictionary enumeration may include some concurrent writes + var container = new InMemoryContainer("conc-snapshot", "/pk"); + for (var i = 0; i < 20; i++) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), + new PartitionKey("a")); + + // Start writes concurrently with export + var writeTasks = Enumerable.Range(20, 30).Select(async i => + { + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes($$$"""{"id":"{{{i}}}","pk":"a"}""")), + new PartitionKey("a")); + }); + + var exportResult = ""; + var exportTask = Task.Run(() => exportResult = container.ExportState()); + await Task.WhenAll(writeTasks.Append(exportTask)); + + // Export should have at least the 20 original items, possibly more + var items = (JArray)JObject.Parse(exportResult)["items"]!; + items.Count.Should().BeGreaterThanOrEqualTo(20); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1198,60 +1198,60 @@ await container.CreateItemStreamAsync( public class CrossContainerExportImportTests { - [Fact] - public async Task ExportFromOneContainer_ImportToAnother_DifferentNames() - { - var source = new InMemoryContainer("source-name", "/pk"); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "data" }), - new PartitionKey("a")); - - var target = new InMemoryContainer("different-name", "/pk"); - target.ImportState(source.ExportState()); - - target.ItemCount.Should().Be(1); - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("data"); - } - - [Fact] - public async Task ExportState_ImportState_MultipleTimes_NoStateLeakage() - { - var container = new InMemoryContainer("leak-test", "/pk"); - - // First cycle - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"A","pk":"a"}""")), - new PartitionKey("a")); - var json1 = container.ExportState(); - - // Wipe and reimport - container.ClearItems(); - container.ImportState("""{"items":[{"id":"B","pk":"b"}]}"""); - - // Second export — should only have B - var json2 = container.ExportState(); - var items2 = (JArray)JObject.Parse(json2)["items"]!; - items2.Should().HaveCount(1); - items2[0]["id"]!.ToString().Should().Be("B"); - } - - [Fact] - public async Task ImportState_FromDifferentContainerSchema_PreservesAllFields() - { - var source = new InMemoryContainer("schema-src", "/pk"); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", fieldA = "hello", fieldB = 42, fieldC = true }), - new PartitionKey("a")); - - var target = new InMemoryContainer("schema-tgt", "/pk"); - target.ImportState(source.ExportState()); - - var result = await target.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["fieldA"]!.ToString().Should().Be("hello"); - result.Resource["fieldB"]!.Value().Should().Be(42); - result.Resource["fieldC"]!.Value().Should().BeTrue(); - } + [Fact] + public async Task ExportFromOneContainer_ImportToAnother_DifferentNames() + { + var source = new InMemoryContainer("source-name", "/pk"); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "data" }), + new PartitionKey("a")); + + var target = new InMemoryContainer("different-name", "/pk"); + target.ImportState(source.ExportState()); + + target.ItemCount.Should().Be(1); + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("data"); + } + + [Fact] + public async Task ExportState_ImportState_MultipleTimes_NoStateLeakage() + { + var container = new InMemoryContainer("leak-test", "/pk"); + + // First cycle + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"A","pk":"a"}""")), + new PartitionKey("a")); + var json1 = container.ExportState(); + + // Wipe and reimport + container.ClearItems(); + container.ImportState("""{"items":[{"id":"B","pk":"b"}]}"""); + + // Second export — should only have B + var json2 = container.ExportState(); + var items2 = (JArray)JObject.Parse(json2)["items"]!; + items2.Should().HaveCount(1); + items2[0]["id"]!.ToString().Should().Be("B"); + } + + [Fact] + public async Task ImportState_FromDifferentContainerSchema_PreservesAllFields() + { + var source = new InMemoryContainer("schema-src", "/pk"); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", fieldA = "hello", fieldB = 42, fieldC = true }), + new PartitionKey("a")); + + var target = new InMemoryContainer("schema-tgt", "/pk"); + target.ImportState(source.ExportState()); + + var result = await target.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["fieldA"]!.ToString().Should().Be("hello"); + result.Resource["fieldB"]!.Value().Should().Be(42); + result.Resource["fieldC"]!.Value().Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1260,131 +1260,131 @@ await source.CreateItemAsync( public class DataFidelityAfterImportTests { - [Fact] - public async Task ImportState_ItemsCanBeUpdatedAfterImport() - { - var container = new InMemoryContainer("fidelity-update", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original"}]}"""); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - new PartitionKey("a")); - - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("updated"); - } - - [Fact] - public async Task ImportState_ItemsCanBeDeletedAfterImport() - { - var container = new InMemoryContainer("fidelity-delete", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - await container.DeleteItemAsync("1", new PartitionKey("a")); - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task ImportState_ItemsReadableViaReadItemStream() - { - var container = new InMemoryContainer("fidelity-stream", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"stream"}]}"""); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - body.Should().Contain("stream"); - } - - [Fact] - public async Task ImportState_ItemsQueryable() - { - var container = new InMemoryContainer("fidelity-query", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","value":10},{"id":"2","pk":"a","value":20}]}"""); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.value > 15"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["id"]!.ToString().Should().Be("2"); - } - - [Fact] - public async Task ImportState_ItemsReadableViaReadMany() - { - var container = new InMemoryContainer("fidelity-readmany", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"b"}]}"""); - - var items = new List<(string, PartitionKey)> - { - ("1", new PartitionKey("a")), - ("3", new PartitionKey("b")) - }; - var response = await container.ReadManyItemsAsync(items); - response.Should().HaveCount(2); - } - - [Fact] - public async Task ImportState_ItemsCountable_ViaLinqCount() - { - var container = new InMemoryContainer("fidelity-linq", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"a"}]}"""); - - var iter = container.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); - all.Should().HaveCount(3); - } - - [Fact] - public async Task ImportState_ItemsAccessibleViaPatchOperations() - { - var container = new InMemoryContainer("fidelity-patch", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original","count":0}]}"""); - - await container.PatchItemAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Set("/name", "patched"), PatchOperation.Increment("/count", 5) }); - - var result = await container.ReadItemAsync("1", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("patched"); - result.Resource["count"]!.Value().Should().Be(5); - } - - [Fact] - public async Task ImportState_ItemsAccessibleViaTransactionalBatch() - { - var container = new InMemoryContainer("fidelity-batch", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"Alice"},{"id":"2","pk":"a","name":"Bob"}]}"""); - - var batch = container.CreateTransactionalBatch(new PartitionKey("a")); - batch.ReadItem("1"); - batch.DeleteItem("2"); - using var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - container.ItemCount.Should().Be(1); - } - - [Fact] - public async Task ImportState_ItemsAccessibleViaChangeFeedAfterModification() - { - var container = new InMemoryContainer("fidelity-cf", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original"}]}"""); - - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "modified" }), - new PartitionKey("a")); - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var page = await iter.ReadNextAsync(); - - page.Should().ContainSingle(); - page.First()["name"]!.ToString().Should().Be("modified"); - } + [Fact] + public async Task ImportState_ItemsCanBeUpdatedAfterImport() + { + var container = new InMemoryContainer("fidelity-update", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original"}]}"""); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + new PartitionKey("a")); + + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("updated"); + } + + [Fact] + public async Task ImportState_ItemsCanBeDeletedAfterImport() + { + var container = new InMemoryContainer("fidelity-delete", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + await container.DeleteItemAsync("1", new PartitionKey("a")); + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task ImportState_ItemsReadableViaReadItemStream() + { + var container = new InMemoryContainer("fidelity-stream", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"stream"}]}"""); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + body.Should().Contain("stream"); + } + + [Fact] + public async Task ImportState_ItemsQueryable() + { + var container = new InMemoryContainer("fidelity-query", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","value":10},{"id":"2","pk":"a","value":20}]}"""); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.value > 15"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["id"]!.ToString().Should().Be("2"); + } + + [Fact] + public async Task ImportState_ItemsReadableViaReadMany() + { + var container = new InMemoryContainer("fidelity-readmany", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"b"}]}"""); + + var items = new List<(string, PartitionKey)> + { + ("1", new PartitionKey("a")), + ("3", new PartitionKey("b")) + }; + var response = await container.ReadManyItemsAsync(items); + response.Should().HaveCount(2); + } + + [Fact] + public async Task ImportState_ItemsCountable_ViaLinqCount() + { + var container = new InMemoryContainer("fidelity-linq", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"},{"id":"2","pk":"a"},{"id":"3","pk":"a"}]}"""); + + var iter = container.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (iter.HasMoreResults) all.AddRange(await iter.ReadNextAsync()); + all.Should().HaveCount(3); + } + + [Fact] + public async Task ImportState_ItemsAccessibleViaPatchOperations() + { + var container = new InMemoryContainer("fidelity-patch", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original","count":0}]}"""); + + await container.PatchItemAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Set("/name", "patched"), PatchOperation.Increment("/count", 5) }); + + var result = await container.ReadItemAsync("1", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("patched"); + result.Resource["count"]!.Value().Should().Be(5); + } + + [Fact] + public async Task ImportState_ItemsAccessibleViaTransactionalBatch() + { + var container = new InMemoryContainer("fidelity-batch", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"Alice"},{"id":"2","pk":"a","name":"Bob"}]}"""); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.ReadItem("1"); + batch.DeleteItem("2"); + using var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + container.ItemCount.Should().Be(1); + } + + [Fact] + public async Task ImportState_ItemsAccessibleViaChangeFeedAfterModification() + { + var container = new InMemoryContainer("fidelity-cf", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"original"}]}"""); + + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "modified" }), + new PartitionKey("a")); + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var page = await iter.ReadNextAsync(); + + page.Should().ContainSingle(); + page.First()["name"]!.ToString().Should().Be("modified"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1393,72 +1393,72 @@ await container.UpsertItemAsync( public class StatePersistenceUniqueKeyTests { - [Fact] - public void ImportState_ViolatesUniqueKeyPolicy_Throws() - { - var properties = new ContainerProperties("uk-import", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - var act = () => container.ImportState(""" + [Fact] + public void ImportState_ViolatesUniqueKeyPolicy_Throws() + { + var properties = new ContainerProperties("uk-import", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + var act = () => container.ImportState(""" {"items":[ {"id":"1","pk":"a","email":"same@test.com"}, {"id":"2","pk":"a","email":"same@test.com"} ]} """); - act.Should().Throw() - .Where(e => e.StatusCode == HttpStatusCode.Conflict); - } - - [Fact] - public void ImportState_UniqueKeyPolicy_DifferentPartitions_NoConflict() - { - var properties = new ContainerProperties("uk-diff-pk", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - container.ImportState(""" + act.Should().Throw() + .Where(e => e.StatusCode == HttpStatusCode.Conflict); + } + + [Fact] + public void ImportState_UniqueKeyPolicy_DifferentPartitions_NoConflict() + { + var properties = new ContainerProperties("uk-diff-pk", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + container.ImportState(""" {"items":[ {"id":"1","pk":"a","email":"same@test.com"}, {"id":"2","pk":"b","email":"same@test.com"} ]} """); - container.ItemCount.Should().Be(2); - } - - [Fact] - public void ImportState_UniqueKeyPolicy_AllValid_Succeeds() - { - var properties = new ContainerProperties("uk-valid", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/email" } } } - } - }; - var container = new InMemoryContainer(properties); - - container.ImportState(""" + container.ItemCount.Should().Be(2); + } + + [Fact] + public void ImportState_UniqueKeyPolicy_AllValid_Succeeds() + { + var properties = new ContainerProperties("uk-valid", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(properties); + + container.ImportState(""" {"items":[ {"id":"1","pk":"a","email":"alice@test.com"}, {"id":"2","pk":"a","email":"bob@test.com"} ]} """); - container.ItemCount.Should().Be(2); - } + container.ItemCount.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1467,70 +1467,70 @@ public void ImportState_UniqueKeyPolicy_AllValid_Succeeds() public class StatePersistencePitrTests { - [Fact] - public async Task ImportState_ThenRestore_OnlyPostImportOperationsRestorable() - { - var container = new InMemoryContainer("pitr-import", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a","name":"imported"}]}"""); - - // Post-import operation - await container.UpsertItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "added" }), - new PartitionKey("a")); - - var checkpoint = DateTimeOffset.UtcNow; - await Task.Delay(100); - - // Another operation after checkpoint - await container.UpsertItemAsync( - JObject.FromObject(new { id = "3", pk = "a", name = "late" }), - new PartitionKey("a")); - - container.RestoreToPointInTime(checkpoint); - - // Only post-import operations up to checkpoint should be present - container.ItemCount.Should().Be(1); - var result = await container.ReadItemAsync("2", new PartitionKey("a")); - result.Resource["name"]!.ToString().Should().Be("added"); - } - - [Fact] - public void ClearItems_ThenRestore_NoDataRestorable() - { - var container = new InMemoryContainer("pitr-clear", "/pk"); - container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); - - container.ClearItems(); - container.RestoreToPointInTime(DateTimeOffset.UtcNow); - - container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task ExportState_RestoreToPointInTime_ThenExport_DifferentResults() - { - var container = new InMemoryContainer("pitr-export", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "first" }), - new PartitionKey("a")); - - var checkpoint = DateTimeOffset.UtcNow; - await Task.Delay(100); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "second" }), - new PartitionKey("a")); - - var exportBefore = container.ExportState(); - container.RestoreToPointInTime(checkpoint); - var exportAfter = container.ExportState(); - - var itemsBefore = (JArray)JObject.Parse(exportBefore)["items"]!; - var itemsAfter = (JArray)JObject.Parse(exportAfter)["items"]!; - - itemsBefore.Count.Should().Be(2); - itemsAfter.Count.Should().Be(1); - } + [Fact] + public async Task ImportState_ThenRestore_OnlyPostImportOperationsRestorable() + { + var container = new InMemoryContainer("pitr-import", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a","name":"imported"}]}"""); + + // Post-import operation + await container.UpsertItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "added" }), + new PartitionKey("a")); + + var checkpoint = DateTimeOffset.UtcNow; + await Task.Delay(100); + + // Another operation after checkpoint + await container.UpsertItemAsync( + JObject.FromObject(new { id = "3", pk = "a", name = "late" }), + new PartitionKey("a")); + + container.RestoreToPointInTime(checkpoint); + + // Only post-import operations up to checkpoint should be present + container.ItemCount.Should().Be(1); + var result = await container.ReadItemAsync("2", new PartitionKey("a")); + result.Resource["name"]!.ToString().Should().Be("added"); + } + + [Fact] + public void ClearItems_ThenRestore_NoDataRestorable() + { + var container = new InMemoryContainer("pitr-clear", "/pk"); + container.ImportState("""{"items":[{"id":"1","pk":"a"}]}"""); + + container.ClearItems(); + container.RestoreToPointInTime(DateTimeOffset.UtcNow); + + container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task ExportState_RestoreToPointInTime_ThenExport_DifferentResults() + { + var container = new InMemoryContainer("pitr-export", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "first" }), + new PartitionKey("a")); + + var checkpoint = DateTimeOffset.UtcNow; + await Task.Delay(100); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "second" }), + new PartitionKey("a")); + + var exportBefore = container.ExportState(); + container.RestoreToPointInTime(checkpoint); + var exportAfter = container.ExportState(); + + var itemsBefore = (JArray)JObject.Parse(exportBefore)["items"]!; + var itemsAfter = (JArray)JObject.Parse(exportAfter)["items"]!; + + itemsBefore.Count.Should().Be(2); + itemsAfter.Count.Should().Be(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1539,103 +1539,103 @@ await container.CreateItemAsync( public class StatePersistenceErrorHandlingTests { - [Fact] - public void ImportState_WithNullItemInArray_Behavior() - { - var container = new InMemoryContainer("err-null-item", "/pk"); - // null item in array — should handle gracefully - var act = () => container.ImportState("""{"items":[null]}"""); - act.Should().Throw(); - } - - [Fact] - public void ExportState_AfterClearItems_ReturnsEmptyItemsArray() - { - var container = new InMemoryContainer("err-clear-export", "/pk"); - container.ClearItems(); - var json = container.ExportState(); - - var items = (JArray)JObject.Parse(json)["items"]!; - items.Should().BeEmpty(); - } - - [Fact] - public void ImportState_ItemsKeyIsNotArray_Behavior() - { - var container = new InMemoryContainer("err-not-array", "/pk"); - container.ImportState("""{"items":"not an array"}"""); - - // "items" is not a JArray, so the if check fails; container is cleared, nothing imported - container.ItemCount.Should().Be(0); - } - - // ─── StateFilePath + auto-persist tests ─────────────────────────────────── - - [Fact] - public async Task StateFilePath_WhenSet_SaveStateOnDispose() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "test-container.json"); - - // Create container, add data, dispose - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "hello" }, - new PartitionKey("a")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "b", Name = "world" }, - new PartitionKey("b")); - container.Dispose(); - - // File should exist with data - File.Exists(filePath).Should().BeTrue(); - var json = JObject.Parse(File.ReadAllText(filePath)); - json["items"].Should().HaveCount(2); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StateFilePath_WhenNull_DisposeDoesNotCreateFile() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var container = new InMemoryContainer("test", "/partitionKey"); - // StateFilePath is null by default - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, - new PartitionKey("a")); - container.Dispose(); - - // No files should have been created - Directory.GetFiles(dir).Should().BeEmpty(); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StateFilePath_LoadOnInit_RestoresData() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "test-container.json"); - - // Seed a file manually - File.WriteAllText(filePath, """ + [Fact] + public void ImportState_WithNullItemInArray_Behavior() + { + var container = new InMemoryContainer("err-null-item", "/pk"); + // null item in array — should handle gracefully + var act = () => container.ImportState("""{"items":[null]}"""); + act.Should().Throw(); + } + + [Fact] + public void ExportState_AfterClearItems_ReturnsEmptyItemsArray() + { + var container = new InMemoryContainer("err-clear-export", "/pk"); + container.ClearItems(); + var json = container.ExportState(); + + var items = (JArray)JObject.Parse(json)["items"]!; + items.Should().BeEmpty(); + } + + [Fact] + public void ImportState_ItemsKeyIsNotArray_Behavior() + { + var container = new InMemoryContainer("err-not-array", "/pk"); + container.ImportState("""{"items":"not an array"}"""); + + // "items" is not a JArray, so the if check fails; container is cleared, nothing imported + container.ItemCount.Should().Be(0); + } + + // ─── StateFilePath + auto-persist tests ─────────────────────────────────── + + [Fact] + public async Task StateFilePath_WhenSet_SaveStateOnDispose() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "test-container.json"); + + // Create container, add data, dispose + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "hello" }, + new PartitionKey("a")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "b", Name = "world" }, + new PartitionKey("b")); + container.Dispose(); + + // File should exist with data + File.Exists(filePath).Should().BeTrue(); + var json = JObject.Parse(File.ReadAllText(filePath)); + json["items"].Should().HaveCount(2); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StateFilePath_WhenNull_DisposeDoesNotCreateFile() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var container = new InMemoryContainer("test", "/partitionKey"); + // StateFilePath is null by default + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, + new PartitionKey("a")); + container.Dispose(); + + // No files should have been created + Directory.GetFiles(dir).Should().BeEmpty(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StateFilePath_LoadOnInit_RestoresData() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "test-container.json"); + + // Seed a file manually + File.WriteAllText(filePath, """ { "items": [ { "id": "1", "partitionKey": "a", "name": "persisted", "value": 0, "isActive": true, "tags": [] } @@ -1643,230 +1643,230 @@ public async Task StateFilePath_LoadOnInit_RestoresData() } """); - // Create container pointing to the file — it should auto-load - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - container.LoadPersistedState(); - - container.ItemCount.Should().Be(1); - var response = await container.ReadItemAsync("1", new PartitionKey("a")); - response.Resource.Name.Should().Be("persisted"); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public void StateFilePath_LoadOnInit_NoFile_StartsEmpty() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "nonexistent.json"); - - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - container.LoadPersistedState(); - - container.ItemCount.Should().Be(0); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StateFilePath_RoundTrip_SaveAndRestore() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "round-trip.json"); - - // First "run" — create data and dispose - var container1 = new InMemoryContainer("test", "/partitionKey"); - container1.StateFilePath = filePath; - await container1.CreateItemAsync( - new TestDocument { Id = "item-1", PartitionKey = "a", Name = "Alice" }, - new PartitionKey("a")); - await container1.CreateItemAsync( - new TestDocument { Id = "item-2", PartitionKey = "b", Name = "Bob" }, - new PartitionKey("b")); - container1.Dispose(); - - // Second "run" — load from file - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.StateFilePath = filePath; - container2.LoadPersistedState(); - - container2.ItemCount.Should().Be(2); - - var alice = await container2.ReadItemAsync("item-1", new PartitionKey("a")); - alice.Resource.Name.Should().Be("Alice"); - - var bob = await container2.ReadItemAsync("item-2", new PartitionKey("b")); - bob.Resource.Name.Should().Be("Bob"); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StateFilePath_Dispose_CreatesDirectoryIfNeeded() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}", "subdir"); - try - { - var filePath = Path.Combine(dir, "test.json"); - - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, - new PartitionKey("a")); - container.Dispose(); - - File.Exists(filePath).Should().BeTrue(); - } - finally - { - if (Directory.Exists(Path.GetDirectoryName(dir)!)) - Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true); - } - } - - [Fact] - public void StateFilePath_DisposeWithEmptyContainer_SavesEmptyState() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "empty.json"); - - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - container.Dispose(); - - File.Exists(filePath).Should().BeTrue(); - var json = JObject.Parse(File.ReadAllText(filePath)); - json["items"].Should().HaveCount(0); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StateFilePath_MultipleSaves_OverwritesPrevious() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "overwrite.json"); - - // First run: 1 item - var c1 = new InMemoryContainer("test", "/partitionKey"); - c1.StateFilePath = filePath; - await c1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "first" }, - new PartitionKey("a")); - c1.Dispose(); - - // Second run: 2 items - var c2 = new InMemoryContainer("test", "/partitionKey"); - c2.StateFilePath = filePath; - c2.LoadPersistedState(); - await c2.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "b", Name = "second" }, - new PartitionKey("b")); - c2.Dispose(); - - // Third run: verify 2 items - var c3 = new InMemoryContainer("test", "/partitionKey"); - c3.StateFilePath = filePath; - c3.LoadPersistedState(); - c3.ItemCount.Should().Be(2); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task StatePersistenceDirectory_ViaOptions_AutoPersists() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - try - { - // Simulate the DI path: create container with persistence directory - var container = new InMemoryContainer("my-container", "/partitionKey"); - var expectedFile = Path.Combine(dir, "in-memory-db_my-container.json"); - container.StateFilePath = expectedFile; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, - new PartitionKey("a")); - container.Dispose(); - - File.Exists(expectedFile).Should().BeTrue(); - - // Restore - var container2 = new InMemoryContainer("my-container", "/partitionKey"); - container2.StateFilePath = expectedFile; - container2.LoadPersistedState(); - container2.ItemCount.Should().Be(1); - } - finally - { - if (Directory.Exists(dir)) - Directory.Delete(dir, recursive: true); - } - } - - [Fact] - public async Task LoadPersistedState_PreservesSystemProperties() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - try - { - var filePath = Path.Combine(dir, "system-props.json"); - - // Create, add item, capture etag, dispose (saves) - var container1 = new InMemoryContainer("test", "/partitionKey"); - container1.StateFilePath = filePath; - var createResponse = await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "a", Name = "Test" }, - new PartitionKey("a")); - var originalEtag = createResponse.ETag; - container1.Dispose(); - - // Reload — ExportState includes _etag and _ts in the JSON, and - // LoadPersistedState should preserve them - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.StateFilePath = filePath; - container2.LoadPersistedState(); - - var readResponse = await container2.ReadItemAsync("1", new PartitionKey("a")); - // The etag should be preserved from the exported state - readResponse.ETag.Should().NotBeNullOrEmpty(); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } + // Create container pointing to the file — it should auto-load + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + container.LoadPersistedState(); + + container.ItemCount.Should().Be(1); + var response = await container.ReadItemAsync("1", new PartitionKey("a")); + response.Resource.Name.Should().Be("persisted"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void StateFilePath_LoadOnInit_NoFile_StartsEmpty() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "nonexistent.json"); + + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + container.LoadPersistedState(); + + container.ItemCount.Should().Be(0); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StateFilePath_RoundTrip_SaveAndRestore() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "round-trip.json"); + + // First "run" — create data and dispose + var container1 = new InMemoryContainer("test", "/partitionKey"); + container1.StateFilePath = filePath; + await container1.CreateItemAsync( + new TestDocument { Id = "item-1", PartitionKey = "a", Name = "Alice" }, + new PartitionKey("a")); + await container1.CreateItemAsync( + new TestDocument { Id = "item-2", PartitionKey = "b", Name = "Bob" }, + new PartitionKey("b")); + container1.Dispose(); + + // Second "run" — load from file + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.StateFilePath = filePath; + container2.LoadPersistedState(); + + container2.ItemCount.Should().Be(2); + + var alice = await container2.ReadItemAsync("item-1", new PartitionKey("a")); + alice.Resource.Name.Should().Be("Alice"); + + var bob = await container2.ReadItemAsync("item-2", new PartitionKey("b")); + bob.Resource.Name.Should().Be("Bob"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StateFilePath_Dispose_CreatesDirectoryIfNeeded() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}", "subdir"); + try + { + var filePath = Path.Combine(dir, "test.json"); + + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, + new PartitionKey("a")); + container.Dispose(); + + File.Exists(filePath).Should().BeTrue(); + } + finally + { + if (Directory.Exists(Path.GetDirectoryName(dir)!)) + Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true); + } + } + + [Fact] + public void StateFilePath_DisposeWithEmptyContainer_SavesEmptyState() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "empty.json"); + + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + container.Dispose(); + + File.Exists(filePath).Should().BeTrue(); + var json = JObject.Parse(File.ReadAllText(filePath)); + json["items"].Should().HaveCount(0); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StateFilePath_MultipleSaves_OverwritesPrevious() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "overwrite.json"); + + // First run: 1 item + var c1 = new InMemoryContainer("test", "/partitionKey"); + c1.StateFilePath = filePath; + await c1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "first" }, + new PartitionKey("a")); + c1.Dispose(); + + // Second run: 2 items + var c2 = new InMemoryContainer("test", "/partitionKey"); + c2.StateFilePath = filePath; + c2.LoadPersistedState(); + await c2.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "b", Name = "second" }, + new PartitionKey("b")); + c2.Dispose(); + + // Third run: verify 2 items + var c3 = new InMemoryContainer("test", "/partitionKey"); + c3.StateFilePath = filePath; + c3.LoadPersistedState(); + c3.ItemCount.Should().Be(2); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task StatePersistenceDirectory_ViaOptions_AutoPersists() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + try + { + // Simulate the DI path: create container with persistence directory + var container = new InMemoryContainer("my-container", "/partitionKey"); + var expectedFile = Path.Combine(dir, "in-memory-db_my-container.json"); + container.StateFilePath = expectedFile; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "test" }, + new PartitionKey("a")); + container.Dispose(); + + File.Exists(expectedFile).Should().BeTrue(); + + // Restore + var container2 = new InMemoryContainer("my-container", "/partitionKey"); + container2.StateFilePath = expectedFile; + container2.LoadPersistedState(); + container2.ItemCount.Should().Be(1); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task LoadPersistedState_PreservesSystemProperties() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos-persist-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "system-props.json"); + + // Create, add item, capture etag, dispose (saves) + var container1 = new InMemoryContainer("test", "/partitionKey"); + container1.StateFilePath = filePath; + var createResponse = await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "a", Name = "Test" }, + new PartitionKey("a")); + var originalEtag = createResponse.ETag; + container1.Dispose(); + + // Reload — ExportState includes _etag and _ts in the JSON, and + // LoadPersistedState should preserve them + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.StateFilePath = filePath; + container2.LoadPersistedState(); + + var readResponse = await container2.ReadItemAsync("1", new PartitionKey("a")); + // The etag should be preserved from the exported state + readResponse.ETag.Should().NotBeNullOrEmpty(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1876,592 +1876,592 @@ public async Task LoadPersistedState_PreservesSystemProperties() // ── Batch 1: Export After Mutations ── public class ExportStateAfterMutationTests { - [Fact] - public async Task ExportState_AfterUpsert_ExportsLatestVersion() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, - new PartitionKey("pk")); - - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items.Should().HaveCount(1); - items[0]!["name"]!.Value().Should().Be("Updated"); - } - - [Fact] - public async Task ExportState_AfterDelete_DeletedItemNotIncluded() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "ToDelete" }, - new PartitionKey("pk")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Keeper" }, - new PartitionKey("pk")); - await container.DeleteItemAsync("1", new PartitionKey("pk")); - - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items.Should().HaveCount(1); - items[0]!["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task ExportState_AfterPatch_ExportsPatchedValues() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Before" }, - new PartitionKey("pk")); - await container.PatchItemAsync("1", new PartitionKey("pk"), - new[] { PatchOperation.Replace("/name", "After") }); - - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items[0]!["name"]!.Value().Should().Be("After"); - } - - [Fact] - public async Task ExportState_WithPartitionKeyNone_HandlesCorrectly() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1" }), - PartitionKey.None); - - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items.Should().HaveCount(1); - items[0]!["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task ExportState_AfterUpsert_ExportsLatestVersion() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Updated" }, + new PartitionKey("pk")); + + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items.Should().HaveCount(1); + items[0]!["name"]!.Value().Should().Be("Updated"); + } + + [Fact] + public async Task ExportState_AfterDelete_DeletedItemNotIncluded() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "ToDelete" }, + new PartitionKey("pk")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Keeper" }, + new PartitionKey("pk")); + await container.DeleteItemAsync("1", new PartitionKey("pk")); + + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items.Should().HaveCount(1); + items[0]!["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task ExportState_AfterPatch_ExportsPatchedValues() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Before" }, + new PartitionKey("pk")); + await container.PatchItemAsync("1", new PartitionKey("pk"), + new[] { PatchOperation.Replace("/name", "After") }); + + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items[0]!["name"]!.Value().Should().Be("After"); + } + + [Fact] + public async Task ExportState_WithPartitionKeyNone_HandlesCorrectly() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1" }), + PartitionKey.None); + + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items.Should().HaveCount(1); + items[0]!["id"]!.Value().Should().Be("1"); + } } // ── Batch 2: Import Fidelity ── public class ImportFidelityDeepDiveTests { - [Fact] - public async Task ImportState_ComputedPropertiesWorkOnImportedItems() - { - var props = new Microsoft.Azure.Cosmos.ContainerProperties("test", "/partitionKey") - { - ComputedProperties = - { - new ComputedProperty { Name = "fullDisplay", Query = "SELECT VALUE CONCAT(c.name, ' (', ToString(c[\"value\"]), ')') FROM c" } - } - }; - var container = new InMemoryContainer(props); - - container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Alice\",\"value\":10}]}"); - - var results = new List(); - var iter = container.GetItemQueryIterator("SELECT c.fullDisplay FROM c WHERE c.id = '1'"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results[0]["fullDisplay"]!.Value().Should().Be("Alice (10)"); - } - - [Fact] - public async Task ImportState_WithIntegerPartitionKeyValues_RoundTrips() - { - var container = new InMemoryContainer("test", "/tenantId"); - - container.ImportState("{\"items\":[{\"id\":\"1\",\"tenantId\":42,\"name\":\"Test\"}]}"); - - var response = await container.ReadItemAsync("1", new PartitionKey(42)); - response.Resource["name"]!.Value().Should().Be("Test"); - } - - [Fact] - public async Task ImportState_ThenRegisterUdf_UdfWorksOnImportedData() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"hello\"}]}"); - container.RegisterUdf("shout", args => args[0]?.ToString()?.ToUpper() + "!"); - - var results = new List(); - var iter = container.GetItemQueryIterator("SELECT udf.shout(c.name) AS r FROM c"); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results[0]["r"]!.Value().Should().Be("HELLO!"); - } - - [Fact] - public async Task ImportState_RegeneratesRidSelfAttachments() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Test\"}]}"); - - var response = await container.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource["_rid"].Should().NotBeNull(); - response.Resource["_self"].Should().NotBeNull(); - response.Resource["_attachments"].Should().NotBeNull(); - } + [Fact] + public async Task ImportState_ComputedPropertiesWorkOnImportedItems() + { + var props = new Microsoft.Azure.Cosmos.ContainerProperties("test", "/partitionKey") + { + ComputedProperties = + { + new ComputedProperty { Name = "fullDisplay", Query = "SELECT VALUE CONCAT(c.name, ' (', ToString(c[\"value\"]), ')') FROM c" } + } + }; + var container = new InMemoryContainer(props); + + container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Alice\",\"value\":10}]}"); + + var results = new List(); + var iter = container.GetItemQueryIterator("SELECT c.fullDisplay FROM c WHERE c.id = '1'"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results[0]["fullDisplay"]!.Value().Should().Be("Alice (10)"); + } + + [Fact] + public async Task ImportState_WithIntegerPartitionKeyValues_RoundTrips() + { + var container = new InMemoryContainer("test", "/tenantId"); + + container.ImportState("{\"items\":[{\"id\":\"1\",\"tenantId\":42,\"name\":\"Test\"}]}"); + + var response = await container.ReadItemAsync("1", new PartitionKey(42)); + response.Resource["name"]!.Value().Should().Be("Test"); + } + + [Fact] + public async Task ImportState_ThenRegisterUdf_UdfWorksOnImportedData() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"hello\"}]}"); + container.RegisterUdf("shout", args => args[0]?.ToString()?.ToUpper() + "!"); + + var results = new List(); + var iter = container.GetItemQueryIterator("SELECT udf.shout(c.name) AS r FROM c"); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results[0]["r"]!.Value().Should().Be("HELLO!"); + } + + [Fact] + public async Task ImportState_RegeneratesRidSelfAttachments() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Test\"}]}"); + + var response = await container.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource["_rid"].Should().NotBeNull(); + response.Resource["_self"].Should().NotBeNull(); + response.Resource["_attachments"].Should().NotBeNull(); + } } // ── Batch 3: TTL Edge Cases ── public class StatePersistenceTtlDeepDiveTests { - [Fact] - public async Task ExportState_WithTTLExpiredItem_ExcludesExpired() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.DefaultTimeToLive = 1; // 1 second TTL - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, - new PartitionKey("pk")); - - await Task.Delay(1500); // wait for TTL to expire - - // ExportState now filters out expired items (matches real Cosmos behavior) - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items.Should().HaveCount(0); - } - - [Fact] - public async Task ImportState_WithOldTimestamp_AndContainerTTL_ItemNotImmediatelyExpired() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.DefaultTimeToLive = 3600; // 1 hour - - // Import state — ImportState sets _timestamps to UtcNow, not the document's _ts - container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Test\",\"_ts\":1000000000}]}"); - - // Item should be readable since timestamps are renewed on import - var response = await container.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource["name"]!.Value().Should().Be("Test"); - } + [Fact] + public async Task ExportState_WithTTLExpiredItem_ExcludesExpired() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.DefaultTimeToLive = 1; // 1 second TTL + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Ephemeral" }, + new PartitionKey("pk")); + + await Task.Delay(1500); // wait for TTL to expire + + // ExportState now filters out expired items (matches real Cosmos behavior) + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items.Should().HaveCount(0); + } + + [Fact] + public async Task ImportState_WithOldTimestamp_AndContainerTTL_ItemNotImmediatelyExpired() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.DefaultTimeToLive = 3600; // 1 hour + + // Import state — ImportState sets _timestamps to UtcNow, not the document's _ts + container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Test\",\"_ts\":1000000000}]}"); + + // Item should be readable since timestamps are renewed on import + var response = await container.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource["name"]!.Value().Should().Be("Test"); + } } // ── Batch 4: Auto-Persist ── public class AutoPersistDeepDiveTests : IDisposable { - private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cosmos-ap-{Guid.NewGuid():N}"); - - public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } - - [Fact] - public void LoadPersistedState_DirectoryDoesNotExist_StartsEmpty() - { - var nonExistentDir = Path.Combine(Path.GetTempPath(), $"cosmos-nx-{Guid.NewGuid():N}"); - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = Path.Combine(nonExistentDir, "state.json"); - - // Should not crash — File.Exists returns false for non-existent dir - container.LoadPersistedState(); - - var state = JObject.Parse(container.ExportState()); - ((JArray)state["items"]!).Should().BeEmpty(); - } - - [Fact] - public void LoadPersistedState_StateFilePathNull_ThrowsInvalidOperationException() - { - var container = new InMemoryContainer("test", "/partitionKey"); - - var act = () => container.LoadPersistedState(); - - act.Should().Throw(); - } - - [Fact] - public async Task Dispose_CalledTwice_NoError() - { - var filePath = Path.Combine(_dir, "state.json"); - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); - - container.Dispose(); - container.Dispose(); // second call should be harmless - - File.Exists(filePath).Should().BeTrue(); - var state = JObject.Parse(File.ReadAllText(filePath)); - ((JArray)state["items"]!).Should().HaveCount(1); - } - - [Fact] - public async Task Dispose_AfterClearItems_SavesEmptyState() - { - var filePath = Path.Combine(_dir, "state.json"); - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); - container.ClearItems(); - container.Dispose(); - - var state = JObject.Parse(File.ReadAllText(filePath)); - ((JArray)state["items"]!).Should().BeEmpty(); - } - - [Fact] - public async Task LoadPersistedState_ModifyItems_Dispose_FinalStateIncludesAll() - { - var filePath = Path.Combine(_dir, "state.json"); - - // Phase 1: create initial state - var container1 = new InMemoryContainer("test", "/partitionKey"); - container1.StateFilePath = filePath; - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - container1.Dispose(); - - // Phase 2: load, modify, dispose - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.StateFilePath = filePath; - container2.LoadPersistedState(); - await container2.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Added" }, - new PartitionKey("pk")); - container2.Dispose(); - - // Phase 3: verify final state - var state = JObject.Parse(File.ReadAllText(filePath)); - var items = (JArray)state["items"]!; - items.Should().HaveCount(2); - } + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cosmos-ap-{Guid.NewGuid():N}"); + + public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } + + [Fact] + public void LoadPersistedState_DirectoryDoesNotExist_StartsEmpty() + { + var nonExistentDir = Path.Combine(Path.GetTempPath(), $"cosmos-nx-{Guid.NewGuid():N}"); + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = Path.Combine(nonExistentDir, "state.json"); + + // Should not crash — File.Exists returns false for non-existent dir + container.LoadPersistedState(); + + var state = JObject.Parse(container.ExportState()); + ((JArray)state["items"]!).Should().BeEmpty(); + } + + [Fact] + public void LoadPersistedState_StateFilePathNull_ThrowsInvalidOperationException() + { + var container = new InMemoryContainer("test", "/partitionKey"); + + var act = () => container.LoadPersistedState(); + + act.Should().Throw(); + } + + [Fact] + public async Task Dispose_CalledTwice_NoError() + { + var filePath = Path.Combine(_dir, "state.json"); + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); + + container.Dispose(); + container.Dispose(); // second call should be harmless + + File.Exists(filePath).Should().BeTrue(); + var state = JObject.Parse(File.ReadAllText(filePath)); + ((JArray)state["items"]!).Should().HaveCount(1); + } + + [Fact] + public async Task Dispose_AfterClearItems_SavesEmptyState() + { + var filePath = Path.Combine(_dir, "state.json"); + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); + container.ClearItems(); + container.Dispose(); + + var state = JObject.Parse(File.ReadAllText(filePath)); + ((JArray)state["items"]!).Should().BeEmpty(); + } + + [Fact] + public async Task LoadPersistedState_ModifyItems_Dispose_FinalStateIncludesAll() + { + var filePath = Path.Combine(_dir, "state.json"); + + // Phase 1: create initial state + var container1 = new InMemoryContainer("test", "/partitionKey"); + container1.StateFilePath = filePath; + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + container1.Dispose(); + + // Phase 2: load, modify, dispose + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.StateFilePath = filePath; + container2.LoadPersistedState(); + await container2.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Added" }, + new PartitionKey("pk")); + container2.Dispose(); + + // Phase 3: verify final state + var state = JObject.Parse(File.ReadAllText(filePath)); + var items = (JArray)state["items"]!; + items.Should().HaveCount(2); + } } // ── Batch 5: ClearItems Fidelity ── public class ClearItemsFidelityTests { - [Fact] - public async Task ClearItems_ClearsUniqueKeyTracking_CanRecreatePreviouslyConflictingItems() - { - var props = new Microsoft.Azure.Cosmos.ContainerProperties("test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(props); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Unique" }, - new PartitionKey("pk")); - - container.ClearItems(); - - // Should now be able to create an item with the same unique key value - var response = await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "Unique" }, - new PartitionKey("pk")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ClearItems_ThenExportState_EmptyExport() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); - - container.ClearItems(); - - var state = JObject.Parse(container.ExportState()); - ((JArray)state["items"]!).Should().BeEmpty(); - } + [Fact] + public async Task ClearItems_ClearsUniqueKeyTracking_CanRecreatePreviouslyConflictingItems() + { + var props = new Microsoft.Azure.Cosmos.ContainerProperties("test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Unique" }, + new PartitionKey("pk")); + + container.ClearItems(); + + // Should now be able to create an item with the same unique key value + var response = await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "Unique" }, + new PartitionKey("pk")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ClearItems_ThenExportState_EmptyExport() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); + + container.ClearItems(); + + var state = JObject.Parse(container.ExportState()); + ((JArray)state["items"]!).Should().BeEmpty(); + } } // ── Batch 6: PITR + Persistence Interaction ── public class PitrPersistenceInteractionTests : IDisposable { - private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cosmos-pitr-{Guid.NewGuid():N}"); - - public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } - - [Fact] - public async Task RestoreToPointInTime_ThenExportState_MatchesRestoredState() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1" }, - new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - - var state = JObject.Parse(container.ExportState()); - var items = (JArray)state["items"]!; - items.Should().HaveCount(1); - items[0]!["name"]!.Value().Should().Be("V1"); - } - - [Fact] - public async Task RestoreToPointInTime_ThenDispose_PersistedFileHasRestoredState() - { - var filePath = Path.Combine(_dir, "state.json"); - var container = new InMemoryContainer("test", "/partitionKey"); - container.StateFilePath = filePath; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1" }, - new PartitionKey("pk")); - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(50); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2" }, - new PartitionKey("pk")); - - container.RestoreToPointInTime(restorePoint); - container.Dispose(); - - var state = JObject.Parse(File.ReadAllText(filePath)); - var items = (JArray)state["items"]!; - items[0]!["name"]!.Value().Should().Be("V1"); - } - - [Fact] - public async Task ImportState_FromExportAfterPITR_DoubleRoundTrip() - { - var container1 = new InMemoryContainer("test", "/partitionKey"); - await container1.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - var snapshot = DateTimeOffset.UtcNow; - await Task.Delay(50); - await container1.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, - new PartitionKey("pk")); - - container1.RestoreToPointInTime(snapshot); - var exported = container1.ExportState(); - - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.ImportState(exported); - - var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("Original"); - } + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cosmos-pitr-{Guid.NewGuid():N}"); + + public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } + + [Fact] + public async Task RestoreToPointInTime_ThenExportState_MatchesRestoredState() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1" }, + new PartitionKey("pk")); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + + var state = JObject.Parse(container.ExportState()); + var items = (JArray)state["items"]!; + items.Should().HaveCount(1); + items[0]!["name"]!.Value().Should().Be("V1"); + } + + [Fact] + public async Task RestoreToPointInTime_ThenDispose_PersistedFileHasRestoredState() + { + var filePath = Path.Combine(_dir, "state.json"); + var container = new InMemoryContainer("test", "/partitionKey"); + container.StateFilePath = filePath; + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V1" }, + new PartitionKey("pk")); + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(50); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "V2" }, + new PartitionKey("pk")); + + container.RestoreToPointInTime(restorePoint); + container.Dispose(); + + var state = JObject.Parse(File.ReadAllText(filePath)); + var items = (JArray)state["items"]!; + items[0]!["name"]!.Value().Should().Be("V1"); + } + + [Fact] + public async Task ImportState_FromExportAfterPITR_DoubleRoundTrip() + { + var container1 = new InMemoryContainer("test", "/partitionKey"); + await container1.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + var snapshot = DateTimeOffset.UtcNow; + await Task.Delay(50); + await container1.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Modified" }, + new PartitionKey("pk")); + + container1.RestoreToPointInTime(snapshot); + var exported = container1.ExportState(); + + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.ImportState(exported); + + var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("Original"); + } } // ── Batch 7: File Operations + Cross-Platform ── public class FileOperationsDeepDiveTests { - [Fact] - public async Task ExportImportFile_PathWithSpaces_Works() - { - var dir = Path.Combine(Path.GetTempPath(), $"cosmos test {Guid.NewGuid():N}"); - try - { - Directory.CreateDirectory(dir); - var filePath = Path.Combine(dir, "state file.json"); - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); - - container.ExportStateToFile(filePath); - - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.ImportStateFromFile(filePath); - - var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("Test"); - } - finally - { - if (Directory.Exists(dir)) Directory.Delete(dir, true); - } - } - - [Fact] - public async Task ExportStateToFile_WritesUtf8() - { - var tempFile = Path.GetTempFileName(); - try - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk", name = "Ünïcödé 🎉" }), - new PartitionKey("pk")); - - container.ExportStateToFile(tempFile); - - var bytes = File.ReadAllBytes(tempFile); - var text = Encoding.UTF8.GetString(bytes); - text.Should().Contain("Ünïcödé 🎉"); - } - finally - { - File.Delete(tempFile); - } - } - - [Fact] - public async Task ExportImportFile_TempPath_CrossPlatformSafe() - { - var filePath = Path.Combine(Path.GetTempPath(), $"cosmos-xp-{Guid.NewGuid():N}.json"); - try - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "CrossPlatform" }, - new PartitionKey("pk")); - - container.ExportStateToFile(filePath); - - File.Exists(filePath).Should().BeTrue(); - var container2 = new InMemoryContainer("test", "/partitionKey"); - container2.ImportStateFromFile(filePath); - - var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); - response.Resource.Name.Should().Be("CrossPlatform"); - } - finally - { - if (File.Exists(filePath)) File.Delete(filePath); - } - } + [Fact] + public async Task ExportImportFile_PathWithSpaces_Works() + { + var dir = Path.Combine(Path.GetTempPath(), $"cosmos test {Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(dir); + var filePath = Path.Combine(dir, "state file.json"); + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); + + container.ExportStateToFile(filePath); + + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.ImportStateFromFile(filePath); + + var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("Test"); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, true); + } + } + + [Fact] + public async Task ExportStateToFile_WritesUtf8() + { + var tempFile = Path.GetTempFileName(); + try + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk", name = "Ünïcödé 🎉" }), + new PartitionKey("pk")); + + container.ExportStateToFile(tempFile); + + var bytes = File.ReadAllBytes(tempFile); + var text = Encoding.UTF8.GetString(bytes); + text.Should().Contain("Ünïcödé 🎉"); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task ExportImportFile_TempPath_CrossPlatformSafe() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cosmos-xp-{Guid.NewGuid():N}.json"); + try + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "CrossPlatform" }, + new PartitionKey("pk")); + + container.ExportStateToFile(filePath); + + File.Exists(filePath).Should().BeTrue(); + var container2 = new InMemoryContainer("test", "/partitionKey"); + container2.ImportStateFromFile(filePath); + + var response = await container2.ReadItemAsync("1", new PartitionKey("pk")); + response.Resource.Name.Should().Be("CrossPlatform"); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } } // ── Batch 8: Error Handling ── public class StatePersistenceErrorHandlingDeepDiveTests { - [Fact] - public void ImportState_TruncatedJson_ThrowsJsonException() - { - var container = new InMemoryContainer("test", "/partitionKey"); + [Fact] + public void ImportState_TruncatedJson_ThrowsJsonException() + { + var container = new InMemoryContainer("test", "/partitionKey"); - var act = () => container.ImportState("{\"items\":[{\"id\":\"1\""); + var act = () => container.ImportState("{\"items\":[{\"id\":\"1\""); - act.Should().Throw(); - } + act.Should().Throw(); + } - [Fact] - public async Task ExportStateToFile_InvalidPath_ThrowsIOException() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); + [Fact] + public async Task ExportStateToFile_InvalidPath_ThrowsIOException() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); - var act = () => container.ExportStateToFile(Path.Combine("Z:\\nonexistent\\path\\that\\should\\not\\exist", "state.json")); + var act = () => container.ExportStateToFile(Path.Combine("Z:\\nonexistent\\path\\that\\should\\not\\exist", "state.json")); - act.Should().Throw(); // DirectoryNotFoundException or IOException - } + act.Should().Throw(); // DirectoryNotFoundException or IOException + } } // ── Batch 9: Change Feed Deep ── public class StatePersistenceChangeFeedDeepDiveTests { - [Fact] - public async Task ImportState_ThenChangeFeed_OnlyGetsPostImportChanges() - { - var container = new InMemoryContainer("test", "/partitionKey"); - container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Imported\"}]}"); - - // Change feed after import should be empty - var iter1 = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - iter1.HasMoreResults.Should().BeFalse("ImportState does not populate change feed"); - - // Post-import write should appear in change feed - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk", Name = "New" }, - new PartitionKey("pk")); - - var iter2 = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - iter2.HasMoreResults.Should().BeTrue(); - var page = await iter2.ReadNextAsync(); - page.Count.Should().Be(1); - page.First()["id"]!.Value().Should().Be("2"); - } + [Fact] + public async Task ImportState_ThenChangeFeed_OnlyGetsPostImportChanges() + { + var container = new InMemoryContainer("test", "/partitionKey"); + container.ImportState("{\"items\":[{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Imported\"}]}"); + + // Change feed after import should be empty + var iter1 = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + iter1.HasMoreResults.Should().BeFalse("ImportState does not populate change feed"); + + // Post-import write should appear in change feed + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk", Name = "New" }, + new PartitionKey("pk")); + + var iter2 = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + iter2.HasMoreResults.Should().BeTrue(); + var page = await iter2.ReadNextAsync(); + page.Count.Should().Be(1); + page.First()["id"]!.Value().Should().Be("2"); + } } // ── Batch 10: Concurrency ── public class StatePersistenceConcurrencyDeepDiveTests { - [Fact] - public async Task ImportState_DuringConcurrentReads_NoCrash() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, - new PartitionKey("pk")); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var readTask = Task.Run(async () => - { - while (!cts.Token.IsCancellationRequested) - { - try - { - await container.ReadItemAsync("1", new PartitionKey("pk")); - } - catch (CosmosException) { /* item may disappear during import */ } - } - }); - - await Task.Delay(100); - container.ImportState("{\"items\":[{\"id\":\"2\",\"partitionKey\":\"pk\",\"name\":\"Imported\"}]}"); - - cts.Cancel(); - await readTask; // should not throw - } - - [Fact] - public async Task ClearItems_DuringConcurrentReads_NoCrash() - { - var container = new InMemoryContainer("test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, - new PartitionKey("pk")); - - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var readTask = Task.Run(async () => - { - while (!cts.Token.IsCancellationRequested) - { - try - { - await container.ReadItemAsync("1", new PartitionKey("pk")); - } - catch (CosmosException) { /* item cleared */ } - } - }); - - await Task.Delay(100); - container.ClearItems(); - - cts.Cancel(); - await readTask; // should not throw - } + [Fact] + public async Task ImportState_DuringConcurrentReads_NoCrash() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Original" }, + new PartitionKey("pk")); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var readTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + try + { + await container.ReadItemAsync("1", new PartitionKey("pk")); + } + catch (CosmosException) { /* item may disappear during import */ } + } + }); + + await Task.Delay(100); + container.ImportState("{\"items\":[{\"id\":\"2\",\"partitionKey\":\"pk\",\"name\":\"Imported\"}]}"); + + cts.Cancel(); + await readTask; // should not throw + } + + [Fact] + public async Task ClearItems_DuringConcurrentReads_NoCrash() + { + var container = new InMemoryContainer("test", "/partitionKey"); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk", Name = "Test" }, + new PartitionKey("pk")); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var readTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + try + { + await container.ReadItemAsync("1", new PartitionKey("pk")); + } + catch (CosmosException) { /* item cleared */ } + } + }); + + await Task.Delay(100); + container.ClearItems(); + + cts.Cancel(); + await readTask; // should not throw + } } // ── Batch 11: Hierarchical PK Edge ── public class HierarchicalPkPersistenceEdgeTests { - [Fact] - public async Task ExportImport_HierarchicalPK_MissingComponent_RoundTrips() - { - var container = new InMemoryContainer("test", new[] { "/tenantId", "/region" }); - // Create an item with both PK components - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenantId = "t1", region = "us" }), - new PartitionKeyBuilder().Add("t1").Add("us").Build()); - - var exported = container.ExportState(); - var container2 = new InMemoryContainer("test", new[] { "/tenantId", "/region" }); - container2.ImportState(exported); - - var response = await container2.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("us").Build()); - response.Resource["tenantId"]!.Value().Should().Be("t1"); - response.Resource["region"]!.Value().Should().Be("us"); - } + [Fact] + public async Task ExportImport_HierarchicalPK_MissingComponent_RoundTrips() + { + var container = new InMemoryContainer("test", new[] { "/tenantId", "/region" }); + // Create an item with both PK components + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", region = "us" }), + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + + var exported = container.ExportState(); + var container2 = new InMemoryContainer("test", new[] { "/tenantId", "/region" }); + container2.ImportState(exported); + + var response = await container2.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + response.Resource["tenantId"]!.Value().Should().Be("t1"); + response.Resource["region"]!.Value().Should().Be("us"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StoredProcedureTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StoredProcedureTests.cs index 135e434..f7b2a77 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StoredProcedureTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StoredProcedureTests.cs @@ -1,12 +1,12 @@ using System.Net; +using System.Text; using AwesomeAssertions; using CosmosDB.InMemoryEmulator.JsTriggers; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Scripts; using Newtonsoft.Json; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -16,824 +16,824 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class StoredProcedureTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - // ─── Create ────────────────────────────────────────────────────────── - - [Fact] - public async Task CreateStoredProcedure_ReturnsCreated() - { - var scripts = _container.Scripts; - - var response = await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spBulkDelete", - Body = "function() { return true; }" - }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Resource.Id.Should().Be("spBulkDelete"); - } - - [Fact] - public async Task CreateStoredProcedure_DuplicateId_Throws409() - { - var scripts = _container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDup", - Body = "function() { return 1; }" - }); - - var act = () => scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDup", - Body = "function() { return 2; }" - }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - // ─── Read ──────────────────────────────────────────────────────────── - - [Fact] - public async Task ReadStoredProcedure_AfterCreate_ReturnsProperties() - { - var scripts = _container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spRead", - Body = "function() { return 'hello'; }" - }); - - var response = await scripts.ReadStoredProcedureAsync("spRead"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("spRead"); - response.Resource.Body.Should().Be("function() { return 'hello'; }"); - } - - [Fact] - public async Task ReadStoredProcedure_NotFound_Throws404() - { - var scripts = _container.Scripts; - - var act = () => scripts.ReadStoredProcedureAsync("nonExistent"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ─── Replace ───────────────────────────────────────────────────────── - - [Fact] - public async Task ReplaceStoredProcedure_UpdatesBody() - { - var scripts = _container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", - Body = "function() { return 'v1'; }" - }); - - var replaceResponse = await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", - Body = "function() { return 'v2'; }" - }); - - replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); - replaceResponse.Resource.Body.Should().Be("function() { return 'v2'; }"); - - var readResponse = await scripts.ReadStoredProcedureAsync("spReplace"); - readResponse.Resource.Body.Should().Be("function() { return 'v2'; }"); - } - - [Fact] - public async Task ReplaceStoredProcedure_NotFound_Throws404() - { - var scripts = _container.Scripts; - - var act = () => scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties - { - Id = "nonExistent", - Body = "function() {}" - }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ─── Delete ────────────────────────────────────────────────────────── - - [Fact] - public async Task DeleteStoredProcedure_RemovesMetadata() - { - var scripts = _container.Scripts; - - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDelete", - Body = "function() {}" - }); - - var deleteResponse = await scripts.DeleteStoredProcedureAsync("spDelete"); - deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var act = () => scripts.ReadStoredProcedureAsync("spDelete"); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteStoredProcedure_NotFound_Throws404() - { - var scripts = _container.Scripts; - - var act = () => scripts.DeleteStoredProcedureAsync("nonExistent"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - // ─── Execute ───────────────────────────────────────────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_WithRegisteredHandler_ExecutesLogic() - { - _container.RegisterStoredProcedure("spGetCount", (partitionKey, args) => - { - return "42"; - }); - - var scripts = _container.Scripts; - var response = await scripts.ExecuteStoredProcedureAsync( - "spGetCount", - new PartitionKey("pk1"), - Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().Be("42"); - } - - [Fact] - public async Task ExecuteStoredProcedure_WithRegisteredHandler_ReceivesArguments() - { - _container.RegisterStoredProcedure("spConcat", (partitionKey, args) => - { - return string.Join("-", args.Select(a => a?.ToString())); - }); - - var scripts = _container.Scripts; - var response = await scripts.ExecuteStoredProcedureAsync( - "spConcat", - new PartitionKey("pk1"), - new dynamic[] { "hello", "world" }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().Be("hello-world"); - } - - [Fact] - public async Task ExecuteStoredProcedure_ReturnsRequestCharge() - { - _container.RegisterStoredProcedure("spCharge", (pk, args) => "ok"); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCharge", new PartitionKey("pk1"), Array.Empty()); - - response.RequestCharge.Should().BeGreaterThan(0); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerReturnsNull_ResourceIsNull() - { - _container.RegisterStoredProcedure("spNull", (pk, args) => null!); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spNull", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().BeNull(); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerReturnsComplexJson_Deserialized() - { - _container.RegisterStoredProcedure("spJson", (pk, args) => - JsonConvert.SerializeObject(new { count = 5, label = "items" })); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spJson", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var parsed = JObject.Parse(response.Resource); - ((int)parsed["count"]!).Should().Be(5); - ((string)parsed["label"]!).Should().Be("items"); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerThrowsException_PropagatesException() - { - _container.RegisterStoredProcedure("spThrow", (pk, args) => - throw new InvalidOperationException("sproc failure")); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spThrow", new PartitionKey("pk1"), Array.Empty()); - - await act.Should().ThrowAsync() - .WithMessage("*sproc failure*"); - } - - [Fact] - public async Task ExecuteStoredProcedure_EmptyArguments_PassedToHandler() - { - dynamic[]? received = null; - _container.RegisterStoredProcedure("spArgs", (pk, args) => - { - received = args; - return "ok"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spArgs", new PartitionKey("pk1"), Array.Empty()); - - received.Should().NotBeNull(); - received.Should().BeEmpty(); - } - - [Fact] - public async Task ExecuteStoredProcedure_ManyArguments_AllPassedToHandler() - { - dynamic[]? received = null; - _container.RegisterStoredProcedure("spMany", (pk, args) => - { - received = args; - return "ok"; - }); - - var manyArgs = Enumerable.Range(0, 10).Select(i => (dynamic)i.ToString()).ToArray(); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spMany", new PartitionKey("pk1"), manyArgs); - - received.Should().HaveCount(10); - } - - [Fact] - public async Task ExecuteStoredProcedure_ComplexJsonArguments_Deserializable() - { - string? firstArg = null; - _container.RegisterStoredProcedure("spComplex", (pk, args) => - { - firstArg = args[0]?.ToString(); - return "ok"; - }); - - var complexObj = JObject.FromObject(new { name = "test", value = 42 }); - await _container.Scripts.ExecuteStoredProcedureAsync( - "spComplex", new PartitionKey("pk1"), new dynamic[] { complexObj }); - - firstArg.Should().NotBeNullOrEmpty(); - } - - // ─── Registration & deregistration ─────────────────────────────────── - - [Fact] - public async Task RegisterStoredProcedure_WithContainerAccess_CanReadItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, - new PartitionKey("pk1")); - - _container.RegisterStoredProcedure("spSum", (partitionKey, args) => - { - var query = new QueryDefinition("SELECT VALUE SUM(c.value) FROM c"); - var iterator = _container.GetItemQueryIterator(query); - var total = 0.0; - while (iterator.HasMoreResults) - { - var response = iterator.ReadNextAsync().GetAwaiter().GetResult(); - foreach (var val in response) - { - total += val; - } - } - return JsonConvert.SerializeObject((int)total); - }); - - var scripts = _container.Scripts; - var result = await scripts.ExecuteStoredProcedureAsync( - "spSum", - new PartitionKey("pk1"), - Array.Empty()); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Resource.Should().Be("30"); - } - - [Fact] - public async Task RegisterStoredProcedure_HandlerCanCreateItems() - { - _container.RegisterStoredProcedure("spCreate", (pk, args) => - { - _container.CreateItemAsync( - new TestDocument { Id = "created-by-sproc", PartitionKey = "pk1", Name = "SprocCreated" }, - new PartitionKey("pk1")).GetAwaiter().GetResult(); - return "created"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spCreate", new PartitionKey("pk1"), Array.Empty()); - - var readBack = await _container.ReadItemAsync("created-by-sproc", new PartitionKey("pk1")); - readBack.Resource.Name.Should().Be("SprocCreated"); - } - - [Fact] - public async Task RegisterStoredProcedure_HandlerCanDeleteItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "to-delete", PartitionKey = "pk1", Name = "Doomed" }, - new PartitionKey("pk1")); - - _container.RegisterStoredProcedure("spDelete", (pk, args) => - { - _container.DeleteItemAsync("to-delete", new PartitionKey("pk1")) - .GetAwaiter().GetResult(); - return "deleted"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spDelete", new PartitionKey("pk1"), Array.Empty()); - - var act = () => _container.ReadItemAsync("to-delete", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task RegisterStoredProcedure_HandlerCanReplaceItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "to-replace", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - _container.RegisterStoredProcedure("spReplace", (pk, args) => - { - _container.ReplaceItemAsync( - new TestDocument { Id = "to-replace", PartitionKey = "pk1", Name = "Updated" }, - "to-replace", - new PartitionKey("pk1")).GetAwaiter().GetResult(); - return "replaced"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spReplace", new PartitionKey("pk1"), Array.Empty()); - - var readBack = await _container.ReadItemAsync("to-replace", new PartitionKey("pk1")); - readBack.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task RegisterStoredProcedure_HandlerCanQueryWithFilter() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 50 }, - new PartitionKey("pk1")); - - _container.RegisterStoredProcedure("spHigh", (pk, args) => - { - var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.value > 20"); - var iterator = _container.GetItemQueryIterator(query); - var names = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - names.AddRange(page); - } - return JsonConvert.SerializeObject(names); - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spHigh", new PartitionKey("pk1"), Array.Empty()); - - var names = JsonConvert.DeserializeObject>(result.Resource); - names.Should().ContainSingle().Which.Should().Be("Bob"); - } - - [Fact] - public async Task RegisterStoredProcedure_BulkDeletePattern() - { - for (int i = 0; i < 5; i++) - { - await _container.CreateItemAsync( - new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1")); - } - - _container.RegisterStoredProcedure("spBulkDelete", (pk, args) => - { - var query = new QueryDefinition("SELECT * FROM c"); - var iterator = _container.GetItemQueryIterator(query); - var deleted = 0; - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - foreach (var item in page) - { - var id = item["id"]!.ToString(); - var itemPk = item["partitionKey"]!.ToString(); - _container.DeleteItemAsync(id, new PartitionKey(itemPk)) - .GetAwaiter().GetResult(); - deleted++; - } - } - return JsonConvert.SerializeObject(new { deleted }); - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spBulkDelete", new PartitionKey("pk1"), Array.Empty()); - - var parsed = JObject.Parse(result.Resource); - ((int)parsed["deleted"]!).Should().Be(5); - } - - [Fact] - public async Task DeregisterStoredProcedure_RemovesHandler() - { - _container.RegisterStoredProcedure("spTemp", (partitionKey, args) => "result"); - _container.DeregisterStoredProcedure("spTemp"); - - var scripts = _container.Scripts; - var act = () => scripts.ExecuteStoredProcedureAsync( - "spTemp", - new PartitionKey("pk1"), - Array.Empty()); - - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task RegisterStoredProcedure_SameIdTwice_OverwritesHandler() - { - _container.RegisterStoredProcedure("spOverwrite", (pk, args) => "first"); - _container.RegisterStoredProcedure("spOverwrite", (pk, args) => "second"); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spOverwrite", new PartitionKey("pk1"), Array.Empty()); - - response.Resource.Should().Be("second"); - } - - [Fact] - public async Task RegisterStoredProcedure_CaseSensitive_DifferentHandlers() - { - _container.RegisterStoredProcedure("myProc", (pk, args) => "lower"); - _container.RegisterStoredProcedure("MYPROC", (pk, args) => "upper"); - - var lower = await _container.Scripts.ExecuteStoredProcedureAsync( - "myProc", new PartitionKey("pk1"), Array.Empty()); - var upper = await _container.Scripts.ExecuteStoredProcedureAsync( - "MYPROC", new PartitionKey("pk1"), Array.Empty()); - - lower.Resource.Should().Be("lower"); - upper.Resource.Should().Be("upper"); - } - - [Fact] - public void DeregisterStoredProcedure_NonExistent_DoesNotThrow() - { - var act = () => _container.DeregisterStoredProcedure("neverRegistered"); - act.Should().NotThrow(); - } - - [Fact] - public async Task DeregisterStoredProcedure_ThenReRegister_Works() - { - _container.RegisterStoredProcedure("spCycle", (pk, args) => "v1"); - _container.DeregisterStoredProcedure("spCycle"); - _container.RegisterStoredProcedure("spCycle", (pk, args) => "v2"); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCycle", new PartitionKey("pk1"), Array.Empty()); - - response.Resource.Should().Be("v2"); - } - - // ─── CRUD edge cases (T8–T13) ──────────────────────────────────────── - - [Fact] - public async Task DeleteStoredProcedure_ThenReCreate_SameId_Works() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "spRecreate", Body = "function() { return 'v1'; }" }); - await scripts.DeleteStoredProcedureAsync("spRecreate"); - - var response = await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "spRecreate", Body = "function() { return 'v2'; }" }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await scripts.ReadStoredProcedureAsync("spRecreate"); - read.Resource.Body.Should().Be("function() { return 'v2'; }"); - } - - [Fact] - public async Task CreateStoredProcedure_MultipleDistinct_AllReadable() - { - var scripts = _container.Scripts; - for (int i = 0; i < 5; i++) - { - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = $"sp-{i}", Body = $"function() {{ return {i}; }}" }); - } - - for (int i = 0; i < 5; i++) - { - var read = await scripts.ReadStoredProcedureAsync($"sp-{i}"); - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Body.Should().Be($"function() {{ return {i}; }}"); - } - } - - [Fact] - public async Task CreateStoredProcedure_SpecialCharactersInId_Works() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp-bulk.delete_v2", Body = "function() {}" }); - - var read = await scripts.ReadStoredProcedureAsync("sp-bulk.delete_v2"); - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Id.Should().Be("sp-bulk.delete_v2"); - } - - [Fact] - public async Task CreateStoredProcedure_BodyPreservedExactly() - { - var body = "function(arg) {\n\tvar x = 'hello world';\n\treturn x + ' ñ 日本語';\n}"; - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "spUnicode", Body = body }); - - var read = await scripts.ReadStoredProcedureAsync("spUnicode"); - read.Resource.Body.Should().Be(body); - } - - [Fact] - public async Task ReplaceStoredProcedure_OldBodyNotAccessible() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "spOldBody", Body = "function() { return 'v1'; }" }); - - await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties - { Id = "spOldBody", Body = "function() { return 'v2'; }" }); - - var read = await scripts.ReadStoredProcedureAsync("spOldBody"); - read.Resource.Body.Should().Be("function() { return 'v2'; }"); - read.Resource.Body.Should().NotBe("function() { return 'v1'; }"); - } - - [Fact] - public async Task CreateStoredProcedure_CaseSensitiveIds() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "myProc", Body = "function() { return 'lower'; }" }); - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "MYPROC", Body = "function() { return 'upper'; }" }); - - var lower = await scripts.ReadStoredProcedureAsync("myProc"); - var upper = await scripts.ReadStoredProcedureAsync("MYPROC"); - - lower.Resource.Body.Should().Be("function() { return 'lower'; }"); - upper.Resource.Body.Should().Be("function() { return 'upper'; }"); - } - - // ─── Execution advanced scenarios (T14–T21) ───────────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_HandlerReturnsEmptyString_ResourceIsEmpty() - { - _container.RegisterStoredProcedure("spEmpty", (pk, args) => ""); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spEmpty", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Should().Be(""); - } - - [Fact] - public async Task ExecuteStoredProcedure_PartitionKeyNull_PassedCorrectly() - { - PartitionKey? received = null; - _container.RegisterStoredProcedure("spPkNull", (pk, args) => - { - received = pk; - return "ok"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spPkNull", PartitionKey.Null, Array.Empty()); - - received.Should().NotBeNull(); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerThrowsCosmosException_PropagatesWithStatusCode() - { - _container.RegisterStoredProcedure("spCosmos", (pk, args) => - throw new CosmosException("bad request", HttpStatusCode.BadRequest, 0, "", 0)); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spCosmos", new PartitionKey("pk1"), Array.Empty()); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerModifiesArguments_NoSideEffects() - { - _container.RegisterStoredProcedure("spMutate", (pk, args) => - { - args[0] = "mutated"; - return "ok"; - }); - - var myArgs = new dynamic[] { "original" }; - await _container.Scripts.ExecuteStoredProcedureAsync( - "spMutate", new PartitionKey("pk1"), myArgs); - - // Reference semantics: the handler mutates the same array - ((string)myArgs[0]).Should().Be("mutated"); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerCanUpsertItems() - { - _container.RegisterStoredProcedure("spUpsert", (pk, args) => - { - _container.UpsertItemAsync( - new TestDocument { Id = "upserted", PartitionKey = "pk1", Name = "Upserted" }, - new PartitionKey("pk1")).GetAwaiter().GetResult(); - return "done"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spUpsert", new PartitionKey("pk1"), Array.Empty()); - - var read = await _container.ReadItemAsync("upserted", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Upserted"); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerCanPatchItems() - { - await _container.CreateItemAsync( - new TestDocument { Id = "to-patch", PartitionKey = "pk1", Name = "Before" }, - new PartitionKey("pk1")); - - _container.RegisterStoredProcedure("spPatch", (pk, args) => - { - _container.PatchItemAsync("to-patch", new PartitionKey("pk1"), - new[] { PatchOperation.Replace("/name", "After") }).GetAwaiter().GetResult(); - return "patched"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spPatch", new PartitionKey("pk1"), Array.Empty()); - - var read = await _container.ReadItemAsync("to-patch", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("After"); - } - - [Fact] - public async Task ExecuteStoredProcedure_SequentialExecutions_IndependentResults() - { - var callCount = 0; - _container.RegisterStoredProcedure("spSeq", (pk, args) => - { - callCount++; - return callCount.ToString(); - }); - - var r1 = await _container.Scripts.ExecuteStoredProcedureAsync( - "spSeq", new PartitionKey("pk1"), Array.Empty()); - var r2 = await _container.Scripts.ExecuteStoredProcedureAsync( - "spSeq", new PartitionKey("pk1"), Array.Empty()); - var r3 = await _container.Scripts.ExecuteStoredProcedureAsync( - "spSeq", new PartitionKey("pk1"), Array.Empty()); - - r1.Resource.Should().Be("1"); - r2.Resource.Should().Be("2"); - r3.Resource.Should().Be("3"); - } - - [Fact] - public async Task ExecuteStoredProcedure_HandlerUsesPartitionKeyToScope() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); - - _container.RegisterStoredProcedure("spScoped", (pk, args) => - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = pk }); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - items.AddRange(page.Select(x => x.Name)); - } - return JsonConvert.SerializeObject(items); - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spScoped", new PartitionKey("pk-a"), Array.Empty()); - - var names = JsonConvert.DeserializeObject>(result.Resource); - names.Should().ContainSingle().Which.Should().Be("A"); - } - - // ─── Response metadata (T25–T27) ──────────────────────────────────── - - [Fact] - public async Task CreateStoredProcedure_Response_HasResourceWithIdAndBody() - { - var response = await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spMeta", - Body = "function() { return 42; }" - }); - - response.Resource.Id.Should().Be("spMeta"); - response.Resource.Body.Should().Be("function() { return 42; }"); - } - - [Fact] - public async Task ReplaceStoredProcedure_Response_HasUpdatedResource() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "spRepMeta", Body = "v1" }); - - var response = await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties - { Id = "spRepMeta", Body = "v2" }); - - response.Resource.Body.Should().Be("v2"); - response.Resource.Id.Should().Be("spRepMeta"); - } - - // ─── Partition key behavior ────────────────────────────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_PartitionKeyValue_PassedCorrectly() - { - PartitionKey? received = null; - _container.RegisterStoredProcedure("spPk", (pk, args) => - { - received = pk; - return "ok"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spPk", new PartitionKey("specific-pk"), Array.Empty()); - - received.Should().NotBeNull(); - received.ToString().Should().Contain("specific-pk"); - } - - [Fact] - public async Task ExecuteStoredProcedure_PartitionKeyNone_PassedCorrectly() - { - PartitionKey? received = null; - _container.RegisterStoredProcedure("spPkNone", (pk, args) => - { - received = pk; - return "ok"; - }); - - await _container.Scripts.ExecuteStoredProcedureAsync( - "spPkNone", PartitionKey.None, Array.Empty()); - - received.Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + // ─── Create ────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateStoredProcedure_ReturnsCreated() + { + var scripts = _container.Scripts; + + var response = await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spBulkDelete", + Body = "function() { return true; }" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Resource.Id.Should().Be("spBulkDelete"); + } + + [Fact] + public async Task CreateStoredProcedure_DuplicateId_Throws409() + { + var scripts = _container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDup", + Body = "function() { return 1; }" + }); + + var act = () => scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDup", + Body = "function() { return 2; }" + }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // ─── Read ──────────────────────────────────────────────────────────── + + [Fact] + public async Task ReadStoredProcedure_AfterCreate_ReturnsProperties() + { + var scripts = _container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spRead", + Body = "function() { return 'hello'; }" + }); + + var response = await scripts.ReadStoredProcedureAsync("spRead"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("spRead"); + response.Resource.Body.Should().Be("function() { return 'hello'; }"); + } + + [Fact] + public async Task ReadStoredProcedure_NotFound_Throws404() + { + var scripts = _container.Scripts; + + var act = () => scripts.ReadStoredProcedureAsync("nonExistent"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ─── Replace ───────────────────────────────────────────────────────── + + [Fact] + public async Task ReplaceStoredProcedure_UpdatesBody() + { + var scripts = _container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = "function() { return 'v1'; }" + }); + + var replaceResponse = await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = "function() { return 'v2'; }" + }); + + replaceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + replaceResponse.Resource.Body.Should().Be("function() { return 'v2'; }"); + + var readResponse = await scripts.ReadStoredProcedureAsync("spReplace"); + readResponse.Resource.Body.Should().Be("function() { return 'v2'; }"); + } + + [Fact] + public async Task ReplaceStoredProcedure_NotFound_Throws404() + { + var scripts = _container.Scripts; + + var act = () => scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties + { + Id = "nonExistent", + Body = "function() {}" + }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ─── Delete ────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteStoredProcedure_RemovesMetadata() + { + var scripts = _container.Scripts; + + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDelete", + Body = "function() {}" + }); + + var deleteResponse = await scripts.DeleteStoredProcedureAsync("spDelete"); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var act = () => scripts.ReadStoredProcedureAsync("spDelete"); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteStoredProcedure_NotFound_Throws404() + { + var scripts = _container.Scripts; + + var act = () => scripts.DeleteStoredProcedureAsync("nonExistent"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ─── Execute ───────────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_WithRegisteredHandler_ExecutesLogic() + { + _container.RegisterStoredProcedure("spGetCount", (partitionKey, args) => + { + return "42"; + }); + + var scripts = _container.Scripts; + var response = await scripts.ExecuteStoredProcedureAsync( + "spGetCount", + new PartitionKey("pk1"), + Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().Be("42"); + } + + [Fact] + public async Task ExecuteStoredProcedure_WithRegisteredHandler_ReceivesArguments() + { + _container.RegisterStoredProcedure("spConcat", (partitionKey, args) => + { + return string.Join("-", args.Select(a => a?.ToString())); + }); + + var scripts = _container.Scripts; + var response = await scripts.ExecuteStoredProcedureAsync( + "spConcat", + new PartitionKey("pk1"), + new dynamic[] { "hello", "world" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().Be("hello-world"); + } + + [Fact] + public async Task ExecuteStoredProcedure_ReturnsRequestCharge() + { + _container.RegisterStoredProcedure("spCharge", (pk, args) => "ok"); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCharge", new PartitionKey("pk1"), Array.Empty()); + + response.RequestCharge.Should().BeGreaterThan(0); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerReturnsNull_ResourceIsNull() + { + _container.RegisterStoredProcedure("spNull", (pk, args) => null!); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spNull", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().BeNull(); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerReturnsComplexJson_Deserialized() + { + _container.RegisterStoredProcedure("spJson", (pk, args) => + JsonConvert.SerializeObject(new { count = 5, label = "items" })); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spJson", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var parsed = JObject.Parse(response.Resource); + ((int)parsed["count"]!).Should().Be(5); + ((string)parsed["label"]!).Should().Be("items"); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerThrowsException_PropagatesException() + { + _container.RegisterStoredProcedure("spThrow", (pk, args) => + throw new InvalidOperationException("sproc failure")); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spThrow", new PartitionKey("pk1"), Array.Empty()); + + await act.Should().ThrowAsync() + .WithMessage("*sproc failure*"); + } + + [Fact] + public async Task ExecuteStoredProcedure_EmptyArguments_PassedToHandler() + { + dynamic[]? received = null; + _container.RegisterStoredProcedure("spArgs", (pk, args) => + { + received = args; + return "ok"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spArgs", new PartitionKey("pk1"), Array.Empty()); + + received.Should().NotBeNull(); + received.Should().BeEmpty(); + } + + [Fact] + public async Task ExecuteStoredProcedure_ManyArguments_AllPassedToHandler() + { + dynamic[]? received = null; + _container.RegisterStoredProcedure("spMany", (pk, args) => + { + received = args; + return "ok"; + }); + + var manyArgs = Enumerable.Range(0, 10).Select(i => (dynamic)i.ToString()).ToArray(); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spMany", new PartitionKey("pk1"), manyArgs); + + received.Should().HaveCount(10); + } + + [Fact] + public async Task ExecuteStoredProcedure_ComplexJsonArguments_Deserializable() + { + string? firstArg = null; + _container.RegisterStoredProcedure("spComplex", (pk, args) => + { + firstArg = args[0]?.ToString(); + return "ok"; + }); + + var complexObj = JObject.FromObject(new { name = "test", value = 42 }); + await _container.Scripts.ExecuteStoredProcedureAsync( + "spComplex", new PartitionKey("pk1"), new dynamic[] { complexObj }); + + firstArg.Should().NotBeNullOrEmpty(); + } + + // ─── Registration & deregistration ─────────────────────────────────── + + [Fact] + public async Task RegisterStoredProcedure_WithContainerAccess_CanReadItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 20 }, + new PartitionKey("pk1")); + + _container.RegisterStoredProcedure("spSum", (partitionKey, args) => + { + var query = new QueryDefinition("SELECT VALUE SUM(c.value) FROM c"); + var iterator = _container.GetItemQueryIterator(query); + var total = 0.0; + while (iterator.HasMoreResults) + { + var response = iterator.ReadNextAsync().GetAwaiter().GetResult(); + foreach (var val in response) + { + total += val; + } + } + return JsonConvert.SerializeObject((int)total); + }); + + var scripts = _container.Scripts; + var result = await scripts.ExecuteStoredProcedureAsync( + "spSum", + new PartitionKey("pk1"), + Array.Empty()); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Resource.Should().Be("30"); + } + + [Fact] + public async Task RegisterStoredProcedure_HandlerCanCreateItems() + { + _container.RegisterStoredProcedure("spCreate", (pk, args) => + { + _container.CreateItemAsync( + new TestDocument { Id = "created-by-sproc", PartitionKey = "pk1", Name = "SprocCreated" }, + new PartitionKey("pk1")).GetAwaiter().GetResult(); + return "created"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spCreate", new PartitionKey("pk1"), Array.Empty()); + + var readBack = await _container.ReadItemAsync("created-by-sproc", new PartitionKey("pk1")); + readBack.Resource.Name.Should().Be("SprocCreated"); + } + + [Fact] + public async Task RegisterStoredProcedure_HandlerCanDeleteItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "to-delete", PartitionKey = "pk1", Name = "Doomed" }, + new PartitionKey("pk1")); + + _container.RegisterStoredProcedure("spDelete", (pk, args) => + { + _container.DeleteItemAsync("to-delete", new PartitionKey("pk1")) + .GetAwaiter().GetResult(); + return "deleted"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spDelete", new PartitionKey("pk1"), Array.Empty()); + + var act = () => _container.ReadItemAsync("to-delete", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RegisterStoredProcedure_HandlerCanReplaceItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "to-replace", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + _container.RegisterStoredProcedure("spReplace", (pk, args) => + { + _container.ReplaceItemAsync( + new TestDocument { Id = "to-replace", PartitionKey = "pk1", Name = "Updated" }, + "to-replace", + new PartitionKey("pk1")).GetAwaiter().GetResult(); + return "replaced"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spReplace", new PartitionKey("pk1"), Array.Empty()); + + var readBack = await _container.ReadItemAsync("to-replace", new PartitionKey("pk1")); + readBack.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task RegisterStoredProcedure_HandlerCanQueryWithFilter() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob", Value = 50 }, + new PartitionKey("pk1")); + + _container.RegisterStoredProcedure("spHigh", (pk, args) => + { + var query = new QueryDefinition("SELECT VALUE c.name FROM c WHERE c.value > 20"); + var iterator = _container.GetItemQueryIterator(query); + var names = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + names.AddRange(page); + } + return JsonConvert.SerializeObject(names); + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spHigh", new PartitionKey("pk1"), Array.Empty()); + + var names = JsonConvert.DeserializeObject>(result.Resource); + names.Should().ContainSingle().Which.Should().Be("Bob"); + } + + [Fact] + public async Task RegisterStoredProcedure_BulkDeletePattern() + { + for (int i = 0; i < 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"item-{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1")); + } + + _container.RegisterStoredProcedure("spBulkDelete", (pk, args) => + { + var query = new QueryDefinition("SELECT * FROM c"); + var iterator = _container.GetItemQueryIterator(query); + var deleted = 0; + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + foreach (var item in page) + { + var id = item["id"]!.ToString(); + var itemPk = item["partitionKey"]!.ToString(); + _container.DeleteItemAsync(id, new PartitionKey(itemPk)) + .GetAwaiter().GetResult(); + deleted++; + } + } + return JsonConvert.SerializeObject(new { deleted }); + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spBulkDelete", new PartitionKey("pk1"), Array.Empty()); + + var parsed = JObject.Parse(result.Resource); + ((int)parsed["deleted"]!).Should().Be(5); + } + + [Fact] + public async Task DeregisterStoredProcedure_RemovesHandler() + { + _container.RegisterStoredProcedure("spTemp", (partitionKey, args) => "result"); + _container.DeregisterStoredProcedure("spTemp"); + + var scripts = _container.Scripts; + var act = () => scripts.ExecuteStoredProcedureAsync( + "spTemp", + new PartitionKey("pk1"), + Array.Empty()); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task RegisterStoredProcedure_SameIdTwice_OverwritesHandler() + { + _container.RegisterStoredProcedure("spOverwrite", (pk, args) => "first"); + _container.RegisterStoredProcedure("spOverwrite", (pk, args) => "second"); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spOverwrite", new PartitionKey("pk1"), Array.Empty()); + + response.Resource.Should().Be("second"); + } + + [Fact] + public async Task RegisterStoredProcedure_CaseSensitive_DifferentHandlers() + { + _container.RegisterStoredProcedure("myProc", (pk, args) => "lower"); + _container.RegisterStoredProcedure("MYPROC", (pk, args) => "upper"); + + var lower = await _container.Scripts.ExecuteStoredProcedureAsync( + "myProc", new PartitionKey("pk1"), Array.Empty()); + var upper = await _container.Scripts.ExecuteStoredProcedureAsync( + "MYPROC", new PartitionKey("pk1"), Array.Empty()); + + lower.Resource.Should().Be("lower"); + upper.Resource.Should().Be("upper"); + } + + [Fact] + public void DeregisterStoredProcedure_NonExistent_DoesNotThrow() + { + var act = () => _container.DeregisterStoredProcedure("neverRegistered"); + act.Should().NotThrow(); + } + + [Fact] + public async Task DeregisterStoredProcedure_ThenReRegister_Works() + { + _container.RegisterStoredProcedure("spCycle", (pk, args) => "v1"); + _container.DeregisterStoredProcedure("spCycle"); + _container.RegisterStoredProcedure("spCycle", (pk, args) => "v2"); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCycle", new PartitionKey("pk1"), Array.Empty()); + + response.Resource.Should().Be("v2"); + } + + // ─── CRUD edge cases (T8–T13) ──────────────────────────────────────── + + [Fact] + public async Task DeleteStoredProcedure_ThenReCreate_SameId_Works() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "spRecreate", Body = "function() { return 'v1'; }" }); + await scripts.DeleteStoredProcedureAsync("spRecreate"); + + var response = await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "spRecreate", Body = "function() { return 'v2'; }" }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await scripts.ReadStoredProcedureAsync("spRecreate"); + read.Resource.Body.Should().Be("function() { return 'v2'; }"); + } + + [Fact] + public async Task CreateStoredProcedure_MultipleDistinct_AllReadable() + { + var scripts = _container.Scripts; + for (int i = 0; i < 5; i++) + { + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = $"sp-{i}", Body = $"function() {{ return {i}; }}" }); + } + + for (int i = 0; i < 5; i++) + { + var read = await scripts.ReadStoredProcedureAsync($"sp-{i}"); + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Body.Should().Be($"function() {{ return {i}; }}"); + } + } + + [Fact] + public async Task CreateStoredProcedure_SpecialCharactersInId_Works() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp-bulk.delete_v2", Body = "function() {}" }); + + var read = await scripts.ReadStoredProcedureAsync("sp-bulk.delete_v2"); + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Id.Should().Be("sp-bulk.delete_v2"); + } + + [Fact] + public async Task CreateStoredProcedure_BodyPreservedExactly() + { + var body = "function(arg) {\n\tvar x = 'hello world';\n\treturn x + ' ñ 日本語';\n}"; + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "spUnicode", Body = body }); + + var read = await scripts.ReadStoredProcedureAsync("spUnicode"); + read.Resource.Body.Should().Be(body); + } + + [Fact] + public async Task ReplaceStoredProcedure_OldBodyNotAccessible() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "spOldBody", Body = "function() { return 'v1'; }" }); + + await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties + { Id = "spOldBody", Body = "function() { return 'v2'; }" }); + + var read = await scripts.ReadStoredProcedureAsync("spOldBody"); + read.Resource.Body.Should().Be("function() { return 'v2'; }"); + read.Resource.Body.Should().NotBe("function() { return 'v1'; }"); + } + + [Fact] + public async Task CreateStoredProcedure_CaseSensitiveIds() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "myProc", Body = "function() { return 'lower'; }" }); + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "MYPROC", Body = "function() { return 'upper'; }" }); + + var lower = await scripts.ReadStoredProcedureAsync("myProc"); + var upper = await scripts.ReadStoredProcedureAsync("MYPROC"); + + lower.Resource.Body.Should().Be("function() { return 'lower'; }"); + upper.Resource.Body.Should().Be("function() { return 'upper'; }"); + } + + // ─── Execution advanced scenarios (T14–T21) ───────────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_HandlerReturnsEmptyString_ResourceIsEmpty() + { + _container.RegisterStoredProcedure("spEmpty", (pk, args) => ""); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spEmpty", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Should().Be(""); + } + + [Fact] + public async Task ExecuteStoredProcedure_PartitionKeyNull_PassedCorrectly() + { + PartitionKey? received = null; + _container.RegisterStoredProcedure("spPkNull", (pk, args) => + { + received = pk; + return "ok"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spPkNull", PartitionKey.Null, Array.Empty()); + + received.Should().NotBeNull(); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerThrowsCosmosException_PropagatesWithStatusCode() + { + _container.RegisterStoredProcedure("spCosmos", (pk, args) => + throw new CosmosException("bad request", HttpStatusCode.BadRequest, 0, "", 0)); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spCosmos", new PartitionKey("pk1"), Array.Empty()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerModifiesArguments_NoSideEffects() + { + _container.RegisterStoredProcedure("spMutate", (pk, args) => + { + args[0] = "mutated"; + return "ok"; + }); + + var myArgs = new dynamic[] { "original" }; + await _container.Scripts.ExecuteStoredProcedureAsync( + "spMutate", new PartitionKey("pk1"), myArgs); + + // Reference semantics: the handler mutates the same array + ((string)myArgs[0]).Should().Be("mutated"); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerCanUpsertItems() + { + _container.RegisterStoredProcedure("spUpsert", (pk, args) => + { + _container.UpsertItemAsync( + new TestDocument { Id = "upserted", PartitionKey = "pk1", Name = "Upserted" }, + new PartitionKey("pk1")).GetAwaiter().GetResult(); + return "done"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spUpsert", new PartitionKey("pk1"), Array.Empty()); + + var read = await _container.ReadItemAsync("upserted", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Upserted"); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerCanPatchItems() + { + await _container.CreateItemAsync( + new TestDocument { Id = "to-patch", PartitionKey = "pk1", Name = "Before" }, + new PartitionKey("pk1")); + + _container.RegisterStoredProcedure("spPatch", (pk, args) => + { + _container.PatchItemAsync("to-patch", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "After") }).GetAwaiter().GetResult(); + return "patched"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spPatch", new PartitionKey("pk1"), Array.Empty()); + + var read = await _container.ReadItemAsync("to-patch", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("After"); + } + + [Fact] + public async Task ExecuteStoredProcedure_SequentialExecutions_IndependentResults() + { + var callCount = 0; + _container.RegisterStoredProcedure("spSeq", (pk, args) => + { + callCount++; + return callCount.ToString(); + }); + + var r1 = await _container.Scripts.ExecuteStoredProcedureAsync( + "spSeq", new PartitionKey("pk1"), Array.Empty()); + var r2 = await _container.Scripts.ExecuteStoredProcedureAsync( + "spSeq", new PartitionKey("pk1"), Array.Empty()); + var r3 = await _container.Scripts.ExecuteStoredProcedureAsync( + "spSeq", new PartitionKey("pk1"), Array.Empty()); + + r1.Resource.Should().Be("1"); + r2.Resource.Should().Be("2"); + r3.Resource.Should().Be("3"); + } + + [Fact] + public async Task ExecuteStoredProcedure_HandlerUsesPartitionKeyToScope() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); + + _container.RegisterStoredProcedure("spScoped", (pk, args) => + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = pk }); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + items.AddRange(page.Select(x => x.Name)); + } + return JsonConvert.SerializeObject(items); + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spScoped", new PartitionKey("pk-a"), Array.Empty()); + + var names = JsonConvert.DeserializeObject>(result.Resource); + names.Should().ContainSingle().Which.Should().Be("A"); + } + + // ─── Response metadata (T25–T27) ──────────────────────────────────── + + [Fact] + public async Task CreateStoredProcedure_Response_HasResourceWithIdAndBody() + { + var response = await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spMeta", + Body = "function() { return 42; }" + }); + + response.Resource.Id.Should().Be("spMeta"); + response.Resource.Body.Should().Be("function() { return 42; }"); + } + + [Fact] + public async Task ReplaceStoredProcedure_Response_HasUpdatedResource() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "spRepMeta", Body = "v1" }); + + var response = await scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties + { Id = "spRepMeta", Body = "v2" }); + + response.Resource.Body.Should().Be("v2"); + response.Resource.Id.Should().Be("spRepMeta"); + } + + // ─── Partition key behavior ────────────────────────────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_PartitionKeyValue_PassedCorrectly() + { + PartitionKey? received = null; + _container.RegisterStoredProcedure("spPk", (pk, args) => + { + received = pk; + return "ok"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spPk", new PartitionKey("specific-pk"), Array.Empty()); + + received.Should().NotBeNull(); + received.ToString().Should().Contain("specific-pk"); + } + + [Fact] + public async Task ExecuteStoredProcedure_PartitionKeyNone_PassedCorrectly() + { + PartitionKey? received = null; + _container.RegisterStoredProcedure("spPkNone", (pk, args) => + { + received = pk; + return "ok"; + }); + + await _container.Scripts.ExecuteStoredProcedureAsync( + "spPkNone", PartitionKey.None, Array.Empty()); + + received.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -844,156 +844,156 @@ await _container.Scripts.ExecuteStoredProcedureAsync( public class StoredProcedureDivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - // ─── Divergent: Real Cosmos returns 404 for unregistered sprocs ────── - - [Fact] - public async Task ExecuteStoredProcedure_NotRegistered_ShouldThrow404() - { - // Expected real Cosmos behavior: - // Executing a stored procedure ID that doesn't exist should throw - // CosmosException with StatusCode 404 NotFound. - var scripts = _container.Scripts; - var act = () => scripts.ExecuteStoredProcedureAsync( - "nonExistent", new PartitionKey("pk1"), Array.Empty()); - await act.Should().ThrowAsync(); - } - - // ─── Divergent: Real Cosmos executes JavaScript server-side ────────── - - [Fact] - public async Task ExecuteStoredProcedure_JavaScriptBody_ShouldExecute() - { - _container.UseJsStoredProcedures(); - - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spJs", - Body = "function(prefix) { var response = getContext().getResponse(); response.setBody(prefix + '-result'); }" - }); - - var result = await scripts.ExecuteStoredProcedureAsync( - "spJs", new PartitionKey("pk1"), new dynamic[] { "test" }); - result.Resource.Should().Be("test-result"); - } - - // ─── Divergent: GetStoredProcedureQueryIterator not implemented ────── - - [Fact] - public async Task GetStoredProcedureQueryIterator_ShouldEnumerateProcedures() - { - var scripts = _container.Scripts; - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); - - var iterator = scripts.GetStoredProcedureQueryIterator(); - var all = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - all.AddRange(page); - } - - all.Should().HaveCount(2); - all.Select(s => s.Id).Should().BeEquivalentTo("sp1", "sp2"); - } - - // ─── Generic type support (was blocked by NSubstitute) ───────────── - - [Fact] - public async Task ExecuteStoredProcedure_NonStringGenericType_ShouldDeserialize() - { - _container.RegisterStoredProcedure("spTyped", (pk, args) => - JsonConvert.SerializeObject(new { count = 42, items = new[] { "a", "b" } })); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spTyped", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - ((int)response.Resource["count"]!).Should().Be(42); - response.Resource["items"]!.ToObject().Should().BeEquivalentTo("a", "b"); - } - - // Sister test: shows the workaround for non-string types - [Fact] - public async Task ExecuteStoredProcedure_StringWithManualDeserialization_Workaround() - { - // Workaround: Use ExecuteStoredProcedureAsync and deserialize manually. - // This pattern works for any result type the stored procedure might return. - _container.RegisterStoredProcedure("spTyped", (pk, args) => - JsonConvert.SerializeObject(new { count = 42, items = new[] { "a", "b" } })); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spTyped", new PartitionKey("pk1"), Array.Empty()); - - var result = JObject.Parse(response.Resource); - ((int)result["count"]!).Should().Be(42); - result["items"]!.ToObject().Should().BeEquivalentTo("a", "b"); - } - - // ─── Stream variants (was blocked by NSubstitute) ───────────────── - - [Fact] - public async Task ExecuteStoredProcedureStreamAsync_ShouldReturnStream() - { - _container.RegisterStoredProcedure("spStream", (pk, args) => - JsonConvert.SerializeObject(new { message = "hello" })); - - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync( - "spStream", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var sr = new StreamReader(response.Content); - var body = await sr.ReadToEndAsync(); - var result = JObject.Parse(body); - ((string)result["message"]!).Should().Be("hello"); - } - - [Fact] - public async Task CrudStreamVariants_ShouldWork() - { - // Create - var createResp = await _container.Scripts.CreateStoredProcedureStreamAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - // Read - var readResp = await _container.Scripts.ReadStoredProcedureStreamAsync("sp1"); - readResp.StatusCode.Should().Be(HttpStatusCode.OK); - using (var sr = new StreamReader(readResp.Content)) - { - var json = await sr.ReadToEndAsync(); - json.Should().Contain("sp1"); - } - - // Replace - var replaceResp = await _container.Scripts.ReplaceStoredProcedureStreamAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function() { return 1; }" }); - replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete - var deleteResp = await _container.Scripts.DeleteStoredProcedureStreamAsync("sp1"); - deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - - // Verify deleted — should throw 404 - Func act = () => _container.Scripts.ReadStoredProcedureStreamAsync("sp1"); - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - // ─── Divergent: Script logging not available (T41) ────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_EnableScriptLogging_ShouldReturnLogs() - { - _container.UseJsStoredProcedures(); - - await _container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties - { - Id = "spLog", - Body = """ + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + // ─── Divergent: Real Cosmos returns 404 for unregistered sprocs ────── + + [Fact] + public async Task ExecuteStoredProcedure_NotRegistered_ShouldThrow404() + { + // Expected real Cosmos behavior: + // Executing a stored procedure ID that doesn't exist should throw + // CosmosException with StatusCode 404 NotFound. + var scripts = _container.Scripts; + var act = () => scripts.ExecuteStoredProcedureAsync( + "nonExistent", new PartitionKey("pk1"), Array.Empty()); + await act.Should().ThrowAsync(); + } + + // ─── Divergent: Real Cosmos executes JavaScript server-side ────────── + + [Fact] + public async Task ExecuteStoredProcedure_JavaScriptBody_ShouldExecute() + { + _container.UseJsStoredProcedures(); + + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spJs", + Body = "function(prefix) { var response = getContext().getResponse(); response.setBody(prefix + '-result'); }" + }); + + var result = await scripts.ExecuteStoredProcedureAsync( + "spJs", new PartitionKey("pk1"), new dynamic[] { "test" }); + result.Resource.Should().Be("test-result"); + } + + // ─── Divergent: GetStoredProcedureQueryIterator not implemented ────── + + [Fact] + public async Task GetStoredProcedureQueryIterator_ShouldEnumerateProcedures() + { + var scripts = _container.Scripts; + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); + + var iterator = scripts.GetStoredProcedureQueryIterator(); + var all = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + all.AddRange(page); + } + + all.Should().HaveCount(2); + all.Select(s => s.Id).Should().BeEquivalentTo("sp1", "sp2"); + } + + // ─── Generic type support (was blocked by NSubstitute) ───────────── + + [Fact] + public async Task ExecuteStoredProcedure_NonStringGenericType_ShouldDeserialize() + { + _container.RegisterStoredProcedure("spTyped", (pk, args) => + JsonConvert.SerializeObject(new { count = 42, items = new[] { "a", "b" } })); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spTyped", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + ((int)response.Resource["count"]!).Should().Be(42); + response.Resource["items"]!.ToObject().Should().BeEquivalentTo("a", "b"); + } + + // Sister test: shows the workaround for non-string types + [Fact] + public async Task ExecuteStoredProcedure_StringWithManualDeserialization_Workaround() + { + // Workaround: Use ExecuteStoredProcedureAsync and deserialize manually. + // This pattern works for any result type the stored procedure might return. + _container.RegisterStoredProcedure("spTyped", (pk, args) => + JsonConvert.SerializeObject(new { count = 42, items = new[] { "a", "b" } })); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spTyped", new PartitionKey("pk1"), Array.Empty()); + + var result = JObject.Parse(response.Resource); + ((int)result["count"]!).Should().Be(42); + result["items"]!.ToObject().Should().BeEquivalentTo("a", "b"); + } + + // ─── Stream variants (was blocked by NSubstitute) ───────────────── + + [Fact] + public async Task ExecuteStoredProcedureStreamAsync_ShouldReturnStream() + { + _container.RegisterStoredProcedure("spStream", (pk, args) => + JsonConvert.SerializeObject(new { message = "hello" })); + + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync( + "spStream", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var sr = new StreamReader(response.Content); + var body = await sr.ReadToEndAsync(); + var result = JObject.Parse(body); + ((string)result["message"]!).Should().Be("hello"); + } + + [Fact] + public async Task CrudStreamVariants_ShouldWork() + { + // Create + var createResp = await _container.Scripts.CreateStoredProcedureStreamAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + // Read + var readResp = await _container.Scripts.ReadStoredProcedureStreamAsync("sp1"); + readResp.StatusCode.Should().Be(HttpStatusCode.OK); + using (var sr = new StreamReader(readResp.Content)) + { + var json = await sr.ReadToEndAsync(); + json.Should().Contain("sp1"); + } + + // Replace + var replaceResp = await _container.Scripts.ReplaceStoredProcedureStreamAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function() { return 1; }" }); + replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete + var deleteResp = await _container.Scripts.DeleteStoredProcedureStreamAsync("sp1"); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify deleted — should throw 404 + Func act = () => _container.Scripts.ReadStoredProcedureStreamAsync("sp1"); + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + // ─── Divergent: Script logging not available (T41) ────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_EnableScriptLogging_ShouldReturnLogs() + { + _container.UseJsStoredProcedures(); + + await _container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties + { + Id = "spLog", + Body = """ function spLog(prefix) { console.log("hello from sproc"); console.log("prefix=" + prefix); @@ -1002,151 +1002,151 @@ function spLog(prefix) { response.setBody(prefix + "-done"); } """ - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spLog", new PartitionKey("pk1"), new dynamic[] { "test" }); - - response.Resource.Should().Be("test-done"); - response.ScriptLog.Should().Contain("hello from sproc"); - response.ScriptLog.Should().Contain("prefix=test"); - } - - // ─── Divergent: Handler exceptions differ from real Cosmos (T42) ──── - - [Fact] - public async Task ExecuteStoredProcedure_HandlerError_ShouldReturn400() - { - // Expected real Cosmos behavior: - // A sproc that throws an error at runtime returns CosmosException with 400 Bad Request. - var scripts = _container.Scripts; - _container.RegisterStoredProcedure("spFail", (pk, args) => - throw new InvalidOperationException("runtime error")); - - var act = () => scripts.ExecuteStoredProcedureAsync( - "spFail", new PartitionKey("pk1"), Array.Empty()); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - // ─── Divergent: No 10-second timeout (T43) ───────────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_10SecondTimeout_ShouldThrow() - { - _container.RegisterStoredProcedure("spSlow", (pk, args) => - { - Thread.Sleep(TimeSpan.FromSeconds(15)); - return "done"; - }); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spSlow", new PartitionKey("pk1"), Array.Empty()); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestTimeout); - } - - // ─── Divergent: No 2MB response limit (T44) ──────────────────────── - - [Fact] - public async Task ExecuteStoredProcedure_2MBResponseLimit_ShouldFail() - { - _container.RegisterStoredProcedure("spBig", (pk, args) => - { - return new string('x', 3 * 1024 * 1024); - }); - - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "spBig", new PartitionKey("pk1"), Array.Empty()); - var ex = await act.Should().ThrowAsync(); - ((int)ex.Which.StatusCode).Should().Be(413); - } - - // ─── Divergent: No system metadata on resources (T45) ────────────── - - [Fact] - public async Task StoredProcedure_SystemMetadata_ShouldHaveEtagTimestamp() - { - var scripts = _container.Scripts; - var response = await scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "spMeta", Body = "function(){}" }); - - response.Resource.ETag.Should().NotBeNullOrEmpty(); - response.Resource.SelfLink.Should().NotBeNullOrEmpty(); - - var readResponse = await scripts.ReadStoredProcedureAsync("spMeta"); - readResponse.Resource.ETag.Should().NotBeNullOrEmpty(); - readResponse.Resource.SelfLink.Should().NotBeNullOrEmpty(); - } - - // ─── Divergent: No partition scoping (T46) ───────────────────────── - - [Fact(Skip = "Real Cosmos DB stored procedures are scoped to a single logical partition. They can " + - "only read/write documents within the partition key value specified in the " + - "ExecuteStoredProcedureAsync call. The emulator's C# handlers have unrestricted access " + - "to the container via closure — they can query/write any partition. Workaround: scope " + - "your handler's queries using QueryRequestOptions.PartitionKey matching the PK passed " + - "to execute.")] - public async Task StoredProcedure_CrossPartition_ShouldBeScoped() - { - // Expected real Cosmos behavior: - // A sproc executed with PartitionKey("A") can only read/write docs in partition "A". - // The emulator's handler has full container access — document this divergence. - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); - - _container.RegisterStoredProcedure("spAll", (pk, args) => - { - var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - items.AddRange(page.Select(x => x.Name)); - } - return JsonConvert.SerializeObject(items); - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spAll", new PartitionKey("pk-a"), Array.Empty()); - - // In real Cosmos, only "A" would be returned. Emulator returns both. - var names = JsonConvert.DeserializeObject>(result.Resource); - names.Should().Contain("A"); - names.Should().Contain("B"); // emulator divergence — no partition scoping - } - - // Sister test: shows the workaround pattern for partition scoping - [Fact] - public async Task StoredProcedure_CrossPartition_EmulatorWorkaround_ScopeManually() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); - await _container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); - - _container.RegisterStoredProcedure("spScoped", (pk, args) => - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = pk }); - var items = new List(); - while (iterator.HasMoreResults) - { - var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); - items.AddRange(page.Select(x => x.Name)); - } - return JsonConvert.SerializeObject(items); - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spScoped", new PartitionKey("pk-a"), Array.Empty()); - - var names = JsonConvert.DeserializeObject>(result.Resource); - names.Should().ContainSingle().Which.Should().Be("A"); - } + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spLog", new PartitionKey("pk1"), new dynamic[] { "test" }); + + response.Resource.Should().Be("test-done"); + response.ScriptLog.Should().Contain("hello from sproc"); + response.ScriptLog.Should().Contain("prefix=test"); + } + + // ─── Divergent: Handler exceptions differ from real Cosmos (T42) ──── + + [Fact] + public async Task ExecuteStoredProcedure_HandlerError_ShouldReturn400() + { + // Expected real Cosmos behavior: + // A sproc that throws an error at runtime returns CosmosException with 400 Bad Request. + var scripts = _container.Scripts; + _container.RegisterStoredProcedure("spFail", (pk, args) => + throw new InvalidOperationException("runtime error")); + + var act = () => scripts.ExecuteStoredProcedureAsync( + "spFail", new PartitionKey("pk1"), Array.Empty()); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ─── Divergent: No 10-second timeout (T43) ───────────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_10SecondTimeout_ShouldThrow() + { + _container.RegisterStoredProcedure("spSlow", (pk, args) => + { + Thread.Sleep(TimeSpan.FromSeconds(15)); + return "done"; + }); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spSlow", new PartitionKey("pk1"), Array.Empty()); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.RequestTimeout); + } + + // ─── Divergent: No 2MB response limit (T44) ──────────────────────── + + [Fact] + public async Task ExecuteStoredProcedure_2MBResponseLimit_ShouldFail() + { + _container.RegisterStoredProcedure("spBig", (pk, args) => + { + return new string('x', 3 * 1024 * 1024); + }); + + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "spBig", new PartitionKey("pk1"), Array.Empty()); + var ex = await act.Should().ThrowAsync(); + ((int)ex.Which.StatusCode).Should().Be(413); + } + + // ─── Divergent: No system metadata on resources (T45) ────────────── + + [Fact] + public async Task StoredProcedure_SystemMetadata_ShouldHaveEtagTimestamp() + { + var scripts = _container.Scripts; + var response = await scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "spMeta", Body = "function(){}" }); + + response.Resource.ETag.Should().NotBeNullOrEmpty(); + response.Resource.SelfLink.Should().NotBeNullOrEmpty(); + + var readResponse = await scripts.ReadStoredProcedureAsync("spMeta"); + readResponse.Resource.ETag.Should().NotBeNullOrEmpty(); + readResponse.Resource.SelfLink.Should().NotBeNullOrEmpty(); + } + + // ─── Divergent: No partition scoping (T46) ───────────────────────── + + [Fact(Skip = "Real Cosmos DB stored procedures are scoped to a single logical partition. They can " + + "only read/write documents within the partition key value specified in the " + + "ExecuteStoredProcedureAsync call. The emulator's C# handlers have unrestricted access " + + "to the container via closure — they can query/write any partition. Workaround: scope " + + "your handler's queries using QueryRequestOptions.PartitionKey matching the PK passed " + + "to execute.")] + public async Task StoredProcedure_CrossPartition_ShouldBeScoped() + { + // Expected real Cosmos behavior: + // A sproc executed with PartitionKey("A") can only read/write docs in partition "A". + // The emulator's handler has full container access — document this divergence. + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); + + _container.RegisterStoredProcedure("spAll", (pk, args) => + { + var iterator = _container.GetItemQueryIterator("SELECT * FROM c"); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + items.AddRange(page.Select(x => x.Name)); + } + return JsonConvert.SerializeObject(items); + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spAll", new PartitionKey("pk-a"), Array.Empty()); + + // In real Cosmos, only "A" would be returned. Emulator returns both. + var names = JsonConvert.DeserializeObject>(result.Resource); + names.Should().Contain("A"); + names.Should().Contain("B"); // emulator divergence — no partition scoping + } + + // Sister test: shows the workaround pattern for partition scoping + [Fact] + public async Task StoredProcedure_CrossPartition_EmulatorWorkaround_ScopeManually() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk-a", Name = "A" }, new PartitionKey("pk-a")); + await _container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk-b", Name = "B" }, new PartitionKey("pk-b")); + + _container.RegisterStoredProcedure("spScoped", (pk, args) => + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = pk }); + var items = new List(); + while (iterator.HasMoreResults) + { + var page = iterator.ReadNextAsync().GetAwaiter().GetResult(); + items.AddRange(page.Select(x => x.Name)); + } + return JsonConvert.SerializeObject(items); + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spScoped", new PartitionKey("pk-a"), Array.Empty()); + + var names = JsonConvert.DeserializeObject>(result.Resource); + names.Should().ContainSingle().Which.Should().Be("A"); + } } @@ -1156,271 +1156,271 @@ await _container.CreateItemAsync( public class UdfGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Udf_RegisterAndUseInQuery() - { - _container.RegisterUdf("double", args => ((double)args[0]) * 2); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 21 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.double(c.value) FROM c"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Udf_MultipleArgs() - { - _container.RegisterUdf("add", args => (double)args[0] + (double)args[1]); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", X = 10, Y = 20 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.add(c.x, c.y) FROM c"); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Udf_NotRegistered_ThrowsOnQuery() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var act = async () => - { - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE udf.nonExistent(c.value)"); - while (iterator.HasMoreResults) - { - await iterator.ReadNextAsync(); - } - }; - - await act.Should().ThrowAsync(); - } - - // ─── Deregister UDF (T30, T31) ────────────────────────────────────── - - [Fact] - public async Task DeregisterUdf_RemovesHandler() - { - _container.RegisterUdf("removable", args => (double)args[0] * 10); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 5 }, - new PartitionKey("pk1")); - - // First query should work - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.removable(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(1); - - // Deregister and second query should throw - _container.DeregisterUdf("removable"); - var act = async () => - { - var it2 = _container.GetItemQueryIterator( - "SELECT VALUE udf.removable(c.value) FROM c"); - while (it2.HasMoreResults) await it2.ReadNextAsync(); - }; - await act.Should().ThrowAsync(); - } - - [Fact] - public void DeregisterUdf_NonExistent_DoesNotThrow() - { - var act = () => _container.DeregisterUdf("neverRegistered"); - act.Should().NotThrow(); - } - - // ─── UDF registration edge cases (T32, T33) ───────────────────────── - - [Fact] - public async Task Udf_RegisterSameIdTwice_OverwritesHandler() - { - _container.RegisterUdf("overwrite", args => 1.0); - _container.RegisterUdf("overwrite", args => 999.0); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 1 }, - new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.overwrite(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - results.Should().HaveCount(1); - results[0].Value().Should().Be(999.0); - } - - [Fact] - public async Task Udf_CaseSensitive_DifferentHandlers() - { - _container.RegisterUdf("myUdf", args => 1.0); - _container.RegisterUdf("MYUDF", args => 2.0); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, - new PartitionKey("pk1")); - - var it1 = _container.GetItemQueryIterator( - "SELECT VALUE udf.myUdf(c.value) FROM c"); - var r1 = new List(); - while (it1.HasMoreResults) { var pg = await it1.ReadNextAsync(); r1.AddRange(pg); } - - var it2 = _container.GetItemQueryIterator( - "SELECT VALUE udf.MYUDF(c.value) FROM c"); - var r2 = new List(); - while (it2.HasMoreResults) { var pg = await it2.ReadNextAsync(); r2.AddRange(pg); } - - r1[0].Value().Should().Be(1.0); - r2[0].Value().Should().Be(2.0); - } - - // ─── UDF query patterns (T34–T37) ─────────────────────────────────── - - [Fact] - public async Task Udf_InWhereClause_FiltersCorrectly() - { - _container.RegisterUdf("isEven", args => ((double)args[0]) % 2 == 0); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 2 }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new UdfDocument { Id = "2", PartitionKey = "pk1", Value = 3 }, new PartitionKey("pk1")); - await _container.CreateItemAsync( - new UdfDocument { Id = "3", PartitionKey = "pk1", Value = 4 }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE udf.isEven(c.value)"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(2); - results.Select(x => x.Value).Should().BeEquivalentTo(new[] { 2.0, 4.0 }); - } - - [Fact] - public async Task Udf_ReturningString_Works() - { - _container.RegisterUdf("label", args => "item-" + args[0]); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.label(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Value().Should().Be("item-42"); - } - - [Fact] - public async Task Udf_ReturningNull_Works() - { - _container.RegisterUdf("nullify", args => null!); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 1 }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.nullify(c.value) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - } - - [Fact] - public async Task Udf_WithNoArguments_Works() - { - _container.RegisterUdf("getVersion", args => "1.0.0"); - - await _container.CreateItemAsync( - new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, new PartitionKey("pk1")); - - var iterator = _container.GetItemQueryIterator( - "SELECT VALUE udf.getVersion() FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCount(1); - results[0].Value().Should().Be("1.0.0"); - } - - // ─── UDF CRUD duplicate detection (T38) ───────────────────────────── - - [Fact] - public async Task CreateUserDefinedFunctionAsync_DuplicateId_Throws409() - { - var scripts = _container.Scripts; - - await scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "udfDup", - Body = "function(x) { return x; }" - }); - - var act = () => scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "udfDup", - Body = "function(x) { return x * 2; }" - }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Udf_RegisterAndUseInQuery() + { + _container.RegisterUdf("double", args => ((double)args[0]) * 2); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 21 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.double(c.value) FROM c"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Udf_MultipleArgs() + { + _container.RegisterUdf("add", args => (double)args[0] + (double)args[1]); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", X = 10, Y = 20 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.add(c.x, c.y) FROM c"); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Udf_NotRegistered_ThrowsOnQuery() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var act = async () => + { + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE udf.nonExistent(c.value)"); + while (iterator.HasMoreResults) + { + await iterator.ReadNextAsync(); + } + }; + + await act.Should().ThrowAsync(); + } + + // ─── Deregister UDF (T30, T31) ────────────────────────────────────── + + [Fact] + public async Task DeregisterUdf_RemovesHandler() + { + _container.RegisterUdf("removable", args => (double)args[0] * 10); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 5 }, + new PartitionKey("pk1")); + + // First query should work + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.removable(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(1); + + // Deregister and second query should throw + _container.DeregisterUdf("removable"); + var act = async () => + { + var it2 = _container.GetItemQueryIterator( + "SELECT VALUE udf.removable(c.value) FROM c"); + while (it2.HasMoreResults) await it2.ReadNextAsync(); + }; + await act.Should().ThrowAsync(); + } + + [Fact] + public void DeregisterUdf_NonExistent_DoesNotThrow() + { + var act = () => _container.DeregisterUdf("neverRegistered"); + act.Should().NotThrow(); + } + + // ─── UDF registration edge cases (T32, T33) ───────────────────────── + + [Fact] + public async Task Udf_RegisterSameIdTwice_OverwritesHandler() + { + _container.RegisterUdf("overwrite", args => 1.0); + _container.RegisterUdf("overwrite", args => 999.0); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 1 }, + new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.overwrite(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + results.Should().HaveCount(1); + results[0].Value().Should().Be(999.0); + } + + [Fact] + public async Task Udf_CaseSensitive_DifferentHandlers() + { + _container.RegisterUdf("myUdf", args => 1.0); + _container.RegisterUdf("MYUDF", args => 2.0); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, + new PartitionKey("pk1")); + + var it1 = _container.GetItemQueryIterator( + "SELECT VALUE udf.myUdf(c.value) FROM c"); + var r1 = new List(); + while (it1.HasMoreResults) { var pg = await it1.ReadNextAsync(); r1.AddRange(pg); } + + var it2 = _container.GetItemQueryIterator( + "SELECT VALUE udf.MYUDF(c.value) FROM c"); + var r2 = new List(); + while (it2.HasMoreResults) { var pg = await it2.ReadNextAsync(); r2.AddRange(pg); } + + r1[0].Value().Should().Be(1.0); + r2[0].Value().Should().Be(2.0); + } + + // ─── UDF query patterns (T34–T37) ─────────────────────────────────── + + [Fact] + public async Task Udf_InWhereClause_FiltersCorrectly() + { + _container.RegisterUdf("isEven", args => ((double)args[0]) % 2 == 0); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 2 }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new UdfDocument { Id = "2", PartitionKey = "pk1", Value = 3 }, new PartitionKey("pk1")); + await _container.CreateItemAsync( + new UdfDocument { Id = "3", PartitionKey = "pk1", Value = 4 }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE udf.isEven(c.value)"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(2); + results.Select(x => x.Value).Should().BeEquivalentTo(new[] { 2.0, 4.0 }); + } + + [Fact] + public async Task Udf_ReturningString_Works() + { + _container.RegisterUdf("label", args => "item-" + args[0]); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 42 }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.label(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Value().Should().Be("item-42"); + } + + [Fact] + public async Task Udf_ReturningNull_Works() + { + _container.RegisterUdf("nullify", args => null!); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 1 }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.nullify(c.value) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + } + + [Fact] + public async Task Udf_WithNoArguments_Works() + { + _container.RegisterUdf("getVersion", args => "1.0.0"); + + await _container.CreateItemAsync( + new UdfDocument { Id = "1", PartitionKey = "pk1", Value = 0 }, new PartitionKey("pk1")); + + var iterator = _container.GetItemQueryIterator( + "SELECT VALUE udf.getVersion() FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCount(1); + results[0].Value().Should().Be("1.0.0"); + } + + // ─── UDF CRUD duplicate detection (T38) ───────────────────────────── + + [Fact] + public async Task CreateUserDefinedFunctionAsync_DuplicateId_Throws409() + { + var scripts = _container.Scripts; + + await scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "udfDup", + Body = "function(x) { return x; }" + }); + + var act = () => scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "udfDup", + Body = "function(x) { return x * 2; }" + }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1429,114 +1429,114 @@ await scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties public class StoredProcedureDualStoreTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task CreateMetadata_WithoutRegisterHandler_ExecuteReturnsDefault() - { - // CRUD create without RegisterStoredProcedure — execute should return OK with default - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() { return 1; }" }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task RegisterHandler_WithoutCreateMetadata_ReadThrows404() - { - // Handler registered but no CRUD metadata — Read should throw 404 - _container.RegisterStoredProcedure("sp1", (pk, args) => "hello"); - - var act = () => _container.Scripts.ReadStoredProcedureAsync("sp1"); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task RegisterHandler_WithoutCreateMetadata_ExecuteWorks() - { - // Handler registered without CRUD create — execution should still work - _container.RegisterStoredProcedure("sp1", (pk, args) => "result"); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - - response.Resource.Should().Be("result"); - } - - [Fact] - public async Task DeregisterHandler_MetadataStillAccessible() - { - // Create metadata + register handler, then deregister handler - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() {}" }); - _container.RegisterStoredProcedure("sp1", (pk, args) => "handler"); - _container.DeregisterStoredProcedure("sp1"); - - // Metadata should still be accessible - var read = await _container.Scripts.ReadStoredProcedureAsync("sp1"); - read.StatusCode.Should().Be(HttpStatusCode.OK); - read.Resource.Id.Should().Be("sp1"); - } - - [Fact] - public async Task DeleteMetadata_AlsoRemovesHandler() - { - // Create via CRUD + register handler, then delete via CRUD - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() {}" }); - _container.RegisterStoredProcedure("sp1", (pk, args) => "alive"); - - await _container.Scripts.DeleteStoredProcedureAsync("sp1"); - - // Handler should be removed too (CRUD delete cleans up both stores) - var act = () => _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - (await act.Should().ThrowAsync()) - .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - - // Metadata should also be gone - var metaAct = () => _container.Scripts.ReadStoredProcedureAsync("sp1"); - await metaAct.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteMetadata_HandlerOnlyRegistered_Throws404_HandlerLeaks() - { - // Register handler only (no CRUD create) - _container.RegisterStoredProcedure("sp1", (pk, args) => "leaked"); - - // Delete throws 404 because no metadata exists - var act = () => _container.Scripts.DeleteStoredProcedureAsync("sp1"); - await act.Should().ThrowAsync(); - - // Handler still works (leaked — dual-store edge case) - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Should().Be("leaked"); - } - - [Fact] - public async Task CreateMetadata_ThenRegisterHandler_BothUsable() - { - // Full lifecycle: CRUD create + register handler - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() { return 'js'; }" }); - _container.RegisterStoredProcedure("sp1", (pk, args) => "csharp"); - - // Read returns metadata - var read = await _container.Scripts.ReadStoredProcedureAsync("sp1"); - read.Resource.Body.Should().Be("function() { return 'js'; }"); - - // Execute uses handler - var exec = await _container.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - exec.Resource.Should().Be("csharp"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task CreateMetadata_WithoutRegisterHandler_ExecuteReturnsDefault() + { + // CRUD create without RegisterStoredProcedure — execute should return OK with default + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() { return 1; }" }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RegisterHandler_WithoutCreateMetadata_ReadThrows404() + { + // Handler registered but no CRUD metadata — Read should throw 404 + _container.RegisterStoredProcedure("sp1", (pk, args) => "hello"); + + var act = () => _container.Scripts.ReadStoredProcedureAsync("sp1"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task RegisterHandler_WithoutCreateMetadata_ExecuteWorks() + { + // Handler registered without CRUD create — execution should still work + _container.RegisterStoredProcedure("sp1", (pk, args) => "result"); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + + response.Resource.Should().Be("result"); + } + + [Fact] + public async Task DeregisterHandler_MetadataStillAccessible() + { + // Create metadata + register handler, then deregister handler + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() {}" }); + _container.RegisterStoredProcedure("sp1", (pk, args) => "handler"); + _container.DeregisterStoredProcedure("sp1"); + + // Metadata should still be accessible + var read = await _container.Scripts.ReadStoredProcedureAsync("sp1"); + read.StatusCode.Should().Be(HttpStatusCode.OK); + read.Resource.Id.Should().Be("sp1"); + } + + [Fact] + public async Task DeleteMetadata_AlsoRemovesHandler() + { + // Create via CRUD + register handler, then delete via CRUD + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() {}" }); + _container.RegisterStoredProcedure("sp1", (pk, args) => "alive"); + + await _container.Scripts.DeleteStoredProcedureAsync("sp1"); + + // Handler should be removed too (CRUD delete cleans up both stores) + var act = () => _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Metadata should also be gone + var metaAct = () => _container.Scripts.ReadStoredProcedureAsync("sp1"); + await metaAct.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteMetadata_HandlerOnlyRegistered_Throws404_HandlerLeaks() + { + // Register handler only (no CRUD create) + _container.RegisterStoredProcedure("sp1", (pk, args) => "leaked"); + + // Delete throws 404 because no metadata exists + var act = () => _container.Scripts.DeleteStoredProcedureAsync("sp1"); + await act.Should().ThrowAsync(); + + // Handler still works (leaked — dual-store edge case) + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + response.Resource.Should().Be("leaked"); + } + + [Fact] + public async Task CreateMetadata_ThenRegisterHandler_BothUsable() + { + // Full lifecycle: CRUD create + register handler + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() { return 'js'; }" }); + _container.RegisterStoredProcedure("sp1", (pk, args) => "csharp"); + + // Read returns metadata + var read = await _container.Scripts.ReadStoredProcedureAsync("sp1"); + read.Resource.Body.Should().Be("function() { return 'js'; }"); + + // Execute uses handler + var exec = await _container.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + exec.Resource.Should().Be("csharp"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1545,28 +1545,28 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class StoredProcedureInputValidationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public void RegisterStoredProcedure_NullId_ThrowsArgumentNullException() - { - var act = () => _container.RegisterStoredProcedure(null!, (pk, args) => "ok"); - act.Should().Throw(); - } - - [Fact] - public void RegisterStoredProcedure_NullHandler_ThrowsArgumentNullException() - { - var act = () => _container.RegisterStoredProcedure("sp1", null!); - act.Should().Throw(); - } - - [Fact] - public void DeregisterStoredProcedure_NullId_ThrowsArgumentNullException() - { - var act = () => _container.DeregisterStoredProcedure(null!); - act.Should().Throw(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public void RegisterStoredProcedure_NullId_ThrowsArgumentNullException() + { + var act = () => _container.RegisterStoredProcedure(null!, (pk, args) => "ok"); + act.Should().Throw(); + } + + [Fact] + public void RegisterStoredProcedure_NullHandler_ThrowsArgumentNullException() + { + var act = () => _container.RegisterStoredProcedure("sp1", null!); + act.Should().Throw(); + } + + [Fact] + public void DeregisterStoredProcedure_NullId_ThrowsArgumentNullException() + { + var act = () => _container.DeregisterStoredProcedure(null!); + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1575,41 +1575,41 @@ public void DeregisterStoredProcedure_NullId_ThrowsArgumentNullException() public class StoredProcedureContainerIsolationTests { - [Fact] - public async Task TwoContainers_SameSprocId_IndependentHandlers() - { - var container1 = new InMemoryContainer("container-1", "/partitionKey"); - var container2 = new InMemoryContainer("container-2", "/partitionKey"); - - container1.RegisterStoredProcedure("sp1", (pk, args) => "from-container-1"); - container2.RegisterStoredProcedure("sp1", (pk, args) => "from-container-2"); - - var r1 = await container1.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - var r2 = await container2.Scripts.ExecuteStoredProcedureAsync( - "sp1", new PartitionKey("pk1"), Array.Empty()); - - r1.Resource.Should().Be("from-container-1"); - r2.Resource.Should().Be("from-container-2"); - } - - [Fact] - public async Task TwoContainers_SameSprocId_IndependentMetadata() - { - var container1 = new InMemoryContainer("container-1", "/partitionKey"); - var container2 = new InMemoryContainer("container-2", "/partitionKey"); - - await container1.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() { return 'body-1'; }" }); - await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { Id = "sp1", Body = "function() { return 'body-2'; }" }); - - var r1 = await container1.Scripts.ReadStoredProcedureAsync("sp1"); - var r2 = await container2.Scripts.ReadStoredProcedureAsync("sp1"); - - r1.Resource.Body.Should().Be("function() { return 'body-1'; }"); - r2.Resource.Body.Should().Be("function() { return 'body-2'; }"); - } + [Fact] + public async Task TwoContainers_SameSprocId_IndependentHandlers() + { + var container1 = new InMemoryContainer("container-1", "/partitionKey"); + var container2 = new InMemoryContainer("container-2", "/partitionKey"); + + container1.RegisterStoredProcedure("sp1", (pk, args) => "from-container-1"); + container2.RegisterStoredProcedure("sp1", (pk, args) => "from-container-2"); + + var r1 = await container1.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + var r2 = await container2.Scripts.ExecuteStoredProcedureAsync( + "sp1", new PartitionKey("pk1"), Array.Empty()); + + r1.Resource.Should().Be("from-container-1"); + r2.Resource.Should().Be("from-container-2"); + } + + [Fact] + public async Task TwoContainers_SameSprocId_IndependentMetadata() + { + var container1 = new InMemoryContainer("container-1", "/partitionKey"); + var container2 = new InMemoryContainer("container-2", "/partitionKey"); + + await container1.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() { return 'body-1'; }" }); + await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { Id = "sp1", Body = "function() { return 'body-2'; }" }); + + var r1 = await container1.Scripts.ReadStoredProcedureAsync("sp1"); + var r2 = await container2.Scripts.ReadStoredProcedureAsync("sp1"); + + r1.Resource.Body.Should().Be("function() { return 'body-1'; }"); + r2.Resource.Body.Should().Be("function() { return 'body-2'; }"); + } } @@ -1619,57 +1619,57 @@ await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class ScriptStreamQueryIteratorTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task GetStoredProcedureQueryStreamIterator_ReturnsSerializedProperties() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); - - var iterator = _container.Scripts.GetStoredProcedureQueryStreamIterator(); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("sp1"); - json.Should().Contain("sp2"); - } - - [Fact] - public async Task GetStoredProcedureQueryStreamIterator_WithQueryDefinition_ReturnsAll() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - var iterator = _container.Scripts.GetStoredProcedureQueryStreamIterator( - new QueryDefinition("SELECT * FROM c")); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("sp1"); - } - - [Fact] - public async Task GetUserDefinedFunctionQueryStreamIterator_ReturnsSerializedProperties() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x){return x;}" }); - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf2", Body = "function(x){return x*2;}" }); - - var iterator = _container.Scripts.GetUserDefinedFunctionQueryStreamIterator(); - var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("udf1"); - json.Should().Contain("udf2"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task GetStoredProcedureQueryStreamIterator_ReturnsSerializedProperties() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); + + var iterator = _container.Scripts.GetStoredProcedureQueryStreamIterator(); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("sp1"); + json.Should().Contain("sp2"); + } + + [Fact] + public async Task GetStoredProcedureQueryStreamIterator_WithQueryDefinition_ReturnsAll() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + var iterator = _container.Scripts.GetStoredProcedureQueryStreamIterator( + new QueryDefinition("SELECT * FROM c")); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("sp1"); + } + + [Fact] + public async Task GetUserDefinedFunctionQueryStreamIterator_ReturnsSerializedProperties() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x){return x;}" }); + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf2", Body = "function(x){return x*2;}" }); + + var iterator = _container.Scripts.GetUserDefinedFunctionQueryStreamIterator(); + var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("udf1"); + json.Should().Contain("udf2"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1678,73 +1678,73 @@ await _container.Scripts.CreateUserDefinedFunctionAsync( public class ScriptMetadataQueryFilteringTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task GetStoredProcedureQueryIterator_WithIdFilter_ReturnsOnlyMatching() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp3", Body = "function(){}" }); - - var iterator = _container.Scripts.GetStoredProcedureQueryIterator( - "SELECT * FROM c WHERE c.id = 'sp2'"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Id.Should().Be("sp2"); - } - - [Fact] - public async Task GetStoredProcedureQueryIterator_WithQueryDefinition_FiltersById() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); - - var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") - .WithParameter("@id", "sp1"); - var iterator = _container.Scripts.GetStoredProcedureQueryIterator(qd); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Id.Should().Be("sp1"); - } - - [Fact] - public async Task GetTriggerQueryIterator_WithIdFilter_ReturnsOnlyMatching() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All, Body = "function(){}" }); - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { Id = "t2", TriggerType = TriggerType.Post, TriggerOperation = TriggerOperation.Create, Body = "function(){}" }); - - var iterator = _container.Scripts.GetTriggerQueryIterator( - "SELECT * FROM c WHERE c.id = 't1'"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Id.Should().Be("t1"); - } - - [Fact] - public async Task GetUserDefinedFunctionQueryIterator_WithIdFilter_ReturnsOnlyMatching() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x){return x;}" }); - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf2", Body = "function(x){return x*2;}" }); - - var iterator = _container.Scripts.GetUserDefinedFunctionQueryIterator( - "SELECT * FROM c WHERE c.id = 'udf2'"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Id.Should().Be("udf2"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task GetStoredProcedureQueryIterator_WithIdFilter_ReturnsOnlyMatching() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp3", Body = "function(){}" }); + + var iterator = _container.Scripts.GetStoredProcedureQueryIterator( + "SELECT * FROM c WHERE c.id = 'sp2'"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Id.Should().Be("sp2"); + } + + [Fact] + public async Task GetStoredProcedureQueryIterator_WithQueryDefinition_FiltersById() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); + + var qd = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", "sp1"); + var iterator = _container.Scripts.GetStoredProcedureQueryIterator(qd); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Id.Should().Be("sp1"); + } + + [Fact] + public async Task GetTriggerQueryIterator_WithIdFilter_ReturnsOnlyMatching() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All, Body = "function(){}" }); + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { Id = "t2", TriggerType = TriggerType.Post, TriggerOperation = TriggerOperation.Create, Body = "function(){}" }); + + var iterator = _container.Scripts.GetTriggerQueryIterator( + "SELECT * FROM c WHERE c.id = 't1'"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Id.Should().Be("t1"); + } + + [Fact] + public async Task GetUserDefinedFunctionQueryIterator_WithIdFilter_ReturnsOnlyMatching() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x){return x;}" }); + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf2", Body = "function(x){return x*2;}" }); + + var iterator = _container.Scripts.GetUserDefinedFunctionQueryIterator( + "SELECT * FROM c WHERE c.id = 'udf2'"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Id.Should().Be("udf2"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1753,16 +1753,16 @@ await _container.Scripts.CreateUserDefinedFunctionAsync( public class JsSprocCollectionAccessTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task JsSproc_CreateDocument_InsertsItem() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spCreate", - Body = @"function() { + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task JsSproc_CreateDocument_InsertsItem() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spCreate", + Body = @"function() { var collection = getContext().getCollection(); collection.createDocument(collection.getSelfLink(), { id: 'new-doc', pk: 'a', value: 42 }, @@ -1771,29 +1771,29 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(doc.id); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCreate", new PartitionKey("a"), Array.Empty()); - result.Resource.Should().Be("new-doc"); - - // Verify the document was actually inserted - var item = await _container.ReadItemAsync("new-doc", new PartitionKey("a")); - item.Resource["value"]!.Value().Should().Be(42); - } - - [Fact] - public async Task JsSproc_ReadDocument_ReturnsItem() - { - _container.UseJsStoredProcedures(); - // Seed an item - await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a", data = "hello" }), - new PartitionKey("a")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spRead", - Body = @"function(docId) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCreate", new PartitionKey("a"), Array.Empty()); + result.Resource.Should().Be("new-doc"); + + // Verify the document was actually inserted + var item = await _container.ReadItemAsync("new-doc", new PartitionKey("a")); + item.Resource["value"]!.Value().Should().Be(42); + } + + [Fact] + public async Task JsSproc_ReadDocument_ReturnsItem() + { + _container.UseJsStoredProcedures(); + // Seed an item + await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a", data = "hello" }), + new PartitionKey("a")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spRead", + Body = @"function(docId) { var collection = getContext().getCollection(); collection.readDocument(collection.getSelfLink() + '/docs/' + docId, {}, function(err, doc) { @@ -1801,25 +1801,25 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(doc.data); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spRead", new PartitionKey("a"), new dynamic[] { "doc1" }); - result.Resource.Should().Be("hello"); - } - - [Fact] - public async Task JsSproc_QueryDocuments_ReturnsFiltered() - { - _container.UseJsStoredProcedures(); - await _container.CreateItemAsync(JObject.FromObject(new { id = "d1", pk = "a", val = 1 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "d2", pk = "a", val = 2 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "d3", pk = "a", val = 3 }), new PartitionKey("a")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spQuery", - Body = @"function() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spRead", new PartitionKey("a"), new dynamic[] { "doc1" }); + result.Resource.Should().Be("hello"); + } + + [Fact] + public async Task JsSproc_QueryDocuments_ReturnsFiltered() + { + _container.UseJsStoredProcedures(); + await _container.CreateItemAsync(JObject.FromObject(new { id = "d1", pk = "a", val = 1 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "d2", pk = "a", val = 2 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "d3", pk = "a", val = 3 }), new PartitionKey("a")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spQuery", + Body = @"function() { var collection = getContext().getCollection(); collection.queryDocuments(collection.getSelfLink(), 'SELECT * FROM c WHERE c.val >= 2', @@ -1828,24 +1828,24 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(JSON.stringify(docs.length)); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spQuery", new PartitionKey("a"), Array.Empty()); - result.Resource.Should().Be("2"); - } - - [Fact] - public async Task JsSproc_ReplaceDocument_UpdatesItem() - { - _container.UseJsStoredProcedures(); - await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a", status = "draft" }), - new PartitionKey("a")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", - Body = @"function(docId) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spQuery", new PartitionKey("a"), Array.Empty()); + result.Resource.Should().Be("2"); + } + + [Fact] + public async Task JsSproc_ReplaceDocument_UpdatesItem() + { + _container.UseJsStoredProcedures(); + await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a", status = "draft" }), + new PartitionKey("a")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = @"function(docId) { var collection = getContext().getCollection(); collection.readDocument(collection.getSelfLink() + '/docs/' + docId, {}, function(err, doc) { @@ -1858,27 +1858,27 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie }); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spReplace", new PartitionKey("a"), new dynamic[] { "doc1" }); - result.Resource.Should().Be("published"); - - var item = await _container.ReadItemAsync("doc1", new PartitionKey("a")); - item.Resource["status"]!.Value().Should().Be("published"); - } - - [Fact] - public async Task JsSproc_DeleteDocument_RemovesItem() - { - _container.UseJsStoredProcedures(); - await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a" }), - new PartitionKey("a")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spDelete", - Body = @"function(docId) { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spReplace", new PartitionKey("a"), new dynamic[] { "doc1" }); + result.Resource.Should().Be("published"); + + var item = await _container.ReadItemAsync("doc1", new PartitionKey("a")); + item.Resource["status"]!.Value().Should().Be("published"); + } + + [Fact] + public async Task JsSproc_DeleteDocument_RemovesItem() + { + _container.UseJsStoredProcedures(); + await _container.CreateItemAsync(JObject.FromObject(new { id = "doc1", pk = "a" }), + new PartitionKey("a")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spDelete", + Body = @"function(docId) { var collection = getContext().getCollection(); collection.deleteDocument(collection.getSelfLink() + '/docs/' + docId, {}, function(err) { @@ -1886,27 +1886,27 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody('deleted'); }); }" - }); - - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spDelete", new PartitionKey("a"), new dynamic[] { "doc1" }); - result.Resource.Should().Be("deleted"); - - var act = () => _container.ReadItemAsync("doc1", new PartitionKey("a")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task JsSproc_PartitionScoped_CannotAccessOtherPartition() - { - _container.UseJsStoredProcedures(); - await _container.CreateItemAsync(JObject.FromObject(new { id = "a1", pk = "a" }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "b1", pk = "b" }), new PartitionKey("b")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spPartition", - Body = @"function() { + }); + + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spDelete", new PartitionKey("a"), new dynamic[] { "doc1" }); + result.Resource.Should().Be("deleted"); + + var act = () => _container.ReadItemAsync("doc1", new PartitionKey("a")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task JsSproc_PartitionScoped_CannotAccessOtherPartition() + { + _container.UseJsStoredProcedures(); + await _container.CreateItemAsync(JObject.FromObject(new { id = "a1", pk = "a" }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "b1", pk = "b" }), new PartitionKey("b")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spPartition", + Body = @"function() { var collection = getContext().getCollection(); collection.queryDocuments(collection.getSelfLink(), 'SELECT * FROM c', @@ -1915,25 +1915,25 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(JSON.stringify(docs.length)); }); }" - }); - - // Execute in partition 'a' — should only see 1 doc - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spPartition", new PartitionKey("a"), Array.Empty()); - result.Resource.Should().Be("1"); - } - - [Fact] - public async Task JsSproc_BulkDelete_Pattern() - { - _container.UseJsStoredProcedures(); - for (int i = 0; i < 5; i++) - await _container.CreateItemAsync(JObject.FromObject(new { id = $"d{i}", pk = "a" }), new PartitionKey("a")); - - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spBulkDelete", - Body = @"function() { + }); + + // Execute in partition 'a' — should only see 1 doc + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spPartition", new PartitionKey("a"), Array.Empty()); + result.Resource.Should().Be("1"); + } + + [Fact] + public async Task JsSproc_BulkDelete_Pattern() + { + _container.UseJsStoredProcedures(); + for (int i = 0; i < 5; i++) + await _container.CreateItemAsync(JObject.FromObject(new { id = $"d{i}", pk = "a" }), new PartitionKey("a")); + + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spBulkDelete", + Body = @"function() { var collection = getContext().getCollection(); var count = 0; collection.queryDocuments(collection.getSelfLink(), @@ -1948,16 +1948,16 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie getContext().getResponse().setBody(JSON.stringify(count)); }); }" - }); + }); - var result = await _container.Scripts.ExecuteStoredProcedureAsync( - "spBulkDelete", new PartitionKey("a"), Array.Empty()); - result.Resource.Should().Be("5"); + var result = await _container.Scripts.ExecuteStoredProcedureAsync( + "spBulkDelete", new PartitionKey("a"), Array.Empty()); + result.Resource.Should().Be("5"); - // All should be gone - var remaining = _container.GetItemLinqQueryable(allowSynchronousQueryExecution: true).ToList(); - remaining.Should().BeEmpty(); - } + // All should be gone + var remaining = _container.GetItemLinqQueryable(allowSynchronousQueryExecution: true).ToList(); + remaining.Should().BeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1966,108 +1966,113 @@ await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedurePropertie public class JsUdfExecutionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task JsUdf_SimpleFunction_ReturnsResult() - { - _container.UseJsTriggers(); - await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "double", Body = "function double(x) { return x * 2; }" - }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", value = 5 }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator("SELECT udf.double(c.value) AS result FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Count.Should().Be(1); - results[0]["result"]!.Value().Should().Be(10); - } - - [Fact] - public async Task JsUdf_StringFunction_Works() - { - _container.UseJsTriggers(); - await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "greet", Body = "function greet(name) { return 'Hello, ' + name + '!'; }" - }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "World" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator("SELECT udf.greet(c.name) AS greeting FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Count.Should().Be(1); - results[0]["greeting"]!.Value().Should().Be("Hello, World!"); - } - - [Fact] - public async Task JsUdf_MultipleArgs_PassedCorrectly() - { - _container.UseJsTriggers(); - await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "add", Body = "function add(a, b) { return a + b; }" - }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", x = 3, y = 7 }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator("SELECT udf.add(c.x, c.y) AS sum FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results[0]["sum"]!.Value().Should().Be(10); - } - - [Fact] - public async Task JsUdf_CSharpPriority_OverJsBody() - { - _container.UseJsTriggers(); - // Register C# handler first - _container.RegisterUdf("priority", args => "csharp-wins"); - // Also register JS body via metadata - await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "priority", Body = "function priority() { return 'js-wins'; }" - }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator("SELECT udf.priority() AS result FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results[0]["result"]!.Value().Should().Be("csharp-wins"); - } - - [Fact] - public async Task JsUdf_InWhereClause_FiltersCorrectly() - { - _container.UseJsTriggers(); - await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties - { - Id = "isEven", Body = "function isEven(n) { return n % 2 === 0; }" - }); - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", val = 2 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", val = 3 }), new PartitionKey("a")); - await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", val = 4 }), new PartitionKey("a")); - - var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE udf.isEven(c.val)"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("1", "3"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task JsUdf_SimpleFunction_ReturnsResult() + { + _container.UseJsTriggers(); + await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "double", + Body = "function double(x) { return x * 2; }" + }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", value = 5 }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator("SELECT udf.double(c.value) AS result FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Count.Should().Be(1); + results[0]["result"]!.Value().Should().Be(10); + } + + [Fact] + public async Task JsUdf_StringFunction_Works() + { + _container.UseJsTriggers(); + await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "greet", + Body = "function greet(name) { return 'Hello, ' + name + '!'; }" + }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "World" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator("SELECT udf.greet(c.name) AS greeting FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Count.Should().Be(1); + results[0]["greeting"]!.Value().Should().Be("Hello, World!"); + } + + [Fact] + public async Task JsUdf_MultipleArgs_PassedCorrectly() + { + _container.UseJsTriggers(); + await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "add", + Body = "function add(a, b) { return a + b; }" + }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", x = 3, y = 7 }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator("SELECT udf.add(c.x, c.y) AS sum FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results[0]["sum"]!.Value().Should().Be(10); + } + + [Fact] + public async Task JsUdf_CSharpPriority_OverJsBody() + { + _container.UseJsTriggers(); + // Register C# handler first + _container.RegisterUdf("priority", args => "csharp-wins"); + // Also register JS body via metadata + await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "priority", + Body = "function priority() { return 'js-wins'; }" + }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator("SELECT udf.priority() AS result FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results[0]["result"]!.Value().Should().Be("csharp-wins"); + } + + [Fact] + public async Task JsUdf_InWhereClause_FiltersCorrectly() + { + _container.UseJsTriggers(); + await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionProperties + { + Id = "isEven", + Body = "function isEven(n) { return n % 2 === 0; }" + }); + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", val = 2 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", val = 3 }), new PartitionKey("a")); + await _container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", val = 4 }), new PartitionKey("a")); + + var iterator = _container.GetItemQueryIterator("SELECT * FROM c WHERE udf.isEven(c.val)"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Select(r => r["id"]!.Value()).Should().BeEquivalentTo("1", "3"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2077,667 +2082,690 @@ await _container.Scripts.CreateUserDefinedFunctionAsync(new UserDefinedFunctionP // ── Phase 1: Bug Fix Verification (BUG-1 already fixed) ── public class StoredProcedureCleanupTests { - [Fact] - public async Task DeleteContainer_ClearsStoredProcedureProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await container.DeleteContainerAsync(); - - // After delete, recreating a sproc with the same ID should not throw 409 - var container2 = new InMemoryContainer("test", "/pk"); - // The properties were cleared on the old container, so this is a new container - var response = await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteContainer_ClearsTriggerProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", Body = "function(){}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All - }); - await container.DeleteContainerAsync(); - - var container2 = new InMemoryContainer("test", "/pk"); - var response = await container2.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", Body = "function(){}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All - }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteContainerStream_ClearsStoredProcedureProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await container.DeleteContainerStreamAsync(); - - var container2 = new InMemoryContainer("test", "/pk"); - var response = await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task DeleteContainerStream_ClearsTriggerProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", Body = "function(){}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All - }); - await container.DeleteContainerStreamAsync(); - - var container2 = new InMemoryContainer("test", "/pk"); - var response = await container2.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", Body = "function(){}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.All - }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + [Fact] + public async Task DeleteContainer_ClearsStoredProcedureProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await container.DeleteContainerAsync(); + + // After delete, recreating a sproc with the same ID should not throw 409 + var container2 = new InMemoryContainer("test", "/pk"); + // The properties were cleared on the old container, so this is a new container + var response = await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteContainer_ClearsTriggerProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + Body = "function(){}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + await container.DeleteContainerAsync(); + + var container2 = new InMemoryContainer("test", "/pk"); + var response = await container2.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + Body = "function(){}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteContainerStream_ClearsStoredProcedureProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await container.DeleteContainerStreamAsync(); + + var container2 = new InMemoryContainer("test", "/pk"); + var response = await container2.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task DeleteContainerStream_ClearsTriggerProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + Body = "function(){}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + await container.DeleteContainerStreamAsync(); + + var container2 = new InMemoryContainer("test", "/pk"); + var response = await container2.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + Body = "function(){}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.All + }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ── Phase 2: Stream API ── public class StoredProcedureStreamTests { - private readonly InMemoryContainer _container = new("sp-stream", "/pk"); - - [Fact] - public async Task ExecuteStreamAsync_WithStreamPayload_DeserializesArgs() - { - _container.RegisterStoredProcedure("spConcat", (pk, args) => string.Join("-", args)); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spConcat", Body = "function(){}" }); - - var payload = new MemoryStream(Encoding.UTF8.GetBytes("[\"hello\", \"world\"]")); - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spConcat", payload, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var result = reader.ReadToEnd(); - result.Should().Contain("hello-world"); - } - - [Fact] - public async Task ExecuteStreamAsync_WithStreamPayload_EmptyArray() - { - _container.RegisterStoredProcedure("spEmpty", (pk, args) => $"count:{args.Length}"); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spEmpty", Body = "function(){}" }); - - var payload = new MemoryStream(Encoding.UTF8.GetBytes("[]")); - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spEmpty", payload, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - reader.ReadToEnd().Should().Contain("count:0"); - } - - [Fact] - public async Task ExecuteStreamAsync_WithStreamPayload_ComplexArgs() - { - _container.RegisterStoredProcedure("spComplex", (pk, args) => - { - var obj = args[0]; - return obj?.ToString() ?? "null"; - }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spComplex", Body = "function(){}" }); - - var payload = new MemoryStream(Encoding.UTF8.GetBytes("[{\"key\":\"val\"}]")); - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spComplex", payload, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ExecuteStreamAsync_ScriptLogHeader_Populated() - { - _container.UseJsStoredProcedures(); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spLog", - Body = "function() { console.log('test-log'); return 'done'; }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync( - "spLog", new PartitionKey("pk1"), Array.Empty(), - new StoredProcedureRequestOptions { EnableScriptLogging = true }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Headers["x-ms-documentdb-script-log-results"].Should().Contain("test-log"); - } - - [Fact] - public async Task ExecuteStreamAsync_WithStreamPayload_NullStream() - { - _container.RegisterStoredProcedure("spNull", (pk, args) => $"count:{args.Length}"); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spNull", Body = "function(){}" }); - - var payload = new MemoryStream(Encoding.UTF8.GetBytes("null")); - var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spNull", payload, new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("sp-stream", "/pk"); + + [Fact] + public async Task ExecuteStreamAsync_WithStreamPayload_DeserializesArgs() + { + _container.RegisterStoredProcedure("spConcat", (pk, args) => string.Join("-", args)); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spConcat", Body = "function(){}" }); + + var payload = new MemoryStream(Encoding.UTF8.GetBytes("[\"hello\", \"world\"]")); + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spConcat", payload, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var result = reader.ReadToEnd(); + result.Should().Contain("hello-world"); + } + + [Fact] + public async Task ExecuteStreamAsync_WithStreamPayload_EmptyArray() + { + _container.RegisterStoredProcedure("spEmpty", (pk, args) => $"count:{args.Length}"); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spEmpty", Body = "function(){}" }); + + var payload = new MemoryStream(Encoding.UTF8.GetBytes("[]")); + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spEmpty", payload, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + reader.ReadToEnd().Should().Contain("count:0"); + } + + [Fact] + public async Task ExecuteStreamAsync_WithStreamPayload_ComplexArgs() + { + _container.RegisterStoredProcedure("spComplex", (pk, args) => + { + var obj = args[0]; + return obj?.ToString() ?? "null"; + }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spComplex", Body = "function(){}" }); + + var payload = new MemoryStream(Encoding.UTF8.GetBytes("[{\"key\":\"val\"}]")); + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spComplex", payload, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ExecuteStreamAsync_ScriptLogHeader_Populated() + { + _container.UseJsStoredProcedures(); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spLog", + Body = "function() { console.log('test-log'); return 'done'; }" + }); + + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync( + "spLog", new PartitionKey("pk1"), Array.Empty(), + new StoredProcedureRequestOptions { EnableScriptLogging = true }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers["x-ms-documentdb-script-log-results"].Should().Contain("test-log"); + } + + [Fact] + public async Task ExecuteStreamAsync_WithStreamPayload_NullStream() + { + _container.RegisterStoredProcedure("spNull", (pk, args) => $"count:{args.Length}"); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "spNull", Body = "function(){}" }); + + var payload = new MemoryStream(Encoding.UTF8.GetBytes("null")); + var response = await _container.Scripts.ExecuteStoredProcedureStreamAsync("spNull", payload, new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ── Phase 3: JS Engine Edge Cases ── public class JsSprocEdgeCaseTests { - private readonly InMemoryContainer _container = new("js-edge", "/pk"); - - public JsSprocEdgeCaseTests() - { - _container.UseJsStoredProcedures(); - } - - [Fact] - public async Task JsSproc_SyntaxError_ThrowsBadRequest() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spBad", Body = "function() { this is not valid javascript }" - }); - - var act = async () => await _container.Scripts.ExecuteStoredProcedureAsync( - "spBad", new PartitionKey("pk1"), Array.Empty()); - - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } - - [Fact] - public async Task JsSproc_RuntimeError_ThrowsBadRequest() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spRuntimeErr", Body = "function() { throw new Error('oops'); }" - }); - - var act = async () => await _container.Scripts.ExecuteStoredProcedureAsync( - "spRuntimeErr", new PartitionKey("pk1"), Array.Empty()); - - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.BadRequest); - } - - [Fact] - public async Task JsSproc_NumberArgument_PassedCorrectly() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spNum", Body = "function(x) { getContext().getResponse().setBody(x * 2); }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spNum", new PartitionKey("pk1"), new dynamic[] { 21 }); - - response.Resource.Should().Be(42); - } - - [Fact] - public async Task JsSproc_ObjectArgument_PassedCorrectly() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spObj", Body = "function(obj) { getContext().getResponse().setBody(obj.name); }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spObj", new PartitionKey("pk1"), new dynamic[] { new { name = "Alice" } }); - - response.Resource.Should().Be("Alice"); - } - - [Fact] - public async Task JsSproc_NullArgument_PassedCorrectly() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spNullArg", Body = "function(x) { getContext().getResponse().setBody(x === null ? 'yes' : 'no'); }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spNullArg", new PartitionKey("pk1"), new dynamic[] { (object)null! }); - - response.Resource.Should().Be("yes"); - } - - [Fact] - public async Task JsSproc_ArrayArgument_PassedCorrectly() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spArr", Body = "function(arr) { getContext().getResponse().setBody(arr.length); }" - }); - - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spArr", new PartitionKey("pk1"), new dynamic[] { new[] { 1, 2, 3 } }); - - response.Resource.Should().Be(3); - } - - [Fact] - public async Task JsSproc_ReplaceBody_ThenReExecute_UsesNewBody() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", Body = "function() { getContext().getResponse().setBody('v1'); }" - }); - - var v1 = await _container.Scripts.ExecuteStoredProcedureAsync( - "spReplace", new PartitionKey("pk1"), Array.Empty()); - v1.Resource.Should().Be("v1"); - - await _container.Scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spReplace", Body = "function() { getContext().getResponse().setBody('v2'); }" - }); - - var v2 = await _container.Scripts.ExecuteStoredProcedureAsync( - "spReplace", new PartitionKey("pk1"), Array.Empty()); - v2.Resource.Should().Be("v2"); - } - - [Fact] - public async Task JsSproc_MultipleSprocsSameContainer_Independent() - { - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spA", Body = "function() { getContext().getResponse().setBody('A'); }" - }); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spB", Body = "function() { getContext().getResponse().setBody('B'); }" - }); - - var rA = await _container.Scripts.ExecuteStoredProcedureAsync("spA", new PartitionKey("pk1"), Array.Empty()); - var rB = await _container.Scripts.ExecuteStoredProcedureAsync("spB", new PartitionKey("pk1"), Array.Empty()); - - rA.Resource.Should().Be("A"); - rB.Resource.Should().Be("B"); - } - - [Fact] - public async Task JsSproc_DeregisterHandler_FallsBackToJsBody() - { - _container.RegisterStoredProcedure("spFallback", (pk, args) => "csharp"); - await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties - { - Id = "spFallback", Body = "function() { getContext().getResponse().setBody('javascript'); }" - }); - - // C# handler takes priority - var r1 = await _container.Scripts.ExecuteStoredProcedureAsync("spFallback", new PartitionKey("pk1"), Array.Empty()); - r1.Resource.Should().Be("csharp"); - - // Deregister C# handler → JS body should take over - _container.DeregisterStoredProcedure("spFallback"); - var r2 = await _container.Scripts.ExecuteStoredProcedureAsync("spFallback", new PartitionKey("pk1"), Array.Empty()); - r2.Resource.Should().Be("javascript"); - } + private readonly InMemoryContainer _container = new("js-edge", "/pk"); + + public JsSprocEdgeCaseTests() + { + _container.UseJsStoredProcedures(); + } + + [Fact] + public async Task JsSproc_SyntaxError_ThrowsBadRequest() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spBad", + Body = "function() { this is not valid javascript }" + }); + + var act = async () => await _container.Scripts.ExecuteStoredProcedureAsync( + "spBad", new PartitionKey("pk1"), Array.Empty()); + + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } + + [Fact] + public async Task JsSproc_RuntimeError_ThrowsBadRequest() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spRuntimeErr", + Body = "function() { throw new Error('oops'); }" + }); + + var act = async () => await _container.Scripts.ExecuteStoredProcedureAsync( + "spRuntimeErr", new PartitionKey("pk1"), Array.Empty()); + + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.BadRequest); + } + + [Fact] + public async Task JsSproc_NumberArgument_PassedCorrectly() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spNum", + Body = "function(x) { getContext().getResponse().setBody(x * 2); }" + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spNum", new PartitionKey("pk1"), new dynamic[] { 21 }); + + response.Resource.Should().Be(42); + } + + [Fact] + public async Task JsSproc_ObjectArgument_PassedCorrectly() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spObj", + Body = "function(obj) { getContext().getResponse().setBody(obj.name); }" + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spObj", new PartitionKey("pk1"), new dynamic[] { new { name = "Alice" } }); + + response.Resource.Should().Be("Alice"); + } + + [Fact] + public async Task JsSproc_NullArgument_PassedCorrectly() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spNullArg", + Body = "function(x) { getContext().getResponse().setBody(x === null ? 'yes' : 'no'); }" + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spNullArg", new PartitionKey("pk1"), new dynamic[] { (object)null! }); + + response.Resource.Should().Be("yes"); + } + + [Fact] + public async Task JsSproc_ArrayArgument_PassedCorrectly() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spArr", + Body = "function(arr) { getContext().getResponse().setBody(arr.length); }" + }); + + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spArr", new PartitionKey("pk1"), new dynamic[] { new[] { 1, 2, 3 } }); + + response.Resource.Should().Be(3); + } + + [Fact] + public async Task JsSproc_ReplaceBody_ThenReExecute_UsesNewBody() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = "function() { getContext().getResponse().setBody('v1'); }" + }); + + var v1 = await _container.Scripts.ExecuteStoredProcedureAsync( + "spReplace", new PartitionKey("pk1"), Array.Empty()); + v1.Resource.Should().Be("v1"); + + await _container.Scripts.ReplaceStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spReplace", + Body = "function() { getContext().getResponse().setBody('v2'); }" + }); + + var v2 = await _container.Scripts.ExecuteStoredProcedureAsync( + "spReplace", new PartitionKey("pk1"), Array.Empty()); + v2.Resource.Should().Be("v2"); + } + + [Fact] + public async Task JsSproc_MultipleSprocsSameContainer_Independent() + { + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spA", + Body = "function() { getContext().getResponse().setBody('A'); }" + }); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spB", + Body = "function() { getContext().getResponse().setBody('B'); }" + }); + + var rA = await _container.Scripts.ExecuteStoredProcedureAsync("spA", new PartitionKey("pk1"), Array.Empty()); + var rB = await _container.Scripts.ExecuteStoredProcedureAsync("spB", new PartitionKey("pk1"), Array.Empty()); + + rA.Resource.Should().Be("A"); + rB.Resource.Should().Be("B"); + } + + [Fact] + public async Task JsSproc_DeregisterHandler_FallsBackToJsBody() + { + _container.RegisterStoredProcedure("spFallback", (pk, args) => "csharp"); + await _container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties + { + Id = "spFallback", + Body = "function() { getContext().getResponse().setBody('javascript'); }" + }); + + // C# handler takes priority + var r1 = await _container.Scripts.ExecuteStoredProcedureAsync("spFallback", new PartitionKey("pk1"), Array.Empty()); + r1.Resource.Should().Be("csharp"); + + // Deregister C# handler → JS body should take over + _container.DeregisterStoredProcedure("spFallback"); + var r2 = await _container.Scripts.ExecuteStoredProcedureAsync("spFallback", new PartitionKey("pk1"), Array.Empty()); + r2.Resource.Should().Be("javascript"); + } } // ── Phase 4: ClearItems & Lifecycle ── public class StoredProcedureLifecycleTests { - [Fact] - public async Task ClearItems_PreservesSprocHandlers() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterStoredProcedure("sp1", (pk, args) => "hello"); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a")); - - container.ClearItems(); - - var response = await container.Scripts.ExecuteStoredProcedureAsync("sp1", new PartitionKey("a"), Array.Empty()); - response.Resource.Should().Be("hello"); - } - - [Fact] - public async Task ClearItems_PreservesSprocProperties() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a")); - - container.ClearItems(); - - var read = await container.Scripts.ReadStoredProcedureAsync("sp1"); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ClearItems_PreservesUdfHandlers() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterUdf("myUdf", args => args[0]); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", val = 42 }), - new PartitionKey("a")); - - container.ClearItems(); - - // Re-add items after clear - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", val = 99 }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT udf.myUdf(c.val) AS r FROM c"); - var results = new List(); - while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); - results.Should().HaveCount(1); - } - - [Fact] - public async Task ClearItems_PreservesTriggerHandlers() - { - var container = new InMemoryContainer("test", "/pk"); - var triggerFired = false; - container.RegisterTrigger("t1", TriggerType.Post, TriggerOperation.Create, (Action)(item => - { - triggerFired = true; - })); - - container.ClearItems(); - - await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "t1" } }); - - triggerFired.Should().BeTrue(); - } + [Fact] + public async Task ClearItems_PreservesSprocHandlers() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterStoredProcedure("sp1", (pk, args) => "hello"); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a")); + + container.ClearItems(); + + var response = await container.Scripts.ExecuteStoredProcedureAsync("sp1", new PartitionKey("a"), Array.Empty()); + response.Resource.Should().Be("hello"); + } + + [Fact] + public async Task ClearItems_PreservesSprocProperties() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a")); + + container.ClearItems(); + + var read = await container.Scripts.ReadStoredProcedureAsync("sp1"); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ClearItems_PreservesUdfHandlers() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterUdf("myUdf", args => args[0]); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", val = 42 }), + new PartitionKey("a")); + + container.ClearItems(); + + // Re-add items after clear + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", val = 99 }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT udf.myUdf(c.val) AS r FROM c"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + results.Should().HaveCount(1); + } + + [Fact] + public async Task ClearItems_PreservesTriggerHandlers() + { + var container = new InMemoryContainer("test", "/pk"); + var triggerFired = false; + container.RegisterTrigger("t1", TriggerType.Post, TriggerOperation.Create, (Action)(item => + { + triggerFired = true; + })); + + container.ClearItems(); + + await container.CreateItemAsync(new TestDocument { Id = "1", PartitionKey = "a" }, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "t1" } }); + + triggerFired.Should().BeTrue(); + } } // ── Phase 5: System Properties & Metadata ── public class StoredProcedureMetadataTests { - [Fact] - public async Task CreateSproc_ETagHasQuotedFormat() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - response.Resource.ETag.Should().StartWith("\"").And.EndWith("\""); - } - - [Fact] - public async Task CreateSproc_SelfLinkPopulated() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - response.Resource.SelfLink.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task CreateSproc_TimestampIsPopulated() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - // StoredProcedureProperties exposes _ts via the underlying JSON - var json = JsonConvert.SerializeObject(response.Resource); - json.Should().Contain("_ts"); - } - - [Fact] - public async Task CreateSproc_RidIsPopulated() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - var json = JsonConvert.SerializeObject(response.Resource); - json.Should().Contain("_rid"); - } - - [Fact] - public async Task ReplaceSproc_ETagChanges() - { - var container = new InMemoryContainer("test", "/pk"); - var createResp = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 1; }" }); - var originalEtag = createResp.Resource.ETag; - - var replaceResp = await container.Scripts.ReplaceStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 2; }" }); - - replaceResp.Resource.ETag.Should().NotBe(originalEtag); - } - - [Fact] - public async Task ReplaceSproc_TimestampUpdates() - { - var container = new InMemoryContainer("test", "/pk"); - var createResp = await container.Scripts.CreateStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 1; }" }); - var originalEtag = createResp.Resource.ETag; - - await Task.Delay(50); - - var replaceResp = await container.Scripts.ReplaceStoredProcedureAsync( - new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 2; }" }); - - // ETag change already tested above; just verify replace succeeded - replaceResp.Resource.ETag.Should().NotBe(originalEtag); - } + [Fact] + public async Task CreateSproc_ETagHasQuotedFormat() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + response.Resource.ETag.Should().StartWith("\"").And.EndWith("\""); + } + + [Fact] + public async Task CreateSproc_SelfLinkPopulated() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + response.Resource.SelfLink.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CreateSproc_TimestampIsPopulated() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + // StoredProcedureProperties exposes _ts via the underlying JSON + var json = JsonConvert.SerializeObject(response.Resource); + json.Should().Contain("_ts"); + } + + [Fact] + public async Task CreateSproc_RidIsPopulated() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + var json = JsonConvert.SerializeObject(response.Resource); + json.Should().Contain("_rid"); + } + + [Fact] + public async Task ReplaceSproc_ETagChanges() + { + var container = new InMemoryContainer("test", "/pk"); + var createResp = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 1; }" }); + var originalEtag = createResp.Resource.ETag; + + var replaceResp = await container.Scripts.ReplaceStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 2; }" }); + + replaceResp.Resource.ETag.Should().NotBe(originalEtag); + } + + [Fact] + public async Task ReplaceSproc_TimestampUpdates() + { + var container = new InMemoryContainer("test", "/pk"); + var createResp = await container.Scripts.CreateStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 1; }" }); + var originalEtag = createResp.Resource.ETag; + + await Task.Delay(50); + + var replaceResp = await container.Scripts.ReplaceStoredProcedureAsync( + new StoredProcedureProperties { Id = "sp1", Body = "function(){ return 2; }" }); + + // ETag change already tested above; just verify replace succeeded + replaceResp.Resource.ETag.Should().NotBe(originalEtag); + } } // ── Phase 6: Typed Response Deserialization ── public class StoredProcedureTypedResponseTests { - private readonly InMemoryContainer _container = new("sp-typed", "/pk"); + private readonly InMemoryContainer _container = new("sp-typed", "/pk"); - [Fact] - public async Task ExecuteSproc_IntTOutput_DeserializesCorrectly() - { - _container.RegisterStoredProcedure("spInt", (pk, args) => "42"); + [Fact] + public async Task ExecuteSproc_IntTOutput_DeserializesCorrectly() + { + _container.RegisterStoredProcedure("spInt", (pk, args) => "42"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spInt", new PartitionKey("pk1"), Array.Empty()); + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spInt", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Should().Be(42); - } + response.Resource.Should().Be(42); + } - [Fact] - public async Task ExecuteSproc_BoolTOutput_DeserializesCorrectly() - { - _container.RegisterStoredProcedure("spBool", (pk, args) => "true"); + [Fact] + public async Task ExecuteSproc_BoolTOutput_DeserializesCorrectly() + { + _container.RegisterStoredProcedure("spBool", (pk, args) => "true"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spBool", new PartitionKey("pk1"), Array.Empty()); + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spBool", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Should().BeTrue(); - } + response.Resource.Should().BeTrue(); + } - [Fact] - public async Task ExecuteSproc_ListTOutput_DeserializesCorrectly() - { - _container.RegisterStoredProcedure("spList", (pk, args) => "[\"a\",\"b\",\"c\"]"); + [Fact] + public async Task ExecuteSproc_ListTOutput_DeserializesCorrectly() + { + _container.RegisterStoredProcedure("spList", (pk, args) => "[\"a\",\"b\",\"c\"]"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync>( - "spList", new PartitionKey("pk1"), Array.Empty()); + var response = await _container.Scripts.ExecuteStoredProcedureAsync>( + "spList", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Should().BeEquivalentTo(new[] { "a", "b", "c" }); - } + response.Resource.Should().BeEquivalentTo(new[] { "a", "b", "c" }); + } - [Fact] - public async Task ExecuteSproc_CustomClassTOutput_DeserializesCorrectly() - { - _container.RegisterStoredProcedure("spCustom", (pk, args) => "{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Alice\"}"); + [Fact] + public async Task ExecuteSproc_CustomClassTOutput_DeserializesCorrectly() + { + _container.RegisterStoredProcedure("spCustom", (pk, args) => "{\"id\":\"1\",\"partitionKey\":\"pk\",\"name\":\"Alice\"}"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spCustom", new PartitionKey("pk1"), Array.Empty()); + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spCustom", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Name.Should().Be("Alice"); - } + response.Resource.Name.Should().Be("Alice"); + } - [Fact] - public async Task ExecuteSproc_StringTOutput_NotDoubleDeserialized() - { - _container.RegisterStoredProcedure("spStr", (pk, args) => "hello"); + [Fact] + public async Task ExecuteSproc_StringTOutput_NotDoubleDeserialized() + { + _container.RegisterStoredProcedure("spStr", (pk, args) => "hello"); - var response = await _container.Scripts.ExecuteStoredProcedureAsync( - "spStr", new PartitionKey("pk1"), Array.Empty()); + var response = await _container.Scripts.ExecuteStoredProcedureAsync( + "spStr", new PartitionKey("pk1"), Array.Empty()); - response.Resource.Should().Be("hello"); - } + response.Resource.Should().Be("hello"); + } } // ── Phase 7: UDF Full CRUD ── public class UdfCrudTests { - private readonly InMemoryContainer _container = new("udf-crud", "/pk"); - - [Fact] - public async Task ReadUdf_AfterCreate_ReturnsProperties() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); - - var response = await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("udf1"); - response.Resource.Body.Should().Contain("return x"); - } - - [Fact] - public async Task ReadUdf_NotFound_Throws404() - { - var act = async () => await _container.Scripts.ReadUserDefinedFunctionAsync("nonexistent"); - - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceUdf_UpdatesBody() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); - - await _container.Scripts.ReplaceUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x * 2; }" }); - - var read = await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); - read.Resource.Body.Should().Contain("x * 2"); - } - - [Fact] - public async Task ReplaceUdf_NotFound_Throws404() - { - var act = async () => await _container.Scripts.ReplaceUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "nonexistent", Body = "function(){}" }); - - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteUdf_Removes_AndThrows404OnRead() - { - await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); - - var deleteResp = await _container.Scripts.DeleteUserDefinedFunctionAsync("udf1"); - deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var act = async () => await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteUdf_NotFound_Throws404() - { - var act = async () => await _container.Scripts.DeleteUserDefinedFunctionAsync("nonexistent"); - - await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); - } - - [Fact] - public async Task UdfStreamCrud_FullCycle() - { - // Create - var createResp = await _container.Scripts.CreateUserDefinedFunctionStreamAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - // Read - var readResp = await _container.Scripts.ReadUserDefinedFunctionStreamAsync("udf1"); - readResp.StatusCode.Should().Be(HttpStatusCode.OK); - - // Replace - var replaceResp = await _container.Scripts.ReplaceUserDefinedFunctionStreamAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x * 3; }" }); - replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); - - // Delete - var deleteResp = await _container.Scripts.DeleteUserDefinedFunctionStreamAsync("udf1"); - deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Udf_SystemProperties_EtagPopulated() - { - var response = await _container.Scripts.CreateUserDefinedFunctionAsync( - new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); - - // UDF properties may not have system properties enriched in the emulator - response.Resource.Id.Should().Be("udf1"); - } + private readonly InMemoryContainer _container = new("udf-crud", "/pk"); + + [Fact] + public async Task ReadUdf_AfterCreate_ReturnsProperties() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); + + var response = await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("udf1"); + response.Resource.Body.Should().Contain("return x"); + } + + [Fact] + public async Task ReadUdf_NotFound_Throws404() + { + var act = async () => await _container.Scripts.ReadUserDefinedFunctionAsync("nonexistent"); + + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceUdf_UpdatesBody() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); + + await _container.Scripts.ReplaceUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x * 2; }" }); + + var read = await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); + read.Resource.Body.Should().Contain("x * 2"); + } + + [Fact] + public async Task ReplaceUdf_NotFound_Throws404() + { + var act = async () => await _container.Scripts.ReplaceUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "nonexistent", Body = "function(){}" }); + + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteUdf_Removes_AndThrows404OnRead() + { + await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); + + var deleteResp = await _container.Scripts.DeleteUserDefinedFunctionAsync("udf1"); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var act = async () => await _container.Scripts.ReadUserDefinedFunctionAsync("udf1"); + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteUdf_NotFound_Throws404() + { + var act = async () => await _container.Scripts.DeleteUserDefinedFunctionAsync("nonexistent"); + + await act.Should().ThrowAsync().Where(e => e.StatusCode == HttpStatusCode.NotFound); + } + + [Fact] + public async Task UdfStreamCrud_FullCycle() + { + // Create + var createResp = await _container.Scripts.CreateUserDefinedFunctionStreamAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + // Read + var readResp = await _container.Scripts.ReadUserDefinedFunctionStreamAsync("udf1"); + readResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Replace + var replaceResp = await _container.Scripts.ReplaceUserDefinedFunctionStreamAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x * 3; }" }); + replaceResp.StatusCode.Should().Be(HttpStatusCode.OK); + + // Delete + var deleteResp = await _container.Scripts.DeleteUserDefinedFunctionStreamAsync("udf1"); + deleteResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Udf_SystemProperties_EtagPopulated() + { + var response = await _container.Scripts.CreateUserDefinedFunctionAsync( + new UserDefinedFunctionProperties { Id = "udf1", Body = "function(x) { return x; }" }); + + // UDF properties may not have system properties enriched in the emulator + response.Resource.Id.Should().Be("udf1"); + } } // ── Phase 8: Concurrency & Edge Cases ── public class StoredProcedureConcurrencyTests { - [Fact] - public async Task ConcurrentSprocExecution_ThreadSafe() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterStoredProcedure("sp1", (pk, args) => "result"); - - var tasks = Enumerable.Range(0, 50).Select(_ => - container.Scripts.ExecuteStoredProcedureAsync("sp1", new PartitionKey("pk1"), Array.Empty())); - - var responses = await Task.WhenAll(tasks); - responses.Should().AllSatisfy(r => r.Resource.Should().Be("result")); - } + [Fact] + public async Task ConcurrentSprocExecution_ThreadSafe() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterStoredProcedure("sp1", (pk, args) => "result"); + + var tasks = Enumerable.Range(0, 50).Select(_ => + container.Scripts.ExecuteStoredProcedureAsync("sp1", new PartitionKey("pk1"), Array.Empty())); + + var responses = await Task.WhenAll(tasks); + responses.Should().AllSatisfy(r => r.Resource.Should().Be("result")); + } } public class StoredProcedureEdgeCaseTests { - [Fact] - public void QueryIterator_NoSprocs_ReturnsEmpty() - { - var container = new InMemoryContainer("test", "/pk"); - var iter = container.Scripts.GetStoredProcedureQueryIterator(); - iter.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task StreamIterator_HasMoreResults_FalseAfterRead() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - - var iter = container.Scripts.GetStoredProcedureQueryStreamIterator(); - iter.HasMoreResults.Should().BeTrue(); - await iter.ReadNextAsync(); - iter.HasMoreResults.Should().BeFalse(); - } - - [Fact] - public async Task StreamQueryIterator_ShouldFilterById() - { - var container = new InMemoryContainer("test", "/pk"); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); - await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); - - var iter = container.Scripts.GetStoredProcedureQueryStreamIterator("SELECT * FROM c WHERE c.id = 'sp1'"); - var response = await iter.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = reader.ReadToEnd(); - body.Should().Contain("sp1"); - body.Should().NotContain("sp2"); - } + [Fact] + public void QueryIterator_NoSprocs_ReturnsEmpty() + { + var container = new InMemoryContainer("test", "/pk"); + var iter = container.Scripts.GetStoredProcedureQueryIterator(); + iter.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task StreamIterator_HasMoreResults_FalseAfterRead() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + + var iter = container.Scripts.GetStoredProcedureQueryStreamIterator(); + iter.HasMoreResults.Should().BeTrue(); + await iter.ReadNextAsync(); + iter.HasMoreResults.Should().BeFalse(); + } + + [Fact] + public async Task StreamQueryIterator_ShouldFilterById() + { + var container = new InMemoryContainer("test", "/pk"); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp1", Body = "function(){}" }); + await container.Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties { Id = "sp2", Body = "function(){}" }); + + var iter = container.Scripts.GetStoredProcedureQueryStreamIterator("SELECT * FROM c WHERE c.id = 'sp1'"); + var response = await iter.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = reader.ReadToEnd(); + body.Should().Contain("sp1"); + body.Should().NotContain("sp2"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StreamCrudTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StreamCrudTests.cs index 73f8b2f..6670cb9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StreamCrudTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StreamCrudTests.cs @@ -10,244 +10,244 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class StreamCrudTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static string ReadStream(Stream stream) - { - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } - - [Fact] - public async Task CreateItemStreamAsync_ValidItem_ReturnsCreated() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().NotBeNull(); - } - - [Fact] - public async Task CreateItemStreamAsync_Duplicate_ReturnsConflict() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReadItemStreamAsync_ExistingItem_ReturnsOkWithContent() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - var body = ReadStream(response.Content); - var jObj = JObject.Parse(body); - jObj["name"]!.ToString().Should().Be("Test"); - } - - [Fact] - public async Task ReadItemStreamAsync_NonExistent_ReturnsNotFound() - { - var response = await _container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task UpsertItemStreamAsync_NewItem_ReturnsCreated() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UpsertItemStreamAsync_ExistingItem_ReturnsOk() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; - var response = await _container.UpsertItemStreamAsync(ToStream(updatedJson), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemStreamAsync_ExistingItem_ReturnsOk() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var replacementJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; - var response = await _container.ReplaceItemStreamAsync(ToStream(replacementJson), "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemStreamAsync_NonExistent_ReturnsNotFound() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItemStreamAsync_ExistingItem_ReturnsNoContent() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task DeleteItemStreamAsync_NonExistent_ReturnsNotFound() - { - var response = await _container.DeleteItemStreamAsync("nonexistent", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static string ReadStream(Stream stream) + { + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + [Fact] + public async Task CreateItemStreamAsync_ValidItem_ReturnsCreated() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().NotBeNull(); + } + + [Fact] + public async Task CreateItemStreamAsync_Duplicate_ReturnsConflict() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReadItemStreamAsync_ExistingItem_ReturnsOkWithContent() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + var body = ReadStream(response.Content); + var jObj = JObject.Parse(body); + jObj["name"]!.ToString().Should().Be("Test"); + } + + [Fact] + public async Task ReadItemStreamAsync_NonExistent_ReturnsNotFound() + { + var response = await _container.ReadItemStreamAsync("nonexistent", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpsertItemStreamAsync_NewItem_ReturnsCreated() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UpsertItemStreamAsync_ExistingItem_ReturnsOk() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; + var response = await _container.UpsertItemStreamAsync(ToStream(updatedJson), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemStreamAsync_ExistingItem_ReturnsOk() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var replacementJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; + var response = await _container.ReplaceItemStreamAsync(ToStream(replacementJson), "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemStreamAsync_NonExistent_ReturnsNotFound() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItemStreamAsync_ExistingItem_ReturnsNoContent() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteItemStreamAsync_NonExistent_ReturnsNotFound() + { + var response = await _container.DeleteItemStreamAsync("nonexistent", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task PatchItemStreamAsync_ExistingItem_ReturnsOk() - { - var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }; - await _container.CreateItemAsync(item, new PartitionKey("pk1")); - - var patchOperations = new[] { PatchOperation.Replace("/name", "Patched") }; - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), patchOperations); + [Fact] + public async Task PatchItemStreamAsync_ExistingItem_ReturnsOk() + { + var item = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 1 }; + await _container.CreateItemAsync(item, new PartitionKey("pk1")); + + var patchOperations = new[] { PatchOperation.Replace("/name", "Patched") }; + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), patchOperations); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task PatchItemStreamAsync_NonExistent_ReturnsNotFound() - { - var patchOperations = new[] { PatchOperation.Replace("/name", "Patched") }; - var response = await _container.PatchItemStreamAsync("nonexistent", new PartitionKey("pk1"), patchOperations); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task PatchItemStreamAsync_NonExistent_ReturnsNotFound() + { + var patchOperations = new[] { PatchOperation.Replace("/name", "Patched") }; + var response = await _container.PatchItemStreamAsync("nonexistent", new PartitionKey("pk1"), patchOperations); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task UpsertItemStreamAsync_MissingLowercaseId_Returns400() - { - var json = """{"Id":"1","partitionKey":"pk1","name":"PascalCase"}"""; - - var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + [Fact] + public async Task UpsertItemStreamAsync_MissingLowercaseId_Returns400() + { + var json = """{"Id":"1","partitionKey":"pk1","name":"PascalCase"}"""; + + var response = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } public class StreamETagHandlingTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task UpsertItemStreamAsync_WithIfMatch_CurrentETag_ReturnsOk() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - var createResponse = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - var etag = createResponse.Headers.ETag; - - var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; - var response = await _container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task UpsertItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; - var response = await _container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task CreateItemStreamAsync_WithNullStream_Throws() - { - var act = () => _container.CreateItemStreamAsync(null!, new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReplaceItemStreamAsync_WithIfMatch_CurrentETag_ReturnsOk() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - var createResponse = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - var etag = createResponse.Headers.ETag; - - var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; - var response = await _container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; - var response = await _container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task UpsertItemStreamAsync_WithIfMatch_CurrentETag_ReturnsOk() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + var createResponse = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + var etag = createResponse.Headers.ETag; + + var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; + var response = await _container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpsertItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; + var response = await _container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task CreateItemStreamAsync_WithNullStream_Throws() + { + var act = () => _container.CreateItemStreamAsync(null!, new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceItemStreamAsync_WithIfMatch_CurrentETag_ReturnsOk() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + var createResponse = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + var etag = createResponse.Headers.ETag; + + var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; + var response = await _container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceItemStreamAsync_WithIfMatch_StaleETag_ReturnsPreconditionFailed() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var updatedJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; + var response = await _container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(updatedJson)), + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } @@ -259,226 +259,226 @@ await _container.CreateItemStreamAsync( /// public class StreamDeleteETagTests5 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task DeleteItemStream_WithIfMatch_StaleETag_Returns412_DoesNotThrow() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + [Fact] + public async Task DeleteItemStream_WithIfMatch_StaleETag_Returns412_DoesNotThrow() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - var response = await _container.DeleteItemStreamAsync( - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + var response = await _container.DeleteItemStreamAsync( + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } public class StreamETagGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task ReadStream_WithIfNoneMatch_CurrentETag_Returns304() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var createResponse = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - var etag = createResponse.Headers.ETag; - - var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etag }); - - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task UpsertStream_WithIfMatch_StaleETag_Returns412() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Updated"}"""), - new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task ReplaceStream_WithIfMatch_StaleETag_Returns412() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Updated"}"""), - "1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task DeleteStream_WithIfMatch_StaleETag_Returns412() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task PatchStream_WithIfMatch_StaleETag_Returns412() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test","value":10}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - [PatchOperation.Set("/name", "Patched")], - new PatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); - - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task CreateStream_ResponseContainsETagHeader() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task ReadStream_WithIfNoneMatch_CurrentETag_Returns304() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var createResponse = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var etag = createResponse.Headers.ETag; + + var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etag }); + + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task UpsertStream_WithIfMatch_StaleETag_Returns412() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Updated"}"""), + new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ReplaceStream_WithIfMatch_StaleETag_Returns412() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Updated"}"""), + "1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task DeleteStream_WithIfMatch_StaleETag_Returns412() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task PatchStream_WithIfMatch_StaleETag_Returns412() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test","value":10}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + var response = await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + [PatchOperation.Set("/name", "Patched")], + new PatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); + + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task CreateStream_ResponseContainsETagHeader() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } } public class ResponseMetadataGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task StreamResponse_StatusCode_InResponseMessage() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task StreamResponse_Content_ContainsDocumentJson() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var readResponse = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - readResponse.Content.Should().NotBeNull(); - - using var reader = new StreamReader(readResponse.Content); - var body = await reader.ReadToEndAsync(); - body.Should().Contain("\"name\""); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task StreamResponse_StatusCode_InResponseMessage() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task StreamResponse_Content_ContainsDocumentJson() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var readResponse = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + readResponse.Content.Should().NotBeNull(); + + using var reader = new StreamReader(readResponse.Content); + var body = await reader.ReadToEndAsync(); + body.Should().Contain("\"name\""); + } } public class StreamOperationGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - [Fact] - public async Task CreateStream_WithoutId_AutoGeneratesId() - { - var json = """{"partitionKey":"pk1","name":"NoId"}"""; - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + [Fact] + public async Task CreateStream_WithoutId_AutoGeneratesId() + { + var json = """{"partitionKey":"pk1","name":"NoId"}"""; + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task CreateStream_Duplicate_Returns409_NotThrow() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + [Fact] + public async Task CreateStream_Duplicate_Returns409_NotThrow() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + var response = await _container.CreateItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } - [Fact] - public async Task ReadStream_NotFound_Returns404StatusCode() - { - var response = await _container.ReadItemStreamAsync("missing", new PartitionKey("pk1")); + [Fact] + public async Task ReadStream_NotFound_Returns404StatusCode() + { + var response = await _container.ReadItemStreamAsync("missing", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task ReplaceStream_NotFound_Returns404StatusCode() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); + [Fact] + public async Task ReplaceStream_NotFound_Returns404StatusCode() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + var response = await _container.ReplaceItemStreamAsync(ToStream(json), "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task DeleteStream_NotFound_Returns404StatusCode() - { - var response = await _container.DeleteItemStreamAsync("missing", new PartitionKey("pk1")); + [Fact] + public async Task DeleteStream_NotFound_Returns404StatusCode() + { + var response = await _container.DeleteItemStreamAsync("missing", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task UpsertStream_NewItem_Returns201_Existing_Returns200() - { - var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; + [Fact] + public async Task UpsertStream_NewItem_Returns201_Existing_Returns200() + { + var json = """{"id":"1","partitionKey":"pk1","name":"Test"}"""; - var createResponse = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var createResponse = await _container.UpsertItemStreamAsync(ToStream(json), new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - var updateJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; - var updateResponse = await _container.UpsertItemStreamAsync(ToStream(updateJson), new PartitionKey("pk1")); - updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } + var updateJson = """{"id":"1","partitionKey":"pk1","name":"Updated"}"""; + var updateResponse = await _container.UpsertItemStreamAsync(ToStream(updateJson), new PartitionKey("pk1")); + updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class StreamOperationGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - [Fact] - public async Task AllStreamMethods_ReturnStatusCode_NotThrow_OnError() - { - // Stream methods return errors via StatusCode, not exceptions - var readResponse = await _container.ReadItemStreamAsync("missing", new PartitionKey("pk1")); - readResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + [Fact] + public async Task AllStreamMethods_ReturnStatusCode_NotThrow_OnError() + { + // Stream methods return errors via StatusCode, not exceptions + var readResponse = await _container.ReadItemStreamAsync("missing", new PartitionKey("pk1")); + readResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - var replaceResponse = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"missing","partitionKey":"pk1"}"""), - "missing", new PartitionKey("pk1")); - replaceResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + var replaceResponse = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"missing","partitionKey":"pk1"}"""), + "missing", new PartitionKey("pk1")); + replaceResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - var deleteResponse = await _container.DeleteItemStreamAsync("missing", new PartitionKey("pk1")); - deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + var deleteResponse = await _container.DeleteItemStreamAsync("missing", new PartitionKey("pk1")); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -487,101 +487,101 @@ public async Task AllStreamMethods_ReturnStatusCode_NotThrow_OnError() public class StreamResponseBodyTests { - private readonly InMemoryContainer _container = new("body-test", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task CreateStream_ResponseContent_ContainsCreatedDocument() - { - using var response = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice"}"""), - new PartitionKey("pk1")); - - response.Content.Should().NotBeNull(); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["id"]!.ToString().Should().Be("1"); - body["name"]!.ToString().Should().Be("Alice"); - } - - [Fact] - public async Task UpsertStream_NewItem_ResponseContent_ContainsDocument() - { - using var response = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Bob"}"""), - new PartitionKey("pk1")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("Bob"); - } - - [Fact] - public async Task UpsertStream_ExistingItem_ResponseContent_ContainsUpdatedDocument() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Old"}"""), - new PartitionKey("pk1")); - - using var response = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"New"}"""), - new PartitionKey("pk1")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("New"); - } - - [Fact] - public async Task ReplaceStream_ResponseContent_ContainsReplacedDocument() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Original"}"""), - new PartitionKey("pk1")); - - using var response = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Replaced"}"""), - "1", new PartitionKey("pk1")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("Replaced"); - } - - [Fact] - public async Task ReadStream_AfterCreateStream_ReturnsCompleteDocument() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","value":42}"""), - new PartitionKey("pk1")); - - using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("Alice"); - body["value"]!.Value().Should().Be(42); - } - - [Fact] - public async Task DeleteStream_Success_ContentIsNull() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - - using var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - response.Content.Should().BeNull(); - } - - [Fact] - public async Task PatchStream_ResponseContent_ContainsPatchedDocument() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Before"}"""), new PartitionKey("pk1")); - - using var response = await _container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), new[] { PatchOperation.Replace("/name", "After") }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("After"); - } + private readonly InMemoryContainer _container = new("body-test", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task CreateStream_ResponseContent_ContainsCreatedDocument() + { + using var response = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice"}"""), + new PartitionKey("pk1")); + + response.Content.Should().NotBeNull(); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["id"]!.ToString().Should().Be("1"); + body["name"]!.ToString().Should().Be("Alice"); + } + + [Fact] + public async Task UpsertStream_NewItem_ResponseContent_ContainsDocument() + { + using var response = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Bob"}"""), + new PartitionKey("pk1")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("Bob"); + } + + [Fact] + public async Task UpsertStream_ExistingItem_ResponseContent_ContainsUpdatedDocument() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Old"}"""), + new PartitionKey("pk1")); + + using var response = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"New"}"""), + new PartitionKey("pk1")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("New"); + } + + [Fact] + public async Task ReplaceStream_ResponseContent_ContainsReplacedDocument() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Original"}"""), + new PartitionKey("pk1")); + + using var response = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Replaced"}"""), + "1", new PartitionKey("pk1")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("Replaced"); + } + + [Fact] + public async Task ReadStream_AfterCreateStream_ReturnsCompleteDocument() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","value":42}"""), + new PartitionKey("pk1")); + + using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("Alice"); + body["value"]!.Value().Should().Be(42); + } + + [Fact] + public async Task DeleteStream_Success_ContentIsNull() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + + using var response = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.Content.Should().BeNull(); + } + + [Fact] + public async Task PatchStream_ResponseContent_ContainsPatchedDocument() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Before"}"""), new PartitionKey("pk1")); + + using var response = await _container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), new[] { PatchOperation.Replace("/name", "After") }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("After"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -590,92 +590,92 @@ await _container.CreateItemStreamAsync( public class StreamSystemPropertyTests { - private readonly InMemoryContainer _container = new("sysprop-test", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task CreateStream_ResponseBody_ContainsEtagSystemProperty() - { - using var response = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), - new PartitionKey("pk1")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["_etag"].Should().NotBeNull(); - body["_etag"]!.ToString().Should().NotBeEmpty(); - } - - [Fact] - public async Task CreateStream_ResponseBody_ContainsTsSystemProperty() - { - using var response = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), - new PartitionKey("pk1")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["_ts"].Should().NotBeNull(); - body["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task UpsertStream_ExistingItem_UpdatesEtagAndTs() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Old"}"""), - new PartitionKey("pk1")); - - using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body1 = JObject.Parse(await ReadStreamAsync(read1.Content)); - var etag1 = body1["_etag"]!.ToString(); - - await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"New"}"""), - new PartitionKey("pk1")); - - using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body2 = JObject.Parse(await ReadStreamAsync(read2.Content)); - body2["_etag"]!.ToString().Should().NotBe(etag1); - } - - [Fact] - public async Task ReplaceStream_UpdatesEtag() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), - new PartitionKey("pk1")); - - using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var etag1 = JObject.Parse(await ReadStreamAsync(read1.Content))["_etag"]!.ToString(); - - await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"replaced"}"""), - "1", new PartitionKey("pk1")); - - using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var etag2 = JObject.Parse(await ReadStreamAsync(read2.Content))["_etag"]!.ToString(); - etag2.Should().NotBe(etag1); - } - - [Fact] - public async Task PatchStream_UpdatesEtagAndTs() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"orig"}"""), new PartitionKey("pk1")); - - using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body1 = JObject.Parse(await ReadStreamAsync(read1.Content)); - var etag1 = body1["_etag"]!.ToString(); - var ts1 = body1["_ts"]!.Value(); - - await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Replace("/name", "patched") }); - - using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body2 = JObject.Parse(await ReadStreamAsync(read2.Content)); - body2["_etag"]!.ToString().Should().NotBe(etag1); - body2["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(ts1); - } + private readonly InMemoryContainer _container = new("sysprop-test", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task CreateStream_ResponseBody_ContainsEtagSystemProperty() + { + using var response = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), + new PartitionKey("pk1")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["_etag"].Should().NotBeNull(); + body["_etag"]!.ToString().Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateStream_ResponseBody_ContainsTsSystemProperty() + { + using var response = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), + new PartitionKey("pk1")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["_ts"].Should().NotBeNull(); + body["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task UpsertStream_ExistingItem_UpdatesEtagAndTs() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Old"}"""), + new PartitionKey("pk1")); + + using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body1 = JObject.Parse(await ReadStreamAsync(read1.Content)); + var etag1 = body1["_etag"]!.ToString(); + + await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"New"}"""), + new PartitionKey("pk1")); + + using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body2 = JObject.Parse(await ReadStreamAsync(read2.Content)); + body2["_etag"]!.ToString().Should().NotBe(etag1); + } + + [Fact] + public async Task ReplaceStream_UpdatesEtag() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), + new PartitionKey("pk1")); + + using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var etag1 = JObject.Parse(await ReadStreamAsync(read1.Content))["_etag"]!.ToString(); + + await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"replaced"}"""), + "1", new PartitionKey("pk1")); + + using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var etag2 = JObject.Parse(await ReadStreamAsync(read2.Content))["_etag"]!.ToString(); + etag2.Should().NotBe(etag1); + } + + [Fact] + public async Task PatchStream_UpdatesEtagAndTs() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"orig"}"""), new PartitionKey("pk1")); + + using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body1 = JObject.Parse(await ReadStreamAsync(read1.Content)); + var etag1 = body1["_etag"]!.ToString(); + var ts1 = body1["_ts"]!.Value(); + + await _container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "patched") }); + + using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body2 = JObject.Parse(await ReadStreamAsync(read2.Content)); + body2["_etag"]!.ToString().Should().NotBe(etag1); + body2["_ts"]!.Value().Should().BeGreaterThanOrEqualTo(ts1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -684,45 +684,45 @@ await _container.CreateItemStreamAsync( public class StreamIsSuccessStatusCodeTests { - private readonly InMemoryContainer _container = new("success-test", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Stream_SuccessResponses_HaveIsSuccessStatusCode_True() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - create.IsSuccessStatusCode.Should().BeTrue(); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - read.IsSuccessStatusCode.Should().BeTrue(); - - using var upsert = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"updated"}"""), new PartitionKey("pk1")); - upsert.IsSuccessStatusCode.Should().BeTrue(); - - using var replace = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"replaced"}"""), - "1", new PartitionKey("pk1")); - replace.IsSuccessStatusCode.Should().BeTrue(); - - using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - delete.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Stream_ErrorResponses_HaveIsSuccessStatusCode_False() - { - using var readMiss = await _container.ReadItemStreamAsync("miss", new PartitionKey("pk1")); - readMiss.IsSuccessStatusCode.Should().BeFalse(); - - using var deleteMiss = await _container.DeleteItemStreamAsync("miss", new PartitionKey("pk1")); - deleteMiss.IsSuccessStatusCode.Should().BeFalse(); - - using var replaceMiss = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"miss","partitionKey":"pk1"}"""), "miss", new PartitionKey("pk1")); - replaceMiss.IsSuccessStatusCode.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("success-test", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Stream_SuccessResponses_HaveIsSuccessStatusCode_True() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + create.IsSuccessStatusCode.Should().BeTrue(); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + read.IsSuccessStatusCode.Should().BeTrue(); + + using var upsert = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"updated"}"""), new PartitionKey("pk1")); + upsert.IsSuccessStatusCode.Should().BeTrue(); + + using var replace = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"replaced"}"""), + "1", new PartitionKey("pk1")); + replace.IsSuccessStatusCode.Should().BeTrue(); + + using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + delete.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Stream_ErrorResponses_HaveIsSuccessStatusCode_False() + { + using var readMiss = await _container.ReadItemStreamAsync("miss", new PartitionKey("pk1")); + readMiss.IsSuccessStatusCode.Should().BeFalse(); + + using var deleteMiss = await _container.DeleteItemStreamAsync("miss", new PartitionKey("pk1")); + deleteMiss.IsSuccessStatusCode.Should().BeFalse(); + + using var replaceMiss = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"miss","partitionKey":"pk1"}"""), "miss", new PartitionKey("pk1")); + replaceMiss.IsSuccessStatusCode.Should().BeFalse(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -731,60 +731,60 @@ public async Task Stream_ErrorResponses_HaveIsSuccessStatusCode_False() public class StreamCrudResponseHeaderTests { - private readonly InMemoryContainer _container = new("hdr-test", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Stream_AllCrudResponses_ContainRequestChargeHeader() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - create.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - read.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - - using var upsert = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","x":"y"}"""), new PartitionKey("pk1")); - upsert.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - - using var replace = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","z":"w"}"""), "1", new PartitionKey("pk1")); - replace.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - - using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - delete.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Stream_AllCrudResponses_ContainActivityIdHeader() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - create.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - read.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - - using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - delete.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Stream_WriteResponses_ContainETagHeader() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - create.Headers.ETag.Should().NotBeNullOrEmpty(); - - using var upsert = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"u"}"""), new PartitionKey("pk1")); - upsert.Headers.ETag.Should().NotBeNullOrEmpty(); - - using var replace = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"r"}"""), "1", new PartitionKey("pk1")); - replace.Headers.ETag.Should().NotBeNullOrEmpty(); - } + private readonly InMemoryContainer _container = new("hdr-test", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Stream_AllCrudResponses_ContainRequestChargeHeader() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + create.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + read.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + + using var upsert = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","x":"y"}"""), new PartitionKey("pk1")); + upsert.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + + using var replace = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","z":"w"}"""), "1", new PartitionKey("pk1")); + replace.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + + using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + delete.Headers["x-ms-request-charge"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Stream_AllCrudResponses_ContainActivityIdHeader() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + create.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + read.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + + using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + delete.Headers["x-ms-activity-id"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Stream_WriteResponses_ContainETagHeader() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + create.Headers.ETag.Should().NotBeNullOrEmpty(); + + using var upsert = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"u"}"""), new PartitionKey("pk1")); + upsert.Headers.ETag.Should().NotBeNullOrEmpty(); + + using var replace = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"r"}"""), "1", new PartitionKey("pk1")); + replace.Headers.ETag.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -793,55 +793,55 @@ public async Task Stream_WriteResponses_ContainETagHeader() public class StreamETagLifecycleTests { - private readonly InMemoryContainer _container = new("etag-life", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task UpsertStream_ChangesETag() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - var etag1 = create.Headers.ETag; - - using var upsert = await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"u"}"""), new PartitionKey("pk1")); - upsert.Headers.ETag.Should().NotBe(etag1); - } - - [Fact] - public async Task ReplaceStream_ChangesETag() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - var etag1 = create.Headers.ETag; - - using var replace = await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"r"}"""), "1", new PartitionKey("pk1")); - replace.Headers.ETag.Should().NotBe(etag1); - } - - [Fact] - public async Task ReadStream_ConsecutiveReads_ETagConsistent() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - - using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - read1.Headers.ETag.Should().Be(read2.Headers.ETag); - } - - [Fact] - public async Task DeleteStream_WithIfMatch_CurrentETag_Succeeds() - { - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - var etag = create.Headers.ETag; - - using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - delete.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("etag-life", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task UpsertStream_ChangesETag() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + var etag1 = create.Headers.ETag; + + using var upsert = await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"u"}"""), new PartitionKey("pk1")); + upsert.Headers.ETag.Should().NotBe(etag1); + } + + [Fact] + public async Task ReplaceStream_ChangesETag() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + var etag1 = create.Headers.ETag; + + using var replace = await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"r"}"""), "1", new PartitionKey("pk1")); + replace.Headers.ETag.Should().NotBe(etag1); + } + + [Fact] + public async Task ReadStream_ConsecutiveReads_ETagConsistent() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + + using var read1 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + using var read2 = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + read1.Headers.ETag.Should().Be(read2.Headers.ETag); + } + + [Fact] + public async Task DeleteStream_WithIfMatch_CurrentETag_Succeeds() + { + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + var etag = create.Headers.ETag; + + using var delete = await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -850,94 +850,94 @@ public async Task DeleteStream_WithIfMatch_CurrentETag_Succeeds() public class StreamDataIntegrityTests { - private readonly InMemoryContainer _container = new("integrity-test", "/partitionKey"); - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task CreateStream_ThenTypedRead_DataRoundTrips() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","value":42}"""), - new PartitionKey("pk1")); - - var result = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - result.Resource.Name.Should().Be("Alice"); - result.Resource.Value.Should().Be(42); - } - - [Fact] - public async Task TypedCreate_ThenStreamRead_DataRoundTrips() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob", Value = 99 }, - new PartitionKey("pk1")); - - using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("Bob"); - body["value"]!.Value().Should().Be(99); - } - - [Fact] - public async Task UpsertStream_ReplacesEntireDocument_NotMerge() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","extra":"field"}"""), - new PartitionKey("pk1")); - - await _container.UpsertItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice"}"""), - new PartitionKey("pk1")); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body = JObject.Parse(await ReadStreamAsync(read.Content)); - body["extra"].Should().BeNull("upsert replaces entire document, it does not merge"); - } - - [Fact] - public async Task DeleteStream_ThenRead_Returns404() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - - await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - read.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteStream_ThenCreate_SameId_Succeeds() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"first"}"""), - new PartitionKey("pk1")); - - await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - - using var create = await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"second"}"""), - new PartitionKey("pk1")); - create.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReplaceStream_FullyReplacesDocument() - { - await _container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","fieldA":"a","fieldB":"b"}"""), - new PartitionKey("pk1")); - - await _container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","fieldA":"updated"}"""), - "1", new PartitionKey("pk1")); - - using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - var body = JObject.Parse(await ReadStreamAsync(read.Content)); - body["fieldA"]!.ToString().Should().Be("updated"); - body["fieldB"].Should().BeNull("replace should fully replace, not merge"); - } + private readonly InMemoryContainer _container = new("integrity-test", "/partitionKey"); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task CreateStream_ThenTypedRead_DataRoundTrips() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","value":42}"""), + new PartitionKey("pk1")); + + var result = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + result.Resource.Name.Should().Be("Alice"); + result.Resource.Value.Should().Be(42); + } + + [Fact] + public async Task TypedCreate_ThenStreamRead_DataRoundTrips() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob", Value = 99 }, + new PartitionKey("pk1")); + + using var response = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("Bob"); + body["value"]!.Value().Should().Be(99); + } + + [Fact] + public async Task UpsertStream_ReplacesEntireDocument_NotMerge() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice","extra":"field"}"""), + new PartitionKey("pk1")); + + await _container.UpsertItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"Alice"}"""), + new PartitionKey("pk1")); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body = JObject.Parse(await ReadStreamAsync(read.Content)); + body["extra"].Should().BeNull("upsert replaces entire document, it does not merge"); + } + + [Fact] + public async Task DeleteStream_ThenRead_Returns404() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + + await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + read.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteStream_ThenCreate_SameId_Succeeds() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"first"}"""), + new PartitionKey("pk1")); + + await _container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + + using var create = await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"second"}"""), + new PartitionKey("pk1")); + create.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReplaceStream_FullyReplacesDocument() + { + await _container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","fieldA":"a","fieldB":"b"}"""), + new PartitionKey("pk1")); + + await _container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","fieldA":"updated"}"""), + "1", new PartitionKey("pk1")); + + using var read = await _container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + var body = JObject.Parse(await ReadStreamAsync(read.Content)); + body["fieldA"]!.ToString().Should().Be("updated"); + body["fieldB"].Should().BeNull("replace should fully replace, not merge"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -946,56 +946,56 @@ await _container.ReplaceItemStreamAsync( public class StreamEdgeCaseTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - [Fact] - public async Task CreateStream_CompositePartitionKey_ExtractsCorrectly() - { - var container = new InMemoryContainer("composite-pk", new[] { "/tenantId", "/userId" }); + [Fact] + public async Task CreateStream_CompositePartitionKey_ExtractsCorrectly() + { + var container = new InMemoryContainer("composite-pk", new[] { "/tenantId", "/userId" }); - using var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","tenantId":"t1","userId":"u1","name":"Alice"}"""), - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + using var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","tenantId":"t1","userId":"u1","name":"Alice"}"""), + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - var result = await container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - result.Resource["name"]!.ToString().Should().Be("Alice"); - } + var result = await container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + result.Resource["name"]!.ToString().Should().Be("Alice"); + } - [Fact] - public async Task CreateStream_UnicodeContent_PreservedInResponse() - { - var container = new InMemoryContainer("unicode-stream", "/pk"); + [Fact] + public async Task CreateStream_UnicodeContent_PreservedInResponse() + { + var container = new InMemoryContainer("unicode-stream", "/pk"); - using var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"日本語テスト🎉"}"""), - new PartitionKey("a")); + using var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"日本語テスト🎉"}"""), + new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.Created); - using var read = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - var body = JObject.Parse(await ReadStreamAsync(read.Content)); - body["name"]!.ToString().Should().Be("日本語テスト🎉"); - } + using var read = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + var body = JObject.Parse(await ReadStreamAsync(read.Content)); + body["name"]!.ToString().Should().Be("日本語テスト🎉"); + } - [Fact] - public async Task CreateStream_RecordsInChangeFeed() - { - var container = new InMemoryContainer("cf-stream", "/pk"); + [Fact] + public async Task CreateStream_RecordsInChangeFeed() + { + var container = new InMemoryContainer("cf-stream", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"feedme"}"""), - new PartitionKey("a")); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"feedme"}"""), + new PartitionKey("a")); - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var page = await iter.ReadNextAsync(); + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var page = await iter.ReadNextAsync(); - page.Should().Contain(j => j["id"]!.ToString() == "1"); - } + page.Should().Contain(j => j["id"]!.ToString() == "1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1004,51 +1004,51 @@ await container.CreateItemStreamAsync( public class StreamDivergentBehaviorTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task ReplaceStream_IdMismatch_Returns400() - { - // SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - var container = new InMemoryContainer("mismatch", "/partitionKey"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); - - using var response = await container.ReplaceItemStreamAsync( - ToStream("""{"id":"wrong","partitionKey":"pk1"}"""), - "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReplaceStream_IdMismatch_AlsoReturns400() - { - // SDK docs state body id must match the id parameter. - // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item - var container = new InMemoryContainer("mismatch-div", "/partitionKey"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"orig"}"""), new PartitionKey("pk1")); - - using var response = await container.ReplaceItemStreamAsync( - ToStream("""{"id":"wrong","partitionKey":"pk1","name":"replaced"}"""), - "1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Stream_ResponseBody_ContainsAllSystemProperties() - { - var container = new InMemoryContainer("sysprop-all", "/pk"); - using var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["_rid"].Should().NotBeNull(); - body["_self"].Should().NotBeNull(); - body["_attachments"].Should().NotBeNull(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task ReplaceStream_IdMismatch_Returns400() + { + // SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + var container = new InMemoryContainer("mismatch", "/partitionKey"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1"}"""), new PartitionKey("pk1")); + + using var response = await container.ReplaceItemStreamAsync( + ToStream("""{"id":"wrong","partitionKey":"pk1"}"""), + "1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReplaceStream_IdMismatch_AlsoReturns400() + { + // SDK docs state body id must match the id parameter. + // Ref: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/how-to-dotnet-create-item + var container = new InMemoryContainer("mismatch-div", "/partitionKey"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"orig"}"""), new PartitionKey("pk1")); + + using var response = await container.ReplaceItemStreamAsync( + ToStream("""{"id":"wrong","partitionKey":"pk1","name":"replaced"}"""), + "1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Stream_ResponseBody_ContainsAllSystemProperties() + { + var container = new InMemoryContainer("sysprop-all", "/pk"); + using var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["_rid"].Should().NotBeNull(); + body["_self"].Should().NotBeNull(); + body["_attachments"].Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1057,45 +1057,45 @@ public async Task Stream_ResponseBody_ContainsAllSystemProperties() public class StreamBugFix_InvalidJsonTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_InvalidJson_Returns400BadRequest() - { - var container = new InMemoryContainer("json-test", "/pk"); - var response = await container.CreateItemStreamAsync( - ToStream("{{not json}}"), new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task UpsertStream_InvalidJson_Returns400BadRequest() - { - var container = new InMemoryContainer("json-test", "/pk"); - var response = await container.UpsertItemStreamAsync( - ToStream("{{not json}}"), new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReplaceStream_InvalidJson_Returns400BadRequest() - { - var container = new InMemoryContainer("json-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.ReplaceItemStreamAsync( - ToStream("{{not json}}"), "1", new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task CreateStream_EmptyStream_Returns400() - { - var container = new InMemoryContainer("json-test", "/pk"); - var response = await container.CreateItemStreamAsync( - new MemoryStream(), new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_InvalidJson_Returns400BadRequest() + { + var container = new InMemoryContainer("json-test", "/pk"); + var response = await container.CreateItemStreamAsync( + ToStream("{{not json}}"), new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UpsertStream_InvalidJson_Returns400BadRequest() + { + var container = new InMemoryContainer("json-test", "/pk"); + var response = await container.UpsertItemStreamAsync( + ToStream("{{not json}}"), new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReplaceStream_InvalidJson_Returns400BadRequest() + { + var container = new InMemoryContainer("json-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.ReplaceItemStreamAsync( + ToStream("{{not json}}"), "1", new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreateStream_EmptyStream_Returns400() + { + var container = new InMemoryContainer("json-test", "/pk"); + var response = await container.CreateItemStreamAsync( + new MemoryStream(), new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1104,66 +1104,66 @@ public async Task CreateStream_EmptyStream_Returns400() public class StreamBugFix_EnableContentResponseOnWriteTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_EnableContentResponseOnWrite_False_ContentIsNull() - { - var container = new InMemoryContainer("ecrw-test", "/pk"); - var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().BeNull(); - } - - [Fact] - public async Task UpsertStream_EnableContentResponseOnWrite_False_ContentIsNull() - { - var container = new InMemoryContainer("ecrw-test", "/pk"); - var response = await container.UpsertItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().BeNull(); - } - - [Fact] - public async Task ReplaceStream_EnableContentResponseOnWrite_False_ContentIsNull() - { - var container = new InMemoryContainer("ecrw-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"new"}"""), "1", new PartitionKey("a"), - new ItemRequestOptions { EnableContentResponseOnWrite = false }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().BeNull(); - } - - [Fact] - public async Task PatchStream_EnableContentResponseOnWrite_False_ContentIsNull() - { - var container = new InMemoryContainer("ecrw-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"before"}"""), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/name", "after") }, - new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().BeNull(); - } - - [Fact] - public async Task CreateStream_EnableContentResponseOnWrite_True_ContentPopulated() - { - var container = new InMemoryContainer("ecrw-test", "/pk"); - var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), - new ItemRequestOptions { EnableContentResponseOnWrite = true }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - response.Content.Should().NotBeNull(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_EnableContentResponseOnWrite_False_ContentIsNull() + { + var container = new InMemoryContainer("ecrw-test", "/pk"); + var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().BeNull(); + } + + [Fact] + public async Task UpsertStream_EnableContentResponseOnWrite_False_ContentIsNull() + { + var container = new InMemoryContainer("ecrw-test", "/pk"); + var response = await container.UpsertItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().BeNull(); + } + + [Fact] + public async Task ReplaceStream_EnableContentResponseOnWrite_False_ContentIsNull() + { + var container = new InMemoryContainer("ecrw-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"new"}"""), "1", new PartitionKey("a"), + new ItemRequestOptions { EnableContentResponseOnWrite = false }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().BeNull(); + } + + [Fact] + public async Task PatchStream_EnableContentResponseOnWrite_False_ContentIsNull() + { + var container = new InMemoryContainer("ecrw-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"before"}"""), new PartitionKey("a")); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/name", "after") }, + new PatchItemRequestOptions { EnableContentResponseOnWrite = false }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().BeNull(); + } + + [Fact] + public async Task CreateStream_EnableContentResponseOnWrite_True_ContentPopulated() + { + var container = new InMemoryContainer("ecrw-test", "/pk"); + var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), + new ItemRequestOptions { EnableContentResponseOnWrite = true }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + response.Content.Should().NotBeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1172,16 +1172,16 @@ public async Task CreateStream_EnableContentResponseOnWrite_True_ContentPopulate public class StreamBugFix_UpsertMissingIdTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task UpsertStream_MissingId_Returns400BadRequest_DoesNotThrow() - { - var container = new InMemoryContainer("upsert-noid", "/pk"); - var response = await container.UpsertItemStreamAsync( - ToStream("""{"pk":"a","name":"no-id"}"""), new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task UpsertStream_MissingId_Returns400BadRequest_DoesNotThrow() + { + var container = new InMemoryContainer("upsert-noid", "/pk"); + var response = await container.UpsertItemStreamAsync( + ToStream("""{"pk":"a","name":"no-id"}"""), new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1190,26 +1190,26 @@ public async Task UpsertStream_MissingId_Returns400BadRequest_DoesNotThrow() public class StreamEnsureSuccessStatusCodeTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Stream_EnsureSuccessStatusCode_OnSuccess_ReturnsSelf() - { - var container = new InMemoryContainer("ensure-test", "/pk"); - using var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var result = response.EnsureSuccessStatusCode(); - result.Should().BeSameAs(response); - } - - [Fact] - public async Task Stream_EnsureSuccessStatusCode_OnFailure_ThrowsCosmosException() - { - var container = new InMemoryContainer("ensure-test", "/pk"); - using var response = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); - var act = () => response.EnsureSuccessStatusCode(); - act.Should().Throw(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Stream_EnsureSuccessStatusCode_OnSuccess_ReturnsSelf() + { + var container = new InMemoryContainer("ensure-test", "/pk"); + using var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var result = response.EnsureSuccessStatusCode(); + result.Should().BeSameAs(response); + } + + [Fact] + public async Task Stream_EnsureSuccessStatusCode_OnFailure_ThrowsCosmosException() + { + var container = new InMemoryContainer("ensure-test", "/pk"); + using var response = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); + var act = () => response.EnsureSuccessStatusCode(); + act.Should().Throw(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1218,26 +1218,26 @@ public async Task Stream_EnsureSuccessStatusCode_OnFailure_ThrowsCosmosException public class StreamErrorAndReadHeaderTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Stream_ErrorResponses_DoNotContainETagHeader() - { - var container = new InMemoryContainer("etag-err", "/pk"); - using var readMiss = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); - readMiss.Headers.ETag.Should().BeNullOrEmpty(); - } - - [Fact] - public async Task ReadStream_ResponseContainsETagHeader() - { - var container = new InMemoryContainer("etag-read", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - response.Headers.ETag.Should().NotBeNullOrEmpty(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Stream_ErrorResponses_DoNotContainETagHeader() + { + var container = new InMemoryContainer("etag-err", "/pk"); + using var readMiss = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); + readMiss.Headers.ETag.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadStream_ResponseContainsETagHeader() + { + var container = new InMemoryContainer("etag-read", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + response.Headers.ETag.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1246,77 +1246,77 @@ await container.CreateItemStreamAsync( public class StreamETagWildcardTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task UpsertStream_WithIfMatch_Wildcard_AlwaysSucceeds() - { - var container = new InMemoryContainer("wild-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.UpsertItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"new"}"""), new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "*" }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ReplaceStream_WithIfMatch_Wildcard_AlwaysSucceeds() - { - var container = new InMemoryContainer("wild-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","pk":"a","name":"new"}"""), "1", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "*" }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteStream_WithIfMatch_Wildcard_AlwaysSucceeds() - { - var container = new InMemoryContainer("wild-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "*" }); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } - - [Fact] - public async Task ReadStream_IfNoneMatch_Wildcard_Returns304() - { - var container = new InMemoryContainer("wild-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.ReadItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { IfNoneMatchEtag = "*" }); - response.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task ReadStream_IfNoneMatch_StaleEtag_Returns200WithContent() - { - var container = new InMemoryContainer("wild-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.ReadItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotBeNull(); - } - - [Fact] - public async Task UpsertStream_IfMatch_OnNonExistentItem_CreatesItem() - { - // If-Match is "applicable only on PUT and DELETE" per REST API docs. - // Upsert uses POST, so If-Match is ignored on the insert path. - var container = new InMemoryContainer("wild-test", "/pk"); - var response = await container.UpsertItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task UpsertStream_WithIfMatch_Wildcard_AlwaysSucceeds() + { + var container = new InMemoryContainer("wild-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.UpsertItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"new"}"""), new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "*" }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ReplaceStream_WithIfMatch_Wildcard_AlwaysSucceeds() + { + var container = new InMemoryContainer("wild-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","pk":"a","name":"new"}"""), "1", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "*" }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteStream_WithIfMatch_Wildcard_AlwaysSucceeds() + { + var container = new InMemoryContainer("wild-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "*" }); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task ReadStream_IfNoneMatch_Wildcard_Returns304() + { + var container = new InMemoryContainer("wild-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.ReadItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { IfNoneMatchEtag = "*" }); + response.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task ReadStream_IfNoneMatch_StaleEtag_Returns200WithContent() + { + var container = new InMemoryContainer("wild-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.ReadItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { IfNoneMatchEtag = "\"stale-etag\"" }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotBeNull(); + } + + [Fact] + public async Task UpsertStream_IfMatch_OnNonExistentItem_CreatesItem() + { + // If-Match is "applicable only on PUT and DELETE" per REST API docs. + // Upsert uses POST, so If-Match is ignored on the insert path. + var container = new InMemoryContainer("wild-test", "/pk"); + var response = await container.UpsertItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "\"some-etag\"" }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1325,50 +1325,50 @@ public async Task UpsertStream_IfMatch_OnNonExistentItem_CreatesItem() public class StreamEdgeCaseAdditionalTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task CreateStream_WithPartitionKeyNone_ExtractsFromDocument() - { - var container = new InMemoryContainer("pknone-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"extracted"}"""), PartitionKey.None); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("extracted")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Theory] - [InlineData("item/1")] - [InlineData("item#1")] - [InlineData("item 1")] - [InlineData("item?1")] - public async Task CreateStream_SpecialCharactersInId_RoundTrips(string itemId) - { - var container = new InMemoryContainer("special-test", "/pk"); - var json = $$"""{"id":"{{itemId}}","pk":"a"}"""; - await container.CreateItemStreamAsync(ToStream(json), new PartitionKey("a")); - - using var response = await container.ReadItemStreamAsync(itemId, new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["id"]!.ToString().Should().Be(itemId); - } - - [Fact] - public async Task DeleteStream_RecordsTombstoneInChangeFeed() - { - var container = new InMemoryContainer("tombstone-test", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - - var checkpointBeforeDelete = container.GetChangeFeedCheckpoint(); - await container.DeleteItemStreamAsync("1", new PartitionKey("a")); - - var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); - checkpointAfterDelete.Should().Be(checkpointBeforeDelete + 1); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task CreateStream_WithPartitionKeyNone_ExtractsFromDocument() + { + var container = new InMemoryContainer("pknone-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"extracted"}"""), PartitionKey.None); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("extracted")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Theory] + [InlineData("item/1")] + [InlineData("item#1")] + [InlineData("item 1")] + [InlineData("item?1")] + public async Task CreateStream_SpecialCharactersInId_RoundTrips(string itemId) + { + var container = new InMemoryContainer("special-test", "/pk"); + var json = $$"""{"id":"{{itemId}}","pk":"a"}"""; + await container.CreateItemStreamAsync(ToStream(json), new PartitionKey("a")); + + using var response = await container.ReadItemStreamAsync(itemId, new PartitionKey("a")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["id"]!.ToString().Should().Be(itemId); + } + + [Fact] + public async Task DeleteStream_RecordsTombstoneInChangeFeed() + { + var container = new InMemoryContainer("tombstone-test", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + + var checkpointBeforeDelete = container.GetChangeFeedCheckpoint(); + await container.DeleteItemStreamAsync("1", new PartitionKey("a")); + + var checkpointAfterDelete = container.GetChangeFeedCheckpoint(); + checkpointAfterDelete.Should().Be(checkpointBeforeDelete + 1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1377,60 +1377,60 @@ await container.CreateItemStreamAsync( public class StreamCancellationTokenTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task ReadItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("cancel-test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => container.ReadItemStreamAsync("1", new PartitionKey("a"), cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task UpsertItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("cancel-test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => container.UpsertItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReplaceItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("cancel-test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => container.ReplaceItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), "1", new PartitionKey("a"), cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("cancel-test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task PatchItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("cancel-test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - var act = () => container.PatchItemStreamAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/name", "x") }, cancellationToken: cts.Token); - await act.Should().ThrowAsync(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task ReadItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("cancel-test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => container.ReadItemStreamAsync("1", new PartitionKey("a"), cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpsertItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("cancel-test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => container.UpsertItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("cancel-test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => container.ReplaceItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), "1", new PartitionKey("a"), cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("cancel-test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PatchItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("cancel-test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => container.PatchItemStreamAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/name", "x") }, cancellationToken: cts.Token); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1439,66 +1439,66 @@ public async Task PatchItemStream_WithCancelledToken_ThrowsOperationCancelled() public class StreamPatchValidationTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task PatchStream_NullOperations_Returns400BadRequest() - { - var container = new InMemoryContainer("patch-val", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), null!); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchStream_EmptyOperations_Returns400BadRequest() - { - var container = new InMemoryContainer("patch-val", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), - Array.Empty()); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchStream_MoreThan10Operations_Returns400BadRequest() - { - var container = new InMemoryContainer("patch-val", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","f0":0,"f1":0,"f2":0,"f3":0,"f4":0,"f5":0,"f6":0,"f7":0,"f8":0,"f9":0,"f10":0}"""), - new PartitionKey("a")); - var ops = Enumerable.Range(0, 11) - .Select(i => PatchOperation.Replace($"/f{i}", i)).ToArray(); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), ops); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchStream_WithFilterPredicate_Match_ReturnsOk() - { - var container = new InMemoryContainer("patch-filter", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","status":"active"}"""), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/status", "inactive") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.status = 'active'" }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task PatchStream_WithFilterPredicate_NoMatch_Returns412() - { - var container = new InMemoryContainer("patch-filter", "/pk"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a","status":"active"}"""), new PartitionKey("a")); - var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/status", "inactive") }, - new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.status = 'archived'" }); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task PatchStream_NullOperations_Returns400BadRequest() + { + var container = new InMemoryContainer("patch-val", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), null!); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchStream_EmptyOperations_Returns400BadRequest() + { + var container = new InMemoryContainer("patch-val", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a")); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), + Array.Empty()); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchStream_MoreThan10Operations_Returns400BadRequest() + { + var container = new InMemoryContainer("patch-val", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","f0":0,"f1":0,"f2":0,"f3":0,"f4":0,"f5":0,"f6":0,"f7":0,"f8":0,"f9":0,"f10":0}"""), + new PartitionKey("a")); + var ops = Enumerable.Range(0, 11) + .Select(i => PatchOperation.Replace($"/f{i}", i)).ToArray(); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), ops); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchStream_WithFilterPredicate_Match_ReturnsOk() + { + var container = new InMemoryContainer("patch-filter", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","status":"active"}"""), new PartitionKey("a")); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/status", "inactive") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.status = 'active'" }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PatchStream_WithFilterPredicate_NoMatch_Returns412() + { + var container = new InMemoryContainer("patch-filter", "/pk"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a","status":"active"}"""), new PartitionKey("a")); + var response = await container.PatchItemStreamAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/status", "inactive") }, + new PatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.status = 'archived'" }); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1507,26 +1507,26 @@ await container.CreateItemStreamAsync( public class StreamMixedApiTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } - - [Fact] - public async Task StreamCreate_TypedPatch_StreamRead_DataConsistent() - { - var container = new InMemoryContainer("mixed-test", "/partitionKey"); - await container.CreateItemStreamAsync( - ToStream("""{"id":"1","partitionKey":"pk1","name":"orig","value":10}"""), - new PartitionKey("pk1")); - - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new[] { PatchOperation.Replace("/name", "patched") }); - - using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var body = JObject.Parse(await ReadStreamAsync(response.Content)); - body["name"]!.ToString().Should().Be("patched"); - body["value"]!.Value().Should().Be(10); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static async Task ReadStreamAsync(Stream s) { using var r = new StreamReader(s); return await r.ReadToEndAsync(); } + + [Fact] + public async Task StreamCreate_TypedPatch_StreamRead_DataConsistent() + { + var container = new InMemoryContainer("mixed-test", "/partitionKey"); + await container.CreateItemStreamAsync( + ToStream("""{"id":"1","partitionKey":"pk1","name":"orig","value":10}"""), + new PartitionKey("pk1")); + + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new[] { PatchOperation.Replace("/name", "patched") }); + + using var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = JObject.Parse(await ReadStreamAsync(response.Content)); + body["name"]!.ToString().Should().Be("patched"); + body["value"]!.Value().Should().Be(10); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1535,17 +1535,17 @@ await container.CreateItemStreamAsync( public class StreamCreateEdgeCaseTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_WithIfMatchEtag_IgnoredOnCreate() - { - var container = new InMemoryContainer("create-ifmatch", "/pk"); - var response = await container.CreateItemStreamAsync( - ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "\"some-fake-etag\"" }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_WithIfMatchEtag_IgnoredOnCreate() + { + var container = new InMemoryContainer("create-ifmatch", "/pk"); + var response = await container.CreateItemStreamAsync( + ToStream("""{"id":"1","pk":"a"}"""), new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "\"some-fake-etag\"" }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1554,15 +1554,15 @@ public async Task CreateStream_WithIfMatchEtag_IgnoredOnCreate() public class StreamErrorMessageDivergentTests { - [Fact] - public async Task Stream_ErrorResponse_ContainsErrorMessage() - { - // Expected real Cosmos behavior: - // response.ErrorMessage contains a descriptive error string for error responses. - var container = new InMemoryContainer("errmsg-test", "/pk"); - using var response = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); - response.ErrorMessage.Should().NotBeNullOrEmpty(); - } + [Fact] + public async Task Stream_ErrorResponse_ContainsErrorMessage() + { + // Expected real Cosmos behavior: + // response.ErrorMessage contains a descriptive error string for error responses. + var container = new InMemoryContainer("errmsg-test", "/pk"); + using var response = await container.ReadItemStreamAsync("missing", new PartitionKey("a")); + response.ErrorMessage.Should().NotBeNullOrEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1572,417 +1572,417 @@ public async Task Stream_ErrorResponse_ContainsErrorMessage() // ── Bug Verification ── public class StreamBugVerificationTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task ReadManyStreamAsync_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = async () => await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReadManyStreamAsync_ReturnsETagHeader() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - - response.Headers["etag"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadManyStreamAsync_WithIfNoneMatch_MatchingETag_Returns304() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var first = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var etag = first.Headers["etag"]; - - var second = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, - new ReadManyRequestOptions { IfNoneMatchEtag = etag }); - - second.StatusCode.Should().Be(HttpStatusCode.NotModified); - } - - [Fact] - public async Task ReadManyStreamAsync_WithIfNoneMatch_StaleETag_Returns200() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var first = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - var etag = first.Headers["etag"]; - - // Modify item to change ETag - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "changed" }), new PartitionKey("a")); - - var second = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, - new ReadManyRequestOptions { IfNoneMatchEtag = etag }); - - second.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateItemStream_WithCancelledToken_ThrowsOperationCancelled() - { - var container = new InMemoryContainer("test", "/pk"); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = async () => await container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), - cancellationToken: cts.Token); - - await act.Should().ThrowAsync(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task ReadManyStreamAsync_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = async () => await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadManyStreamAsync_ReturnsETagHeader() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + + response.Headers["etag"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadManyStreamAsync_WithIfNoneMatch_MatchingETag_Returns304() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var first = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var etag = first.Headers["etag"]; + + var second = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, + new ReadManyRequestOptions { IfNoneMatchEtag = etag }); + + second.StatusCode.Should().Be(HttpStatusCode.NotModified); + } + + [Fact] + public async Task ReadManyStreamAsync_WithIfNoneMatch_StaleETag_Returns200() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var first = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + var etag = first.Headers["etag"]; + + // Modify item to change ETag + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "changed" }), new PartitionKey("a")); + + var second = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }, + new ReadManyRequestOptions { IfNoneMatchEtag = etag }); + + second.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateItemStream_WithCancelledToken_ThrowsOperationCancelled() + { + var container = new InMemoryContainer("test", "/pk"); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = async () => await container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), + cancellationToken: cts.Token); + + await act.Should().ThrowAsync(); + } } // ── ReadMany Coverage ── public class StreamReadManyCoverageTests { - [Fact] - public async Task ReadManyStreamAsync_EmptyList_Returns200WithEmptyDocuments() - { - var container = new InMemoryContainer("test", "/pk"); - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)>()); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(reader.ReadToEnd()); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStreamAsync_MixedFoundAndNotFound_ReturnsOnlyFound() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")), ("missing", new PartitionKey("a")) }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(reader.ReadToEnd()); - var docs = (JArray)body["Documents"]!; - docs.Should().HaveCount(1); - docs[0]!["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task ReadManyStreamAsync_ResponseContainsEnvelopeFormat() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(reader.ReadToEnd()); - body["_rid"].Should().NotBeNull(); - body["Documents"].Should().NotBeNull(); - body["_count"]!.Value().Should().Be(1); - } - - [Fact] - public async Task ReadManyStreamAsync_TtlExpiredItems_ExcludedFromResults() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - await Task.Delay(1500); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); - - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(reader.ReadToEnd()); - ((JArray)body["Documents"]!).Should().BeEmpty(); - } + [Fact] + public async Task ReadManyStreamAsync_EmptyList_Returns200WithEmptyDocuments() + { + var container = new InMemoryContainer("test", "/pk"); + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)>()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(reader.ReadToEnd()); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStreamAsync_MixedFoundAndNotFound_ReturnsOnlyFound() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")), ("missing", new PartitionKey("a")) }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(reader.ReadToEnd()); + var docs = (JArray)body["Documents"]!; + docs.Should().HaveCount(1); + docs[0]!["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task ReadManyStreamAsync_ResponseContainsEnvelopeFormat() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(reader.ReadToEnd()); + body["_rid"].Should().NotBeNull(); + body["Documents"].Should().NotBeNull(); + body["_count"]!.Value().Should().Be(1); + } + + [Fact] + public async Task ReadManyStreamAsync_TtlExpiredItems_ExcludedFromResults() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + await Task.Delay(1500); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("a")) }); + + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(reader.ReadToEnd()); + ((JArray)body["Documents"]!).Should().BeEmpty(); + } } // ── Unique Key Violations ── public class StreamUniqueKeyViolationTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_UniqueKeyViolation_Returns409Conflict() - { - var props = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } - }; - var container = new InMemoryContainer(props); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"unique\"}"), new PartitionKey("a")); - - var response = await container.CreateItemStreamAsync(ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"unique\"}"), new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task ReplaceStream_UniqueKeyViolation_Returns409Conflict() - { - var props = new ContainerProperties("test", "/pk") - { - UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } - }; - var container = new InMemoryContainer(props); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"alice\"}"), new PartitionKey("a")); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"bob\"}"), new PartitionKey("a")); - - var response = await container.ReplaceItemStreamAsync( - ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"alice\"}"), "2", new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_UniqueKeyViolation_Returns409Conflict() + { + var props = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } + }; + var container = new InMemoryContainer(props); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"unique\"}"), new PartitionKey("a")); + + var response = await container.CreateItemStreamAsync(ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"unique\"}"), new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceStream_UniqueKeyViolation_Returns409Conflict() + { + var props = new ContainerProperties("test", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } + }; + var container = new InMemoryContainer(props); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"alice\"}"), new PartitionKey("a")); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"bob\"}"), new PartitionKey("a")); + + var response = await container.ReplaceItemStreamAsync( + ToStream("{\"id\":\"2\",\"pk\":\"a\",\"name\":\"alice\"}"), "2", new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ── TTL in Stream Reads ── public class StreamTtlTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task ReadStream_TtlExpiredItem_Returns404() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - await Task.Delay(1500); - - var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task UpsertStream_TtlExpiredItem_Returns201Created() - { - var container = new InMemoryContainer("test", "/pk"); - container.DefaultTimeToLive = 1; - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - await Task.Delay(1500); - - var response = await container.UpsertItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"renewed\"}"), new PartitionKey("a")); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task ReadStream_TtlExpiredItem_Returns404() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + await Task.Delay(1500); + + var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpsertStream_TtlExpiredItem_Returns201Created() + { + var container = new InMemoryContainer("test", "/pk"); + container.DefaultTimeToLive = 1; + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + await Task.Delay(1500); + + var response = await container.UpsertItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"renewed\"}"), new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } } // ── C# Triggers on Stream API ── public class StreamCSharpTriggerTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_PreTriggerCSharp_ModifiesDocument() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["injected"] = "yes"; return doc; })); - - var response = await container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResp = await container.ReadItemAsync("1", new PartitionKey("a")); - readResp.Resource["injected"]!.Value().Should().Be("yes"); - } - - [Fact] - public async Task CreateStream_PostTriggerCSharp_Fires() - { - var container = new InMemoryContainer("test", "/pk"); - var triggered = false; - container.RegisterTrigger("postAudit", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => triggered = true)); - - var response = await container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "postAudit" } }); - - response.StatusCode.Should().Be(HttpStatusCode.Created); - triggered.Should().BeTrue(); - } - - [Fact] - public async Task DeleteStream_PostTriggerCSharp_Fires() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - - var triggered = false; - container.RegisterTrigger("postDel", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => triggered = true)); - - var response = await container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "postDel" } }); - - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - triggered.Should().BeTrue(); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_PreTriggerCSharp_ModifiesDocument() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["injected"] = "yes"; return doc; })); + + var response = await container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResp = await container.ReadItemAsync("1", new PartitionKey("a")); + readResp.Resource["injected"]!.Value().Should().Be("yes"); + } + + [Fact] + public async Task CreateStream_PostTriggerCSharp_Fires() + { + var container = new InMemoryContainer("test", "/pk"); + var triggered = false; + container.RegisterTrigger("postAudit", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => triggered = true)); + + var response = await container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "postAudit" } }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + triggered.Should().BeTrue(); + } + + [Fact] + public async Task DeleteStream_PostTriggerCSharp_Fires() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + + var triggered = false; + container.RegisterTrigger("postDel", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => triggered = true)); + + var response = await container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "postDel" } }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + triggered.Should().BeTrue(); + } } // ── ETag Header Lifecycle ── public class StreamETagHeaderLifecycleTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task PatchStream_ResponseContainsETagHeader() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"old\"}"), new PartitionKey("a")); - - var patchBody = new { operations = new[] { new { op = "replace", path = "/name", value = "new" } } }; - var response = await container.PatchItemStreamAsync( - "1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/name", "new") }); - - response.Headers["etag"].Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task ReadStream_ETagHeader_MatchesBodyEtag() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - - var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - var headerEtag = response.Headers["etag"]; - using var reader = new StreamReader(response.Content); - var body = JObject.Parse(reader.ReadToEnd()); - var bodyEtag = body["_etag"]!.Value(); - - headerEtag.Should().Be(bodyEtag); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task PatchStream_ResponseContainsETagHeader() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"old\"}"), new PartitionKey("a")); + + var patchBody = new { operations = new[] { new { op = "replace", path = "/name", value = "new" } } }; + var response = await container.PatchItemStreamAsync( + "1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/name", "new") }); + + response.Headers["etag"].Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReadStream_ETagHeader_MatchesBodyEtag() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + + var response = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + var headerEtag = response.Headers["etag"]; + using var reader = new StreamReader(response.Content); + var body = JObject.Parse(reader.ReadToEnd()); + var bodyEtag = body["_etag"]!.Value(); + + headerEtag.Should().Be(bodyEtag); + } } // ── Replace Body Validation ── public class StreamReplaceValidationTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - [Fact] - public async Task ReplaceStream_BodyIdMatchesParameterId_Succeeds() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + [Fact] + public async Task ReplaceStream_BodyIdMatchesParameterId_Succeeds() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - var response = await container.ReplaceItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"updated\"}"), "1", new PartitionKey("a")); + var response = await container.ReplaceItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"updated\"}"), "1", new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task ReplaceStream_EmptyBody_Returns400BadRequest() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + [Fact] + public async Task ReplaceStream_EmptyBody_Returns400BadRequest() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - var response = await container.ReplaceItemStreamAsync( - ToStream(""), "1", new PartitionKey("a")); + var response = await container.ReplaceItemStreamAsync( + ToStream(""), "1", new PartitionKey("a")); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ── Session Token Header ── public class StreamSessionTokenTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - [Fact] - public async Task Stream_CrudResponses_ContainSessionTokenHeader() - { - var container = new InMemoryContainer("test", "/pk"); + [Fact] + public async Task Stream_CrudResponses_ContainSessionTokenHeader() + { + var container = new InMemoryContainer("test", "/pk"); - var create = await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); - create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + var create = await container.CreateItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a")); + create.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - var read = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + var read = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + read.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - var upsert = await container.UpsertItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"u\"}"), new PartitionKey("a")); - upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); - } + var upsert = await container.UpsertItemStreamAsync(ToStream("{\"id\":\"1\",\"pk\":\"a\",\"name\":\"u\"}"), new PartitionKey("a")); + upsert.Headers["x-ms-session-token"].Should().NotBeNullOrEmpty(); + } } // ── Post-Trigger Rollback + Change Feed (divergent behavior) ── public class StreamPostTriggerChangeFeedTests { - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task CreateStream_PostTriggerException_DoesNotAppearInChangeFeed() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterTrigger("failPost", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => throw new Exception("trigger failure"))); - - try - { - await container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failPost" } }); - } - catch { /* expected */ } - - var iter = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - iter.HasMoreResults.Should().BeFalse("failed write should not appear in change feed"); - } - - [Fact] - public async Task CreateStream_PostTriggerException_RollsBackItem_ActualBehavior() - { - var container = new InMemoryContainer("test", "/pk"); - container.RegisterTrigger("failPost", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => throw new Exception("trigger failure"))); - - try - { - await container.CreateItemStreamAsync( - ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failPost" } }); - } - catch (CosmosException) - { - // Post-trigger failure throws CosmosException in stream API - } - - // Item should be rolled back - var readResp = await container.ReadItemStreamAsync("1", new PartitionKey("a")); - readResp.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task CreateStream_PostTriggerException_DoesNotAppearInChangeFeed() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterTrigger("failPost", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => throw new Exception("trigger failure"))); + + try + { + await container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failPost" } }); + } + catch { /* expected */ } + + var iter = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + iter.HasMoreResults.Should().BeFalse("failed write should not appear in change feed"); + } + + [Fact] + public async Task CreateStream_PostTriggerException_RollsBackItem_ActualBehavior() + { + var container = new InMemoryContainer("test", "/pk"); + container.RegisterTrigger("failPost", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => throw new Exception("trigger failure"))); + + try + { + await container.CreateItemStreamAsync( + ToStream("{\"id\":\"1\",\"pk\":\"a\"}"), new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failPost" } }); + } + catch (CosmosException) + { + // Post-trigger failure throws CosmosException in stream API + } + + // Item should be rolled back + var readResp = await container.ReadItemStreamAsync("1", new PartitionKey("a")); + readResp.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StringCaseSensitivityTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StringCaseSensitivityTests.cs index 1d877ad..9455545 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StringCaseSensitivityTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/StringCaseSensitivityTests.cs @@ -1,7 +1,7 @@ +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json.Linq; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -14,176 +14,176 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class StringCaseSensitivityTests { - private readonly InMemoryContainer _container = new("case-test", "/pk"); - - private async Task SeedAsync() - { - await CreateItem("""{"id":"1","pk":"a","name":"Alice"}"""); - await CreateItem("""{"id":"2","pk":"a","name":"alice"}"""); - await CreateItem("""{"id":"3","pk":"a","name":"BOB"}"""); - await CreateItem("""{"id":"4","pk":"a","name":"bob"}"""); - } - - private async Task CreateItem(string json) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); - } - - // ═══════════════════════════════════════════════════════════════════ - // C1: Equality (=, !=, IN) must be case-sensitive - // ═══════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_WhereEquals_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Alice'"); - var page = await iter.ReadNextAsync(); - - // Only "Alice" (id=1), NOT "alice" (id=2) - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_WhereNotEquals_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name != 'Alice'"); - var page = await iter.ReadNextAsync(); - - // "alice", "BOB", "bob" — but NOT "Alice" - page.Should().HaveCount(3); - page.Select(d => d["id"]!.Value()).Should().NotContain("1"); - } - - [Fact] - public async Task Query_WhereIn_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name IN ('alice', 'bob')"); - var page = await iter.ReadNextAsync(); - - // Only lowercase matches: id=2 and id=4 - page.Should().HaveCount(2); - page.Select(d => d["id"]!.Value()).Should().BeEquivalentTo(["2", "4"]); - } - - // ═══════════════════════════════════════════════════════════════════ - // C2: Comparison (<, >, <=, >=) and ORDER BY must be case-sensitive - // ═══════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_OrderBy_String_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT c.name FROM c ORDER BY c.name ASC"); - var page = await iter.ReadNextAsync(); - - var names = page.Select(d => d["name"]!.Value()).ToList(); - - // Ordinal sort: uppercase letters (A=65, B=66) sort before lowercase (a=97, b=98) - // Expected: "Alice", "BOB", "alice", "bob" - names.Should().BeEquivalentTo( - new[] { "Alice", "BOB", "alice", "bob" }, - opts => opts.WithStrictOrdering()); - } - - [Fact] - public async Task Query_WhereGreaterThan_String_IsCaseSensitive() - { - await SeedAsync(); - - // In ordinal comparison, 'a' (97) > 'Z' (90), so "alice" > "BOB" - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name > 'Z'"); - var page = await iter.ReadNextAsync(); - - // "alice" and "bob" are > "Z" in ordinal; "Alice" and "BOB" are < "Z" - page.Should().HaveCount(2); - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); - } - - // ═══════════════════════════════════════════════════════════════════ - // C3: LIKE must be case-sensitive - // ═══════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Like_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'A%'"); - var page = await iter.ReadNextAsync(); - - // Only "Alice" starts with uppercase A, not "alice" - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_Like_LowercasePattern_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'b%'"); - var page = await iter.ReadNextAsync(); - - // Only "bob" starts with lowercase b, not "BOB" - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("4"); - } - - // ═══════════════════════════════════════════════════════════════════ - // C4: LIKE without ESCAPE must treat regex metacharacters as literals - // ═══════════════════════════════════════════════════════════════════ - - [Fact] - public async Task Query_Like_WithSquareBrackets_TreatedAsLiteral() - { - var container = new InMemoryContainer("like-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","code":"test[1]"}""")), new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","code":"testX"}""")), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.code LIKE 'test[1]'"); - var page = await iter.ReadNextAsync(); - - // Should match only "test[1]" literally, not treat [1] as regex char class - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_Like_WithDot_TreatedAsLiteral() - { - var container = new InMemoryContainer("like-test2", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","code":"foo.bar"}""")), new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","code":"fooXbar"}""")), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.code LIKE 'foo.bar'"); - var page = await iter.ReadNextAsync(); - - // Should match only "foo.bar" literally, not treat . as regex any-char - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("case-test", "/pk"); + + private async Task SeedAsync() + { + await CreateItem("""{"id":"1","pk":"a","name":"Alice"}"""); + await CreateItem("""{"id":"2","pk":"a","name":"alice"}"""); + await CreateItem("""{"id":"3","pk":"a","name":"BOB"}"""); + await CreateItem("""{"id":"4","pk":"a","name":"bob"}"""); + } + + private async Task CreateItem(string json) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("a")); + } + + // ═══════════════════════════════════════════════════════════════════ + // C1: Equality (=, !=, IN) must be case-sensitive + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_WhereEquals_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Alice'"); + var page = await iter.ReadNextAsync(); + + // Only "Alice" (id=1), NOT "alice" (id=2) + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_WhereNotEquals_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name != 'Alice'"); + var page = await iter.ReadNextAsync(); + + // "alice", "BOB", "bob" — but NOT "Alice" + page.Should().HaveCount(3); + page.Select(d => d["id"]!.Value()).Should().NotContain("1"); + } + + [Fact] + public async Task Query_WhereIn_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name IN ('alice', 'bob')"); + var page = await iter.ReadNextAsync(); + + // Only lowercase matches: id=2 and id=4 + page.Should().HaveCount(2); + page.Select(d => d["id"]!.Value()).Should().BeEquivalentTo(["2", "4"]); + } + + // ═══════════════════════════════════════════════════════════════════ + // C2: Comparison (<, >, <=, >=) and ORDER BY must be case-sensitive + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_OrderBy_String_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT c.name FROM c ORDER BY c.name ASC"); + var page = await iter.ReadNextAsync(); + + var names = page.Select(d => d["name"]!.Value()).ToList(); + + // Ordinal sort: uppercase letters (A=65, B=66) sort before lowercase (a=97, b=98) + // Expected: "Alice", "BOB", "alice", "bob" + names.Should().BeEquivalentTo( + new[] { "Alice", "BOB", "alice", "bob" }, + opts => opts.WithStrictOrdering()); + } + + [Fact] + public async Task Query_WhereGreaterThan_String_IsCaseSensitive() + { + await SeedAsync(); + + // In ordinal comparison, 'a' (97) > 'Z' (90), so "alice" > "BOB" + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name > 'Z'"); + var page = await iter.ReadNextAsync(); + + // "alice" and "bob" are > "Z" in ordinal; "Alice" and "BOB" are < "Z" + page.Should().HaveCount(2); + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); + } + + // ═══════════════════════════════════════════════════════════════════ + // C3: LIKE must be case-sensitive + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Like_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'A%'"); + var page = await iter.ReadNextAsync(); + + // Only "Alice" starts with uppercase A, not "alice" + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_Like_LowercasePattern_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'b%'"); + var page = await iter.ReadNextAsync(); + + // Only "bob" starts with lowercase b, not "BOB" + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("4"); + } + + // ═══════════════════════════════════════════════════════════════════ + // C4: LIKE without ESCAPE must treat regex metacharacters as literals + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Query_Like_WithSquareBrackets_TreatedAsLiteral() + { + var container = new InMemoryContainer("like-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","code":"test[1]"}""")), new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","code":"testX"}""")), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.code LIKE 'test[1]'"); + var page = await iter.ReadNextAsync(); + + // Should match only "test[1]" literally, not treat [1] as regex char class + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_Like_WithDot_TreatedAsLiteral() + { + var container = new InMemoryContainer("like-test2", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","code":"foo.bar"}""")), new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","code":"fooXbar"}""")), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.code LIKE 'foo.bar'"); + var page = await iter.ReadNextAsync(); + + // Should match only "foo.bar" literally, not treat . as regex any-char + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -192,82 +192,82 @@ await container.CreateItemStreamAsync( public class StringComparisonOperatorTests { - private readonly InMemoryContainer _container = new("case-cmp", "/pk"); - - private async Task SeedAsync() - { - foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), - new PartitionKey("a")); - } - } - - [Fact] - public async Task Query_WhereLessThan_String_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name < 'a'"); - var page = await iter.ReadNextAsync(); - - // Ordinal: uppercase chars (A=65, B=66) < 'a' (97) - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); - } - - [Fact] - public async Task Query_WhereLessThanOrEqual_String_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name <= 'BOB'"); - var page = await iter.ReadNextAsync(); - - // Ordinal: "Alice" <= "BOB" (A d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); - } - - [Fact] - public async Task Query_WhereGreaterThanOrEqual_String_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name >= 'alice'"); - var page = await iter.ReadNextAsync(); - - // Ordinal: "alice" >= "alice" (equal), "bob" >= "alice" - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); - } - - [Fact] - public async Task Query_NotIn_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name NOT IN ('Alice', 'BOB')"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); - } - - [Fact] - public async Task Query_Between_String_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'"); - var page = await iter.ReadNextAsync(); - - // Ordinal range [A, Z]: "Alice" and "BOB" are in range; "alice" > "Z", "bob" > "Z" - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); - } + private readonly InMemoryContainer _container = new("case-cmp", "/pk"); + + private async Task SeedAsync() + { + foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), + new PartitionKey("a")); + } + } + + [Fact] + public async Task Query_WhereLessThan_String_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name < 'a'"); + var page = await iter.ReadNextAsync(); + + // Ordinal: uppercase chars (A=65, B=66) < 'a' (97) + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); + } + + [Fact] + public async Task Query_WhereLessThanOrEqual_String_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name <= 'BOB'"); + var page = await iter.ReadNextAsync(); + + // Ordinal: "Alice" <= "BOB" (A d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); + } + + [Fact] + public async Task Query_WhereGreaterThanOrEqual_String_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name >= 'alice'"); + var page = await iter.ReadNextAsync(); + + // Ordinal: "alice" >= "alice" (equal), "bob" >= "alice" + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); + } + + [Fact] + public async Task Query_NotIn_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name NOT IN ('Alice', 'BOB')"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "bob"]); + } + + [Fact] + public async Task Query_Between_String_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'"); + var page = await iter.ReadNextAsync(); + + // Ordinal range [A, Z]: "Alice" and "BOB" are in range; "alice" > "Z", "bob" > "Z" + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "BOB"]); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -276,28 +276,28 @@ public async Task Query_Between_String_IsCaseSensitive() public class StringOrderByDescTests { - [Fact] - public async Task Query_OrderByDesc_String_IsCaseSensitive() - { - var container = new InMemoryContainer("case-ord", "/pk"); - foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) - { - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), - new PartitionKey("a")); - } - - var iter = container.GetItemQueryIterator( - "SELECT c.name FROM c ORDER BY c.name DESC"); - var page = await iter.ReadNextAsync(); - - var names = page.Select(d => d["name"]!.Value()).ToList(); - // Reverse ordinal: "bob", "alice", "BOB", "Alice" - names.Should().BeEquivalentTo( - new[] { "bob", "alice", "BOB", "Alice" }, - opts => opts.WithStrictOrdering()); - } + [Fact] + public async Task Query_OrderByDesc_String_IsCaseSensitive() + { + var container = new InMemoryContainer("case-ord", "/pk"); + foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) + { + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), + new PartitionKey("a")); + } + + var iter = container.GetItemQueryIterator( + "SELECT c.name FROM c ORDER BY c.name DESC"); + var page = await iter.ReadNextAsync(); + + var names = page.Select(d => d["name"]!.Value()).ToList(); + // Reverse ordinal: "bob", "alice", "BOB", "Alice" + names.Should().BeEquivalentTo( + new[] { "bob", "alice", "BOB", "Alice" }, + opts => opts.WithStrictOrdering()); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -306,103 +306,103 @@ await container.CreateItemStreamAsync( public class StringLikeExtendedTests { - private readonly InMemoryContainer _container = new("case-like", "/pk"); - - private async Task SeedAsync() - { - foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), - new PartitionKey("a")); - } - } - - [Fact] - public async Task Query_Like_ExactMatch_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'Alice'"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Query_Like_UnderscoreWildcard_IsCaseSensitive() - { - await SeedAsync(); - - // _lice matches any single char + "lice" — both "Alice" and "alice" match - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE '_lice'"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(2); - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_NotLike_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "BOB", "bob"]); - } - - [Fact] - public async Task Query_Like_MiddlePercent_IsCaseSensitive() - { - await SeedAsync(); - - // A%e matches strings starting with 'A' and ending with 'e' - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'A%e'"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("Alice"); - } - - [Fact] - public async Task Query_Like_SubstringPercent_IsCaseSensitive() - { - await SeedAsync(); - - // %li% matches any string containing "li" - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE '%li%'"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_Like_EmptyPattern_MatchesOnlyEmpty() - { - var container = new InMemoryContainer("empty-like", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":""}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"notempty"}""")), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE ''"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } + private readonly InMemoryContainer _container = new("case-like", "/pk"); + + private async Task SeedAsync() + { + foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), + new PartitionKey("a")); + } + } + + [Fact] + public async Task Query_Like_ExactMatch_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'Alice'"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Query_Like_UnderscoreWildcard_IsCaseSensitive() + { + await SeedAsync(); + + // _lice matches any single char + "lice" — both "Alice" and "alice" match + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE '_lice'"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(2); + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_NotLike_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name NOT LIKE 'A%'"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["alice", "BOB", "bob"]); + } + + [Fact] + public async Task Query_Like_MiddlePercent_IsCaseSensitive() + { + await SeedAsync(); + + // A%e matches strings starting with 'A' and ending with 'e' + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'A%e'"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("Alice"); + } + + [Fact] + public async Task Query_Like_SubstringPercent_IsCaseSensitive() + { + await SeedAsync(); + + // %li% matches any string containing "li" + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE '%li%'"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_Like_EmptyPattern_MatchesOnlyEmpty() + { + var container = new InMemoryContainer("empty-like", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":""}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"notempty"}""")), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE ''"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -411,176 +411,176 @@ await container.CreateItemStreamAsync( public class StringFunctionCaseSensitivityTests { - private readonly InMemoryContainer _container = new("case-func", "/pk"); - - private async Task SeedAsync() - { - foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) - { - await _container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), - new PartitionKey("a")); - } - } - - [Fact] - public async Task Query_Contains_Default_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE CONTAINS(c.name, 'ali')"); - var page = await iter.ReadNextAsync(); - - // "ali" is in "alice" (id=2) but not "Alice" (starts with 'A') - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("2"); - } - - [Fact] - public async Task Query_Contains_ThirdArgTrue_IsCaseInsensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', true)"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_StartsWith_Default_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE STARTSWITH(c.name, 'a')"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_StartsWith_ThirdArgTrue_IsCaseInsensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE STARTSWITH(c.name, 'a', true)"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_EndsWith_Default_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE ENDSWITH(c.name, 'CE')"); - var page = await iter.ReadNextAsync(); - - // No name ends with uppercase "CE" — "Alice" ends with "ce", "alice" ends with "ce" - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_EndsWith_ThirdArgTrue_IsCaseInsensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE ENDSWITH(c.name, 'CE', true)"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_StringEquals_Default_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE StringEquals(c.name, 'alice')"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_StringEquals_ThirdArgTrue_IsCaseInsensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE StringEquals(c.name, 'alice', true)"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_IndexOf_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT c.name, INDEX_OF(c.name, 'a') AS idx FROM c ORDER BY c.id"); - var page = await iter.ReadNextAsync(); - - var results = page.ToList(); - // "Alice" → INDEX_OF('a') = -1 (capital A, not lowercase a) - results[0]["idx"]!.Value().Should().Be(-1); - // "alice" → INDEX_OF('a') = 0 - results[1]["idx"]!.Value().Should().Be(0); - } - - [Fact] - public async Task Query_Replace_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT REPLACE(c.name, 'a', 'X') AS replaced FROM c ORDER BY c.id"); - var page = await iter.ReadNextAsync(); - - var results = page.ToList(); - // "Alice" → no 'a' (has 'A') → "Alice" - results[0]["replaced"]!.Value().Should().Be("Alice"); - // "alice" → has 'a' at 0 → "Xlice" - results[1]["replaced"]!.Value().Should().Be("Xlice"); - } - - [Fact] - public async Task Query_RegexMatch_Default_IsCaseSensitive() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE RegexMatch(c.name, '^a')"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_RegexMatch_WithIgnoreCaseModifier() - { - await SeedAsync(); - - var iter = _container.GetItemQueryIterator( - "SELECT * FROM c WHERE RegexMatch(c.name, '^a', 'i')"); - var page = await iter.ReadNextAsync(); - - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } + private readonly InMemoryContainer _container = new("case-func", "/pk"); + + private async Task SeedAsync() + { + foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) + { + await _container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), + new PartitionKey("a")); + } + } + + [Fact] + public async Task Query_Contains_Default_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE CONTAINS(c.name, 'ali')"); + var page = await iter.ReadNextAsync(); + + // "ali" is in "alice" (id=2) but not "Alice" (starts with 'A') + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("2"); + } + + [Fact] + public async Task Query_Contains_ThirdArgTrue_IsCaseInsensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', true)"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_StartsWith_Default_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE STARTSWITH(c.name, 'a')"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_StartsWith_ThirdArgTrue_IsCaseInsensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE STARTSWITH(c.name, 'a', true)"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_EndsWith_Default_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE ENDSWITH(c.name, 'CE')"); + var page = await iter.ReadNextAsync(); + + // No name ends with uppercase "CE" — "Alice" ends with "ce", "alice" ends with "ce" + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_EndsWith_ThirdArgTrue_IsCaseInsensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE ENDSWITH(c.name, 'CE', true)"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_StringEquals_Default_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE StringEquals(c.name, 'alice')"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_StringEquals_ThirdArgTrue_IsCaseInsensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE StringEquals(c.name, 'alice', true)"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_IndexOf_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT c.name, INDEX_OF(c.name, 'a') AS idx FROM c ORDER BY c.id"); + var page = await iter.ReadNextAsync(); + + var results = page.ToList(); + // "Alice" → INDEX_OF('a') = -1 (capital A, not lowercase a) + results[0]["idx"]!.Value().Should().Be(-1); + // "alice" → INDEX_OF('a') = 0 + results[1]["idx"]!.Value().Should().Be(0); + } + + [Fact] + public async Task Query_Replace_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT REPLACE(c.name, 'a', 'X') AS replaced FROM c ORDER BY c.id"); + var page = await iter.ReadNextAsync(); + + var results = page.ToList(); + // "Alice" → no 'a' (has 'A') → "Alice" + results[0]["replaced"]!.Value().Should().Be("Alice"); + // "alice" → has 'a' at 0 → "Xlice" + results[1]["replaced"]!.Value().Should().Be("Xlice"); + } + + [Fact] + public async Task Query_RegexMatch_Default_IsCaseSensitive() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE RegexMatch(c.name, '^a')"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_RegexMatch_WithIgnoreCaseModifier() + { + await SeedAsync(); + + var iter = _container.GetItemQueryIterator( + "SELECT * FROM c WHERE RegexMatch(c.name, '^a', 'i')"); + var page = await iter.ReadNextAsync(); + + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -589,49 +589,49 @@ public async Task Query_RegexMatch_WithIgnoreCaseModifier() public class CaseTransformComparisonTests { - [Fact] - public async Task Query_Lower_EnablesCaseInsensitiveEquality() - { - var container = new InMemoryContainer("lower-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"BOB"}""")), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE LOWER(c.name) = 'alice'"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(2); - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); - } - - [Fact] - public async Task Query_Upper_EnablesCaseInsensitiveEquality() - { - var container = new InMemoryContainer("upper-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"BOB"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"bob"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE UPPER(c.name) = 'BOB'"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(2); - page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["BOB", "bob"]); - } + [Fact] + public async Task Query_Lower_EnablesCaseInsensitiveEquality() + { + var container = new InMemoryContainer("lower-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"BOB"}""")), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE LOWER(c.name) = 'alice'"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(2); + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["Alice", "alice"]); + } + + [Fact] + public async Task Query_Upper_EnablesCaseInsensitiveEquality() + { + var container = new InMemoryContainer("upper-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"BOB"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"bob"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"3","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE UPPER(c.name) = 'BOB'"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(2); + page.Select(d => d["name"]!.Value()).Should().BeEquivalentTo(["BOB", "bob"]); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -640,60 +640,60 @@ await container.CreateItemStreamAsync( public class StringDistinctGroupByTests { - private async Task CreateSeededContainerAsync() - { - var container = new InMemoryContainer("case-dg", "/pk"); - foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) - { - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes( - $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), - new PartitionKey("a")); - } - return container; - } - - [Fact] - public async Task Query_Distinct_PreservesCaseDifferences() - { - var container = await CreateSeededContainerAsync(); - - var iter = container.GetItemQueryIterator( - "SELECT DISTINCT c.name FROM c"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(4, "Alice, alice, BOB, bob are all distinct values"); - } - - [Fact] - public async Task Query_GroupBy_TreatsCaseDifferencesAsSeparateGroups() - { - var container = await CreateSeededContainerAsync(); - - var iter = container.GetItemQueryIterator( - "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name"); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(4, "Alice, alice, BOB, bob should be 4 separate groups"); - } - - [Fact] - public async Task Query_MinMax_String_UsesOrdinalComparison() - { - var container = await CreateSeededContainerAsync(); - - var iterMin = container.GetItemQueryIterator( - "SELECT VALUE MIN(c.name) FROM c"); - var min = (await iterMin.ReadNextAsync()).First(); - - var iterMax = container.GetItemQueryIterator( - "SELECT VALUE MAX(c.name) FROM c"); - var max = (await iterMax.ReadNextAsync()).First(); - - // Ordinal: 'A' (65) < 'B' (66) < 'a' (97) < 'b' (98) - min.Value().Should().Be("Alice"); - max.Value().Should().Be("bob"); - } + private async Task CreateSeededContainerAsync() + { + var container = new InMemoryContainer("case-dg", "/pk"); + foreach (var (id, name) in new[] { ("1", "Alice"), ("2", "alice"), ("3", "BOB"), ("4", "bob") }) + { + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes( + $$$"""{"id":"{{{id}}}","pk":"a","name":"{{{name}}}"}""")), + new PartitionKey("a")); + } + return container; + } + + [Fact] + public async Task Query_Distinct_PreservesCaseDifferences() + { + var container = await CreateSeededContainerAsync(); + + var iter = container.GetItemQueryIterator( + "SELECT DISTINCT c.name FROM c"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(4, "Alice, alice, BOB, bob are all distinct values"); + } + + [Fact] + public async Task Query_GroupBy_TreatsCaseDifferencesAsSeparateGroups() + { + var container = await CreateSeededContainerAsync(); + + var iter = container.GetItemQueryIterator( + "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name"); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(4, "Alice, alice, BOB, bob should be 4 separate groups"); + } + + [Fact] + public async Task Query_MinMax_String_UsesOrdinalComparison() + { + var container = await CreateSeededContainerAsync(); + + var iterMin = container.GetItemQueryIterator( + "SELECT VALUE MIN(c.name) FROM c"); + var min = (await iterMin.ReadNextAsync()).First(); + + var iterMax = container.GetItemQueryIterator( + "SELECT VALUE MAX(c.name) FROM c"); + var max = (await iterMax.ReadNextAsync()).First(); + + // Ordinal: 'A' (65) < 'B' (66) < 'a' (97) < 'b' (98) + min.Value().Should().Be("Alice"); + max.Value().Should().Be("bob"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -702,62 +702,62 @@ public async Task Query_MinMax_String_UsesOrdinalComparison() public class StringCaseEdgeCaseTests { - [Fact] - public async Task Query_ParameterizedQuery_StringIsCaseSensitive() - { - var container = new InMemoryContainer("param-test", "/pk"); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"alice"}""")), - new PartitionKey("a")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @n") - .WithParameter("@n", "Alice"); - var iter = container.GetItemQueryIterator(query); - var page = await iter.ReadNextAsync(); - - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_PropertyNameLookup_IsCaseSensitive() - { - var container = new InMemoryContainer("propcase", "/pk"); - // JSON has property "name" (lowercase) - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), - new PartitionKey("a")); - - // Query with "Name" (capital N) — should not match the "name" property - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.Name = 'Alice'"); - var page = await iter.ReadNextAsync(); - - page.Should().BeEmpty("JSON property lookup is case-sensitive: 'Name' != 'name'"); - } - - [Fact] - public async Task Query_UnicodeCase_IsOrdinal() - { - var container = new InMemoryContainer("unicode-case", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "über" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name = 'Über'"); - var page = await iter.ReadNextAsync(); - - // Ordinal: 'Ü' (220) != 'ü' (252) → only exact match - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task Query_ParameterizedQuery_StringIsCaseSensitive() + { + var container = new InMemoryContainer("param-test", "/pk"); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"2","pk":"a","name":"alice"}""")), + new PartitionKey("a")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @n") + .WithParameter("@n", "Alice"); + var iter = container.GetItemQueryIterator(query); + var page = await iter.ReadNextAsync(); + + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_PropertyNameLookup_IsCaseSensitive() + { + var container = new InMemoryContainer("propcase", "/pk"); + // JSON has property "name" (lowercase) + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes("""{"id":"1","pk":"a","name":"Alice"}""")), + new PartitionKey("a")); + + // Query with "Name" (capital N) — should not match the "name" property + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.Name = 'Alice'"); + var page = await iter.ReadNextAsync(); + + page.Should().BeEmpty("JSON property lookup is case-sensitive: 'Name' != 'name'"); + } + + [Fact] + public async Task Query_UnicodeCase_IsOrdinal() + { + var container = new InMemoryContainer("unicode-case", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "über" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name = 'Über'"); + var page = await iter.ReadNextAsync(); + + // Ordinal: 'Ü' (220) != 'ü' (252) → only exact match + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -766,49 +766,49 @@ await container.CreateItemAsync( public class StringConcatNullTests { - [Fact] - public async Task Query_StringConcat_WithNull_ReturnsUndefined() - { - var container = new InMemoryContainer("concat-null", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT VALUE c.name || null FROM c"); - var page = await iter.ReadNextAsync(); - // null || string → undefined → omitted by SELECT VALUE - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_StringConcat_NullOnLeft_ReturnsUndefined() - { - var container = new InMemoryContainer("concat-null", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "world" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT VALUE null || c.name FROM c"); - var page = await iter.ReadNextAsync(); - // null || string → undefined → omitted by SELECT VALUE - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_StringConcat_PreservesCase() - { - var container = new InMemoryContainer("concat-case", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT VALUE c.name || '-suffix' FROM c"); - var page = await iter.ReadNextAsync(); - page.First().Value().Should().Be("Alice-suffix"); - } + [Fact] + public async Task Query_StringConcat_WithNull_ReturnsUndefined() + { + var container = new InMemoryContainer("concat-null", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT VALUE c.name || null FROM c"); + var page = await iter.ReadNextAsync(); + // null || string → undefined → omitted by SELECT VALUE + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_StringConcat_NullOnLeft_ReturnsUndefined() + { + var container = new InMemoryContainer("concat-null", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "world" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT VALUE null || c.name FROM c"); + var page = await iter.ReadNextAsync(); + // null || string → undefined → omitted by SELECT VALUE + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_StringConcat_PreservesCase() + { + var container = new InMemoryContainer("concat-case", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT VALUE c.name || '-suffix' FROM c"); + var page = await iter.ReadNextAsync(); + page.First().Value().Should().Be("Alice-suffix"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -817,125 +817,125 @@ await container.CreateItemAsync( public class StringLikeExtendedDeepDiveTests { - [Fact] - public async Task Query_Like_WithEscape_IsCaseSensitive() - { - var container = new InMemoryContainer("like-esc", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ABC" }), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name LIKE 'A!%' ESCAPE '!'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("A%"); - } - - [Fact] - public async Task Query_NotLike_WithEscape_IsCaseSensitive() - { - var container = new InMemoryContainer("like-esc", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ABC" }), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.name NOT LIKE 'A!%' ESCAPE '!'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(2); - } - - [Theory] - [InlineData("+")] - [InlineData("*")] - [InlineData("^")] - [InlineData("$")] - [InlineData("(")] - [InlineData(")")] - [InlineData("{")] - [InlineData("}")] - [InlineData("?")] - public async Task Query_Like_AdditionalRegexMetachars_TreatedAsLiteral(string metaChar) - { - var container = new InMemoryContainer("like-meta", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = $"a{metaChar}b" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", name = "axb" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator( - $"SELECT * FROM c WHERE c.name LIKE 'a{metaChar}b'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_Like_OnNullField_DoesNotMatch() - { - var container = new InMemoryContainer("like-null", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A%'"); - var page = await iter.ReadNextAsync(); - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Like_PercentOnly_MatchesAllNonNull() - { - var container = new InMemoryContainer("like-pct", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(2); // id 1 and 2 (not 3 — null) - } - - [Fact] - public async Task Query_Like_UnderscoreOnly_MatchesSingleCharStrings() - { - var container = new InMemoryContainer("like-under", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", code = "A" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", code = "AB" }), new PartitionKey("a")); - await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", code = "" }), new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.code LIKE '_'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["code"]!.Value().Should().Be("A"); - } - - [Fact] - public async Task Query_Like_UnderscoreWithNewline_MatchesNewline() - { - var container = new InMemoryContainer("like-newline", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "a\nb" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a_b'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - } - - [Fact] - public async Task Query_Like_BackslashInData_TreatedAsLiteral() - { - var container = new InMemoryContainer("like-bs", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = @"a\b" }), - new PartitionKey("a")); - - var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a\\b'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - } + [Fact] + public async Task Query_Like_WithEscape_IsCaseSensitive() + { + var container = new InMemoryContainer("like-esc", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ABC" }), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name LIKE 'A!%' ESCAPE '!'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("A%"); + } + + [Fact] + public async Task Query_NotLike_WithEscape_IsCaseSensitive() + { + var container = new InMemoryContainer("like-esc", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ABC" }), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.name NOT LIKE 'A!%' ESCAPE '!'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(2); + } + + [Theory] + [InlineData("+")] + [InlineData("*")] + [InlineData("^")] + [InlineData("$")] + [InlineData("(")] + [InlineData(")")] + [InlineData("{")] + [InlineData("}")] + [InlineData("?")] + public async Task Query_Like_AdditionalRegexMetachars_TreatedAsLiteral(string metaChar) + { + var container = new InMemoryContainer("like-meta", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = $"a{metaChar}b" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", name = "axb" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator( + $"SELECT * FROM c WHERE c.name LIKE 'a{metaChar}b'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_Like_OnNullField_DoesNotMatch() + { + var container = new InMemoryContainer("like-null", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A%'"); + var page = await iter.ReadNextAsync(); + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Like_PercentOnly_MatchesAllNonNull() + { + var container = new InMemoryContainer("like-pct", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a" }), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(2); // id 1 and 2 (not 3 — null) + } + + [Fact] + public async Task Query_Like_UnderscoreOnly_MatchesSingleCharStrings() + { + var container = new InMemoryContainer("like-under", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", code = "A" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", code = "AB" }), new PartitionKey("a")); + await container.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", code = "" }), new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.code LIKE '_'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["code"]!.Value().Should().Be("A"); + } + + [Fact] + public async Task Query_Like_UnderscoreWithNewline_MatchesNewline() + { + var container = new InMemoryContainer("like-newline", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "a\nb" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a_b'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + } + + [Fact] + public async Task Query_Like_BackslashInData_TreatedAsLiteral() + { + var container = new InMemoryContainer("like-bs", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = @"a\b" }), + new PartitionKey("a")); + + var iter = container.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a\\b'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -944,112 +944,112 @@ await container.CreateItemAsync( public class StringFunctionEdgeCaseDeepDiveTests { - private async Task SeedAsync() - { - var c = new InMemoryContainer("func-edge", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); - return c; - } - - [Fact] - public async Task Query_Contains_ExplicitFalse_SameAsDefault() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, 'ali', false)"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_StartsWith_ExplicitFalse_SameAsDefault() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, 'a', false)"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["name"]!.Value().Should().Be("alice"); - } - - [Fact] - public async Task Query_EndsWith_ExplicitFalse_SameAsDefault() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, 'ce', false)"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(2); // Both "Alice" and "alice" end with "ce" case-sensitively - } - - [Fact] - public async Task Query_StringEquals_ExplicitFalse_SameAsDefault() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice', false)"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - } - - [Fact] - public async Task Query_Contains_NullInput_ReturnsNoMatch() - { - var c = new InMemoryContainer("null-fn", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, 'x')"); - var page = await iter.ReadNextAsync(); - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_StartsWith_NullInput_ReturnsNoMatch() - { - var c = new InMemoryContainer("null-fn", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, 'x')"); - var page = await iter.ReadNextAsync(); - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_EndsWith_NullInput_ReturnsNoMatch() - { - var c = new InMemoryContainer("null-fn", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, 'x')"); - var page = await iter.ReadNextAsync(); - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_Contains_EmptySearchString_MatchesAll() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, '')"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(3); - } - - [Fact] - public async Task Query_StartsWith_EmptyPrefix_MatchesAll() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, '')"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(3); - } - - [Fact] - public async Task Query_EndsWith_EmptySuffix_MatchesAll() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, '')"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(3); - } + private async Task SeedAsync() + { + var c = new InMemoryContainer("func-edge", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); + return c; + } + + [Fact] + public async Task Query_Contains_ExplicitFalse_SameAsDefault() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, 'ali', false)"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_StartsWith_ExplicitFalse_SameAsDefault() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, 'a', false)"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["name"]!.Value().Should().Be("alice"); + } + + [Fact] + public async Task Query_EndsWith_ExplicitFalse_SameAsDefault() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, 'ce', false)"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(2); // Both "Alice" and "alice" end with "ce" case-sensitively + } + + [Fact] + public async Task Query_StringEquals_ExplicitFalse_SameAsDefault() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STRING_EQUALS(c.name, 'alice', false)"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + } + + [Fact] + public async Task Query_Contains_NullInput_ReturnsNoMatch() + { + var c = new InMemoryContainer("null-fn", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, 'x')"); + var page = await iter.ReadNextAsync(); + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_StartsWith_NullInput_ReturnsNoMatch() + { + var c = new InMemoryContainer("null-fn", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, 'x')"); + var page = await iter.ReadNextAsync(); + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_EndsWith_NullInput_ReturnsNoMatch() + { + var c = new InMemoryContainer("null-fn", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, 'x')"); + var page = await iter.ReadNextAsync(); + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_Contains_EmptySearchString_MatchesAll() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE CONTAINS(c.name, '')"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(3); + } + + [Fact] + public async Task Query_StartsWith_EmptyPrefix_MatchesAll() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE STARTSWITH(c.name, '')"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(3); + } + + [Fact] + public async Task Query_EndsWith_EmptySuffix_MatchesAll() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ENDSWITH(c.name, '')"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(3); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1058,45 +1058,45 @@ public async Task Query_EndsWith_EmptySuffix_MatchesAll() public class StringArrayConcatTests { - [Fact] - public async Task Query_ArrayContains_StringElement_IsCaseSensitive() - { - var c = new InMemoryContainer("arr-case", "/pk"); - await c.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "Alice" } }), - new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'alice')"); - var page = await iter.ReadNextAsync(); - page.Should().BeEmpty(); // case-sensitive: "Alice" ≠ "alice" - } - - [Fact] - public async Task Query_ConcatFunction_NullArg_ReturnsUndefined() - { - var c = new InMemoryContainer("concat-fn", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT VALUE CONCAT('a', null, 'b') FROM c"); - var page = await iter.ReadNextAsync(); - // Cosmos DB: CONCAT with null arg returns undefined - page.Should().BeEmpty(); - } - - [Fact] - public async Task Query_ConcatFunction_PreservesCase() - { - var c = new InMemoryContainer("concat-fn", "/pk"); - await c.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", first = "Alice", last = "BOB" }), - new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c"); - var page = await iter.ReadNextAsync(); - page.First().Value().Should().Be("Alice BOB"); - } + [Fact] + public async Task Query_ArrayContains_StringElement_IsCaseSensitive() + { + var c = new InMemoryContainer("arr-case", "/pk"); + await c.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "Alice" } }), + new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, 'alice')"); + var page = await iter.ReadNextAsync(); + page.Should().BeEmpty(); // case-sensitive: "Alice" ≠ "alice" + } + + [Fact] + public async Task Query_ConcatFunction_NullArg_ReturnsUndefined() + { + var c = new InMemoryContainer("concat-fn", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT VALUE CONCAT('a', null, 'b') FROM c"); + var page = await iter.ReadNextAsync(); + // Cosmos DB: CONCAT with null arg returns undefined + page.Should().BeEmpty(); + } + + [Fact] + public async Task Query_ConcatFunction_PreservesCase() + { + var c = new InMemoryContainer("concat-fn", "/pk"); + await c.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", first = "Alice", last = "BOB" }), + new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT VALUE CONCAT(c.first, ' ', c.last) FROM c"); + var page = await iter.ReadNextAsync(); + page.First().Value().Should().Be("Alice BOB"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1105,31 +1105,31 @@ await c.CreateItemAsync( public class StringNullEmptyEdgeCaseTests { - [Fact] - public async Task Query_EmptyStringEquality_Works() - { - var c = new InMemoryContainer("empty-eq", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name = ''"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Query_Between_WithNullValue_ExcludesNull() - { - var c = new InMemoryContainer("between-null", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); // name is null/undefined - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task Query_EmptyStringEquality_Works() + { + var c = new InMemoryContainer("empty-eq", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "Bob" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name = ''"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Query_Between_WithNullValue_ExcludesNull() + { + var c = new InMemoryContainer("between-null", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a" }), new PartitionKey("a")); // name is null/undefined + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'A' AND 'Z'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1138,82 +1138,82 @@ public async Task Query_Between_WithNullValue_ExcludesNull() public class StringComposedOperationTests { - private async Task SeedAsync() - { - var c = new InMemoryContainer("composed-test", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", name = "bob" }), new PartitionKey("a")); - return c; - } - - [Fact] - public async Task Query_DistinctLower_CollapsesCaseDifferences() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator( - "SELECT DISTINCT VALUE LOWER(c.name) FROM c"); - var page = await iter.ReadNextAsync(); - page.Select(t => t.Value()).Should().BeEquivalentTo("alice", "bob"); - } - - [Fact] - public async Task Query_GroupByLower_MergesCaseVariants() - { - var c = await SeedAsync(); - var iter = c.GetItemQueryIterator( - "SELECT LOWER(c.name) AS lname, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.name)"); - var page = await iter.ReadNextAsync(); - var results = page.ToList(); - results.Should().HaveCount(2); - results.Should().Contain(r => r["lname"]!.Value() == "alice" && r["cnt"]!.Value() == 2); - results.Should().Contain(r => r["lname"]!.Value() == "bob" && r["cnt"]!.Value() == 2); - } - - [Fact] - public async Task Query_RegexMatch_CombinedIgnoreCaseMultiline() - { - var c = new InMemoryContainer("regex-combined", "/pk"); - await c.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", text = "hello\nAlice" }), - new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT * FROM c WHERE RegexMatch(c.text, '^alice', 'im')"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); // "Alice" at start of second line, case-insensitive - } - - [Fact] - public async Task Query_MultipleOrderBy_StringTiebreaking() - { - var c = new InMemoryContainer("multi-order", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", last = "Smith", first = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", last = "Smith", first = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", last = "Brown", first = "Bob" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c ORDER BY c.last ASC, c.first ASC"); - var page = await iter.ReadNextAsync(); - var ids = page.Select(d => d["id"]!.Value()).ToList(); - ids[0].Should().Be("3"); // Brown - // Smith, Alice vs Smith, alice — ordinal: 'A'(65) < 'a'(97) - ids[1].Should().Be("2"); // Alice - ids[2].Should().Be("1"); // alice - } - - [Fact] - public async Task Query_CrossPartition_CaseSensitivity_Consistent() - { - var c = new InMemoryContainer("cross-pk", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b", name = "alice" }), new PartitionKey("b")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(1); - page.First()["pk"]!.Value().Should().Be("a"); - } + private async Task SeedAsync() + { + var c = new InMemoryContainer("composed-test", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "4", pk = "a", name = "bob" }), new PartitionKey("a")); + return c; + } + + [Fact] + public async Task Query_DistinctLower_CollapsesCaseDifferences() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator( + "SELECT DISTINCT VALUE LOWER(c.name) FROM c"); + var page = await iter.ReadNextAsync(); + page.Select(t => t.Value()).Should().BeEquivalentTo("alice", "bob"); + } + + [Fact] + public async Task Query_GroupByLower_MergesCaseVariants() + { + var c = await SeedAsync(); + var iter = c.GetItemQueryIterator( + "SELECT LOWER(c.name) AS lname, COUNT(1) AS cnt FROM c GROUP BY LOWER(c.name)"); + var page = await iter.ReadNextAsync(); + var results = page.ToList(); + results.Should().HaveCount(2); + results.Should().Contain(r => r["lname"]!.Value() == "alice" && r["cnt"]!.Value() == 2); + results.Should().Contain(r => r["lname"]!.Value() == "bob" && r["cnt"]!.Value() == 2); + } + + [Fact] + public async Task Query_RegexMatch_CombinedIgnoreCaseMultiline() + { + var c = new InMemoryContainer("regex-combined", "/pk"); + await c.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", text = "hello\nAlice" }), + new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT * FROM c WHERE RegexMatch(c.text, '^alice', 'im')"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); // "Alice" at start of second line, case-insensitive + } + + [Fact] + public async Task Query_MultipleOrderBy_StringTiebreaking() + { + var c = new InMemoryContainer("multi-order", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", last = "Smith", first = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", last = "Smith", first = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", last = "Brown", first = "Bob" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c ORDER BY c.last ASC, c.first ASC"); + var page = await iter.ReadNextAsync(); + var ids = page.Select(d => d["id"]!.Value()).ToList(); + ids[0].Should().Be("3"); // Brown + // Smith, Alice vs Smith, alice — ordinal: 'A'(65) < 'a'(97) + ids[1].Should().Be("2"); // Alice + ids[2].Should().Be("1"); // alice + } + + [Fact] + public async Task Query_CrossPartition_CaseSensitivity_Consistent() + { + var c = new InMemoryContainer("cross-pk", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "b", name = "alice" }), new PartitionKey("b")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name = 'Alice'"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(1); + page.First()["pk"]!.Value().Should().Be("a"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1222,31 +1222,31 @@ public async Task Query_CrossPartition_CaseSensitivity_Consistent() public class StringIndexOfEdgeCaseTests { - [Fact] - public async Task Query_IndexOf_NullInput_ReturnsUndefined() - { - var c = new InMemoryContainer("indexof-null", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT INDEX_OF(c.name, 'a') AS idx FROM c"); - var page = await iter.ReadNextAsync(); - page.First()["idx"].Should().BeNull("undefined field property is absent from JSON"); - } - - [Fact] - public async Task Query_IndexOf_WithStartPosition_IsCaseSensitive() - { - var c = new InMemoryContainer("indexof-start", "/pk"); - await c.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "hello hello" }), - new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT INDEX_OF(c.name, 'hello', 1) AS idx FROM c"); - var page = await iter.ReadNextAsync(); - page.First()["idx"]!.Value().Should().Be(6); - } + [Fact] + public async Task Query_IndexOf_NullInput_ReturnsUndefined() + { + var c = new InMemoryContainer("indexof-null", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT INDEX_OF(c.name, 'a') AS idx FROM c"); + var page = await iter.ReadNextAsync(); + page.First()["idx"].Should().BeNull("undefined field property is absent from JSON"); + } + + [Fact] + public async Task Query_IndexOf_WithStartPosition_IsCaseSensitive() + { + var c = new InMemoryContainer("indexof-start", "/pk"); + await c.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "hello hello" }), + new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT INDEX_OF(c.name, 'hello', 1) AS idx FROM c"); + var page = await iter.ReadNextAsync(); + page.First()["idx"]!.Value().Should().Be(6); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1256,625 +1256,625 @@ await c.CreateItemAsync( // ── A1: REGEX_MATCH null → undefined ── public class RegexMatchNullUndefinedTests { - private readonly InMemoryContainer _container = new("regexnull", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task RegexMatch_NullFirstArg_ReturnsUndefined() - { - var results = await Query("SELECT VALUE RegexMatch(null, 'abc') FROM c"); - results.Should().BeEmpty("null input returns undefined, omitted from SELECT VALUE"); - } - - [Fact] - public async Task RegexMatch_NullSecondArg_ReturnsUndefined() - { - var results = await Query("SELECT VALUE RegexMatch('abc', null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task RegexMatch_UndefinedField_ReturnsUndefined() - { - var results = await Query("SELECT VALUE RegexMatch(c.missing, 'abc') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task RegexMatch_NullInput_NotInWhere() - { - var results = await Query("SELECT * FROM c WHERE RegexMatch(null, 'x')"); - results.Should().BeEmpty("undefined is falsy in WHERE"); - } + private readonly InMemoryContainer _container = new("regexnull", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task RegexMatch_NullFirstArg_ReturnsUndefined() + { + var results = await Query("SELECT VALUE RegexMatch(null, 'abc') FROM c"); + results.Should().BeEmpty("null input returns undefined, omitted from SELECT VALUE"); + } + + [Fact] + public async Task RegexMatch_NullSecondArg_ReturnsUndefined() + { + var results = await Query("SELECT VALUE RegexMatch('abc', null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task RegexMatch_UndefinedField_ReturnsUndefined() + { + var results = await Query("SELECT VALUE RegexMatch(c.missing, 'abc') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task RegexMatch_NullInput_NotInWhere() + { + var results = await Query("SELECT * FROM c WHERE RegexMatch(null, 'x')"); + results.Should().BeEmpty("undefined is falsy in WHERE"); + } } // ── A2: INDEX_OF null → undefined ── public class IndexOfNullUndefinedTests { - private readonly InMemoryContainer _container = new("idxnull", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task IndexOf_NullFirstArg_ReturnsUndefined() - { - var results = await Query("SELECT VALUE INDEX_OF(null, 'a') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task IndexOf_NullSecondArg_ReturnsUndefined() - { - var results = await Query("SELECT VALUE INDEX_OF('abc', null) FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task IndexOf_UndefinedField_ReturnsUndefined() - { - var results = await Query("SELECT VALUE INDEX_OF(c.missing, 'a') FROM c"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task IndexOf_NullInSelect_ShowsAsNoProperty() - { - var results = await Query("SELECT INDEX_OF(null, 'a') AS idx FROM c"); - results.Should().ContainSingle(); - results[0]["idx"].Should().BeNull("undefined property is absent from JSON"); - } + private readonly InMemoryContainer _container = new("idxnull", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "hello" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task IndexOf_NullFirstArg_ReturnsUndefined() + { + var results = await Query("SELECT VALUE INDEX_OF(null, 'a') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task IndexOf_NullSecondArg_ReturnsUndefined() + { + var results = await Query("SELECT VALUE INDEX_OF('abc', null) FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task IndexOf_UndefinedField_ReturnsUndefined() + { + var results = await Query("SELECT VALUE INDEX_OF(c.missing, 'a') FROM c"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task IndexOf_NullInSelect_ShowsAsNoProperty() + { + var results = await Query("SELECT INDEX_OF(null, 'a') AS idx FROM c"); + results.Should().ContainSingle(); + results[0]["idx"].Should().BeNull("undefined property is absent from JSON"); + } } // ── A3: INDEX_OF bounds checking ── public class IndexOfBoundsTests { - private readonly InMemoryContainer _container = new("idxbounds", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task IndexOf_NegativeStartPos_ReturnsUndefined() - { - var results = await Query("SELECT VALUE INDEX_OF('hello', 'e', -1) FROM c"); - results.Should().BeEmpty("negative start position returns undefined"); - } - - [Fact] - public async Task IndexOf_StartPosBeyondLength_ReturnsUndefined() - { - var results = await Query("SELECT VALUE INDEX_OF('hello', 'e', 100) FROM c"); - results.Should().BeEmpty("start position beyond string length returns undefined"); - } + private readonly InMemoryContainer _container = new("idxbounds", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task IndexOf_NegativeStartPos_ReturnsUndefined() + { + var results = await Query("SELECT VALUE INDEX_OF('hello', 'e', -1) FROM c"); + results.Should().BeEmpty("negative start position returns undefined"); + } + + [Fact] + public async Task IndexOf_StartPosBeyondLength_ReturnsUndefined() + { + var results = await Query("SELECT VALUE INDEX_OF('hello', 'e', 100) FROM c"); + results.Should().BeEmpty("start position beyond string length returns undefined"); + } } // ── A4: LIKE on non-string types (divergent — skip + sister) ── public class LikeNonStringTypeTests { - private readonly InMemoryContainer _container = new("likenonstr", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", age = 42 }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task Like_NumberLeftOperand_ReturnsUndefined() - { - var results = await Query("SELECT * FROM c WHERE c.age LIKE '42'"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task Like_BooleanLeftOperand_ReturnsUndefined() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", flag = true }), new PartitionKey("a")); - var results = await Query("SELECT * FROM c WHERE c.flag LIKE 'True'"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("likenonstr", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", age = 42 }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task Like_NumberLeftOperand_ReturnsUndefined() + { + var results = await Query("SELECT * FROM c WHERE c.age LIKE '42'"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task Like_BooleanLeftOperand_ReturnsUndefined() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", flag = true }), new PartitionKey("a")); + var results = await Query("SELECT * FROM c WHERE c.flag LIKE 'True'"); + results.Should().BeEmpty(); + } } // ── B1: LIKE with pipe character ── public class LikePipeCharTests { - [Fact] - public async Task Like_WithPipeChar_TreatedAsLiteral() - { - var c = new InMemoryContainer("likepipe", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "a|b" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "b" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a|b'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task Like_WithPipeChar_TreatedAsLiteral() + { + var c = new InMemoryContainer("likepipe", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "a|b" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "b" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'a|b'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } } // ── B2: LIKE consecutive wildcards ── public class LikeConsecutiveWildcardTests { - [Fact] - public async Task Like_ConsecutivePercents_SameAsOnePercent() - { - var c = new InMemoryContainer("likecwild", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "anything" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%%'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle(); - } - - [Fact] - public async Task Like_ConsecutiveUnderscores_MatchesExactLength() - { - var c = new InMemoryContainer("likeunder", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "ab" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "abc" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '__'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Like_MixedWildcards_PercentThenUnderscore() - { - var c = new InMemoryContainer("likemixed", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "x" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ab" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%_'"); - var page = await iter.ReadNextAsync(); - page.Count().Should().Be(2, "matches strings of length >= 1"); - } + [Fact] + public async Task Like_ConsecutivePercents_SameAsOnePercent() + { + var c = new InMemoryContainer("likecwild", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "anything" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%%'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle(); + } + + [Fact] + public async Task Like_ConsecutiveUnderscores_MatchesExactLength() + { + var c = new InMemoryContainer("likeunder", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "ab" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "abc" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '__'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Like_MixedWildcards_PercentThenUnderscore() + { + var c = new InMemoryContainer("likemixed", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "x" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "ab" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '%_'"); + var page = await iter.ReadNextAsync(); + page.Count().Should().Be(2, "matches strings of length >= 1"); + } } // ── B3: LIKE ESCAPE case sensitivity ── public class LikeEscapeCaseSensitivityTests { - [Fact] - public async Task Like_EscapedPercent_CaseSensitive() - { - var c = new InMemoryContainer("likeesc", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%b" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%b" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A!%b' ESCAPE '!'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Like_EscapedUnderscore_CaseSensitive() - { - var c = new InMemoryContainer("likeescus", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A_b" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a_b" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A!_b' ESCAPE '!'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task Like_EscapedPercent_CaseSensitive() + { + var c = new InMemoryContainer("likeesc", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A%b" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a%b" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A!%b' ESCAPE '!'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Like_EscapedUnderscore_CaseSensitive() + { + var c = new InMemoryContainer("likeescus", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "A_b" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "a_b" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'A!_b' ESCAPE '!'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } } // ── B4: LIKE with Unicode ── public class LikeUnicodeTests { - [Fact] - public async Task Like_UnicodeInPattern_CaseSensitive() - { - var c = new InMemoryContainer("likeuni", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "über" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'Über%'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task Like_UnicodeWildcard_MatchesUnicode() - { - var c = new InMemoryContainer("likeuniwild", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '_ber'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle(); - } + [Fact] + public async Task Like_UnicodeInPattern_CaseSensitive() + { + var c = new InMemoryContainer("likeuni", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "über" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE 'Über%'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task Like_UnicodeWildcard_MatchesUnicode() + { + var c = new InMemoryContainer("likeuniwild", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Über" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name LIKE '_ber'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle(); + } } // ── B5: CONTAINS/STARTSWITH/ENDSWITH third arg ── public class StringFunctionThirdArgTests { - private readonly InMemoryContainer _container = new("thirdarg", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task Contains_ThirdArgFalse_Boolean_StaysCaseSensitive() - { - var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', false)"); - results.Should().BeEmpty("false means case-sensitive, no match"); - } - - [Fact] - public async Task Contains_ThirdArgNonBooleanString_StaysCaseSensitive() - { - var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', 'yes')"); - results.Should().BeEmpty("only 'true' activates case-insensitive"); - } + private readonly InMemoryContainer _container = new("thirdarg", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task Contains_ThirdArgFalse_Boolean_StaysCaseSensitive() + { + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', false)"); + results.Should().BeEmpty("false means case-sensitive, no match"); + } + + [Fact] + public async Task Contains_ThirdArgNonBooleanString_StaysCaseSensitive() + { + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, 'ALI', 'yes')"); + results.Should().BeEmpty("only 'true' activates case-insensitive"); + } } // ── B6: STRING_EQUALS with undefined/null ── public class StringEqualsEdgeCaseTests { - private readonly InMemoryContainer _container = new("streq", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task StringEquals_UndefinedField_ReturnsUndefined() - { - var results = await Query("SELECT * FROM c WHERE StringEquals(c.missing, 'alice')"); - results.Should().BeEmpty(); - } - - [Fact] - public async Task StringEquals_NullArg_ReturnsUndefined() - { - var results = await Query("SELECT VALUE StringEquals(null, 'alice') FROM c"); - results.Should().BeEmpty("null input returns undefined"); - } + private readonly InMemoryContainer _container = new("streq", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task StringEquals_UndefinedField_ReturnsUndefined() + { + var results = await Query("SELECT * FROM c WHERE StringEquals(c.missing, 'alice')"); + results.Should().BeEmpty(); + } + + [Fact] + public async Task StringEquals_NullArg_ReturnsUndefined() + { + var results = await Query("SELECT VALUE StringEquals(null, 'alice') FROM c"); + results.Should().BeEmpty("null input returns undefined"); + } } // ── B7: REPLACE edge cases ── public class ReplaceCaseSensitivityEdgeCaseTests { - private readonly InMemoryContainer _container = new("replcase", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task Replace_CaseSensitiveSearch_NoMatch() - { - var results = await Query("SELECT VALUE REPLACE('Alice', 'ALICE', 'Bob') FROM c"); - results.Should().ContainSingle().Which.Should().Be("Alice"); - } - - [Fact] - public async Task Replace_UndefinedInput_ReturnsUndefined() - { - var results = await Query("SELECT VALUE REPLACE(c.missing, 'a', 'b') FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("replcase", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task Replace_CaseSensitiveSearch_NoMatch() + { + var results = await Query("SELECT VALUE REPLACE('Alice', 'ALICE', 'Bob') FROM c"); + results.Should().ContainSingle().Which.Should().Be("Alice"); + } + + [Fact] + public async Task Replace_UndefinedInput_ReturnsUndefined() + { + var results = await Query("SELECT VALUE REPLACE(c.missing, 'a', 'b') FROM c"); + results.Should().BeEmpty(); + } } // ── B8: ORDER BY mixed types ── public class OrderByMixedTypeTests { - [Fact] - public async Task OrderBy_StringWithNull_NullSortsFirst() - { - var c = new InMemoryContainer("ordmixed", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = (string?)null }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT c.name FROM c ORDER BY c.name ASC"); - var page = await iter.ReadNextAsync(); - var names = page.Select(x => x["name"]?.Value()).ToList(); - names[0].Should().BeNull("null sorts before strings in ASC order"); - } + [Fact] + public async Task OrderBy_StringWithNull_NullSortsFirst() + { + var c = new InMemoryContainer("ordmixed", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Bob" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = (string?)null }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT c.name FROM c ORDER BY c.name ASC"); + var page = await iter.ReadNextAsync(); + var names = page.Select(x => x["name"]?.Value()).ToList(); + names[0].Should().BeNull("null sorts before strings in ASC order"); + } } // ── B9: BETWEEN inclusive boundaries ── public class BetweenInclusiveTests { - [Fact] - public async Task Between_ExactLowerBound_IsInclusive() - { - var c = new InMemoryContainer("between", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "bob" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Charlie" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'Alice' AND 'bob'"); - var page = await iter.ReadNextAsync(); - page.Any(x => x["name"]!.Value() == "Alice").Should().BeTrue("lower bound is inclusive"); - } - - [Fact] - public async Task Between_ExactUpperBound_IsInclusive() - { - var c = new InMemoryContainer("betweenupp", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "bob" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'Alice' AND 'bob'"); - var page = await iter.ReadNextAsync(); - page.Any(x => x["name"]!.Value() == "bob").Should().BeTrue("upper bound is inclusive"); - } + [Fact] + public async Task Between_ExactLowerBound_IsInclusive() + { + var c = new InMemoryContainer("between", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "bob" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Charlie" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'Alice' AND 'bob'"); + var page = await iter.ReadNextAsync(); + page.Any(x => x["name"]!.Value() == "Alice").Should().BeTrue("lower bound is inclusive"); + } + + [Fact] + public async Task Between_ExactUpperBound_IsInclusive() + { + var c = new InMemoryContainer("betweenupp", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "bob" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name BETWEEN 'Alice' AND 'bob'"); + var page = await iter.ReadNextAsync(); + page.Any(x => x["name"]!.Value() == "bob").Should().BeTrue("upper bound is inclusive"); + } } // ── B10: IN operator large list ── public class InOperatorLargeListTests { - [Fact] - public async Task In_LargeList_AllCaseSensitive() - { - var c = new InMemoryContainer("inlarge", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name IN ('Alice', 'Bob')"); - var page = await iter.ReadNextAsync(); - page.Count().Should().Be(2); - page.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Alice", "Bob"); - } + [Fact] + public async Task In_LargeList_AllCaseSensitive() + { + var c = new InMemoryContainer("inlarge", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Bob" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT * FROM c WHERE c.name IN ('Alice', 'Bob')"); + var page = await iter.ReadNextAsync(); + page.Count().Should().Be(2); + page.Select(x => x["name"]!.Value()).Should().BeEquivalentTo("Alice", "Bob"); + } } // ── B11: Empty string edge cases ── public class EmptyStringFunctionTests { - private readonly InMemoryContainer _container = new("emptystr", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task StartsWith_EmptyStringInput_EmptyPrefix_ReturnsTrue() - { - var results = await Query("SELECT VALUE STARTSWITH('', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task EndsWith_EmptyStringInput_EmptyPrefix_ReturnsTrue() - { - var results = await Query("SELECT VALUE ENDSWITH('', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } - - [Fact] - public async Task Contains_EmptyStringInput_EmptySub_ReturnsTrue() - { - var results = await Query("SELECT VALUE CONTAINS('', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("emptystr", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task StartsWith_EmptyStringInput_EmptyPrefix_ReturnsTrue() + { + var results = await Query("SELECT VALUE STARTSWITH('', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task EndsWith_EmptyStringInput_EmptyPrefix_ReturnsTrue() + { + var results = await Query("SELECT VALUE ENDSWITH('', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } + + [Fact] + public async Task Contains_EmptyStringInput_EmptySub_ReturnsTrue() + { + var results = await Query("SELECT VALUE CONTAINS('', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } } // ── B12: REGEX_MATCH invalid regex / empty pattern ── public class RegexMatchEdgeCaseTests { - private readonly InMemoryContainer _container = new("regexedge", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task RegexMatch_InvalidPattern_ReturnsUndefined() - { - var results = await Query("SELECT VALUE RegexMatch('test', '[invalid') FROM c"); - results.Should().BeEmpty("invalid regex returns undefined"); - } - - [Fact] - public async Task RegexMatch_EmptyPattern_MatchesAll() - { - var results = await Query("SELECT VALUE RegexMatch('test', '') FROM c"); - results.Should().ContainSingle().Which.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("regexedge", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task RegexMatch_InvalidPattern_ReturnsUndefined() + { + var results = await Query("SELECT VALUE RegexMatch('test', '[invalid') FROM c"); + results.Should().BeEmpty("invalid regex returns undefined"); + } + + [Fact] + public async Task RegexMatch_EmptyPattern_MatchesAll() + { + var results = await Query("SELECT VALUE RegexMatch('test', '') FROM c"); + results.Should().ContainSingle().Which.Should().BeTrue(); + } } // ── B13: String concat with undefined ── public class StringConcatUndefinedTests { - private readonly InMemoryContainer _container = new("concatundef", "/pk"); - - private async Task> Query(string sql) - { - await EnsureSeeded(); - var iter = _container.GetItemQueryIterator(sql); - var page = await iter.ReadNextAsync(); - return page.ToList(); - } - - private bool _seeded; - private async Task EnsureSeeded() - { - if (_seeded) return; - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - _seeded = true; - } - - [Fact] - public async Task StringConcat_UndefinedField_ReturnsUndefined() - { - var results = await Query("SELECT VALUE c.missing || 'suffix' FROM c"); - results.Should().BeEmpty("concat with undefined field returns undefined"); - } - - [Fact] - public async Task StringConcat_BothUndefined_ReturnsUndefined() - { - var results = await Query("SELECT VALUE c.x || c.y FROM c"); - results.Should().BeEmpty(); - } + private readonly InMemoryContainer _container = new("concatundef", "/pk"); + + private async Task> Query(string sql) + { + await EnsureSeeded(); + var iter = _container.GetItemQueryIterator(sql); + var page = await iter.ReadNextAsync(); + return page.ToList(); + } + + private bool _seeded; + private async Task EnsureSeeded() + { + if (_seeded) return; + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + _seeded = true; + } + + [Fact] + public async Task StringConcat_UndefinedField_ReturnsUndefined() + { + var results = await Query("SELECT VALUE c.missing || 'suffix' FROM c"); + results.Should().BeEmpty("concat with undefined field returns undefined"); + } + + [Fact] + public async Task StringConcat_BothUndefined_ReturnsUndefined() + { + var results = await Query("SELECT VALUE c.x || c.y FROM c"); + results.Should().BeEmpty(); + } } // ── B14: DISTINCT in subquery ── public class DistinctSubqueryCaseSensitiveTests { - [Fact] - public async Task Distinct_InSubquery_CaseSensitive() - { - var c = new InMemoryContainer("distsub", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator("SELECT DISTINCT VALUE c.name FROM c"); - var page = await iter.ReadNextAsync(); - page.Should().HaveCount(2).And.Contain("Alice").And.Contain("alice"); - } + [Fact] + public async Task Distinct_InSubquery_CaseSensitive() + { + var c = new InMemoryContainer("distsub", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator("SELECT DISTINCT VALUE c.name FROM c"); + var page = await iter.ReadNextAsync(); + page.Should().HaveCount(2).And.Contain("Alice").And.Contain("alice"); + } } // ── B15: GROUP BY with HAVING case-sensitive ── public class GroupByHavingCaseSensitiveTests { - [Fact] - public async Task GroupBy_Having_CaseSensitiveFilter() - { - var c = new InMemoryContainer("grphaving", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); - - var iter = c.GetItemQueryIterator( - "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name HAVING c.name = 'Alice'"); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["cnt"]!.Value().Should().Be(2); - } + [Fact] + public async Task GroupBy_Having_CaseSensitiveFilter() + { + var c = new InMemoryContainer("grphaving", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "3", pk = "a", name = "Alice" }), new PartitionKey("a")); + + var iter = c.GetItemQueryIterator( + "SELECT c.name, COUNT(1) AS cnt FROM c GROUP BY c.name HAVING c.name = 'Alice'"); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["cnt"]!.Value().Should().Be(2); + } } // ── B16: LIKE with parameterized pattern ── public class LikeParameterizedTests { - [Fact] - public async Task Like_ParameterizedPattern_CaseSensitive() - { - var c = new InMemoryContainer("likeparam", "/pk"); - await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); - await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); - - var qd = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pat").WithParameter("@pat", "A%"); - var iter = c.GetItemQueryIterator(qd); - var page = await iter.ReadNextAsync(); - page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task Like_ParameterizedPattern_CaseSensitive() + { + var c = new InMemoryContainer("likeparam", "/pk"); + await c.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", name = "Alice" }), new PartitionKey("a")); + await c.CreateItemAsync(JObject.FromObject(new { id = "2", pk = "a", name = "alice" }), new PartitionKey("a")); + + var qd = new QueryDefinition("SELECT * FROM c WHERE c.name LIKE @pat").WithParameter("@pat", "A%"); + var iter = c.GetItemQueryIterator(qd); + var page = await iter.ReadNextAsync(); + page.Should().ContainSingle().Which["id"]!.Value().Should().Be("1"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchConcurrentEtagBugTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchConcurrentEtagBugTests.cs index 8ab6b7e..b010c03 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchConcurrentEtagBugTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchConcurrentEtagBugTests.cs @@ -15,106 +15,106 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class TransactionalBatchConcurrentEtagBugTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task ConcurrentBatches_SameItem_ETagConflict_OneFailsOnePasses() - { - // Seed the item - var doc = JObject.FromObject(new { id = "counter", pk = "a", count = 0 }); - await _container.CreateItemAsync(doc, new PartitionKey("a")); - - // Read the item to get initial ETag - var readResponse = await _container.ReadItemAsync("counter", new PartitionKey("a")); - var initialEtag = readResponse.ETag; - - // Two concurrent batches both trying to replace the same item with the same stale ETag - var batch1 = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch1.ReplaceItem("counter", - JObject.FromObject(new { id = "counter", pk = "a", count = 1 }), - new TransactionalBatchItemRequestOptions { IfMatchEtag = initialEtag }); - - var batch2 = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch2.ReplaceItem("counter", - JObject.FromObject(new { id = "counter", pk = "a", count = 2 }), - new TransactionalBatchItemRequestOptions { IfMatchEtag = initialEtag }); - - // Execute both concurrently - var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); - - // One should succeed, the other should fail with PreconditionFailed - var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToArray(); - var successCount = statuses.Count(s => s == HttpStatusCode.OK); - var failCount = statuses.Count(s => s == HttpStatusCode.PreconditionFailed); - - successCount.Should().Be(1, "exactly one batch should succeed"); - failCount.Should().Be(1, "exactly one batch should fail with PreconditionFailed (412)"); - } - - [Fact] - public async Task ConcurrentBatches_CreateSameItem_OneConflicts() - { - // Two concurrent batches both trying to create the same item - var batch1 = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch1.CreateItem(JObject.FromObject(new { id = "unique-doc", pk = "a", version = 1 })); - - var batch2 = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch2.CreateItem(JObject.FromObject(new { id = "unique-doc", pk = "a", version = 2 })); - - var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); - - var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToArray(); - var successCount = statuses.Count(s => s == HttpStatusCode.OK || s == HttpStatusCode.Created); - var conflictCount = statuses.Count(s => s == HttpStatusCode.Conflict); - - successCount.Should().Be(1, "exactly one batch should succeed"); - conflictCount.Should().Be(1, "exactly one batch should fail with Conflict (409)"); - } - - [Fact] - public async Task ConcurrentBatches_AtomicCounter_IncrementPattern() - { - // Seed the "latest" counter doc - var latest = JObject.FromObject(new { id = "latest", pk = "a", attemptNumber = 0 }); - await _container.CreateItemAsync(latest, new PartitionKey("a")); - - // Simulate 3 concurrent "read-modify-write" operations via batches - async Task IncrementWithRetry() - { - for (var retry = 0; retry < 10; retry++) - { - var read = await _container.ReadItemAsync("latest", new PartitionKey("a")); - var currentNumber = read.Resource["attemptNumber"]!.Value(); - var newNumber = currentNumber + 1; - var etag = read.ETag; - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.ReplaceItem("latest", - JObject.FromObject(new { id = "latest", pk = "a", attemptNumber = newNumber }), - new TransactionalBatchItemRequestOptions { IfMatchEtag = etag }); - batch.CreateItem(JObject.FromObject(new { id = $"attempt-{newNumber}", pk = "a", number = newNumber })); - - var result = await batch.ExecuteAsync(); - if (result.IsSuccessStatusCode) - return newNumber; - - // Retry on conflict/precondition failed - if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) - continue; - - throw new Exception($"Unexpected batch status: {result.StatusCode}"); - } - - throw new Exception("Too many retries"); - } - - var tasks = Enumerable.Range(0, 3).Select(_ => IncrementWithRetry()).ToArray(); - var results = await Task.WhenAll(tasks); - - var sorted = results.OrderBy(x => x).ToArray(); - sorted.Should().BeEquivalentTo([1, 2, 3], - "transactional batch with ETag should prevent duplicate attempt numbers"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task ConcurrentBatches_SameItem_ETagConflict_OneFailsOnePasses() + { + // Seed the item + var doc = JObject.FromObject(new { id = "counter", pk = "a", count = 0 }); + await _container.CreateItemAsync(doc, new PartitionKey("a")); + + // Read the item to get initial ETag + var readResponse = await _container.ReadItemAsync("counter", new PartitionKey("a")); + var initialEtag = readResponse.ETag; + + // Two concurrent batches both trying to replace the same item with the same stale ETag + var batch1 = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch1.ReplaceItem("counter", + JObject.FromObject(new { id = "counter", pk = "a", count = 1 }), + new TransactionalBatchItemRequestOptions { IfMatchEtag = initialEtag }); + + var batch2 = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch2.ReplaceItem("counter", + JObject.FromObject(new { id = "counter", pk = "a", count = 2 }), + new TransactionalBatchItemRequestOptions { IfMatchEtag = initialEtag }); + + // Execute both concurrently + var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); + + // One should succeed, the other should fail with PreconditionFailed + var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToArray(); + var successCount = statuses.Count(s => s == HttpStatusCode.OK); + var failCount = statuses.Count(s => s == HttpStatusCode.PreconditionFailed); + + successCount.Should().Be(1, "exactly one batch should succeed"); + failCount.Should().Be(1, "exactly one batch should fail with PreconditionFailed (412)"); + } + + [Fact] + public async Task ConcurrentBatches_CreateSameItem_OneConflicts() + { + // Two concurrent batches both trying to create the same item + var batch1 = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch1.CreateItem(JObject.FromObject(new { id = "unique-doc", pk = "a", version = 1 })); + + var batch2 = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch2.CreateItem(JObject.FromObject(new { id = "unique-doc", pk = "a", version = 2 })); + + var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); + + var statuses = results.Select(r => r.StatusCode).OrderBy(s => s).ToArray(); + var successCount = statuses.Count(s => s == HttpStatusCode.OK || s == HttpStatusCode.Created); + var conflictCount = statuses.Count(s => s == HttpStatusCode.Conflict); + + successCount.Should().Be(1, "exactly one batch should succeed"); + conflictCount.Should().Be(1, "exactly one batch should fail with Conflict (409)"); + } + + [Fact] + public async Task ConcurrentBatches_AtomicCounter_IncrementPattern() + { + // Seed the "latest" counter doc + var latest = JObject.FromObject(new { id = "latest", pk = "a", attemptNumber = 0 }); + await _container.CreateItemAsync(latest, new PartitionKey("a")); + + // Simulate 3 concurrent "read-modify-write" operations via batches + async Task IncrementWithRetry() + { + for (var retry = 0; retry < 10; retry++) + { + var read = await _container.ReadItemAsync("latest", new PartitionKey("a")); + var currentNumber = read.Resource["attemptNumber"]!.Value(); + var newNumber = currentNumber + 1; + var etag = read.ETag; + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.ReplaceItem("latest", + JObject.FromObject(new { id = "latest", pk = "a", attemptNumber = newNumber }), + new TransactionalBatchItemRequestOptions { IfMatchEtag = etag }); + batch.CreateItem(JObject.FromObject(new { id = $"attempt-{newNumber}", pk = "a", number = newNumber })); + + var result = await batch.ExecuteAsync(); + if (result.IsSuccessStatusCode) + return newNumber; + + // Retry on conflict/precondition failed + if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) + continue; + + throw new Exception($"Unexpected batch status: {result.StatusCode}"); + } + + throw new Exception("Too many retries"); + } + + var tasks = Enumerable.Range(0, 3).Select(_ => IncrementWithRetry()).ToArray(); + var results = await Task.WhenAll(tasks); + + var sorted = results.OrderBy(x => x).ToArray(); + sorted.Should().BeEquivalentTo([1, 2, 3], + "transactional batch with ETag should prevent duplicate attempt numbers"); + } } /// @@ -125,233 +125,233 @@ async Task IncrementWithRetry() /// public class TransactionalBatchConcurrentRollbackTests { - [Fact] - public async Task FailingBatch_DoesNotWipeOutConcurrentSuccessfulBatchWrites() - { - var container = new InMemoryContainer("test", "/pk"); - - // Batch A creates items successfully - var batchA = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batchA.CreateItem(JObject.FromObject(new { id = "itemA1", pk = "pk1" })); - batchA.CreateItem(JObject.FromObject(new { id = "itemA2", pk = "pk1" })); - - // Batch B will fail (creates itemB1, then tries to create itemA1 which conflicts) - var batchB = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batchB.CreateItem(JObject.FromObject(new { id = "itemB1", pk = "pk1" })); - batchB.CreateItem(JObject.FromObject(new { id = "itemA1", pk = "pk1" })); // conflict with batch A - - // Execute A first, then B - var resultA = await batchA.ExecuteAsync(); - resultA.IsSuccessStatusCode.Should().BeTrue("Batch A should succeed"); - - var resultB = await batchB.ExecuteAsync(); - resultB.IsSuccessStatusCode.Should().BeFalse("Batch B should fail due to conflict on itemA1"); - resultB.StatusCode.Should().Be(HttpStatusCode.Conflict); - - // Batch A's items should still be present after Batch B's rollback - var readA1 = await container.ReadItemAsync("itemA1", new PartitionKey("pk1")); - readA1.Should().NotBeNull("itemA1 from Batch A should survive Batch B's rollback"); - - var readA2 = await container.ReadItemAsync("itemA2", new PartitionKey("pk1")); - readA2.Should().NotBeNull("itemA2 from Batch A should survive Batch B's rollback"); - - // Batch B's itemB1 should NOT exist (it was rolled back) - var act = () => container.ReadItemAsync("itemB1", new PartitionKey("pk1")); - await act.Should().ThrowAsync() - .Where(ex => ex.StatusCode == HttpStatusCode.NotFound, - "itemB1 should have been rolled back by Batch B's failure"); - } - - [Fact] - public async Task ConcurrentBatches_FailingBatchRollback_PreservesConcurrentWrites() - { - var container = new InMemoryContainer("test", "/pk"); - - // Pre-create a seed item to cause conflict - await container.CreateItemAsync( - JObject.FromObject(new { id = "seed", pk = "pk1" }), - new PartitionKey("pk1")); - - // Task A: creates new items in a batch (should succeed) - var taskA = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(JObject.FromObject(new { id = "fromA", pk = "pk1", source = "A" })); - return await batch.ExecuteAsync(); - }); - - // Task B: tries to create "seed" (conflict) in a batch (should fail) - var taskB = Task.Run(async () => - { - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(JObject.FromObject(new { id = "seed", pk = "pk1", source = "B" })); // conflict - return await batch.ExecuteAsync(); - }); - - var results = await Task.WhenAll(taskA, taskB); - - // Task A should succeed, Task B should fail - var successBatch = results.First(r => r.IsSuccessStatusCode); - var failedBatch = results.First(r => !r.IsSuccessStatusCode); - - successBatch.Should().NotBeNull("one batch should succeed"); - failedBatch.StatusCode.Should().Be(HttpStatusCode.Conflict); - - // Items from the successful batch should be preserved - var readSeed = await container.ReadItemAsync("seed", new PartitionKey("pk1")); - readSeed.Should().NotBeNull("seed item should still exist"); - - var readFromA = await container.ReadItemAsync("fromA", new PartitionKey("pk1")); - readFromA.Should().NotBeNull("items from successful batch should survive concurrent rollback"); - } - - [Fact] - public async Task ConcurrentBatches_MultipleCreateRetryPattern_NoAttemptNumberDuplicates() - { - // This reproduces the exact pattern from the reported bug: - // Two concurrent Save() calls that each read-then-batch with retry - var container = new InMemoryContainer("test", "/runNumber"); - - async Task SaveWithRetry(int runNumber) - { - for (var retry = 0; retry < 10; retry++) - { - // Read the "latest" document - int attemptNumber; - string? latestEtag = null; - bool latestExists; - - try - { - var read = await container.ReadItemAsync("latest", new PartitionKey(runNumber.ToString())); - attemptNumber = read.Resource["attemptNumber"]!.Value() + 1; - latestEtag = read.ETag; - latestExists = true; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - attemptNumber = 1; - latestExists = false; - } - - var runId = $"{runNumber}-{attemptNumber}"; - var batch = container.CreateTransactionalBatch(new PartitionKey(runNumber.ToString())); - - if (latestExists) - { - batch.ReplaceItem("latest", - JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber }), - new TransactionalBatchItemRequestOptions { IfMatchEtag = latestEtag }); - } - else - { - batch.CreateItem(JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber })); - } - - batch.CreateItem(JObject.FromObject(new { id = runId, runNumber = runNumber.ToString(), attemptNumber })); - - var result = await batch.ExecuteAsync(); - if (result.IsSuccessStatusCode) - return attemptNumber; - - if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) - continue; - - throw new Exception($"Unexpected batch status: {result.StatusCode}"); - } - - throw new Exception("Too many retries"); - } - - // Run two concurrent saves for the SAME run number (exactly like the reported bug) - var tasks = new[] { SaveWithRetry(602021330), SaveWithRetry(602021330) }; - var results = await Task.WhenAll(tasks); - - var sorted = results.OrderBy(x => x).ToArray(); - sorted.Should().BeEquivalentTo(new[] { 1, 2 }, - "concurrent saves should produce unique sequential attempt numbers via retry"); - - // Verify the latest doc has the highest attempt number - var latest = await container.ReadItemAsync("latest", new PartitionKey("602021330")); - latest.Resource["attemptNumber"]!.Value().Should().Be(2); - } - - [Fact] - public async Task ConcurrentBatches_MultipleRunNumbers_MaintainsIsolation() - { - // Same as above but with two different run numbers (different partitions) - var container = new InMemoryContainer("test", "/runNumber"); - - async Task SaveWithRetry(int runNumber) - { - for (var retry = 0; retry < 10; retry++) - { - int attemptNumber; - string? latestEtag = null; - bool latestExists; - - try - { - var read = await container.ReadItemAsync("latest", new PartitionKey(runNumber.ToString())); - attemptNumber = read.Resource["attemptNumber"]!.Value() + 1; - latestEtag = read.ETag; - latestExists = true; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - attemptNumber = 1; - latestExists = false; - } - - var runId = $"{runNumber}-{attemptNumber}"; - var batch = container.CreateTransactionalBatch(new PartitionKey(runNumber.ToString())); - - if (latestExists) - { - batch.ReplaceItem("latest", - JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber }), - new TransactionalBatchItemRequestOptions { IfMatchEtag = latestEtag }); - } - else - { - batch.CreateItem(JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber })); - } - - batch.CreateItem(JObject.FromObject(new { id = runId, runNumber = runNumber.ToString(), attemptNumber })); - - var result = await batch.ExecuteAsync(); - if (result.IsSuccessStatusCode) - return attemptNumber; - - if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) - continue; - - throw new Exception($"Unexpected batch status: {result.StatusCode}"); - } - - throw new Exception("Too many retries"); - } - - var tasks = new[] - { - SaveWithRetry(100), SaveWithRetry(200), - SaveWithRetry(100), SaveWithRetry(200) - }; - var results = await Task.WhenAll(tasks); - - var run100 = new List(); - var run200 = new List(); - - // We need to figure out which result goes to which run number - // Since tasks are indexed, we know: [0]=100, [1]=200, [2]=100, [3]=200 - run100.Add(results[0]); - run100.Add(results[2]); - run200.Add(results[1]); - run200.Add(results[3]); - - run100.OrderBy(x => x).Should().BeEquivalentTo(new[] { 1, 2 }, - "run 100 should have attempt numbers 1 and 2"); - run200.OrderBy(x => x).Should().BeEquivalentTo(new[] { 1, 2 }, - "run 200 should have attempt numbers 1 and 2"); - } + [Fact] + public async Task FailingBatch_DoesNotWipeOutConcurrentSuccessfulBatchWrites() + { + var container = new InMemoryContainer("test", "/pk"); + + // Batch A creates items successfully + var batchA = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batchA.CreateItem(JObject.FromObject(new { id = "itemA1", pk = "pk1" })); + batchA.CreateItem(JObject.FromObject(new { id = "itemA2", pk = "pk1" })); + + // Batch B will fail (creates itemB1, then tries to create itemA1 which conflicts) + var batchB = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batchB.CreateItem(JObject.FromObject(new { id = "itemB1", pk = "pk1" })); + batchB.CreateItem(JObject.FromObject(new { id = "itemA1", pk = "pk1" })); // conflict with batch A + + // Execute A first, then B + var resultA = await batchA.ExecuteAsync(); + resultA.IsSuccessStatusCode.Should().BeTrue("Batch A should succeed"); + + var resultB = await batchB.ExecuteAsync(); + resultB.IsSuccessStatusCode.Should().BeFalse("Batch B should fail due to conflict on itemA1"); + resultB.StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Batch A's items should still be present after Batch B's rollback + var readA1 = await container.ReadItemAsync("itemA1", new PartitionKey("pk1")); + readA1.Should().NotBeNull("itemA1 from Batch A should survive Batch B's rollback"); + + var readA2 = await container.ReadItemAsync("itemA2", new PartitionKey("pk1")); + readA2.Should().NotBeNull("itemA2 from Batch A should survive Batch B's rollback"); + + // Batch B's itemB1 should NOT exist (it was rolled back) + var act = () => container.ReadItemAsync("itemB1", new PartitionKey("pk1")); + await act.Should().ThrowAsync() + .Where(ex => ex.StatusCode == HttpStatusCode.NotFound, + "itemB1 should have been rolled back by Batch B's failure"); + } + + [Fact] + public async Task ConcurrentBatches_FailingBatchRollback_PreservesConcurrentWrites() + { + var container = new InMemoryContainer("test", "/pk"); + + // Pre-create a seed item to cause conflict + await container.CreateItemAsync( + JObject.FromObject(new { id = "seed", pk = "pk1" }), + new PartitionKey("pk1")); + + // Task A: creates new items in a batch (should succeed) + var taskA = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(JObject.FromObject(new { id = "fromA", pk = "pk1", source = "A" })); + return await batch.ExecuteAsync(); + }); + + // Task B: tries to create "seed" (conflict) in a batch (should fail) + var taskB = Task.Run(async () => + { + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(JObject.FromObject(new { id = "seed", pk = "pk1", source = "B" })); // conflict + return await batch.ExecuteAsync(); + }); + + var results = await Task.WhenAll(taskA, taskB); + + // Task A should succeed, Task B should fail + var successBatch = results.First(r => r.IsSuccessStatusCode); + var failedBatch = results.First(r => !r.IsSuccessStatusCode); + + successBatch.Should().NotBeNull("one batch should succeed"); + failedBatch.StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Items from the successful batch should be preserved + var readSeed = await container.ReadItemAsync("seed", new PartitionKey("pk1")); + readSeed.Should().NotBeNull("seed item should still exist"); + + var readFromA = await container.ReadItemAsync("fromA", new PartitionKey("pk1")); + readFromA.Should().NotBeNull("items from successful batch should survive concurrent rollback"); + } + + [Fact] + public async Task ConcurrentBatches_MultipleCreateRetryPattern_NoAttemptNumberDuplicates() + { + // This reproduces the exact pattern from the reported bug: + // Two concurrent Save() calls that each read-then-batch with retry + var container = new InMemoryContainer("test", "/runNumber"); + + async Task SaveWithRetry(int runNumber) + { + for (var retry = 0; retry < 10; retry++) + { + // Read the "latest" document + int attemptNumber; + string? latestEtag = null; + bool latestExists; + + try + { + var read = await container.ReadItemAsync("latest", new PartitionKey(runNumber.ToString())); + attemptNumber = read.Resource["attemptNumber"]!.Value() + 1; + latestEtag = read.ETag; + latestExists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + attemptNumber = 1; + latestExists = false; + } + + var runId = $"{runNumber}-{attemptNumber}"; + var batch = container.CreateTransactionalBatch(new PartitionKey(runNumber.ToString())); + + if (latestExists) + { + batch.ReplaceItem("latest", + JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber }), + new TransactionalBatchItemRequestOptions { IfMatchEtag = latestEtag }); + } + else + { + batch.CreateItem(JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber })); + } + + batch.CreateItem(JObject.FromObject(new { id = runId, runNumber = runNumber.ToString(), attemptNumber })); + + var result = await batch.ExecuteAsync(); + if (result.IsSuccessStatusCode) + return attemptNumber; + + if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) + continue; + + throw new Exception($"Unexpected batch status: {result.StatusCode}"); + } + + throw new Exception("Too many retries"); + } + + // Run two concurrent saves for the SAME run number (exactly like the reported bug) + var tasks = new[] { SaveWithRetry(602021330), SaveWithRetry(602021330) }; + var results = await Task.WhenAll(tasks); + + var sorted = results.OrderBy(x => x).ToArray(); + sorted.Should().BeEquivalentTo(new[] { 1, 2 }, + "concurrent saves should produce unique sequential attempt numbers via retry"); + + // Verify the latest doc has the highest attempt number + var latest = await container.ReadItemAsync("latest", new PartitionKey("602021330")); + latest.Resource["attemptNumber"]!.Value().Should().Be(2); + } + + [Fact] + public async Task ConcurrentBatches_MultipleRunNumbers_MaintainsIsolation() + { + // Same as above but with two different run numbers (different partitions) + var container = new InMemoryContainer("test", "/runNumber"); + + async Task SaveWithRetry(int runNumber) + { + for (var retry = 0; retry < 10; retry++) + { + int attemptNumber; + string? latestEtag = null; + bool latestExists; + + try + { + var read = await container.ReadItemAsync("latest", new PartitionKey(runNumber.ToString())); + attemptNumber = read.Resource["attemptNumber"]!.Value() + 1; + latestEtag = read.ETag; + latestExists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + attemptNumber = 1; + latestExists = false; + } + + var runId = $"{runNumber}-{attemptNumber}"; + var batch = container.CreateTransactionalBatch(new PartitionKey(runNumber.ToString())); + + if (latestExists) + { + batch.ReplaceItem("latest", + JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber }), + new TransactionalBatchItemRequestOptions { IfMatchEtag = latestEtag }); + } + else + { + batch.CreateItem(JObject.FromObject(new { id = "latest", runNumber = runNumber.ToString(), attemptNumber })); + } + + batch.CreateItem(JObject.FromObject(new { id = runId, runNumber = runNumber.ToString(), attemptNumber })); + + var result = await batch.ExecuteAsync(); + if (result.IsSuccessStatusCode) + return attemptNumber; + + if (result.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed) + continue; + + throw new Exception($"Unexpected batch status: {result.StatusCode}"); + } + + throw new Exception("Too many retries"); + } + + var tasks = new[] + { + SaveWithRetry(100), SaveWithRetry(200), + SaveWithRetry(100), SaveWithRetry(200) + }; + var results = await Task.WhenAll(tasks); + + var run100 = new List(); + var run200 = new List(); + + // We need to figure out which result goes to which run number + // Since tasks are indexed, we know: [0]=100, [1]=200, [2]=100, [3]=200 + run100.Add(results[0]); + run100.Add(results[2]); + run200.Add(results[1]); + run200.Add(results[3]); + + run100.OrderBy(x => x).Should().BeEquivalentTo(new[] { 1, 2 }, + "run 100 should have attempt numbers 1 and 2"); + run200.OrderBy(x => x).Should().BeEquivalentTo(new[] { 1, 2 }, + "run 200 should have attempt numbers 1 and 2"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchTests.cs index 6237f9e..d86dde9 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TransactionalBatchTests.cs @@ -1,609 +1,609 @@ using System.Net; +using System.Text; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; -using Xunit; -using System.Text; using Newtonsoft.Json.Linq; +using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; public class TransactionalBatchTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_CreateMultipleItems_AllSucceed() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }); + [Fact] + public async Task Batch_CreateMultipleItems_AllSucceed() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Bob" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(2); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - response[1].StatusCode.Should().Be(HttpStatusCode.Created); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(2); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + response[1].StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task Batch_ReadItem_ExecutesSuccessfully() - { - var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; - await _container.CreateItemAsync(doc, new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReadItem_ExecutesSuccessfully() + { + var doc = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }; + await _container.CreateItemAsync(doc, new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(1); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(1); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task Batch_UpsertItem_CreatesNew() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_UpsertItem_CreatesNew() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Alice"); - } + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Alice"); + } - [Fact] - public async Task Batch_UpsertItem_UpdatesExisting() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_UpsertItem_UpdatesExisting() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alicia" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alicia" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Alicia"); - } + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Alicia"); + } - [Fact] - public async Task Batch_ReplaceItem_UpdatesExisting() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReplaceItem_UpdatesExisting() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Updated"); - } + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Updated"); + } - [Fact] - public async Task Batch_DeleteItem_RemovesItem() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_DeleteItem_RemovesItem() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task Batch_PatchItem_AppliesOperations() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchItem_AppliesOperations() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new List - { - PatchOperation.Set("/name", "Patched"), - PatchOperation.Increment("/value", 5) - }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new List + { + PatchOperation.Set("/name", "Patched"), + PatchOperation.Increment("/value", 5) + }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Patched"); - readResponse.Resource.Value.Should().Be(15); - } + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Patched"); + readResponse.Resource.Value.Should().Be(15); + } - [Fact] - public async Task Batch_MixedOperations_AllExecuteInSequence() - { - await _container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_MixedOperations_AllExecuteInSequence() + { + await _container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); - batch.ReplaceItem("existing", new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Replaced" }); - batch.ReadItem("existing"); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); + batch.ReplaceItem("existing", new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Replaced" }); + batch.ReadItem("existing"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(3); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(3); + } - [Fact] - public async Task Batch_CreateItemStream_Works() - { - var doc = new TestDocument { Id = "stream1", PartitionKey = "pk1", Name = "Stream" }; - var json = JsonConvert.SerializeObject(doc); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + [Fact] + public async Task Batch_CreateItemStream_Works() + { + var doc = new TestDocument { Id = "stream1", PartitionKey = "pk1", Name = "Stream" }; + var json = JsonConvert.SerializeObject(doc); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(stream); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(stream); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("stream1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("Stream"); - } + var readResponse = await _container.ReadItemAsync("stream1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("Stream"); + } - [Fact] - public async Task Batch_FluentApi_ChainsOperations() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")) - .CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }) - .CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); + [Fact] + public async Task Batch_FluentApi_ChainsOperations() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")) + .CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }) + .CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Count.Should().Be(2); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Count.Should().Be(2); + } - // ── Atomicity / rollback tests ── + // ── Atomicity / rollback tests ── - [Fact] - public async Task Batch_FailingOperation_RollsBackPreviousOperations() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); + [Fact] + public async Task Batch_FailingOperation_RollsBackPreviousOperations() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); + response.IsSuccessStatusCode.Should().BeFalse(); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_FailingOperation_MarksEarlierOpsAsFailedDependency() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); + [Fact] + public async Task Batch_FailingOperation_MarksEarlierOpsAsFailedDependency() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[1].StatusCode.Should().Be(HttpStatusCode.Conflict); - } + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[1].StatusCode.Should().Be(HttpStatusCode.Conflict); + } - [Fact] - public async Task Batch_ExceedingMaxOperations_ThrowsBadRequest() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 101; i++) - { - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - } + [Fact] + public async Task Batch_ExceedingMaxOperations_ThrowsBadRequest() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 101; i++) + { + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + } - var act = () => batch.ExecuteAsync(); + var act = () => batch.ExecuteAsync(); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_DeleteNonExistentItem_FailsAndRollsBack() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.DeleteItem("nonexistent"); + [Fact] + public async Task Batch_DeleteNonExistentItem_FailsAndRollsBack() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.DeleteItem("nonexistent"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); + response.IsSuccessStatusCode.Should().BeFalse(); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_UpsertItemStream_Works() - { - var doc = new TestDocument { Id = "stream1", PartitionKey = "pk1", Name = "UpsertStream" }; - var json = JsonConvert.SerializeObject(doc); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + [Fact] + public async Task Batch_UpsertItemStream_Works() + { + var doc = new TestDocument { Id = "stream1", PartitionKey = "pk1", Name = "UpsertStream" }; + var json = JsonConvert.SerializeObject(doc); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(stream); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(stream); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("stream1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("UpsertStream"); - } + var readResponse = await _container.ReadItemAsync("stream1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("UpsertStream"); + } - [Fact] - public async Task Batch_ReplaceItemStream_Works() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReplaceItemStream_Works() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamReplaced" }; - var json = JsonConvert.SerializeObject(updated); - var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var updated = new TestDocument { Id = "1", PartitionKey = "pk1", Name = "StreamReplaced" }; + var json = JsonConvert.SerializeObject(updated); + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", stream); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", stream); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - readResponse.Resource.Name.Should().Be("StreamReplaced"); - } + var readResponse = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + readResponse.Resource.Name.Should().Be("StreamReplaced"); + } } public class TransactionalBatchGapTests2 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_FailedOp_PriorOps_MarkedFailedDependency() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "first", PartitionKey = "pk1", Name = "First" }); - batch.ReadItem("nonexistent"); + [Fact] + public async Task Batch_FailedOp_PriorOps_MarkedFailedDependency() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "first", PartitionKey = "pk1", Name = "First" }); + batch.ReadItem("nonexistent"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - // First op should be marked FailedDependency after rollback - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - } + response.IsSuccessStatusCode.Should().BeFalse(); + // First op should be marked FailedDependency after rollback + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + } - [Fact] - public async Task Batch_ReplaceInBatch_NonExistent_Fails() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); + [Fact] + public async Task Batch_ReplaceInBatch_NonExistent_Fails() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } + response.IsSuccessStatusCode.Should().BeFalse(); + } - [Fact] - public async Task Batch_DeleteInBatch_NonExistent_Fails() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("nonexistent"); + [Fact] + public async Task Batch_DeleteInBatch_NonExistent_Fails() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("nonexistent"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } + response.IsSuccessStatusCode.Should().BeFalse(); + } - [Fact] - public async Task Batch_PatchInBatch_AppliesChanges() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchInBatch_AppliesChanges() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")]); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")]); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + } - [Fact] - public async Task Batch_WithIfMatch_StaleETag_FailsBatch() - { - var create = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_WithIfMatch_StaleETag_FailsBatch() + { + var create = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new TransactionalBatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = "\"stale\"" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } - [Fact] - public async Task Batch_EmptyBatch_Returns200() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + [Fact] + public async Task Batch_EmptyBatch_Returns200() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } public class TransactionalBatchGapTests3 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_SubsequentOpsAfterFailure_Also424() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "first", PartitionKey = "pk1", Name = "A" }); - batch.ReadItem("nonexistent"); // This will fail (404) - batch.CreateItem(new TestDocument { Id = "third", PartitionKey = "pk1", Name = "C" }); - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeFalse(); - // All ops marked as FailedDependency after rollback - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[2].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - } - - [Fact] - public async Task Batch_ReadResult_ContainsDocumentData() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - var result = response.GetOperationResultAtIndex(0); - result.Resource.Should().NotBeNull(); - result.Resource.Name.Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_SubsequentOpsAfterFailure_Also424() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "first", PartitionKey = "pk1", Name = "A" }); + batch.ReadItem("nonexistent"); // This will fail (404) + batch.CreateItem(new TestDocument { Id = "third", PartitionKey = "pk1", Name = "C" }); + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + // All ops marked as FailedDependency after rollback + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[2].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + } + + [Fact] + public async Task Batch_ReadResult_ContainsDocumentData() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + var result = response.GetOperationResultAtIndex(0); + result.Resource.Should().NotBeNull(); + result.Resource.Name.Should().Be("Alice"); + } } public class TransactionalBatchEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_EmptyBatch_ExecuteAsync_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + [Fact] + public async Task Batch_EmptyBatch_ExecuteAsync_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - // An empty batch should succeed with no operations - response.IsSuccessStatusCode.Should().BeTrue(); - } + // An empty batch should succeed with no operations + response.IsSuccessStatusCode.Should().BeTrue(); + } - [Fact] - public async Task Batch_PatchItem_WithIfMatch_Succeeds() - { - var createResponse = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchItem_WithIfMatch_Succeeds() + { + var createResponse = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], - new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = createResponse.ETag }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], + new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = createResponse.ETag }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + response.IsSuccessStatusCode.Should().BeTrue(); + } } public class TransactionalBatchGapTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_FailingOperation_RollsBackPrevious() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); + [Fact] + public async Task Batch_FailingOperation_RollsBackPrevious() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Duplicate" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); + response.IsSuccessStatusCode.Should().BeFalse(); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_CreateDuplicate_InBatch_Fails409() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "First" }); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "Second" }); + [Fact] + public async Task Batch_CreateDuplicate_InBatch_Fails409() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "First" }); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "Second" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } - [Fact] - public async Task Batch_Over100Operations_Throws() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 101; i++) - { - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - } + [Fact] + public async Task Batch_Over100Operations_Throws() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 101; i++) + { + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + } - var act = () => batch.ExecuteAsync(); + var act = () => batch.ExecuteAsync(); - await act.Should().ThrowAsync(); - } + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_UpsertInBatch_CreatesOrReplaces() - { - await _container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_UpsertInBatch_CreatesOrReplaces() + { + await _container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Updated" }); - batch.UpsertItem(new TestDocument { Id = "new-item", PartitionKey = "pk1", Name = "NewItem" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Updated" }); + batch.UpsertItem(new TestDocument { Id = "new-item", PartitionKey = "pk1", Name = "NewItem" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var read1 = await _container.ReadItemAsync("existing", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("Updated"); + var read1 = await _container.ReadItemAsync("existing", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("Updated"); - var read2 = await _container.ReadItemAsync("new-item", new PartitionKey("pk1")); - read2.Resource.Name.Should().Be("NewItem"); - } + var read2 = await _container.ReadItemAsync("new-item", new PartitionKey("pk1")); + read2.Resource.Name.Should().Be("NewItem"); + } - [Fact] - public async Task Batch_Rollback_RestoresExactSnapshot() - { - await _container.CreateItemAsync( - new TestDocument { Id = "pre-existing", PartitionKey = "pk1", Name = "PreExisting", Value = 42 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_Rollback_RestoresExactSnapshot() + { + await _container.CreateItemAsync( + new TestDocument { Id = "pre-existing", PartitionKey = "pk1", Name = "PreExisting", Value = 42 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "New" }); - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "New" }); + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "Bad" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); + response.IsSuccessStatusCode.Should().BeFalse(); - var read = await _container.ReadItemAsync("pre-existing", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("PreExisting"); - read.Resource.Value.Should().Be(42); + var read = await _container.ReadItemAsync("pre-existing", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("PreExisting"); + read.Resource.Value.Should().Be(42); - var act = () => _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_OperationOrder_Matters() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); + [Fact] + public async Task Batch_OperationOrder_Matters() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Replaced"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Replaced"); + } } public class TransactionalBatchGapTests4 { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_SingleOp_BehavesLikeRegularOp() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Single" }); - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(1); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Single"); - } - - [Fact] - public async Task Batch_WithRequestOptions_ExecuteAsync() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WithOptions" }); - - using var response = await batch.ExecuteAsync(new TransactionalBatchRequestOptions()); - - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(1); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("WithOptions"); - } - - [Fact] - public async Task Batch_MaxDocumentSizePerBatch_Enforced() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - // Add many items that together exceed 2MB - for (var i = 0; i < 10; i++) - { - var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total - batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); - } - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_SingleOp_BehavesLikeRegularOp() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Single" }); + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(1); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Single"); + } + + [Fact] + public async Task Batch_WithRequestOptions_ExecuteAsync() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "WithOptions" }); + + using var response = await batch.ExecuteAsync(new TransactionalBatchRequestOptions()); + + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(1); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("WithOptions"); + } + + [Fact] + public async Task Batch_MaxDocumentSizePerBatch_Enforced() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + // Add many items that together exceed 2MB + for (var i = 0; i < 10; i++) + { + var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total + batch.CreateItem(new { id = $"{i}", partitionKey = "pk1", data = largeValue }); + } + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } } @@ -611,111 +611,111 @@ public async Task Batch_MaxDocumentSizePerBatch_Enforced() public class TransactionalBatchStatusCodeTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_CreateItem_Returns201Created() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_CreateItem_Returns201Created() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task Batch_ReadItem_Returns200OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReadItem_Returns200OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task Batch_ReplaceItem_Returns200OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReplaceItem_Returns200OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task Batch_DeleteItem_Returns204NoContent() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_DeleteItem_Returns204NoContent() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.NoContent); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.NoContent); + } - [Fact] - public async Task Batch_UpsertItem_NewItem_Returns201Created() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_UpsertItem_NewItem_Returns201Created() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + } - [Fact] - public async Task Batch_UpsertItem_ExistingItem_Returns200OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_UpsertItem_ExistingItem_Returns200OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } - [Fact] - public async Task Batch_PatchItem_Returns200OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchItem_Returns200OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")]); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")]); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } } @@ -723,76 +723,76 @@ await _container.CreateItemAsync( public class TransactionalBatchRollbackIntegrityTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_FailedRollback_RestoresTimestamps() - { - // Pre-existing item count for reference - _container.DefaultTimeToLive = 3600; - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "Ghost" }); - batch.ReadItem("nonexistent"); // Will fail → rollback - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // The rolled-back item should not exist, and critically its timestamp should not leak. - // If timestamps leak, a future item with the same key could inherit a stale timestamp - // and expire prematurely via TTL. - var act = () => _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Now create the item fresh — it should get a fresh timestamp, not a leaked one - await _container.CreateItemAsync( - new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "Real" }, - new PartitionKey("pk1")); - - // Item should be readable (not expired from a leaked old timestamp) - var read = await _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Real"); - } - - [Fact] - public async Task Batch_FailedRollback_DoesNotLeakChangeFeedEntries() - { - // Get initial change feed state - var iteratorBefore = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - var beforeItems = new List(); - while (iteratorBefore.HasMoreResults) - { - var feedResponse = await iteratorBefore.ReadNextAsync(); - if (feedResponse.StatusCode == HttpStatusCode.NotModified) break; - beforeItems.AddRange(feedResponse); - } - - // Execute a batch that will fail - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "ghost1", PartitionKey = "pk1", Name = "Ghost1" }); - batch.CreateItem(new TestDocument { Id = "ghost2", PartitionKey = "pk1", Name = "Ghost2" }); - batch.ReadItem("nonexistent"); // Will fail → rollback - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // Change feed should NOT contain phantom entries for ghost1/ghost2 - var iteratorAfter = _container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), - ChangeFeedMode.Incremental); - var afterItems = new List(); - while (iteratorAfter.HasMoreResults) - { - var feedResponse = await iteratorAfter.ReadNextAsync(); - if (feedResponse.StatusCode == HttpStatusCode.NotModified) break; - afterItems.AddRange(feedResponse); - } - - afterItems.Count.Should().Be(beforeItems.Count, - "change feed should not contain phantom entries from a rolled-back batch"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_FailedRollback_RestoresTimestamps() + { + // Pre-existing item count for reference + _container.DefaultTimeToLive = 3600; + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "Ghost" }); + batch.ReadItem("nonexistent"); // Will fail → rollback + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // The rolled-back item should not exist, and critically its timestamp should not leak. + // If timestamps leak, a future item with the same key could inherit a stale timestamp + // and expire prematurely via TTL. + var act = () => _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Now create the item fresh — it should get a fresh timestamp, not a leaked one + await _container.CreateItemAsync( + new TestDocument { Id = "will-rollback", PartitionKey = "pk1", Name = "Real" }, + new PartitionKey("pk1")); + + // Item should be readable (not expired from a leaked old timestamp) + var read = await _container.ReadItemAsync("will-rollback", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Real"); + } + + [Fact] + public async Task Batch_FailedRollback_DoesNotLeakChangeFeedEntries() + { + // Get initial change feed state + var iteratorBefore = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + var beforeItems = new List(); + while (iteratorBefore.HasMoreResults) + { + var feedResponse = await iteratorBefore.ReadNextAsync(); + if (feedResponse.StatusCode == HttpStatusCode.NotModified) break; + beforeItems.AddRange(feedResponse); + } + + // Execute a batch that will fail + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "ghost1", PartitionKey = "pk1", Name = "Ghost1" }); + batch.CreateItem(new TestDocument { Id = "ghost2", PartitionKey = "pk1", Name = "Ghost2" }); + batch.ReadItem("nonexistent"); // Will fail → rollback + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Change feed should NOT contain phantom entries for ghost1/ghost2 + var iteratorAfter = _container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), + ChangeFeedMode.Incremental); + var afterItems = new List(); + while (iteratorAfter.HasMoreResults) + { + var feedResponse = await iteratorAfter.ReadNextAsync(); + if (feedResponse.StatusCode == HttpStatusCode.NotModified) break; + afterItems.AddRange(feedResponse); + } + + afterItems.Count.Should().Be(beforeItems.Count, + "change feed should not contain phantom entries from a rolled-back batch"); + } } @@ -800,43 +800,43 @@ public async Task Batch_FailedRollback_DoesNotLeakChangeFeedEntries() public class TransactionalBatchRequestOptionsTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_PatchItem_WithIfMatchEtag_StaleEtag_FailsBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchItem_WithIfMatchEtag_StaleEtag_FailsBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], - new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], + new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = "\"stale-etag\"" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - } + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + } - [Fact] - public async Task Batch_PatchItem_WithIfMatchEtag_CurrentEtag_Succeeds() - { - var createResponse = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_PatchItem_WithIfMatchEtag_CurrentEtag_Succeeds() + { + var createResponse = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original", Value = 10 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], - new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = createResponse.ETag }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched")], + new TransactionalBatchPatchItemRequestOptions { IfMatchEtag = createResponse.ETag }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + } } @@ -844,51 +844,51 @@ public async Task Batch_PatchItem_WithIfMatchEtag_CurrentEtag_Succeeds() public class TransactionalBatchSizeLimitTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_StreamOperations_ContributeToSizeLimit() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - { - var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total - var json = JsonConvert.SerializeObject(new { id = $"s{i}", partitionKey = "pk1", data = largeValue }); - var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - batch.CreateItemStream(stream); - } - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } - - [Fact] - public async Task Batch_Exactly2MB_Succeeds() - { - // Create a payload that fits within 2MB including system property overhead (~200 bytes) - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - var dataSize = (2 * 1024 * 1024) - 500; // Leave room for JSON overhead + system properties - var data = new string('a', dataSize); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = data }); - - // This should succeed — item fits within 2MB after enrichment - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_JustOver2MB_FailsEntityTooLarge() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - // Create a single item that is clearly over 2MB in batch payload - var data = new string('a', 2 * 1024 * 1024 + 1000); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = data }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_StreamOperations_ContributeToSizeLimit() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + { + var largeValue = new string('x', 300 * 1024); // 300KB each = 3MB total + var json = JsonConvert.SerializeObject(new { id = $"s{i}", partitionKey = "pk1", data = largeValue }); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + batch.CreateItemStream(stream); + } + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [Fact] + public async Task Batch_Exactly2MB_Succeeds() + { + // Create a payload that fits within 2MB including system property overhead (~200 bytes) + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var dataSize = (2 * 1024 * 1024) - 500; // Leave room for JSON overhead + system properties + var data = new string('a', dataSize); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = data }); + + // This should succeed — item fits within 2MB after enrichment + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_JustOver2MB_FailsEntityTooLarge() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + // Create a single item that is clearly over 2MB in batch payload + var data = new string('a', 2 * 1024 * 1024 + 1000); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = data }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } } @@ -896,27 +896,27 @@ public async Task Batch_JustOver2MB_FailsEntityTooLarge() public class TransactionalBatchEnumerationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_Response_CanBeEnumerated_WithForeach() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); - batch.CreateItem(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }); - - using var response = await batch.ExecuteAsync(); - - var results = new List(); - foreach (var result in response) - { - results.Add(result); - } - - results.Count.Should().Be(3); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_Response_CanBeEnumerated_WithForeach() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); + batch.CreateItem(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "C" }); + + using var response = await batch.ExecuteAsync(); + + var results = new List(); + foreach (var result in response) + { + results.Add(result); + } + + results.Count.Should().Be(3); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } } @@ -924,47 +924,47 @@ public async Task Batch_Response_CanBeEnumerated_WithForeach() public class TransactionalBatchResultMetadataTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_CreateResult_HasETag() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_CreateResult_HasETag() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Batch_ReplaceResult_HasETag() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_ReplaceResult_HasETag() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } - [Fact] - public async Task Batch_UpsertResult_HasETag() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_UpsertResult_HasETag() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } } @@ -972,109 +972,109 @@ public async Task Batch_UpsertResult_HasETag() public class TransactionalBatchIntraBatchSequenceTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_CreateThenRead_SameItemInBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.ReadItem("1"); + [Fact] + public async Task Batch_CreateThenRead_SameItemInBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.ReadItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(2); + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(2); - var readResult = response.GetOperationResultAtIndex(1); - readResult.Resource.Name.Should().Be("Alice"); - } + var readResult = response.GetOperationResultAtIndex(1); + readResult.Resource.Name.Should().Be("Alice"); + } - [Fact] - public async Task Batch_CreateThenDelete_SameItemInBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - batch.DeleteItem("1"); + [Fact] + public async Task Batch_CreateThenDelete_SameItemInBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } - [Fact] - public async Task Batch_DeleteThenCreate_SameId_InBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_DeleteThenCreate_SameId_InBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Recreated"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Recreated"); + } - [Fact] - public async Task Batch_UpsertThenPatch_SameItemInBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); - batch.PatchItem("1", [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); + [Fact] + public async Task Batch_UpsertThenPatch_SameItemInBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }); + batch.PatchItem("1", [PatchOperation.Set("/name", "Patched"), PatchOperation.Increment("/value", 5)]); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - read.Resource.Value.Should().Be(15); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + read.Resource.Value.Should().Be(15); + } - [Fact] - public async Task Batch_MultiplePatches_SameItemInBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 0 }, - new PartitionKey("pk1")); + [Fact] + public async Task Batch_MultiplePatches_SameItemInBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 0 }, + new PartitionKey("pk1")); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", [PatchOperation.Increment("/value", 10)]); - batch.PatchItem("1", [PatchOperation.Increment("/value", 20)]); - batch.PatchItem("1", [PatchOperation.Set("/name", "Triple-Patched")]); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", [PatchOperation.Increment("/value", 10)]); + batch.PatchItem("1", [PatchOperation.Increment("/value", 20)]); + batch.PatchItem("1", [PatchOperation.Set("/name", "Triple-Patched")]); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Value.Should().Be(30); - read.Resource.Name.Should().Be("Triple-Patched"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Value.Should().Be(30); + read.Resource.Name.Should().Be("Triple-Patched"); + } - [Fact] - public async Task Batch_CreateThenUpsert_SameItemInBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }); + [Fact] + public async Task Batch_CreateThenUpsert_SameItemInBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessStatusCode.Should().BeTrue(); - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Upserted"); - } + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Upserted"); + } } @@ -1082,47 +1082,47 @@ public async Task Batch_CreateThenUpsert_SameItemInBatch() public class TransactionalBatchBoundaryTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_Exactly100Operations_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 100; i++) - { - batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); - } - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(100); - } - - [Fact] - public async Task Batch_ResponseIndexer_OutOfRange_ReturnsNull() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var response = await batch.ExecuteAsync(); - - // Accessing beyond the count should return null (not throw) - var outOfRange = response[response.Count]; - outOfRange.Should().BeNull(); - } - - [Fact] - public async Task Batch_ResponseIndexer_NegativeIndex_ReturnsNull() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var response = await batch.ExecuteAsync(); - - var negativeIndex = response[-1]; - negativeIndex.Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_Exactly100Operations_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 100; i++) + { + batch.CreateItem(new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }); + } + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(100); + } + + [Fact] + public async Task Batch_ResponseIndexer_OutOfRange_ReturnsNull() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var response = await batch.ExecuteAsync(); + + // Accessing beyond the count should return null (not throw) + var outOfRange = response[response.Count]; + outOfRange.Should().BeNull(); + } + + [Fact] + public async Task Batch_ResponseIndexer_NegativeIndex_ReturnsNull() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var response = await batch.ExecuteAsync(); + + var negativeIndex = response[-1]; + negativeIndex.Should().BeNull(); + } } @@ -1130,26 +1130,26 @@ public async Task Batch_ResponseIndexer_NegativeIndex_ReturnsNull() public class TransactionalBatchExecutionTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_TwoBatches_SamePartition_Sequential_BothSucceed() - { - var batch1 = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch1.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }); - using var response1 = await batch1.ExecuteAsync(); - response1.IsSuccessStatusCode.Should().BeTrue(); - - var batch2 = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch2.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }); - using var response2 = await batch2.ExecuteAsync(); - response2.IsSuccessStatusCode.Should().BeTrue(); - - var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read1.Resource.Name.Should().Be("First"); - var read2 = await _container.ReadItemAsync("2", new PartitionKey("pk1")); - read2.Resource.Name.Should().Be("Second"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_TwoBatches_SamePartition_Sequential_BothSucceed() + { + var batch1 = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch1.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }); + using var response1 = await batch1.ExecuteAsync(); + response1.IsSuccessStatusCode.Should().BeTrue(); + + var batch2 = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch2.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Second" }); + using var response2 = await batch2.ExecuteAsync(); + response2.IsSuccessStatusCode.Should().BeTrue(); + + var read1 = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read1.Resource.Name.Should().Be("First"); + var read2 = await _container.ReadItemAsync("2", new PartitionKey("pk1")); + read2.Resource.Name.Should().Be("Second"); + } } @@ -1157,25 +1157,25 @@ public async Task Batch_TwoBatches_SamePartition_Sequential_BothSucceed() public class TransactionalBatchPartitionKeyTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_AllOperations_UseBatchPartitionKey() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_AllOperations_UseBatchPartitionKey() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); - // Item should be readable with the batch's partition key - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Alice"); + // Item should be readable with the batch's partition key + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Alice"); - // Item should NOT be readable with a different partition key - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk2")); - await act.Should().ThrowAsync(); - } + // Item should NOT be readable with a different partition key + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk2")); + await act.Should().ThrowAsync(); + } } @@ -1183,30 +1183,30 @@ public async Task Batch_AllOperations_UseBatchPartitionKey() public class TransactionalBatchResponsePropertiesTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - [Fact] - public async Task Batch_Response_RequestCharge_IsPopulated() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + [Fact] + public async Task Batch_Response_RequestCharge_IsPopulated() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.RequestCharge.Should().BeGreaterThan(0); - } + response.RequestCharge.Should().BeGreaterThan(0); + } - [Fact] - public async Task Batch_FailedResponse_RequestCharge_IsPopulated() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("nonexistent"); + [Fact] + public async Task Batch_FailedResponse_RequestCharge_IsPopulated() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("nonexistent"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.RequestCharge.Should().BeGreaterThan(0); - } + response.IsSuccessStatusCode.Should().BeFalse(); + response.RequestCharge.Should().BeGreaterThan(0); + } } @@ -1214,69 +1214,69 @@ public async Task Batch_FailedResponse_RequestCharge_IsPopulated() public class TransactionalBatchDivergentBehaviourTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_ResourceStream_AvailableOnResults() - { - // Real Cosmos DB: ResourceStream on each operation result contains the serialized document body. - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var response = await batch.ExecuteAsync(); - - response[0].ResourceStream.Should().NotBeNull(); - using var reader = new StreamReader(response[0].ResourceStream); - var json = await reader.ReadToEndAsync(); - var doc = JObject.Parse(json); - doc["name"]!.Value().Should().Be("Alice"); - } - - // Sister test documenting actual in-memory behaviour for ResourceStream - [Fact] - public async Task Batch_ResourceStream_InMemory_IsPopulatedOnResults() - { - // Previously divergent: NSubstitute mocks returned null for ResourceStream. - // Now ResourceStream is wired up and returns the serialized document body. - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].ResourceStream.Should().NotBeNull(); - } - - [Fact] - public async Task Batch_CancellationToken_Respected() - { - // Real Cosmos DB: Passing a cancelled CancellationToken to ExecuteAsync throws OperationCanceledException. - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - var act = () => batch.ExecuteAsync(cts.Token); - await act.Should().ThrowAsync(); - } - - // Sister test documenting actual in-memory behaviour for CancellationToken - [Fact] - public async Task Batch_CancellationToken_NotCancelled_Succeeds() - { - // Verify that a non-cancelled token allows the batch to complete normally. - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - - using var cts = new CancellationTokenSource(); - - using var response = await batch.ExecuteAsync(cts.Token); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Alice"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_ResourceStream_AvailableOnResults() + { + // Real Cosmos DB: ResourceStream on each operation result contains the serialized document body. + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var response = await batch.ExecuteAsync(); + + response[0].ResourceStream.Should().NotBeNull(); + using var reader = new StreamReader(response[0].ResourceStream); + var json = await reader.ReadToEndAsync(); + var doc = JObject.Parse(json); + doc["name"]!.Value().Should().Be("Alice"); + } + + // Sister test documenting actual in-memory behaviour for ResourceStream + [Fact] + public async Task Batch_ResourceStream_InMemory_IsPopulatedOnResults() + { + // Previously divergent: NSubstitute mocks returned null for ResourceStream. + // Now ResourceStream is wired up and returns the serialized document body. + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].ResourceStream.Should().NotBeNull(); + } + + [Fact] + public async Task Batch_CancellationToken_Respected() + { + // Real Cosmos DB: Passing a cancelled CancellationToken to ExecuteAsync throws OperationCanceledException. + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => batch.ExecuteAsync(cts.Token); + await act.Should().ThrowAsync(); + } + + // Sister test documenting actual in-memory behaviour for CancellationToken + [Fact] + public async Task Batch_CancellationToken_NotCancelled_Succeeds() + { + // Verify that a non-cancelled token allows the batch to complete normally. + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + + using var cts = new CancellationTokenSource(); + + using var response = await batch.ExecuteAsync(cts.Token); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Alice"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1285,85 +1285,85 @@ public async Task Batch_CancellationToken_NotCancelled_Succeeds() public class TransactionalBatchResponsePropertyDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_Response_ActivityId_IsPopulated() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.ActivityId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_Response_Diagnostics_IsNotNull() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Diagnostics.Should().NotBeNull(); - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } - - [Fact] - public async Task Batch_Response_Headers_IsNotNull() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Headers.Should().NotBeNull(); - } - - [Fact] - public async Task Batch_FailedResponse_ErrorMessage_IsPopulated() - { - await _container.CreateItemAsync( - new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "B" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.ErrorMessage.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_Response_RetryAfter_IsTimeSpanZero() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.RetryAfter.Should().Be(TimeSpan.Zero); - } - - [Fact] - public async Task Batch_FailedResponse_Count_IncludesAllOperations() - { - await _container.CreateItemAsync( - new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "ok1", PartitionKey = "pk1", Name = "B" }); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "C" }); // fails - batch.CreateItem(new TestDocument { Id = "ok2", PartitionKey = "pk1", Name = "D" }); // won't execute - - using var response = await batch.ExecuteAsync(); - response.Count.Should().Be(3); // all ops counted even though batch failed - } - - [Fact] - public async Task Batch_Response_IsDisposable() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - var response = await batch.ExecuteAsync(); - - var dispose = () => response.Dispose(); - dispose.Should().NotThrow(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_Response_ActivityId_IsPopulated() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.ActivityId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_Response_Diagnostics_IsNotNull() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Diagnostics.Should().NotBeNull(); + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } + + [Fact] + public async Task Batch_Response_Headers_IsNotNull() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Headers.Should().NotBeNull(); + } + + [Fact] + public async Task Batch_FailedResponse_ErrorMessage_IsPopulated() + { + await _container.CreateItemAsync( + new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "B" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.ErrorMessage.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_Response_RetryAfter_IsTimeSpanZero() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.RetryAfter.Should().Be(TimeSpan.Zero); + } + + [Fact] + public async Task Batch_FailedResponse_Count_IncludesAllOperations() + { + await _container.CreateItemAsync( + new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "ok1", PartitionKey = "pk1", Name = "B" }); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "C" }); // fails + batch.CreateItem(new TestDocument { Id = "ok2", PartitionKey = "pk1", Name = "D" }); // won't execute + + using var response = await batch.ExecuteAsync(); + response.Count.Should().Be(3); // all ops counted even though batch failed + } + + [Fact] + public async Task Batch_Response_IsDisposable() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + var response = await batch.ExecuteAsync(); + + var dispose = () => response.Dispose(); + dispose.Should().NotThrow(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1372,183 +1372,183 @@ public async Task Batch_Response_IsDisposable() public class TransactionalBatchOperationResultDataTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_CreateResult_ResourceStream_ContainsDocument() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Alice"); - } - - [Fact] - public async Task Batch_UpsertResult_ResourceStream_ContainsDocument() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob" }); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Bob"); - } - - [Fact] - public async Task Batch_ReplaceResult_ResourceStream_ContainsDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Updated"); - } - - [Fact] - public async Task Batch_ReadResult_ResourceStream_ContainsDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Read" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Read"); - } - - [Fact] - public async Task Batch_PatchResult_ResourceStream_ContainsDocument() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var json = await reader.ReadToEndAsync(); - json.Should().Contain("Patched"); - } - - [Fact] - public async Task Batch_PatchResult_HasETag() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); - using var response = await batch.ExecuteAsync(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_DeleteResult_ResourceStream_IsNullOrEmpty() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); - response[0].ResourceStream.Should().BeNull(); - } - - [Fact] - public async Task Batch_CreateItemStream_Result_HasETag() - { - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_CreateItemStream_Result_ResourceStream_ContainsDocument() - { - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "StreamDoc" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - var rs = response[0].ResourceStream; - rs.Should().NotBeNull(); - using var reader = new StreamReader(rs); - var content = await reader.ReadToEndAsync(); - content.Should().Contain("StreamDoc"); - } - - [Fact] - public async Task Batch_UpsertItemStream_Result_HasETag() - { - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_ReplaceItemStream_Result_HasETag() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "Replaced" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response[0].ETag.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Batch_OperationResult_IsSuccessStatusCode_TrueForSuccessOps() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response[0].IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_OperationResult_IsSuccessStatusCode_FalseForFailedOps() - { - await _container.CreateItemAsync( - new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "ok1", PartitionKey = "pk1", Name = "B" }); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "C" }); - - using var response = await batch.ExecuteAsync(); - response[0].IsSuccessStatusCode.Should().BeFalse(); // rolled back → FailedDependency - response[1].IsSuccessStatusCode.Should().BeFalse(); // 409 - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_CreateResult_ResourceStream_ContainsDocument() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Alice"); + } + + [Fact] + public async Task Batch_UpsertResult_ResourceStream_ContainsDocument() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Bob" }); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Bob"); + } + + [Fact] + public async Task Batch_ReplaceResult_ResourceStream_ContainsDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Updated"); + } + + [Fact] + public async Task Batch_ReadResult_ResourceStream_ContainsDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Read" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Read"); + } + + [Fact] + public async Task Batch_PatchResult_ResourceStream_ContainsDocument() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var json = await reader.ReadToEndAsync(); + json.Should().Contain("Patched"); + } + + [Fact] + public async Task Batch_PatchResult_HasETag() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); + using var response = await batch.ExecuteAsync(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_DeleteResult_ResourceStream_IsNullOrEmpty() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + using var response = await batch.ExecuteAsync(); + response[0].ResourceStream.Should().BeNull(); + } + + [Fact] + public async Task Batch_CreateItemStream_Result_HasETag() + { + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_CreateItemStream_Result_ResourceStream_ContainsDocument() + { + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "StreamDoc" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + var rs = response[0].ResourceStream; + rs.Should().NotBeNull(); + using var reader = new StreamReader(rs); + var content = await reader.ReadToEndAsync(); + content.Should().Contain("StreamDoc"); + } + + [Fact] + public async Task Batch_UpsertItemStream_Result_HasETag() + { + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_ReplaceItemStream_Result_HasETag() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "Replaced" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response[0].ETag.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Batch_OperationResult_IsSuccessStatusCode_TrueForSuccessOps() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response[0].IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_OperationResult_IsSuccessStatusCode_FalseForFailedOps() + { + await _container.CreateItemAsync( + new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "ok1", PartitionKey = "pk1", Name = "B" }); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "C" }); + + using var response = await batch.ExecuteAsync(); + response[0].IsSuccessStatusCode.Should().BeFalse(); // rolled back → FailedDependency + response[1].IsSuccessStatusCode.Should().BeFalse(); // 409 + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1557,70 +1557,70 @@ await _container.CreateItemAsync( public class TransactionalBatchRequestOptionsDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_ReplaceItem_WithIfMatchEtag_CurrentEtag_Succeeds() - { - var created = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_UpsertItem_WithIfMatchEtag_StaleEtag_Fails() - { - var created = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - // Update to make the original ETag stale - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - "1", new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C" }, - new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_DeleteItem_WithIfMatchEtag_StaleEtag_Fails() - { - var created = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await _container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, - "1", new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_DeleteItem_WithIfMatchEtag_CurrentEtag_Succeeds() - { - var created = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_ReplaceItem_WithIfMatchEtag_CurrentEtag_Succeeds() + { + var created = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_UpsertItem_WithIfMatchEtag_StaleEtag_Fails() + { + var created = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + // Update to make the original ETag stale + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + "1", new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "C" }, + new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_DeleteItem_WithIfMatchEtag_StaleEtag_Fails() + { + var created = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await _container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "B" }, + "1", new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_DeleteItem_WithIfMatchEtag_CurrentEtag_Succeeds() + { + var created = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1", new TransactionalBatchItemRequestOptions { IfMatchEtag = created.ETag }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1629,48 +1629,48 @@ public async Task Batch_DeleteItem_WithIfMatchEtag_CurrentEtag_Succeeds() public class TransactionalBatchErrorScenarioTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_PatchNonExistentItem_FailsBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - batch.PatchItem("nonexistent", new[] { PatchOperation.Replace("/name", "X") }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); // rolled back - } - - [Fact] - public async Task Batch_ExceedingMaxOps_ThrowsBadRequest_MessageContainsLimit() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i <= 100; i++) - batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); - - var act = () => batch.ExecuteAsync(); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Batch_CreateThenCreate_SameId_FailsAndRollsBack() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "First" }); - batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "Second" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[1].StatusCode.Should().Be(HttpStatusCode.Conflict); - - // Verify rollback — item should not exist - var act = () => _container.ReadItemAsync("dup", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_PatchNonExistentItem_FailsBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + batch.PatchItem("nonexistent", new[] { PatchOperation.Replace("/name", "X") }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); // rolled back + } + + [Fact] + public async Task Batch_ExceedingMaxOps_ThrowsBadRequest_MessageContainsLimit() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i <= 100; i++) + batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); + + var act = () => batch.ExecuteAsync(); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Batch_CreateThenCreate_SameId_FailsAndRollsBack() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "First" }); + batch.CreateItem(new TestDocument { Id = "dup", PartitionKey = "pk1", Name = "Second" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[1].StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Verify rollback — item should not exist + var act = () => _container.ReadItemAsync("dup", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1679,52 +1679,52 @@ public async Task Batch_CreateThenCreate_SameId_FailsAndRollsBack() public class TransactionalBatchUniqueKeyTests { - [Fact] - public async Task Batch_CreateItem_UniqueKeyViolation_FailsBatch() - { - var containerProps = new ContainerProperties("uk-batch", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(containerProps); - - await container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "UniqueMe" }, - new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "UniqueMe" }); // violates unique key - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_UpsertItem_IntoUniqueKeyConstraint_Succeeds() - { - var containerProps = new ContainerProperties("uk-batch", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(containerProps); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueMe" }, - new PartitionKey("pk1")); - - // Upsert same id with same unique key value — should succeed - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueMe" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + [Fact] + public async Task Batch_CreateItem_UniqueKeyViolation_FailsBatch() + { + var containerProps = new ContainerProperties("uk-batch", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(containerProps); + + await container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "UniqueMe" }, + new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "UniqueMe" }); // violates unique key + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_UpsertItem_IntoUniqueKeyConstraint_Succeeds() + { + var containerProps = new ContainerProperties("uk-batch", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(containerProps); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueMe" }, + new PartitionKey("pk1")); + + // Upsert same id with same unique key value — should succeed + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueMe" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1733,125 +1733,125 @@ await container.CreateItemAsync( public class TransactionalBatchComplexSequenceTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_CreateThenPatch_SameItemInBatch() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Initial" }); - batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Batch_ReplaceThenDelete_SameItemInBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - batch.DeleteItem("1"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Batch_ReplaceThenRead_SameItemInBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); - batch.ReadItem("1"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var result = response.GetOperationResultAtIndex(1); - result.Resource.Name.Should().Be("New"); - } - - [Fact] - public async Task Batch_PatchThenRead_SameItemInBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); - batch.ReadItem("1"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var result = response.GetOperationResultAtIndex(1); - result.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Batch_DeleteThenRead_SameId_FailsBatch() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - batch.ReadItem("1"); // Should find nothing → 404 → fail - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // Item should be restored by rollback - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("A"); - } - - [Fact] - public async Task Batch_CreateThenReplace_ThenPatch_ThenRead_SameItem() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); - batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); - batch.ReadItem("1"); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var result = response.GetOperationResultAtIndex(3); - result.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Batch_UpsertThenDelete_ThenUpsert_SameItem() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }); - batch.DeleteItem("1"); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Recreated"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_CreateThenPatch_SameItemInBatch() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Initial" }); + batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Batch_ReplaceThenDelete_SameItemInBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + batch.DeleteItem("1"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Batch_ReplaceThenRead_SameItemInBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); + batch.ReadItem("1"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var result = response.GetOperationResultAtIndex(1); + result.Resource.Name.Should().Be("New"); + } + + [Fact] + public async Task Batch_PatchThenRead_SameItemInBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); + batch.ReadItem("1"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var result = response.GetOperationResultAtIndex(1); + result.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Batch_DeleteThenRead_SameId_FailsBatch() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + batch.ReadItem("1"); // Should find nothing → 404 → fail + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Item should be restored by rollback + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("A"); + } + + [Fact] + public async Task Batch_CreateThenReplace_ThenPatch_ThenRead_SameItem() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Created" }); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }); + batch.PatchItem("1", new[] { PatchOperation.Replace("/name", "Patched") }); + batch.ReadItem("1"); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var result = response.GetOperationResultAtIndex(3); + result.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Batch_UpsertThenDelete_ThenUpsert_SameItem() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" }); + batch.DeleteItem("1"); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Recreated"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1860,56 +1860,56 @@ public async Task Batch_UpsertThenDelete_ThenUpsert_SameItem() public class TransactionalBatchStreamDeepTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_UpsertItemStream_UpdatesExisting() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "Updated" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task Batch_ReplaceItemStream_NonExistent_FailsBatch() - { - var json = JsonConvert.SerializeObject(new { id = "missing", partitionKey = "pk1", name = "X" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("missing", new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_CreateItemStream_ReturnsCorrectStatusCode() - { - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_MixedStreamAndTyped_AllSucceed() - { - var json = JsonConvert.SerializeObject(new { id = "2", partitionKey = "pk1", name = "StreamDoc" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "TypedDoc" }); - batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response.Count.Should().Be(2); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_UpsertItemStream_UpdatesExisting() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "Updated" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task Batch_ReplaceItemStream_NonExistent_FailsBatch() + { + var json = JsonConvert.SerializeObject(new { id = "missing", partitionKey = "pk1", name = "X" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("missing", new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_CreateItemStream_ReturnsCorrectStatusCode() + { + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "A" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_MixedStreamAndTyped_AllSucceed() + { + var json = JsonConvert.SerializeObject(new { id = "2", partitionKey = "pk1", name = "StreamDoc" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "TypedDoc" }); + batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response.Count.Should().Be(2); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1918,52 +1918,52 @@ public async Task Batch_MixedStreamAndTyped_AllSucceed() public class TransactionalBatchChangeFeedIntegrationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_SuccessfulCreates_AppearInChangeFeed() - { - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var iter = _container.GetChangeFeedIterator(checkpoint); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - changes.AddRange(page); - } - changes.Should().HaveCountGreaterThanOrEqualTo(2); - } - - [Fact] - public async Task Batch_SuccessfulDeletes_AppearAsChangeFeedTombstones() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - - var checkpoint = _container.GetChangeFeedCheckpoint(); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var iter = _container.GetChangeFeedIterator(checkpoint); - var changes = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - changes.AddRange(page); - } - changes.Should().NotBeEmpty(); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_SuccessfulCreates_AppearInChangeFeed() + { + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var iter = _container.GetChangeFeedIterator(checkpoint); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + changes.AddRange(page); + } + changes.Should().HaveCountGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task Batch_SuccessfulDeletes_AppearAsChangeFeedTombstones() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + + var checkpoint = _container.GetChangeFeedCheckpoint(); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var iter = _container.GetChangeFeedIterator(checkpoint); + var changes = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + changes.AddRange(page); + } + changes.Should().NotBeEmpty(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1972,59 +1972,61 @@ await _container.CreateItemAsync( public class TransactionalBatchSerializationTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_CreateItem_WithNestedObject_Succeeds() - { - var doc = new TestDocument - { - Id = "1", PartitionKey = "pk1", Name = "A", - Nested = new NestedObject { Description = "nested", Score = 9.5 } - }; - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(doc); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Nested!.Description.Should().Be("nested"); - } - - [Fact] - public async Task Batch_CreateItem_WithSpecialCharactersInId_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "special-id_123.test", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("special-id_123.test", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("A"); - } - - [Fact] - public async Task Batch_CreateItem_WithNullProperties_Succeeds() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = null! }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_Stream_UTF8_Encoding_Preserved() - { - var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "héllo wörld" }); - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("héllo wörld"); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_CreateItem_WithNestedObject_Succeeds() + { + var doc = new TestDocument + { + Id = "1", + PartitionKey = "pk1", + Name = "A", + Nested = new NestedObject { Description = "nested", Score = 9.5 } + }; + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(doc); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Nested!.Description.Should().Be("nested"); + } + + [Fact] + public async Task Batch_CreateItem_WithSpecialCharactersInId_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "special-id_123.test", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("special-id_123.test", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("A"); + } + + [Fact] + public async Task Batch_CreateItem_WithNullProperties_Succeeds() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = null! }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_Stream_UTF8_Encoding_Preserved() + { + var json = JsonConvert.SerializeObject(new { id = "1", partitionKey = "pk1", name = "héllo wörld" }); + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = await _container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("héllo wörld"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2033,24 +2035,24 @@ public async Task Batch_Stream_UTF8_Encoding_Preserved() public class TransactionalBatch2MBErrorPathTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_Over2MB_ResponseIndexer_ReturnsResults() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - var bigString = new string('A', 1024 * 1024); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = bigString }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = bigString }); - batch.CreateItem(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "small" }); - - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); - response.IsSuccessStatusCode.Should().BeFalse(); - response.Count.Should().Be(3); - response[0].Should().NotBeNull(); - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_Over2MB_ResponseIndexer_ReturnsResults() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + var bigString = new string('A', 1024 * 1024); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = bigString }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = bigString }); + batch.CreateItem(new TestDocument { Id = "3", PartitionKey = "pk1", Name = "small" }); + + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + response.IsSuccessStatusCode.Should().BeFalse(); + response.Count.Should().Be(3); + response[0].Should().NotBeNull(); + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2059,73 +2061,73 @@ public async Task Batch_Over2MB_ResponseIndexer_ReturnsResults() public class TransactionalBatchSkippedAndDivergentTests { - private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); - - [Fact] - public async Task Batch_PartitionKeyMismatch_Document_Vs_BatchPK_ThrowsBadRequest() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk2", Name = "A" }); // doc says pk2, batch says pk1 - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact(Skip = "Divergent: emulator diagnostics returns zero elapsed time")] - public async Task Batch_Diagnostics_ContainsRequestLatency() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Diagnostics.GetClientElapsedTime().Should().BeGreaterThan(TimeSpan.Zero); - } - - [Fact] - public async Task Batch_Diagnostics_InMemory_ReturnsZeroElapsedTime() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); - } - - [Fact(Skip = "Divergent: emulator uses synthetic session tokens")] - public async Task Batch_Headers_ContainSessionToken() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.Headers.Session.Should().NotBeNullOrEmpty(); - response.Headers.Session.Should().MatchRegex(@"\d+:#\d+"); - } - - [Fact(Skip = "Divergent: emulator always returns 1.0 RU")] - public async Task Batch_RequestCharge_ScalesWithOperationCount() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.RequestCharge.Should().BeGreaterThan(10d); // real Cosmos: ~5.3 RU per create - } - - [Fact] - public async Task Batch_RequestCharge_InMemory_AlwaysReturns1RU() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - for (var i = 0; i < 10; i++) - batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); - using var response = await batch.ExecuteAsync(); - response.RequestCharge.Should().Be(1.0); - } - - [Fact(Skip = "Divergent: emulator batch is not isolated from concurrent direct CRUD")] - public async Task Batch_ConcurrentBatchAndDirectCrud_IsolationGuaranteed() - { - // Real Cosmos: batch execution is serialized within a partition key - // Emulator: snapshot/restore but no global lock - await Task.CompletedTask; - } + private readonly InMemoryContainer _container = new("test-container", "/partitionKey"); + + [Fact] + public async Task Batch_PartitionKeyMismatch_Document_Vs_BatchPK_ThrowsBadRequest() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk2", Name = "A" }); // doc says pk2, batch says pk1 + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact(Skip = "Divergent: emulator diagnostics returns zero elapsed time")] + public async Task Batch_Diagnostics_ContainsRequestLatency() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Diagnostics.GetClientElapsedTime().Should().BeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public async Task Batch_Diagnostics_InMemory_ReturnsZeroElapsedTime() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Diagnostics.GetClientElapsedTime().Should().Be(TimeSpan.Zero); + } + + [Fact(Skip = "Divergent: emulator uses synthetic session tokens")] + public async Task Batch_Headers_ContainSessionToken() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.Headers.Session.Should().NotBeNullOrEmpty(); + response.Headers.Session.Should().MatchRegex(@"\d+:#\d+"); + } + + [Fact(Skip = "Divergent: emulator always returns 1.0 RU")] + public async Task Batch_RequestCharge_ScalesWithOperationCount() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.RequestCharge.Should().BeGreaterThan(10d); // real Cosmos: ~5.3 RU per create + } + + [Fact] + public async Task Batch_RequestCharge_InMemory_AlwaysReturns1RU() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + for (var i = 0; i < 10; i++) + batch.CreateItem(new TestDocument { Id = i.ToString(), PartitionKey = "pk1", Name = "A" }); + using var response = await batch.ExecuteAsync(); + response.RequestCharge.Should().Be(1.0); + } + + [Fact(Skip = "Divergent: emulator batch is not isolated from concurrent direct CRUD")] + public async Task Batch_ConcurrentBatchAndDirectCrud_IsolationGuaranteed() + { + // Real Cosmos: batch execution is serialized within a partition key + // Emulator: snapshot/restore but no global lock + await Task.CompletedTask; + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -2135,484 +2137,484 @@ public async Task Batch_ConcurrentBatchAndDirectCrud_IsolationGuaranteed() // ── Bug Fix Verification ── public class TransactionalBatchBugFixTests { - private readonly InMemoryContainer _container = new("batch-bugfix", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Batch_UpsertItemStream_NewItem_Returns201Created() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_UpsertItemStream_ExistingItem_Returns200OK() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_CreateItemStream_ResourceStream_ContainsSystemProperties() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - using var reader = new StreamReader(response[0].ResourceStream); - var body = JObject.Parse(reader.ReadToEnd()); - body["_ts"].Should().NotBeNull("system property _ts should be present"); - body["_etag"].Should().NotBeNull("system property _etag should be present"); - } - - [Fact] - public async Task Batch_UpsertItemStream_ResourceStream_ContainsSystemProperties() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); - using var response = await batch.ExecuteAsync(); - - using var reader = new StreamReader(response[0].ResourceStream); - var body = JObject.Parse(reader.ReadToEnd()); - body["_ts"].Should().NotBeNull(); - body["_etag"].Should().NotBeNull(); - } - - [Fact] - public async Task Batch_ReplaceItemStream_ResourceStream_ContainsSystemProperties() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")); - using var response = await batch.ExecuteAsync(); - - using var reader = new StreamReader(response[0].ResourceStream); - var body = JObject.Parse(reader.ReadToEnd()); - body["_ts"].Should().NotBeNull(); - body["_etag"].Should().NotBeNull(); - } + private readonly InMemoryContainer _container = new("batch-bugfix", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Batch_UpsertItemStream_NewItem_Returns201Created() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_UpsertItemStream_ExistingItem_Returns200OK() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_CreateItemStream_ResourceStream_ContainsSystemProperties() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + using var reader = new StreamReader(response[0].ResourceStream); + var body = JObject.Parse(reader.ReadToEnd()); + body["_ts"].Should().NotBeNull("system property _ts should be present"); + body["_etag"].Should().NotBeNull("system property _etag should be present"); + } + + [Fact] + public async Task Batch_UpsertItemStream_ResourceStream_ContainsSystemProperties() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItemStream(ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Alice\"}")); + using var response = await batch.ExecuteAsync(); + + using var reader = new StreamReader(response[0].ResourceStream); + var body = JObject.Parse(reader.ReadToEnd()); + body["_ts"].Should().NotBeNull(); + body["_etag"].Should().NotBeNull(); + } + + [Fact] + public async Task Batch_ReplaceItemStream_ResourceStream_ContainsSystemProperties() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", ToStream("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")); + using var response = await batch.ExecuteAsync(); + + using var reader = new StreamReader(response[0].ResourceStream); + var body = JObject.Parse(reader.ReadToEnd()); + body["_ts"].Should().NotBeNull(); + body["_etag"].Should().NotBeNull(); + } } // ── Request Options Extended ── public class TransactionalBatchRequestOptionsExtendedTests { - private readonly InMemoryContainer _container = new("batch-reqopt", "/partitionKey"); - - [Fact] - public async Task Batch_PatchItem_WithFilterPredicate_MatchingFilter_Succeeds() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Updated") }, - new TransactionalBatchPatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Alice'" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_PatchItem_WithFilterPredicate_NonMatchingFilter_Fails() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Updated") }, - new TransactionalBatchPatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'NonExistent'" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_ReplaceItemStream_WithIfMatchEtag_StaleEtag_Fails() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", - new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")), - new TransactionalBatchItemRequestOptions { IfMatchEtag = "stale-etag" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_ReplaceItemStream_WithIfMatchEtag_CurrentEtag_Succeeds() - { - var createResp = await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - var currentEtag = createResp.ETag; - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItemStream("1", - new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")), - new TransactionalBatchItemRequestOptions { IfMatchEtag = currentEtag }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("batch-reqopt", "/partitionKey"); + + [Fact] + public async Task Batch_PatchItem_WithFilterPredicate_MatchingFilter_Succeeds() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Updated") }, + new TransactionalBatchPatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'Alice'" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_PatchItem_WithFilterPredicate_NonMatchingFilter_Fails() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Updated") }, + new TransactionalBatchPatchItemRequestOptions { FilterPredicate = "FROM c WHERE c.name = 'NonExistent'" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_ReplaceItemStream_WithIfMatchEtag_StaleEtag_Fails() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", + new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")), + new TransactionalBatchItemRequestOptions { IfMatchEtag = "stale-etag" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_ReplaceItemStream_WithIfMatchEtag_CurrentEtag_Succeeds() + { + var createResp = await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + var currentEtag = createResp.ETag; + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItemStream("1", + new MemoryStream(Encoding.UTF8.GetBytes("{\"id\":\"1\",\"partitionKey\":\"pk1\",\"name\":\"Updated\"}")), + new TransactionalBatchItemRequestOptions { IfMatchEtag = currentEtag }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + } } // ── Edge Cases Extended ── public class TransactionalBatchEdgeCaseExtendedTests { - private readonly InMemoryContainer _container = new("batch-edge", "/partitionKey"); - - private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); - - [Fact] - public async Task Batch_FirstOperationFails_AllSubsequent424() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - // Replace non-existent item (fails) - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - response[1].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[2].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - } - - [Fact] - public async Task Batch_LastOperationFails_AllPrior424() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); - // Replace non-existent item (fails) - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[1].StatusCode.Should().Be(HttpStatusCode.FailedDependency); - response[2].StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Batch_StreamWithInvalidJson_ThrowsOnExecute() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(ToStream("not valid json")); - var act = async () => await batch.ExecuteAsync(); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Batch_CreateItemStream_MissingIdField_AutoGeneratesId() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItemStream(ToStream("{\"partitionKey\":\"pk1\",\"name\":\"NoId\"}")); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - response[0].StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_SingleItemFails_ResponseHasOneEntry() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); - - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - response.Count.Should().Be(1); - response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private readonly InMemoryContainer _container = new("batch-edge", "/partitionKey"); + + private static MemoryStream ToStream(string json) => new(Encoding.UTF8.GetBytes(json)); + + [Fact] + public async Task Batch_FirstOperationFails_AllSubsequent424() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + // Replace non-existent item (fails) + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + response[1].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[2].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + } + + [Fact] + public async Task Batch_LastOperationFails_AllPrior424() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "B" }); + // Replace non-existent item (fails) + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response[0].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[1].StatusCode.Should().Be(HttpStatusCode.FailedDependency); + response[2].StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Batch_StreamWithInvalidJson_ThrowsOnExecute() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(ToStream("not valid json")); + var act = async () => await batch.ExecuteAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Batch_CreateItemStream_MissingIdField_AutoGeneratesId() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItemStream(ToStream("{\"partitionKey\":\"pk1\",\"name\":\"NoId\"}")); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + response[0].StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_SingleItemFails_ResponseHasOneEntry() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); + + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + response.Count.Should().Be(1); + response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ── TTL Tests ── public class TransactionalBatchTtlExtendedTests { - [Fact] - public async Task Batch_CreateItem_WithContainerTtl_ItemGetsTimestamp() - { - var container = new InMemoryContainer("batch-ttl", "/partitionKey"); - container.DefaultTimeToLive = 3600; - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource["_ts"].Should().NotBeNull(); - } - - [Fact] - public async Task Batch_CreateItem_WithContainerTtl_ItemExpires() - { - var container = new InMemoryContainer("batch-ttl2", "/partitionKey"); - container.DefaultTimeToLive = 1; - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - await Task.Delay(2000); - var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - readResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Batch_Rollback_DoesNotLeakTtlMetadata() - { - var container = new InMemoryContainer("batch-ttl3", "/partitionKey"); - container.DefaultTimeToLive = 3600; - - await container.CreateItemAsync( - new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); - // This will fail — non-existent item - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // Original item should still be there and unchanged - var item = await container.ReadItemAsync("existing", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("Original"); - } + [Fact] + public async Task Batch_CreateItem_WithContainerTtl_ItemGetsTimestamp() + { + var container = new InMemoryContainer("batch-ttl", "/partitionKey"); + container.DefaultTimeToLive = 3600; + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource["_ts"].Should().NotBeNull(); + } + + [Fact] + public async Task Batch_CreateItem_WithContainerTtl_ItemExpires() + { + var container = new InMemoryContainer("batch-ttl2", "/partitionKey"); + container.DefaultTimeToLive = 1; + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + await Task.Delay(2000); + var readResponse = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + readResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Batch_Rollback_DoesNotLeakTtlMetadata() + { + var container = new InMemoryContainer("batch-ttl3", "/partitionKey"); + container.DefaultTimeToLive = 3600; + + await container.CreateItemAsync( + new TestDocument { Id = "existing", PartitionKey = "pk1", Name = "Original" }, new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "new1", PartitionKey = "pk1", Name = "New" }); + // This will fail — non-existent item + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Original item should still be there and unchanged + var item = await container.ReadItemAsync("existing", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("Original"); + } } // ── Unique Key Extended ── public class TransactionalBatchUniqueKeyExtendedTests { - [Fact] - public async Task Batch_Rollback_FreesUniqueKeySlots_RecreateSucceeds() - { - var props = new ContainerProperties("batch-uk1", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } - }; - var container = new InMemoryContainer(props); - - // Batch: create unique item, then fail - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }); - batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - // Now create with same unique key — should succeed since batch was rolled back - var batch2 = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch2.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }); - using var response2 = await batch2.ExecuteAsync(); - response2.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_IntraBatch_TwoItems_SameUniqueKey_SecondFails() - { - var props = new ContainerProperties("batch-uk2", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } - }; - var container = new InMemoryContainer(props); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "SameName" }); - batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "SameName" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Fact] - public async Task Batch_UpsertAsCreate_ConflictingUniqueKey_Fails() - { - var props = new ContainerProperties("batch-uk3", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } - }; - var container = new InMemoryContainer(props); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }, new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "UniqueAlice" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - } + [Fact] + public async Task Batch_Rollback_FreesUniqueKeySlots_RecreateSucceeds() + { + var props = new ContainerProperties("batch-uk1", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } + }; + var container = new InMemoryContainer(props); + + // Batch: create unique item, then fail + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }); + batch.ReplaceItem("nonexistent", new TestDocument { Id = "nonexistent", PartitionKey = "pk1", Name = "X" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + // Now create with same unique key — should succeed since batch was rolled back + var batch2 = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch2.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }); + using var response2 = await batch2.ExecuteAsync(); + response2.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_IntraBatch_TwoItems_SameUniqueKey_SecondFails() + { + var props = new ContainerProperties("batch-uk2", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } + }; + var container = new InMemoryContainer(props); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "SameName" }); + batch.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "SameName" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Fact] + public async Task Batch_UpsertAsCreate_ConflictingUniqueKey_Fails() + { + var props = new ContainerProperties("batch-uk3", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy { UniqueKeys = { new UniqueKey { Paths = { "/name" } } } } + }; + var container = new InMemoryContainer(props); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "UniqueAlice" }, new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "2", PartitionKey = "pk1", Name = "UniqueAlice" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + } } // ── GetOperationResultAtIndex ── public class TransactionalBatchGetOperationResultTests { - private readonly InMemoryContainer _container = new("batch-getop", "/partitionKey"); - - [Fact] - public async Task Batch_GetOperationResultAtIndex_Create_ReturnsTypedResource() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - - var result = response.GetOperationResultAtIndex(0); - result.Resource.Id.Should().Be("1"); - result.Resource.Name.Should().Be("Alice"); - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_GetOperationResultAtIndex_Upsert_ReturnsTypedResource() - { - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - - var result = response.GetOperationResultAtIndex(0); - result.Resource.Id.Should().Be("1"); - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Batch_GetOperationResultAtIndex_Replace_ReturnsTypedResource() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); - using var response = await batch.ExecuteAsync(); - - var result = response.GetOperationResultAtIndex(0); - result.Resource.Name.Should().Be("Updated"); - result.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_GetOperationResultAtIndex_Patch_ReturnsTypedResource() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Patched") }); - using var response = await batch.ExecuteAsync(); - - var result = response.GetOperationResultAtIndex(0); - result.Resource.Name.Should().Be("Patched"); - result.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_GetOperationResultAtIndex_Delete_ResourceIsDefault() - { - await _container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); - - var result = response.GetOperationResultAtIndex(0); - result.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("batch-getop", "/partitionKey"); + + [Fact] + public async Task Batch_GetOperationResultAtIndex_Create_ReturnsTypedResource() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + + var result = response.GetOperationResultAtIndex(0); + result.Resource.Id.Should().Be("1"); + result.Resource.Name.Should().Be("Alice"); + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_GetOperationResultAtIndex_Upsert_ReturnsTypedResource() + { + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + + var result = response.GetOperationResultAtIndex(0); + result.Resource.Id.Should().Be("1"); + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Batch_GetOperationResultAtIndex_Replace_ReturnsTypedResource() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }); + using var response = await batch.ExecuteAsync(); + + var result = response.GetOperationResultAtIndex(0); + result.Resource.Name.Should().Be("Updated"); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_GetOperationResultAtIndex_Patch_ReturnsTypedResource() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice", Value = 10 }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] { PatchOperation.Set("/name", "Patched") }); + using var response = await batch.ExecuteAsync(); + + var result = response.GetOperationResultAtIndex(0); + result.Resource.Name.Should().Be("Patched"); + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_GetOperationResultAtIndex_Delete_ResourceIsDefault() + { + await _container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }, new PartitionKey("pk1")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + using var response = await batch.ExecuteAsync(); + + var result = response.GetOperationResultAtIndex(0); + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } // ── Concurrent Batches ── public class TransactionalBatchConcurrentExtendedTests { - [Fact] - public async Task Batch_TwoConcurrentBatches_DifferentPartitions_BothSucceed() - { - var container = new InMemoryContainer("batch-conc", "/partitionKey"); - - var batch1 = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch1.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); - var batch2 = container.CreateTransactionalBatch(new PartitionKey("pk2")); - batch2.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }); - - var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); - results[0].IsSuccessStatusCode.Should().BeTrue(); - results[1].IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_ThenDirectCrud_StateConsistent() - { - var container = new InMemoryContainer("batch-state", "/partitionKey"); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - // Direct CRUD should see the batch result - var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource.Name.Should().Be("Alice"); - - // Direct update should succeed - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, "1", new PartitionKey("pk1")); - var updated = await container.ReadItemAsync("1", new PartitionKey("pk1")); - updated.Resource.Name.Should().Be("Updated"); - } + [Fact] + public async Task Batch_TwoConcurrentBatches_DifferentPartitions_BothSucceed() + { + var container = new InMemoryContainer("batch-conc", "/partitionKey"); + + var batch1 = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch1.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }); + var batch2 = container.CreateTransactionalBatch(new PartitionKey("pk2")); + batch2.CreateItem(new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }); + + var results = await Task.WhenAll(batch1.ExecuteAsync(), batch2.ExecuteAsync()); + results[0].IsSuccessStatusCode.Should().BeTrue(); + results[1].IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_ThenDirectCrud_StateConsistent() + { + var container = new InMemoryContainer("batch-state", "/partitionKey"); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" }); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + // Direct CRUD should see the batch result + var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource.Name.Should().Be("Alice"); + + // Direct update should succeed + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, "1", new PartitionKey("pk1")); + var updated = await container.ReadItemAsync("1", new PartitionKey("pk1")); + updated.Resource.Name.Should().Be("Updated"); + } } // ── Miscellaneous ── public class TransactionalBatchMiscExtendedTests { - [Fact] - public async Task Batch_PatchItem_AllFivePatchTypes_InSingleBatch() - { - var container = new InMemoryContainer("batch-allpatch", "/partitionKey"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", counter = 10, tags = new[] { "a" }, extra = "old" }), - new PartitionKey("pk1")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new[] - { - PatchOperation.Set("/name", "Updated"), // Set + [Fact] + public async Task Batch_PatchItem_AllFivePatchTypes_InSingleBatch() + { + var container = new InMemoryContainer("batch-allpatch", "/partitionKey"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Alice", counter = 10, tags = new[] { "a" }, extra = "old" }), + new PartitionKey("pk1")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new[] + { + PatchOperation.Set("/name", "Updated"), // Set PatchOperation.Replace("/extra", "new"), // Replace PatchOperation.Add("/added", "newField"), // Add PatchOperation.Remove("/tags"), // Remove PatchOperation.Increment("/counter", 5), // Increment }); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource["name"]!.Value().Should().Be("Updated"); - item.Resource["extra"]!.Value().Should().Be("new"); - item.Resource["added"]!.Value().Should().Be("newField"); - item.Resource["tags"].Should().BeNull(); - item.Resource["counter"]!.Value().Should().Be(15); - } - - [Fact] - public async Task Batch_NestedPartitionKeyPath_WorksCorrectly() - { - var container = new InMemoryContainer("batch-nested-pk", "/metadata/partitionKey"); - var doc = JObject.FromObject(new { id = "1", metadata = new { partitionKey = "pk1" }, name = "Alice" }); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(doc); - using var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); - item.Resource["name"]!.Value().Should().Be("Alice"); - } + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource["name"]!.Value().Should().Be("Updated"); + item.Resource["extra"]!.Value().Should().Be("new"); + item.Resource["added"]!.Value().Should().Be("newField"); + item.Resource["tags"].Should().BeNull(); + item.Resource["counter"]!.Value().Should().Be(15); + } + + [Fact] + public async Task Batch_NestedPartitionKeyPath_WorksCorrectly() + { + var container = new InMemoryContainer("batch-nested-pk", "/metadata/partitionKey"); + var doc = JObject.FromObject(new { id = "1", metadata = new { partitionKey = "pk1" }, name = "Alice" }); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(doc); + using var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var item = await container.ReadItemAsync("1", new PartitionKey("pk1")); + item.Resource["name"]!.Value().Should().Be("Alice"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TriggerTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TriggerTests.cs index 9fca5cc..24db052 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TriggerTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TriggerTests.cs @@ -15,654 +15,654 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class TriggerRegistrationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public void RegisterTrigger_PreTrigger_StoresHandler() - { - _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["added"] = true; - return doc; - })); - - // Trigger is registered — we verify it fires in Phase 2 tests - // For now, just verify no exception + deregister works - _container.DeregisterTrigger("addField"); - } - - [Fact] - public void DeregisterTrigger_RemovesTrigger() - { - _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => doc)); - - _container.DeregisterTrigger("myTrigger"); - - // Using a deregistered trigger should fail - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateTriggerAsync_StoresTriggerProperties() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function() { /* JS body */ }" - }); - - // Register a C# handler for this trigger - _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["modified"] = true; - return doc; - })); - - // Now the trigger should fire - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["modified"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task ReadTriggerAsync_ReturnsStoredTrigger() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function() {}" - }); - - var response = await _container.Scripts.ReadTriggerAsync("myTrigger"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Resource.Id.Should().Be("myTrigger"); - response.Resource.TriggerType.Should().Be(TriggerType.Pre); - } - - [Fact] - public async Task ReadTriggerAsync_NotFound_Throws() - { - var act = () => _container.Scripts.ReadTriggerAsync("nonexistent"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteTriggerAsync_RemovesTrigger() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function() {}" - }); - - var response = await _container.Scripts.DeleteTriggerAsync("myTrigger"); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var act = () => _container.Scripts.ReadTriggerAsync("myTrigger"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ReplaceTriggerAsync_UpdatesTrigger() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Pre, - TriggerOperation = TriggerOperation.Create, - Body = "function() {}" - }); - - var response = await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "myTrigger", - TriggerType = TriggerType.Post, - TriggerOperation = TriggerOperation.All, - Body = "function() { /* updated */ }" - }); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var read = await _container.Scripts.ReadTriggerAsync("myTrigger"); - read.Resource.TriggerType.Should().Be(TriggerType.Post); - read.Resource.TriggerOperation.Should().Be(TriggerOperation.All); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public void RegisterTrigger_PreTrigger_StoresHandler() + { + _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["added"] = true; + return doc; + })); + + // Trigger is registered — we verify it fires in Phase 2 tests + // For now, just verify no exception + deregister works + _container.DeregisterTrigger("addField"); + } + + [Fact] + public void DeregisterTrigger_RemovesTrigger() + { + _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => doc)); + + _container.DeregisterTrigger("myTrigger"); + + // Using a deregistered trigger should fail + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateTriggerAsync_StoresTriggerProperties() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function() { /* JS body */ }" + }); + + // Register a C# handler for this trigger + _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["modified"] = true; + return doc; + })); + + // Now the trigger should fire + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["modified"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task ReadTriggerAsync_ReturnsStoredTrigger() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function() {}" + }); + + var response = await _container.Scripts.ReadTriggerAsync("myTrigger"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Resource.Id.Should().Be("myTrigger"); + response.Resource.TriggerType.Should().Be(TriggerType.Pre); + } + + [Fact] + public async Task ReadTriggerAsync_NotFound_Throws() + { + var act = () => _container.Scripts.ReadTriggerAsync("nonexistent"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteTriggerAsync_RemovesTrigger() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function() {}" + }); + + var response = await _container.Scripts.DeleteTriggerAsync("myTrigger"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var act = () => _container.Scripts.ReadTriggerAsync("myTrigger"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceTriggerAsync_UpdatesTrigger() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function() {}" + }); + + var response = await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "myTrigger", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All, + Body = "function() { /* updated */ }" + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var read = await _container.Scripts.ReadTriggerAsync("myTrigger"); + read.Resource.TriggerType.Should().Be(TriggerType.Post); + read.Resource.TriggerOperation.Should().Be(TriggerOperation.All); + } } // ─── Phase 2: Pre-Trigger Execution ───────────────────────────────────── public class PreTriggerExecutionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnCreate() - { - _container.RegisterTrigger("addCreatedBy", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["createdBy"] = "trigger"; - return doc; - })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addCreatedBy" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["createdBy"]!.Value().Should().Be("trigger"); - } - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnUpsert() - { - _container.RegisterTrigger("stamp", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["stamped"] = true; - return doc; - })); - - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "stamp" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["stamped"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnReplace() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "original" }), - new PartitionKey("a")); - - _container.RegisterTrigger("addVersion", TriggerType.Pre, TriggerOperation.Replace, - (Func)(doc => - { - doc["version"] = 2; - return doc; - })); - - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addVersion" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["version"]!.Value().Should().Be(2); - item["name"]!.Value().Should().Be("updated"); - } - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnCreateStream() - { - _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["streamModified"] = true; - return doc; - })); - - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["streamModified"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnUpsertStream() - { - _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["streamModified"] = true; - return doc; - })); - - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["streamModified"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_ModifiesDocument_OnReplaceStream() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Replace, - (Func)(doc => - { - doc["streamModified"] = true; - return doc; - })); - - var json = """{"id":"1","pk":"a","name":"replaced"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "addField" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["streamModified"]!.Value().Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_NonExistentTrigger_ThrowsBadRequest() - { - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "doesNotExist" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PreTrigger_OperationMismatch_TriggerNotFired() - { - // Register trigger for Create only - _container.RegisterTrigger("createOnly", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["triggered"] = true; - return doc; - })); - - // Create item first (without trigger) - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Replace with trigger — should NOT fire because trigger is for Create, not Replace - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "replaced" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "createOnly" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["triggered"].Should().BeNull(); - } - - [Fact] - public async Task PreTrigger_MultipleTriggers_ChainInOrder() - { - _container.RegisterTrigger("first", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["first"] = true; - doc["order"] = "1"; - return doc; - })); - - _container.RegisterTrigger("second", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["second"] = true; - doc["order"] = doc["order"]!.Value() + ",2"; - return doc; - })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "first", "second" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - // Real Cosmos only fires the first matching trigger - item["first"]!.Value().Should().BeTrue(); - item["order"]!.Value().Should().Be("1"); - item.ContainsKey("second").Should().BeFalse(); - } - - [Fact] - public async Task PreTrigger_TriggerOperationAll_FiresOnAnyOperation() - { - _container.RegisterTrigger("allOps", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => - { - doc["triggered"] = true; - return doc; - })); - - var options = new ItemRequestOptions { PreTriggers = new List { "allOps" } }; - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), options); - - var item1 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item1["triggered"]!.Value().Should().BeTrue(); - - // Replace — trigger should fire again - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "replaced" }), - "1", new PartitionKey("a"), options); - - var item2 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item2["triggered"]!.Value().Should().BeTrue(); - item2["name"]!.Value().Should().Be("replaced"); - - // Upsert — trigger should fire again - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "upserted" }), - new PartitionKey("a"), options); - - var item3 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item3["triggered"]!.Value().Should().BeTrue(); - item3["name"]!.Value().Should().Be("upserted"); - } - - [Fact] - public async Task PreTrigger_NoPreTriggersSpecified_TriggerNotFired() - { - _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => - { - doc["triggered"] = true; - return doc; - })); - - // Create without specifying PreTriggers - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["triggered"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnCreate() + { + _container.RegisterTrigger("addCreatedBy", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["createdBy"] = "trigger"; + return doc; + })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addCreatedBy" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["createdBy"]!.Value().Should().Be("trigger"); + } + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnUpsert() + { + _container.RegisterTrigger("stamp", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["stamped"] = true; + return doc; + })); + + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "stamp" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["stamped"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnReplace() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "original" }), + new PartitionKey("a")); + + _container.RegisterTrigger("addVersion", TriggerType.Pre, TriggerOperation.Replace, + (Func)(doc => + { + doc["version"] = 2; + return doc; + })); + + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addVersion" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["version"]!.Value().Should().Be(2); + item["name"]!.Value().Should().Be("updated"); + } + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnCreateStream() + { + _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["streamModified"] = true; + return doc; + })); + + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["streamModified"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnUpsertStream() + { + _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["streamModified"] = true; + return doc; + })); + + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["streamModified"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_ModifiesDocument_OnReplaceStream() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Replace, + (Func)(doc => + { + doc["streamModified"] = true; + return doc; + })); + + var json = """{"id":"1","pk":"a","name":"replaced"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "addField" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["streamModified"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_NonExistentTrigger_ThrowsBadRequest() + { + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "doesNotExist" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PreTrigger_OperationMismatch_TriggerNotFired() + { + // Register trigger for Create only + _container.RegisterTrigger("createOnly", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["triggered"] = true; + return doc; + })); + + // Create item first (without trigger) + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Replace with trigger — should NOT fire because trigger is for Create, not Replace + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "replaced" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "createOnly" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["triggered"].Should().BeNull(); + } + + [Fact] + public async Task PreTrigger_MultipleTriggers_ChainInOrder() + { + _container.RegisterTrigger("first", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["first"] = true; + doc["order"] = "1"; + return doc; + })); + + _container.RegisterTrigger("second", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["second"] = true; + doc["order"] = doc["order"]!.Value() + ",2"; + return doc; + })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "first", "second" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + // Real Cosmos only fires the first matching trigger + item["first"]!.Value().Should().BeTrue(); + item["order"]!.Value().Should().Be("1"); + item.ContainsKey("second").Should().BeFalse(); + } + + [Fact] + public async Task PreTrigger_TriggerOperationAll_FiresOnAnyOperation() + { + _container.RegisterTrigger("allOps", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => + { + doc["triggered"] = true; + return doc; + })); + + var options = new ItemRequestOptions { PreTriggers = new List { "allOps" } }; + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), options); + + var item1 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item1["triggered"]!.Value().Should().BeTrue(); + + // Replace — trigger should fire again + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "replaced" }), + "1", new PartitionKey("a"), options); + + var item2 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item2["triggered"]!.Value().Should().BeTrue(); + item2["name"]!.Value().Should().Be("replaced"); + + // Upsert — trigger should fire again + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "upserted" }), + new PartitionKey("a"), options); + + var item3 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item3["triggered"]!.Value().Should().BeTrue(); + item3["name"]!.Value().Should().Be("upserted"); + } + + [Fact] + public async Task PreTrigger_NoPreTriggersSpecified_TriggerNotFired() + { + _container.RegisterTrigger("addField", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => + { + doc["triggered"] = true; + return doc; + })); + + // Create without specifying PreTriggers + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["triggered"].Should().BeNull(); + } } // ─── Phase 3: Post-Trigger Execution ──────────────────────────────────── public class PostTriggerExecutionTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PostTrigger_FiresAfterCreate() - { - _container.RegisterTrigger("audit", TriggerType.Post, TriggerOperation.Create, - (Action)(doc => - { - // Post-trigger creates an audit entry - var auditDoc = new JObject - { - ["id"] = "audit-" + doc["id"]!.Value(), - ["pk"] = doc["pk"]!.Value(), - ["action"] = "created" - }; - _container.CreateItemAsync(auditDoc, new PartitionKey(doc["pk"]!.Value())).GetAwaiter().GetResult(); - })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "audit" } }); - - var audit = (await _container.ReadItemAsync("audit-1", new PartitionKey("a"))).Resource; - audit["action"]!.Value().Should().Be("created"); - } - - [Fact] - public async Task PostTrigger_ExceptionRollsBackWrite() - { - _container.RegisterTrigger("failingTrigger", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failingTrigger" } }); - - await act.Should().ThrowAsync(); - - // Item should NOT exist — the write was rolled back - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); - await readAct.Should().ThrowAsync(); - } - - [Fact] - public async Task PostTrigger_FiresAfterUpsert() - { - var postTriggered = false; - _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => postTriggered = true)); - - await _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "flag" } }); - - postTriggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_FiresAfterReplace() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - var postTriggered = false; - _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.Replace, - (Action)(_ => postTriggered = true)); - - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "flag" } }); - - postTriggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_NonExistentTrigger_ThrowsBadRequest() - { - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "nope" } }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PostTrigger_OnStream_FiresAfterCreate() - { - var postTriggered = false; - _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => postTriggered = true)); - - var json = """{"id":"1","pk":"a"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "flag" } }); - - postTriggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_OperationMismatch_NotFired() - { - var postTriggered = false; - _container.RegisterTrigger("createOnly", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => postTriggered = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - // Replace with a Create-only post-trigger — should not fire - await _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "createOnly" } }); - - postTriggered.Should().BeFalse(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PostTrigger_FiresAfterCreate() + { + _container.RegisterTrigger("audit", TriggerType.Post, TriggerOperation.Create, + (Action)(doc => + { + // Post-trigger creates an audit entry + var auditDoc = new JObject + { + ["id"] = "audit-" + doc["id"]!.Value(), + ["pk"] = doc["pk"]!.Value(), + ["action"] = "created" + }; + _container.CreateItemAsync(auditDoc, new PartitionKey(doc["pk"]!.Value())).GetAwaiter().GetResult(); + })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "audit" } }); + + var audit = (await _container.ReadItemAsync("audit-1", new PartitionKey("a"))).Resource; + audit["action"]!.Value().Should().Be("created"); + } + + [Fact] + public async Task PostTrigger_ExceptionRollsBackWrite() + { + _container.RegisterTrigger("failingTrigger", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failingTrigger" } }); + + await act.Should().ThrowAsync(); + + // Item should NOT exist — the write was rolled back + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); + await readAct.Should().ThrowAsync(); + } + + [Fact] + public async Task PostTrigger_FiresAfterUpsert() + { + var postTriggered = false; + _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => postTriggered = true)); + + await _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "flag" } }); + + postTriggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_FiresAfterReplace() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var postTriggered = false; + _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.Replace, + (Action)(_ => postTriggered = true)); + + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "flag" } }); + + postTriggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_NonExistentTrigger_ThrowsBadRequest() + { + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "nope" } }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostTrigger_OnStream_FiresAfterCreate() + { + var postTriggered = false; + _container.RegisterTrigger("flag", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => postTriggered = true)); + + var json = """{"id":"1","pk":"a"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await _container.CreateItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "flag" } }); + + postTriggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_OperationMismatch_NotFired() + { + var postTriggered = false; + _container.RegisterTrigger("createOnly", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => postTriggered = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Replace with a Create-only post-trigger — should not fire + await _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "createOnly" } }); + + postTriggered.Should().BeFalse(); + } } // ─── Phase 4: Delete Trigger Execution ────────────────────────────────── public class DeleteTriggerTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task PreTrigger_OnDelete_ThrowingTriggerAborts() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - - _container.RegisterTrigger("blockDelete", TriggerType.Pre, TriggerOperation.Delete, - (Func)(_ => throw new InvalidOperationException("Delete blocked!"))); - - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "blockDelete" } }); + [Fact] + public async Task PreTrigger_OnDelete_ThrowingTriggerAborts() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + _container.RegisterTrigger("blockDelete", TriggerType.Pre, TriggerOperation.Delete, + (Func)(_ => throw new InvalidOperationException("Delete blocked!"))); + + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "blockDelete" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should still exist — delete was aborted - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } + // Item should still exist — delete was aborted + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } - [Fact] - public async Task PreTrigger_OnDelete_NonThrowingAllowsDeletion() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task PreTrigger_OnDelete_NonThrowingAllowsDeletion() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - var preFired = false; - _container.RegisterTrigger("auditDelete", TriggerType.Pre, TriggerOperation.Delete, - (Func)(doc => { preFired = true; return doc; })); + var preFired = false; + _container.RegisterTrigger("auditDelete", TriggerType.Pre, TriggerOperation.Delete, + (Func)(doc => { preFired = true; return doc; })); - await _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "auditDelete" } }); + await _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "auditDelete" } }); - preFired.Should().BeTrue(); + preFired.Should().BeTrue(); - var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); - await readAct.Should().ThrowAsync(); - } + var readAct = () => _container.ReadItemAsync("1", new PartitionKey("a")); + await readAct.Should().ThrowAsync(); + } - [Fact] - public async Task PostTrigger_FiresAfterDelete() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task PostTrigger_FiresAfterDelete() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - var postFired = false; - string? deletedId = null; - _container.RegisterTrigger("afterDelete", TriggerType.Post, TriggerOperation.Delete, - (Action)(doc => { postFired = true; deletedId = doc["id"]!.Value(); })); + var postFired = false; + string? deletedId = null; + _container.RegisterTrigger("afterDelete", TriggerType.Post, TriggerOperation.Delete, + (Action)(doc => { postFired = true; deletedId = doc["id"]!.Value(); })); - await _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "afterDelete" } }); + await _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "afterDelete" } }); - postFired.Should().BeTrue(); - deletedId.Should().Be("1"); - } + postFired.Should().BeTrue(); + deletedId.Should().Be("1"); + } - [Fact] - public async Task PostTrigger_ExceptionOnDelete_RollsBackDelete() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); + [Fact] + public async Task PostTrigger_ExceptionOnDelete_RollsBackDelete() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); - _container.RegisterTrigger("failingPostDelete", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); + _container.RegisterTrigger("failingPostDelete", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); - var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failingPostDelete" } }); + var act = () => _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failingPostDelete" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should still exist — the delete was rolled back - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["id"]!.Value().Should().Be("1"); - } + // Item should still exist — the delete was rolled back + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["id"]!.Value().Should().Be("1"); + } - [Fact] - public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringUpsertStream() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = "original" }), - new PartitionKey("a")); + [Fact] + public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringUpsertStream() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = "original" }), + new PartitionKey("a")); - _container.RegisterTrigger("failUpsert", TriggerType.Post, TriggerOperation.Upsert, - (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); + _container.RegisterTrigger("failUpsert", TriggerType.Post, TriggerOperation.Upsert, + (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); - var json = """{"id":"1","pk":"a","value":"updated"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var json = """{"id":"1","pk":"a","value":"updated"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var act = () => _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failUpsert" } }); + var act = () => _container.UpsertItemStreamAsync(stream, new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failUpsert" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should be rolled back to original value - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["value"]!.Value().Should().Be("original"); - } + // Item should be rolled back to original value + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["value"]!.Value().Should().Be("original"); + } - [Fact] - public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringReplaceStream() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", value = "original" }), - new PartitionKey("a")); + [Fact] + public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringReplaceStream() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = "original" }), + new PartitionKey("a")); - _container.RegisterTrigger("failReplace", TriggerType.Post, TriggerOperation.Replace, - (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); + _container.RegisterTrigger("failReplace", TriggerType.Post, TriggerOperation.Replace, + (Action)(_ => throw new InvalidOperationException("Post-trigger failed!"))); - var json = """{"id":"1","pk":"a","value":"replaced"}"""; - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var json = """{"id":"1","pk":"a","value":"replaced"}"""; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - var act = () => _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "failReplace" } }); + var act = () => _container.ReplaceItemStreamAsync(stream, "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "failReplace" } }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); - // Item should be rolled back to original value - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["value"]!.Value().Should().Be("original"); - } + // Item should be rolled back to original value + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["value"]!.Value().Should().Be("original"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -671,130 +671,142 @@ await _container.CreateItemAsync( public class TriggerRegistrationEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task RegisterTrigger_SameIdTwice_OverwritesHandler() - { - _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["v"] = 1; return doc; })); - _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["v"] = 2; return doc; })); - - // The second registration should overwrite — verify by reading the stored item - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "t1" } }); - var stored = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - stored["v"]!.Value().Should().Be(2); - } - - [Fact] - public async Task RegisterTrigger_CaseSensitive_DifferentTriggers() - { - _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["tag"] = "lower"; return doc; })); - _container.RegisterTrigger("MyTrigger", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["tag"] = "upper"; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); - var s1 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - s1["tag"]!.Value().Should().Be("lower"); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "MyTrigger" } }); - var s2 = (await _container.ReadItemAsync("2", new PartitionKey("a"))).Resource; - s2["tag"]!.Value().Should().Be("upper"); - } - - [Fact] - public void DeregisterTrigger_NonExistent_DoesNotThrow() - { - var act = () => _container.DeregisterTrigger("nonexistent"); - act.Should().NotThrow(); - } - - [Fact] - public async Task DeregisterTrigger_ThenReRegister_Works() - { - _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["v"] = 1; return doc; })); - _container.DeregisterTrigger("t1"); - _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["v"] = 2; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "t1" } }); - var stored = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - stored["v"]!.Value().Should().Be(2); - } - - [Fact] - public async Task CreateTriggerAsync_DuplicateId_ThrowsConflict() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "dup", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() {}" - }); - - var act = () => _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "dup", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() {}" - }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); - } - - [Fact] - public async Task CreateTriggerAsync_ReturnsCreatedStatusCode() - { - var response = await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() {}" - }); - response.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task ReplaceTriggerAsync_ReturnsOkStatusCode() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() {}" - }); - - var response = await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties - { - Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run2() {}" - }); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task DeleteTriggerAsync_ReturnsNoContentStatusCode() - { - await _container.Scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create, - Body = "function run() {}" - }); - - var response = await _container.Scripts.DeleteTriggerAsync("t1"); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task RegisterTrigger_SameIdTwice_OverwritesHandler() + { + _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["v"] = 1; return doc; })); + _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["v"] = 2; return doc; })); + + // The second registration should overwrite — verify by reading the stored item + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "t1" } }); + var stored = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + stored["v"]!.Value().Should().Be(2); + } + + [Fact] + public async Task RegisterTrigger_CaseSensitive_DifferentTriggers() + { + _container.RegisterTrigger("myTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["tag"] = "lower"; return doc; })); + _container.RegisterTrigger("MyTrigger", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["tag"] = "upper"; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "myTrigger" } }); + var s1 = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + s1["tag"]!.Value().Should().Be("lower"); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "MyTrigger" } }); + var s2 = (await _container.ReadItemAsync("2", new PartitionKey("a"))).Resource; + s2["tag"]!.Value().Should().Be("upper"); + } + + [Fact] + public void DeregisterTrigger_NonExistent_DoesNotThrow() + { + var act = () => _container.DeregisterTrigger("nonexistent"); + act.Should().NotThrow(); + } + + [Fact] + public async Task DeregisterTrigger_ThenReRegister_Works() + { + _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["v"] = 1; return doc; })); + _container.DeregisterTrigger("t1"); + _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["v"] = 2; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "t1" } }); + var stored = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + stored["v"]!.Value().Should().Be(2); + } + + [Fact] + public async Task CreateTriggerAsync_DuplicateId_ThrowsConflict() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "dup", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() {}" + }); + + var act = () => _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "dup", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() {}" + }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateTriggerAsync_ReturnsCreatedStatusCode() + { + var response = await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() {}" + }); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task ReplaceTriggerAsync_ReturnsOkStatusCode() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() {}" + }); + + var response = await _container.Scripts.ReplaceTriggerAsync(new TriggerProperties + { + Id = "t1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run2() {}" + }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteTriggerAsync_ReturnsNoContentStatusCode() + { + await _container.Scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create, + Body = "function run() {}" + }); + + var response = await _container.Scripts.DeleteTriggerAsync("t1"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -803,85 +815,85 @@ await _container.Scripts.CreateTriggerAsync(new TriggerProperties public class PreTriggerEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PreTrigger_ThrowingHandler_AbortsCreate() - { - _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Create, - (Func)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - _container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task PreTrigger_ThrowingHandler_AbortsUpsert() - { - _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Upsert, - (Func)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - _container.ItemCount.Should().Be(0); - } - - [Fact] - public async Task PreTrigger_ThrowingHandler_AbortsReplace() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), - new PartitionKey("a")); - - _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Replace, - (Func)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "new" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["v"]!.Value().Should().Be("orig"); - } - - [Fact] - public async Task PreTrigger_EmptyTriggersArray_NoEffect() - { - _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["tag"] = "fired"; return doc; })); - - var result = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List() }); - - result.Resource["tag"].Should().BeNull(); - } - - [Fact] - public async Task PreTrigger_OperationSpecific_Delete_NotFiredOnCreate() - { - _container.RegisterTrigger("delOnly", TriggerType.Pre, TriggerOperation.Delete, - (Func)(doc => { doc["fired"] = true; return doc; })); - - var result = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "delOnly" } }); - - result.Resource["fired"].Should().BeNull(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PreTrigger_ThrowingHandler_AbortsCreate() + { + _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Create, + (Func)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + _container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task PreTrigger_ThrowingHandler_AbortsUpsert() + { + _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Upsert, + (Func)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + _container.ItemCount.Should().Be(0); + } + + [Fact] + public async Task PreTrigger_ThrowingHandler_AbortsReplace() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), + new PartitionKey("a")); + + _container.RegisterTrigger("fail", TriggerType.Pre, TriggerOperation.Replace, + (Func)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "new" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["v"]!.Value().Should().Be("orig"); + } + + [Fact] + public async Task PreTrigger_EmptyTriggersArray_NoEffect() + { + _container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["tag"] = "fired"; return doc; })); + + var result = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List() }); + + result.Resource["tag"].Should().BeNull(); + } + + [Fact] + public async Task PreTrigger_OperationSpecific_Delete_NotFiredOnCreate() + { + _container.RegisterTrigger("delOnly", TriggerType.Pre, TriggerOperation.Delete, + (Func)(doc => { doc["fired"] = true; return doc; })); + + var result = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "delOnly" } }); + + result.Resource["fired"].Should().BeNull(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -890,77 +902,77 @@ public async Task PreTrigger_OperationSpecific_Delete_NotFiredOnCreate() public class PostTriggerEdgeCaseTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PostTrigger_ReceivesEnrichedDoc_WithSystemProperties() - { - JObject? received = null; - _container.RegisterTrigger("capture", TriggerType.Post, TriggerOperation.Create, - (Action)(doc => received = doc)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "capture" } }); - - received.Should().NotBeNull(); - received!["_ts"].Should().NotBeNull(); - received!["_etag"].Should().NotBeNull(); - } - - [Fact] - public async Task PostTrigger_MultiplePostTriggers_ChainInOrder() - { - var order = new List(); - _container.RegisterTrigger("first", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => order.Add("first"))); - _container.RegisterTrigger("second", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => order.Add("second"))); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "first", "second" } }); - - // Real Cosmos only fires the first matching trigger - order.Should().BeEquivalentTo(new[] { "first" }); - } - - [Fact] - public async Task PostTrigger_ExceptionRollsBack_Upsert_ExistingItem() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), - new PartitionKey("a")); - - _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Upsert, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "new" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["v"]!.Value().Should().Be("orig"); - } - - [Fact] - public async Task PostTrigger_OperationAll_FiresOnCreate() - { - var fired = false; - _container.RegisterTrigger("all", TriggerType.Post, TriggerOperation.All, - (Action)(_ => fired = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "all" } }); - - fired.Should().BeTrue(); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PostTrigger_ReceivesEnrichedDoc_WithSystemProperties() + { + JObject? received = null; + _container.RegisterTrigger("capture", TriggerType.Post, TriggerOperation.Create, + (Action)(doc => received = doc)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "capture" } }); + + received.Should().NotBeNull(); + received!["_ts"].Should().NotBeNull(); + received!["_etag"].Should().NotBeNull(); + } + + [Fact] + public async Task PostTrigger_MultiplePostTriggers_ChainInOrder() + { + var order = new List(); + _container.RegisterTrigger("first", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => order.Add("first"))); + _container.RegisterTrigger("second", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => order.Add("second"))); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "first", "second" } }); + + // Real Cosmos only fires the first matching trigger + order.Should().BeEquivalentTo(new[] { "first" }); + } + + [Fact] + public async Task PostTrigger_ExceptionRollsBack_Upsert_ExistingItem() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), + new PartitionKey("a")); + + _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Upsert, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "new" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["v"]!.Value().Should().Be("orig"); + } + + [Fact] + public async Task PostTrigger_OperationAll_FiresOnCreate() + { + var fired = false; + _container.RegisterTrigger("all", TriggerType.Post, TriggerOperation.All, + (Action)(_ => fired = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "all" } }); + + fired.Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -969,48 +981,48 @@ await _container.CreateItemAsync( public class TriggerRollbackTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PostTrigger_RollsBack_EtagIsRestoredToOriginal() - { - var created = await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a")); - var origEtag = created.ETag; - - _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Replace, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "new" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - var read = await _container.ReadItemAsync("1", new PartitionKey("a")); - read.ETag.Should().Be(origEtag); - } - - [Fact] - public async Task PostTrigger_RollsBack_ItemContentIsExactlyOriginal() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "original" }), - new PartitionKey("a")); - - _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Replace, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "changed" }), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - - await act.Should().ThrowAsync(); - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["v"]!.Value().Should().Be("original"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PostTrigger_RollsBack_EtagIsRestoredToOriginal() + { + var created = await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + var origEtag = created.ETag; + + _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Replace, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "new" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + var read = await _container.ReadItemAsync("1", new PartitionKey("a")); + read.ETag.Should().Be(origEtag); + } + + [Fact] + public async Task PostTrigger_RollsBack_ItemContentIsExactlyOriginal() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "original" }), + new PartitionKey("a")); + + _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Replace, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "changed" }), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + + await act.Should().ThrowAsync(); + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["v"]!.Value().Should().Be("original"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1019,53 +1031,53 @@ await _container.CreateItemAsync( public class TriggerMixedTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PreAndPostTrigger_BothFire_OnSameOperation() - { - var preFired = false; - var postFired = false; - _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { preFired = true; return doc; })); - _container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => postFired = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions - { - PreTriggers = new List { "pre" }, - PostTriggers = new List { "post" } - }); - - preFired.Should().BeTrue(); - postFired.Should().BeTrue(); - } - - [Fact] - public async Task PreTrigger_PostTrigger_PostSeesPreModifiedDoc() - { - _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["preTag"] = "set"; return doc; })); - - JObject? postDoc = null; - _container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, - (Action)(doc => postDoc = doc)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions - { - PreTriggers = new List { "pre" }, - PostTriggers = new List { "post" } - }); - - postDoc.Should().NotBeNull(); - postDoc!["preTag"]!.Value().Should().Be("set"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PreAndPostTrigger_BothFire_OnSameOperation() + { + var preFired = false; + var postFired = false; + _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { preFired = true; return doc; })); + _container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => postFired = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions + { + PreTriggers = new List { "pre" }, + PostTriggers = new List { "post" } + }); + + preFired.Should().BeTrue(); + postFired.Should().BeTrue(); + } + + [Fact] + public async Task PreTrigger_PostTrigger_PostSeesPreModifiedDoc() + { + _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["preTag"] = "set"; return doc; })); + + JObject? postDoc = null; + _container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, + (Action)(doc => postDoc = doc)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions + { + PreTriggers = new List { "pre" }, + PostTriggers = new List { "post" } + }); + + postDoc.Should().NotBeNull(); + postDoc!["preTag"]!.Value().Should().Be("set"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1074,24 +1086,24 @@ await _container.CreateItemAsync( public class TriggerUnsupportedOperationTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); + private readonly InMemoryContainer _container = new("test-container", "/pk"); - [Fact] - public async Task PatchItemAsync_DoesNotSupportTriggers() - { - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), - new PartitionKey("a")); + [Fact] + public async Task PatchItemAsync_DoesNotSupportTriggers() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), + new PartitionKey("a")); - var preFired = false; - _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { preFired = true; return doc; })); + var preFired = false; + _container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { preFired = true; return doc; })); - await _container.PatchItemAsync("1", new PartitionKey("a"), - new[] { PatchOperation.Replace("/v", "patched") }); + await _container.PatchItemAsync("1", new PartitionKey("a"), + new[] { PatchOperation.Replace("/v", "patched") }); - preFired.Should().BeFalse(); - } + preFired.Should().BeFalse(); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1100,63 +1112,63 @@ await _container.CreateItemAsync( public class PatchItemStreamAsyncTriggerTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PatchItemStreamAsync_PreTrigger_Fires() - { - _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { doc["injected"] = "by-trigger"; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "original" }), - new PartitionKey("a")); - - await _container.PatchItemStreamAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "patched")], - new PatchItemRequestOptions { PreTriggers = new List { "add-field" } }); - - var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - read["injected"]!.ToString().Should().Be("by-trigger"); - read["name"]!.ToString().Should().Be("patched"); - } - - [Fact] - public async Task PatchItemStreamAsync_PostTrigger_Fires() - { - var called = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => called = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "original" }), - new PartitionKey("a")); - - await _container.PatchItemStreamAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "patched")], - new PatchItemRequestOptions { PostTriggers = new List { "post-flag" } }); - - called.Should().BeTrue(); - } - - [Fact] - public async Task PatchItemStreamAsync_PostTrigger_RollbackOnError() - { - _container.RegisterTrigger("fail-post", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("boom"))); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "original" }), - new PartitionKey("a")); - - var act = () => _container.PatchItemStreamAsync("1", new PartitionKey("a"), - [PatchOperation.Set("/name", "patched")], - new PatchItemRequestOptions { PostTriggers = new List { "fail-post" } }); - await act.Should().ThrowAsync(); - - var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - read["name"]!.ToString().Should().Be("original", "patch should be rolled back on post-trigger failure"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PatchItemStreamAsync_PreTrigger_Fires() + { + _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { doc["injected"] = "by-trigger"; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "original" }), + new PartitionKey("a")); + + await _container.PatchItemStreamAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "patched")], + new PatchItemRequestOptions { PreTriggers = new List { "add-field" } }); + + var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + read["injected"]!.ToString().Should().Be("by-trigger"); + read["name"]!.ToString().Should().Be("patched"); + } + + [Fact] + public async Task PatchItemStreamAsync_PostTrigger_Fires() + { + var called = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => called = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "original" }), + new PartitionKey("a")); + + await _container.PatchItemStreamAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "patched")], + new PatchItemRequestOptions { PostTriggers = new List { "post-flag" } }); + + called.Should().BeTrue(); + } + + [Fact] + public async Task PatchItemStreamAsync_PostTrigger_RollbackOnError() + { + _container.RegisterTrigger("fail-post", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("boom"))); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "original" }), + new PartitionKey("a")); + + var act = () => _container.PatchItemStreamAsync("1", new PartitionKey("a"), + [PatchOperation.Set("/name", "patched")], + new PatchItemRequestOptions { PostTriggers = new List { "fail-post" } }); + await act.Should().ThrowAsync(); + + var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + read["name"]!.ToString().Should().Be("original", "patch should be rolled back on post-trigger failure"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1165,112 +1177,112 @@ await _container.CreateItemAsync( public class TransactionalBatchTriggerTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - private static TransactionalBatchItemRequestOptions WithPreTrigger(string triggerName) => - new() { Properties = new Dictionary { ["x-ms-pre-trigger-include"] = new[] { triggerName } } }; - - private static TransactionalBatchItemRequestOptions WithPostTrigger(string triggerName) => - new() { Properties = new Dictionary { ["x-ms-post-trigger-include"] = new[] { triggerName } } }; - - [Fact] - public async Task Batch_CreateItem_WithPreTrigger_FiresTrigger() - { - _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { doc["injected"] = "batch-pre"; return doc; })); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPreTrigger("add-field")); - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - read["injected"]!.ToString().Should().Be("batch-pre"); - } - - [Fact] - public async Task Batch_CreateItem_WithPostTrigger_FiresTrigger() - { - var called = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => called = true)); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPostTrigger("post-flag")); - await batch.ExecuteAsync(); - - called.Should().BeTrue(); - } - - [Fact] - public async Task Batch_UpsertItem_WithPostTrigger_FiresTrigger() - { - var called = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => called = true)); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), - new PartitionKey("a")); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.UpsertItem(JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), WithPostTrigger("post-flag")); - await batch.ExecuteAsync(); - - called.Should().BeTrue(); - } - - [Fact] - public async Task Batch_PatchItem_WithPreTrigger_FiresTrigger() - { - _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { doc["injected"] = "batch-patch"; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), - new PartitionKey("a")); - - var patchOptions = new TransactionalBatchPatchItemRequestOptions - { - Properties = new Dictionary { ["x-ms-pre-trigger-include"] = new[] { "add-field" } } - }; - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.PatchItem("1", [PatchOperation.Set("/name", "patched")], patchOptions); - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeTrue(); - - var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - read["injected"]!.ToString().Should().Be("batch-patch"); - } - - [Fact] - public async Task Batch_TriggerFailure_CausesRollback() - { - _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("trigger boom"))); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPostTrigger("fail")); - var response = await batch.ExecuteAsync(); - response.IsSuccessStatusCode.Should().BeFalse(); - - var act = () => _container.ReadItemAsync("1", new PartitionKey("a")); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task Batch_WithoutTriggerProperties_DoesNotFireTriggers() - { - var called = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => called = true)); - - var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" })); - await batch.ExecuteAsync(); - - called.Should().BeFalse("triggers should not fire unless explicitly specified via Properties"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + private static TransactionalBatchItemRequestOptions WithPreTrigger(string triggerName) => + new() { Properties = new Dictionary { ["x-ms-pre-trigger-include"] = new[] { triggerName } } }; + + private static TransactionalBatchItemRequestOptions WithPostTrigger(string triggerName) => + new() { Properties = new Dictionary { ["x-ms-post-trigger-include"] = new[] { triggerName } } }; + + [Fact] + public async Task Batch_CreateItem_WithPreTrigger_FiresTrigger() + { + _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { doc["injected"] = "batch-pre"; return doc; })); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPreTrigger("add-field")); + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + read["injected"]!.ToString().Should().Be("batch-pre"); + } + + [Fact] + public async Task Batch_CreateItem_WithPostTrigger_FiresTrigger() + { + var called = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => called = true)); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPostTrigger("post-flag")); + await batch.ExecuteAsync(); + + called.Should().BeTrue(); + } + + [Fact] + public async Task Batch_UpsertItem_WithPostTrigger_FiresTrigger() + { + var called = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => called = true)); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), + new PartitionKey("a")); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.UpsertItem(JObject.FromObject(new { id = "1", pk = "a", name = "updated" }), WithPostTrigger("post-flag")); + await batch.ExecuteAsync(); + + called.Should().BeTrue(); + } + + [Fact] + public async Task Batch_PatchItem_WithPreTrigger_FiresTrigger() + { + _container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { doc["injected"] = "batch-patch"; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "orig" }), + new PartitionKey("a")); + + var patchOptions = new TransactionalBatchPatchItemRequestOptions + { + Properties = new Dictionary { ["x-ms-pre-trigger-include"] = new[] { "add-field" } } + }; + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.PatchItem("1", [PatchOperation.Set("/name", "patched")], patchOptions); + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeTrue(); + + var read = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + read["injected"]!.ToString().Should().Be("batch-patch"); + } + + [Fact] + public async Task Batch_TriggerFailure_CausesRollback() + { + _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("trigger boom"))); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" }), WithPostTrigger("fail")); + var response = await batch.ExecuteAsync(); + response.IsSuccessStatusCode.Should().BeFalse(); + + var act = () => _container.ReadItemAsync("1", new PartitionKey("a")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Batch_WithoutTriggerProperties_DoesNotFireTriggers() + { + var called = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => called = true)); + + var batch = _container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a" })); + await batch.ExecuteAsync(); + + called.Should().BeFalse("triggers should not fire unless explicitly specified via Properties"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1279,62 +1291,68 @@ public async Task Batch_WithoutTriggerProperties_DoesNotFireTriggers() public class TriggerDivergentBehaviorTests { - private readonly InMemoryContainer _container = new("test-container", "/pk"); - - [Fact] - public async Task PatchItemAsync_FiresTriggers_RealCosmos() - { - await Task.CompletedTask; - } - - [Fact] - public async Task GetTriggerQueryIterator_ReturnsAllTriggers_RealCosmos() - { - var scripts = _container.Scripts; - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t1", Body = "function(){}", TriggerType = TriggerType.Pre, TriggerOperation = TriggerOperation.Create - }); - await scripts.CreateTriggerAsync(new TriggerProperties - { - Id = "t2", Body = "function(){}", TriggerType = TriggerType.Post, TriggerOperation = TriggerOperation.All - }); - - var iterator = scripts.GetTriggerQueryIterator(); - var all = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - all.AddRange(page); - } - - all.Should().HaveCount(2); - all.Select(t => t.Id).Should().BeEquivalentTo("t1", "t2"); - } - - [Fact] - public async Task PostTriggerRollback_ChangeFeedClean_RealCosmos() - { - var checkpoint = _container.GetChangeFeedCheckpoint(); - - _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = _container.GetChangeFeedIterator(checkpoint); - var items = new List(); - while (changes.HasMoreResults) - { - var page = await changes.ReadNextAsync(); - items.AddRange(page); - } - items.Should().BeEmpty("change feed should not contain entries for rolled-back operations"); - } + private readonly InMemoryContainer _container = new("test-container", "/pk"); + + [Fact] + public async Task PatchItemAsync_FiresTriggers_RealCosmos() + { + await Task.CompletedTask; + } + + [Fact] + public async Task GetTriggerQueryIterator_ReturnsAllTriggers_RealCosmos() + { + var scripts = _container.Scripts; + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t1", + Body = "function(){}", + TriggerType = TriggerType.Pre, + TriggerOperation = TriggerOperation.Create + }); + await scripts.CreateTriggerAsync(new TriggerProperties + { + Id = "t2", + Body = "function(){}", + TriggerType = TriggerType.Post, + TriggerOperation = TriggerOperation.All + }); + + var iterator = scripts.GetTriggerQueryIterator(); + var all = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + all.AddRange(page); + } + + all.Should().HaveCount(2); + all.Select(t => t.Id).Should().BeEquivalentTo("t1", "t2"); + } + + [Fact] + public async Task PostTriggerRollback_ChangeFeedClean_RealCosmos() + { + var checkpoint = _container.GetChangeFeedCheckpoint(); + + _container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = _container.GetChangeFeedIterator(checkpoint); + var items = new List(); + while (changes.HasMoreResults) + { + var page = await changes.ReadNextAsync(); + items.AddRange(page); + } + items.Should().BeEmpty("change feed should not contain entries for rolled-back operations"); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -1344,426 +1362,426 @@ public async Task PostTriggerRollback_ChangeFeedClean_RealCosmos() // ── B1-B5: Change Feed Rollback on Post-Trigger Failure ── public class TriggerChangeFeedRollbackTests { - private static async Task> GetChangeFeedSince(InMemoryContainer container, long checkpoint) - { - var iter = container.GetChangeFeedIterator(checkpoint); - var items = new List(); - while (iter.HasMoreResults) - { - var page = await iter.ReadNextAsync(); - items.AddRange(page); - } - return items; - } - - [Fact] - public async Task PostTrigger_ExceptionOnUpsert_ChangeFeedIsClean() - { - var container = new InMemoryContainer("cf-upsert", "/pk"); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty("change feed should not contain entries for rolled-back upsert"); - } - - [Fact] - public async Task PostTrigger_ExceptionOnReplace_ChangeFeedIsClean() - { - var container = new InMemoryContainer("cf-replace", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), new PartitionKey("a")); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "upd" }), "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty("change feed should not contain entries for rolled-back replace"); - } - - [Fact] - public async Task PostTrigger_ExceptionOnCreateStream_ChangeFeedIsClean() - { - var container = new InMemoryContainer("cf-cstream", "/pk"); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.CreateItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty(); - } - - [Fact] - public async Task PostTrigger_ExceptionOnUpsertStream_ChangeFeedIsClean() - { - var container = new InMemoryContainer("cf-ustream", "/pk"); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.UpsertItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty(); - } - - [Fact] - public async Task PostTrigger_ExceptionOnReplaceStream_ChangeFeedIsClean() - { - var container = new InMemoryContainer("cf-rstream", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.ReplaceItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"v\":\"upd\"}")), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty(); - } - - [Fact] - public async Task PostTrigger_ExceptionOnDelete_ChangeFeedHasNoTombstone() - { - var container = new InMemoryContainer("cf-delete", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty("delete tombstone should not appear for rolled-back delete"); - } - - [Fact] - public async Task PostTrigger_ExceptionOnDeleteStream_ChangeFeedHasNoTombstone() - { - var container = new InMemoryContainer("cf-dstream", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var checkpoint = container.GetChangeFeedCheckpoint(); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - var changes = await GetChangeFeedSince(container, checkpoint); - changes.Should().BeEmpty(); - } + private static async Task> GetChangeFeedSince(InMemoryContainer container, long checkpoint) + { + var iter = container.GetChangeFeedIterator(checkpoint); + var items = new List(); + while (iter.HasMoreResults) + { + var page = await iter.ReadNextAsync(); + items.AddRange(page); + } + return items; + } + + [Fact] + public async Task PostTrigger_ExceptionOnUpsert_ChangeFeedIsClean() + { + var container = new InMemoryContainer("cf-upsert", "/pk"); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty("change feed should not contain entries for rolled-back upsert"); + } + + [Fact] + public async Task PostTrigger_ExceptionOnReplace_ChangeFeedIsClean() + { + var container = new InMemoryContainer("cf-replace", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), new PartitionKey("a")); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "upd" }), "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty("change feed should not contain entries for rolled-back replace"); + } + + [Fact] + public async Task PostTrigger_ExceptionOnCreateStream_ChangeFeedIsClean() + { + var container = new InMemoryContainer("cf-cstream", "/pk"); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.CreateItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty(); + } + + [Fact] + public async Task PostTrigger_ExceptionOnUpsertStream_ChangeFeedIsClean() + { + var container = new InMemoryContainer("cf-ustream", "/pk"); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.UpsertItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty(); + } + + [Fact] + public async Task PostTrigger_ExceptionOnReplaceStream_ChangeFeedIsClean() + { + var container = new InMemoryContainer("cf-rstream", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.ReplaceItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"v\":\"upd\"}")), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty(); + } + + [Fact] + public async Task PostTrigger_ExceptionOnDelete_ChangeFeedHasNoTombstone() + { + var container = new InMemoryContainer("cf-delete", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty("delete tombstone should not appear for rolled-back delete"); + } + + [Fact] + public async Task PostTrigger_ExceptionOnDeleteStream_ChangeFeedHasNoTombstone() + { + var container = new InMemoryContainer("cf-dstream", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + var checkpoint = container.GetChangeFeedCheckpoint(); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + var changes = await GetChangeFeedSince(container, checkpoint); + changes.Should().BeEmpty(); + } } // ── M1-M6: Stream Trigger Coverage ── public class TriggerStreamCoverageTests { - private readonly InMemoryContainer _container = new("trig-stream", "/pk"); - - [Fact] - public async Task PostTrigger_OnUpsertStream_FiresAfterUpsert() - { - var triggered = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => triggered = true)); - - await _container.UpsertItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), - new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-flag" } }); - - triggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_OnReplaceStream_FiresAfterReplace() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var triggered = false; - _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, - (Action)(_ => triggered = true)); - - await _container.ReplaceItemStreamAsync( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"v\":\"upd\"}")), - "1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-flag" } }); - - triggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_OnDeleteStream_FiresAfterDelete() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - var triggered = false; - _container.RegisterTrigger("post-del", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => triggered = true)); - - await _container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-del" } }); - - triggered.Should().BeTrue(); - } - - [Fact] - public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringDeleteStream() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - - _container.RegisterTrigger("fail-post", TriggerType.Post, TriggerOperation.Delete, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => _container.DeleteItemStreamAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail-post" } }); - await act.Should().ThrowAsync(); - - // Item should still exist (rolled back) - var read = await _container.ReadItemStreamAsync("1", new PartitionKey("a")); - read.StatusCode.Should().Be(HttpStatusCode.OK); - } + private readonly InMemoryContainer _container = new("trig-stream", "/pk"); + + [Fact] + public async Task PostTrigger_OnUpsertStream_FiresAfterUpsert() + { + var triggered = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => triggered = true)); + + await _container.UpsertItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\"}")), + new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-flag" } }); + + triggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_OnReplaceStream_FiresAfterReplace() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var triggered = false; + _container.RegisterTrigger("post-flag", TriggerType.Post, TriggerOperation.All, + (Action)(_ => triggered = true)); + + await _container.ReplaceItemStreamAsync( + new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"id\":\"1\",\"pk\":\"a\",\"v\":\"upd\"}")), + "1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-flag" } }); + + triggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_OnDeleteStream_FiresAfterDelete() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + var triggered = false; + _container.RegisterTrigger("post-del", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => triggered = true)); + + await _container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-del" } }); + + triggered.Should().BeTrue(); + } + + [Fact] + public async Task PostTrigger_Stream_RollsBack_OnExceptionDuringDeleteStream() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + + _container.RegisterTrigger("fail-post", TriggerType.Post, TriggerOperation.Delete, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => _container.DeleteItemStreamAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail-post" } }); + await act.Should().ThrowAsync(); + + // Item should still exist (rolled back) + var read = await _container.ReadItemStreamAsync("1", new PartitionKey("a")); + read.StatusCode.Should().Be(HttpStatusCode.OK); + } } // ── M7: TriggerOperation Filtering ── public class TriggerOperationFilteringDeepDiveTests { - private readonly InMemoryContainer _container = new("trig-filter", "/pk"); + private readonly InMemoryContainer _container = new("trig-filter", "/pk"); - [Fact] - public async Task PreTrigger_OperationAll_FiresOnDelete() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task PreTrigger_OperationAll_FiresOnDelete() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var triggered = false; - _container.RegisterTrigger("pre-all", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { triggered = true; return doc; })); + var triggered = false; + _container.RegisterTrigger("pre-all", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { triggered = true; return doc; })); - await _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre-all" } }); + await _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre-all" } }); - triggered.Should().BeTrue(); - } + triggered.Should().BeTrue(); + } - [Fact] - public async Task PostTrigger_OperationAll_FiresOnDelete() - { - await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); + [Fact] + public async Task PostTrigger_OperationAll_FiresOnDelete() + { + await _container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a")); - var triggered = false; - _container.RegisterTrigger("post-all", TriggerType.Post, TriggerOperation.All, - (Action)(_ => triggered = true)); + var triggered = false; + _container.RegisterTrigger("post-all", TriggerType.Post, TriggerOperation.All, + (Action)(_ => triggered = true)); - await _container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "post-all" } }); + await _container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "post-all" } }); - triggered.Should().BeTrue(); - } + triggered.Should().BeTrue(); + } } // ── E4: Multiple Triggers ── public class TriggerMultipleTriggerTests { - private readonly InMemoryContainer _container = new("trig-multi", "/pk"); - - [Fact] - public async Task PreTrigger_MultipleTriggers_FirstMismatchSecondMatches() - { - _container.RegisterTrigger("trigA", TriggerType.Pre, TriggerOperation.Replace, - (Func)(doc => { doc["trigA"] = true; return doc; })); - _container.RegisterTrigger("trigB", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["trigB"] = true; return doc; })); - - await _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "trigA", "trigB" } }); - - var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["trigA"].Should().BeNull("trigA is for Replace, should not fire on Create"); - item["trigB"]!.Value().Should().BeTrue("trigB is for Create, should fire"); - } - - [Fact] - public async Task PreTrigger_MultipleTriggers_FirstNotFound_ThrowsBadRequest() - { - _container.RegisterTrigger("trigB", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => doc)); - - var act = () => _container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "unknown", "trigB" } }); - await act.Should().ThrowAsync(); - } + private readonly InMemoryContainer _container = new("trig-multi", "/pk"); + + [Fact] + public async Task PreTrigger_MultipleTriggers_FirstMismatchSecondMatches() + { + _container.RegisterTrigger("trigA", TriggerType.Pre, TriggerOperation.Replace, + (Func)(doc => { doc["trigA"] = true; return doc; })); + _container.RegisterTrigger("trigB", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["trigB"] = true; return doc; })); + + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "trigA", "trigB" } }); + + var item = (await _container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["trigA"].Should().BeNull("trigA is for Replace, should not fire on Create"); + item["trigB"]!.Value().Should().BeTrue("trigB is for Create, should fire"); + } + + [Fact] + public async Task PreTrigger_MultipleTriggers_FirstNotFound_ThrowsBadRequest() + { + _container.RegisterTrigger("trigB", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => doc)); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "unknown", "trigB" } }); + await act.Should().ThrowAsync(); + } } // ── E9: Same Name for Pre and Post ── public class TriggerSameNameTests { - [Fact] - public async Task RegisterTrigger_SameNameForPreAndPost_LastWins() - { - var container = new InMemoryContainer("trig-samename", "/pk"); - - var preFired = false; - container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.All, - (Func)(doc => { preFired = true; return doc; })); - // This overwrites the pre-trigger registration - container.RegisterTrigger("t1", TriggerType.Post, TriggerOperation.All, - (Action)(_ => { })); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "t1" } }); - - preFired.Should().BeFalse("pre-trigger was overwritten by post-trigger registration"); - } + [Fact] + public async Task RegisterTrigger_SameNameForPreAndPost_LastWins() + { + var container = new InMemoryContainer("trig-samename", "/pk"); + + var preFired = false; + container.RegisterTrigger("t1", TriggerType.Pre, TriggerOperation.All, + (Func)(doc => { preFired = true; return doc; })); + // This overwrites the pre-trigger registration + container.RegisterTrigger("t1", TriggerType.Post, TriggerOperation.All, + (Action)(_ => { })); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "t1" } }); + + preFired.Should().BeFalse("pre-trigger was overwritten by post-trigger registration"); + } } // ── M22: Triggers + IfMatchEtag ── public class TriggerETagInteractionTests { - [Fact] - public async Task PreTrigger_WithStaleEtag_EtagCheckedBeforeTrigger() - { - var container = new InMemoryContainer("trig-etag", "/pk"); - await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), new PartitionKey("a")); - - var preFired = false; - container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Replace, - (Func)(doc => { preFired = true; return doc; })); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", v = "upd" }), "1", new PartitionKey("a"), - new ItemRequestOptions { IfMatchEtag = "stale-etag", PreTriggers = new List { "pre" } }); - await act.Should().ThrowAsync(); - preFired.Should().BeFalse("ETag check should happen before trigger"); - } + [Fact] + public async Task PreTrigger_WithStaleEtag_EtagCheckedBeforeTrigger() + { + var container = new InMemoryContainer("trig-etag", "/pk"); + await container.CreateItemAsync(JObject.FromObject(new { id = "1", pk = "a", v = "orig" }), new PartitionKey("a")); + + var preFired = false; + container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Replace, + (Func)(doc => { preFired = true; return doc; })); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", v = "upd" }), "1", new PartitionKey("a"), + new ItemRequestOptions { IfMatchEtag = "stale-etag", PreTriggers = new List { "pre" } }); + await act.Should().ThrowAsync(); + preFired.Should().BeFalse("ETag check should happen before trigger"); + } } // ── M25: Upsert New Item Rollback ── public class TriggerUpsertNewItemRollbackTests { - [Fact] - public async Task PostTrigger_ExceptionRollsBack_Upsert_NewItem() - { - var container = new InMemoryContainer("trig-upsert-new", "/pk"); - - container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, - (Action)(_ => throw new InvalidOperationException("fail!"))); - - var act = () => container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions { PostTriggers = new List { "fail" } }); - await act.Should().ThrowAsync(); - - // Item should not exist at all (was a new upsert, rolled back) - var readAct = () => container.ReadItemAsync("1", new PartitionKey("a")); - await readAct.Should().ThrowAsync(); - } + [Fact] + public async Task PostTrigger_ExceptionRollsBack_Upsert_NewItem() + { + var container = new InMemoryContainer("trig-upsert-new", "/pk"); + + container.RegisterTrigger("fail", TriggerType.Post, TriggerOperation.All, + (Action)(_ => throw new InvalidOperationException("fail!"))); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions { PostTriggers = new List { "fail" } }); + await act.Should().ThrowAsync(); + + // Item should not exist at all (was a new upsert, rolled back) + var readAct = () => container.ReadItemAsync("1", new PartitionKey("a")); + await readAct.Should().ThrowAsync(); + } } // ── M9-M10: EnableContentResponseOnWrite + Triggers ── public class TriggerContentResponseOptionsTests { - [Fact] - public async Task PreTrigger_WithEnableContentResponseOnWriteFalse_StillModifiesDoc() - { - var container = new InMemoryContainer("trig-ecrow", "/pk"); - container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.Create, - (Func)(doc => { doc["injected"] = "yes"; return doc; })); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions - { - EnableContentResponseOnWrite = false, - PreTriggers = new List { "add-field" } - }); - - var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; - item["injected"]!.Value().Should().Be("yes"); - } - - [Fact] - public async Task PostTrigger_WithEnableContentResponseOnWriteFalse_StillFires() - { - var container = new InMemoryContainer("trig-ecrow2", "/pk"); - var triggered = false; - container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, - (Action)(_ => triggered = true)); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), - new ItemRequestOptions - { - EnableContentResponseOnWrite = false, - PostTriggers = new List { "post" } - }); - - triggered.Should().BeTrue(); - } + [Fact] + public async Task PreTrigger_WithEnableContentResponseOnWriteFalse_StillModifiesDoc() + { + var container = new InMemoryContainer("trig-ecrow", "/pk"); + container.RegisterTrigger("add-field", TriggerType.Pre, TriggerOperation.Create, + (Func)(doc => { doc["injected"] = "yes"; return doc; })); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions + { + EnableContentResponseOnWrite = false, + PreTriggers = new List { "add-field" } + }); + + var item = (await container.ReadItemAsync("1", new PartitionKey("a"))).Resource; + item["injected"]!.Value().Should().Be("yes"); + } + + [Fact] + public async Task PostTrigger_WithEnableContentResponseOnWriteFalse_StillFires() + { + var container = new InMemoryContainer("trig-ecrow2", "/pk"); + var triggered = false; + container.RegisterTrigger("post", TriggerType.Post, TriggerOperation.Create, + (Action)(_ => triggered = true)); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), new PartitionKey("a"), + new ItemRequestOptions + { + EnableContentResponseOnWrite = false, + PostTriggers = new List { "post" } + }); + + triggered.Should().BeTrue(); + } } // ── M12-M13: Nonexistent Item with Triggers ── public class TriggerNonexistentItemTests { - [Fact] - public async Task ReplaceNonExistentItem_WithPreTrigger_ThrowsNotFound() - { - var container = new InMemoryContainer("trig-nexist", "/pk"); - container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Replace, - (Func)(doc => doc)); - - var act = () => container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a" }), "1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre" } }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteNonExistentItem_WithPreTrigger_ThrowsNotFound() - { - var container = new InMemoryContainer("trig-nexist2", "/pk"); - container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Delete, - (Func)(doc => doc)); - - var act = () => container.DeleteItemAsync("1", new PartitionKey("a"), - new ItemRequestOptions { PreTriggers = new List { "pre" } }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task ReplaceNonExistentItem_WithPreTrigger_ThrowsNotFound() + { + var container = new InMemoryContainer("trig-nexist", "/pk"); + container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Replace, + (Func)(doc => doc)); + + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), "1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre" } }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteNonExistentItem_WithPreTrigger_ThrowsNotFound() + { + var container = new InMemoryContainer("trig-nexist2", "/pk"); + container.RegisterTrigger("pre", TriggerType.Pre, TriggerOperation.Delete, + (Func)(doc => doc)); + + var act = () => container.DeleteItemAsync("1", new PartitionKey("a"), + new ItemRequestOptions { PreTriggers = new List { "pre" } }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlExpiryPointReadBugReproduction.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlExpiryPointReadBugReproduction.cs index 3344de9..137602e 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlExpiryPointReadBugReproduction.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlExpiryPointReadBugReproduction.cs @@ -1,7 +1,7 @@ +using System.Net; using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Newtonsoft.Json; -using System.Net; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -16,90 +16,90 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class TtlExpiryPointReadBugReproduction { - [Fact] - public async Task ItemWithTTL_ShouldReturn404AfterExpiry() - { - var containerProps = new ContainerProperties("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 // Container TTL enabled, per-item TTL controls - }; - var container = new InMemoryContainer(containerProps); - - // Create item with 1-second TTL using standard "ttl" property name (no underscore) - var item = new TtlTestDocument { Id = "expiring-item", PartitionKey = "pk1", Ttl = 1, Data = "test" }; - await container.CreateItemAsync(item, new PartitionKey("pk1")); - - // Verify it exists immediately - var readBefore = await container.ReadItemAsync("expiring-item", new PartitionKey("pk1")); - readBefore.StatusCode.Should().Be(HttpStatusCode.OK); - - // Wait for TTL to expire - await Task.Delay(2000); - - // Point read should return 404 for expired items - var act = async () => await container.ReadItemAsync("expiring-item", new PartitionKey("pk1")); - var exception = (await act.Should().ThrowAsync()).Which; - exception.StatusCode.Should().Be(HttpStatusCode.NotFound, - "Item with expired TTL should return 404 on point read after expiry"); - } - - [Fact] - public async Task ItemWithTTL_StreamRead_ShouldReturn404AfterExpiry() - { - var containerProps = new ContainerProperties("ttl-stream-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - var container = new InMemoryContainer(containerProps); - - var item = new TtlTestDocument { Id = "stream-expiring", PartitionKey = "pk1", Ttl = 1, Data = "test" }; - await container.CreateItemAsync(item, new PartitionKey("pk1")); - - await Task.Delay(2000); - - var response = await container.ReadItemStreamAsync("stream-expiring", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound, - "Stream point read should also return 404 for expired TTL items"); - } - - [Fact] - public async Task ItemWithTTL_Query_ShouldFilterExpiredItems() - { - var containerProps = new ContainerProperties("ttl-query-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - var container = new InMemoryContainer(containerProps); - - var item = new TtlTestDocument { Id = "query-expiring", PartitionKey = "pk1", Ttl = 1, Data = "test" }; - await container.CreateItemAsync(item, new PartitionKey("pk1")); - - await Task.Delay(2000); - - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.id = 'query-expiring'")); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty("expired TTL items should be filtered from query results"); - } + [Fact] + public async Task ItemWithTTL_ShouldReturn404AfterExpiry() + { + var containerProps = new ContainerProperties("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 // Container TTL enabled, per-item TTL controls + }; + var container = new InMemoryContainer(containerProps); + + // Create item with 1-second TTL using standard "ttl" property name (no underscore) + var item = new TtlTestDocument { Id = "expiring-item", PartitionKey = "pk1", Ttl = 1, Data = "test" }; + await container.CreateItemAsync(item, new PartitionKey("pk1")); + + // Verify it exists immediately + var readBefore = await container.ReadItemAsync("expiring-item", new PartitionKey("pk1")); + readBefore.StatusCode.Should().Be(HttpStatusCode.OK); + + // Wait for TTL to expire + await Task.Delay(2000); + + // Point read should return 404 for expired items + var act = async () => await container.ReadItemAsync("expiring-item", new PartitionKey("pk1")); + var exception = (await act.Should().ThrowAsync()).Which; + exception.StatusCode.Should().Be(HttpStatusCode.NotFound, + "Item with expired TTL should return 404 on point read after expiry"); + } + + [Fact] + public async Task ItemWithTTL_StreamRead_ShouldReturn404AfterExpiry() + { + var containerProps = new ContainerProperties("ttl-stream-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + var container = new InMemoryContainer(containerProps); + + var item = new TtlTestDocument { Id = "stream-expiring", PartitionKey = "pk1", Ttl = 1, Data = "test" }; + await container.CreateItemAsync(item, new PartitionKey("pk1")); + + await Task.Delay(2000); + + var response = await container.ReadItemStreamAsync("stream-expiring", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "Stream point read should also return 404 for expired TTL items"); + } + + [Fact] + public async Task ItemWithTTL_Query_ShouldFilterExpiredItems() + { + var containerProps = new ContainerProperties("ttl-query-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + var container = new InMemoryContainer(containerProps); + + var item = new TtlTestDocument { Id = "query-expiring", PartitionKey = "pk1", Ttl = 1, Data = "test" }; + await container.CreateItemAsync(item, new PartitionKey("pk1")); + + await Task.Delay(2000); + + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.id = 'query-expiring'")); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty("expired TTL items should be filtered from query results"); + } } public class TtlTestDocument { - [JsonProperty("id")] - public string Id { get; set; } = default!; + [JsonProperty("id")] + public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] - public string PartitionKey { get; set; } = default!; + [JsonProperty("partitionKey")] + public string PartitionKey { get; set; } = default!; - [JsonProperty("ttl")] - public int Ttl { get; set; } + [JsonProperty("ttl")] + public int Ttl { get; set; } - [JsonProperty("data")] - public string Data { get; set; } = default!; + [JsonProperty("data")] + public string Data { get; set; } = default!; } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlTests.cs index b101f9a..4c3055f 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/TtlTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text; using AwesomeAssertions; using CosmosDB.InMemoryEmulator.ProductionExtensions; using Microsoft.Azure.Cosmos; @@ -5,8 +7,6 @@ using Microsoft.Azure.Cosmos.Scripts; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Net; -using System.Text; using Xunit; namespace CosmosDB.InMemoryEmulator.Tests; @@ -17,122 +17,122 @@ namespace CosmosDB.InMemoryEmulator.Tests; public class TtlContainerLevelTests { - [Fact] - public async Task ContainerTtl_ExpiredItems_NotReturnedByRead() - { - var container = new InMemoryContainer("ttl-container", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ContainerTtl_ExpiredItems_NotReturnedByQuery() - { - var container = new InMemoryContainer("ttl-container", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ContainerTtl_NonExpiredItems_StillReturned() - { - var container = new InMemoryContainer("ttl-container", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "LongLived" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("LongLived"); - } - - [Fact] - public async Task ContainerTtl_NullMeansNoExpiration() - { - var container = new InMemoryContainer("ttl-container", "/partitionKey") - { - DefaultTimeToLive = null - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoExpiry" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("NoExpiry"); - } - - [Fact] - public async Task ContainerTtl_LazyEviction_OnRead() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 2 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(3)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ContainerTtl_DeletedItemNotEvictedTwice() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 2 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await container.DeleteItemAsync("1", new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(3)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task ContainerTtl_ExpiredItems_NotReturnedByRead() + { + var container = new InMemoryContainer("ttl-container", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ContainerTtl_ExpiredItems_NotReturnedByQuery() + { + var container = new InMemoryContainer("ttl-container", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ContainerTtl_NonExpiredItems_StillReturned() + { + var container = new InMemoryContainer("ttl-container", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "LongLived" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("LongLived"); + } + + [Fact] + public async Task ContainerTtl_NullMeansNoExpiration() + { + var container = new InMemoryContainer("ttl-container", "/partitionKey") + { + DefaultTimeToLive = null + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoExpiry" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("NoExpiry"); + } + + [Fact] + public async Task ContainerTtl_LazyEviction_OnRead() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 2 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(3)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ContainerTtl_DeletedItemNotEvictedTwice() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 2 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await container.DeleteItemAsync("1", new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(3)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -141,157 +141,157 @@ await container.CreateItemAsync( public class TtlPerItemTests { - [Fact] - public async Task PerItemTtl_FromJsonField_Honored() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Short","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_MinusOne_NeverExpires_EvenWithContainerTtl() - { - // A1: In real Cosmos DB, _ttl = -1 means "never expire" even if container has DefaultTimeToLive. - // Bug fix: IsExpired was treating _ttl=-1 as elapsed >= -1 which is always true. - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should still be readable because _ttl=-1 overrides container TTL - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Immortal"); - } - - [Fact] - public async Task PerItemTtl_MinusOne_WithContainerTtlMinusOne_NeverExpires() - { - // A2: DefaultTimeToLive=-1 + _ttl=-1 → never expires - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Immortal"); - } - - [Fact] - public async Task PerItemTtl_IgnoredWhenContainerTtlIsNull() - { - // A3: Real Cosmos ignores per-item _ttl entirely when DefaultTimeToLive is null (TTL feature OFF). - // Bug fix: IsExpired was checking per-item _ttl before checking container-level setting. - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = null - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Survives","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should still be readable because container TTL is null (feature disabled) - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Survives"); - } - - [Fact] - public async Task PerItemTtl_LargerThanContainer_UsesItemTtl() - { - // A4: Per-item _ttl overrides container default — item lives longer - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"LongerLived","_ttl":60}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Container TTL is 1s but item has _ttl=60s, so item should still be alive - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("LongerLived"); - } - - [Fact] - public async Task PerItemTtl_SmallerThanContainer_UsesItemTtl() - { - // A5: Per-item _ttl overrides container default — item dies sooner - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"ShortLived","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_MultipleItemsDifferentTtls() - { - // A6: Multiple items in same container with different per-item TTLs - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; - var longJson = """{"id":"long","partitionKey":"pk1","name":"Long","_ttl":60}"""; - var defaultJson = """{"id":"default","partitionKey":"pk1","name":"Default"}"""; - - await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); - await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(longJson)), new PartitionKey("pk1")); - await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(defaultJson)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Short-lived item should be expired - var actShort = () => container.ReadItemAsync("short", new PartitionKey("pk1")); - var exShort = await actShort.Should().ThrowAsync(); - exShort.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - - // Long-lived and default items should still exist - var readLong = await container.ReadItemAsync("long", new PartitionKey("pk1")); - readLong.Resource.Name.Should().Be("Long"); - - var readDefault = await container.ReadItemAsync("default", new PartitionKey("pk1")); - readDefault.Resource.Name.Should().Be("Default"); - } + [Fact] + public async Task PerItemTtl_FromJsonField_Honored() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Short","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_MinusOne_NeverExpires_EvenWithContainerTtl() + { + // A1: In real Cosmos DB, _ttl = -1 means "never expire" even if container has DefaultTimeToLive. + // Bug fix: IsExpired was treating _ttl=-1 as elapsed >= -1 which is always true. + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should still be readable because _ttl=-1 overrides container TTL + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Immortal"); + } + + [Fact] + public async Task PerItemTtl_MinusOne_WithContainerTtlMinusOne_NeverExpires() + { + // A2: DefaultTimeToLive=-1 + _ttl=-1 → never expires + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Immortal"); + } + + [Fact] + public async Task PerItemTtl_IgnoredWhenContainerTtlIsNull() + { + // A3: Real Cosmos ignores per-item _ttl entirely when DefaultTimeToLive is null (TTL feature OFF). + // Bug fix: IsExpired was checking per-item _ttl before checking container-level setting. + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = null + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Survives","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should still be readable because container TTL is null (feature disabled) + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Survives"); + } + + [Fact] + public async Task PerItemTtl_LargerThanContainer_UsesItemTtl() + { + // A4: Per-item _ttl overrides container default — item lives longer + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"LongerLived","_ttl":60}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Container TTL is 1s but item has _ttl=60s, so item should still be alive + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("LongerLived"); + } + + [Fact] + public async Task PerItemTtl_SmallerThanContainer_UsesItemTtl() + { + // A5: Per-item _ttl overrides container default — item dies sooner + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"ShortLived","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_MultipleItemsDifferentTtls() + { + // A6: Multiple items in same container with different per-item TTLs + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; + var longJson = """{"id":"long","partitionKey":"pk1","name":"Long","_ttl":60}"""; + var defaultJson = """{"id":"default","partitionKey":"pk1","name":"Default"}"""; + + await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); + await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(longJson)), new PartitionKey("pk1")); + await container.CreateItemStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(defaultJson)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Short-lived item should be expired + var actShort = () => container.ReadItemAsync("short", new PartitionKey("pk1")); + var exShort = await actShort.Should().ThrowAsync(); + exShort.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Long-lived and default items should still exist + var readLong = await container.ReadItemAsync("long", new PartitionKey("pk1")); + readLong.Resource.Name.Should().Be("Long"); + + var readDefault = await container.ReadItemAsync("default", new PartitionKey("pk1")); + readDefault.Resource.Name.Should().Be("Default"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -300,64 +300,64 @@ public async Task PerItemTtl_MultipleItemsDifferentTtls() public class TtlContainerMinusOneTests { - [Fact] - public async Task ContainerTtlMinusOne_ItemsWithoutPerItemTtl_NeverExpire() - { - // B1: DefaultTimeToLive=-1 means TTL is enabled but items don't expire by default. - // Only items with explicit _ttl will expire. - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoItemTtl" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("NoItemTtl"); - } - - [Fact] - public async Task ContainerTtlMinusOne_ItemWithPerItemTtl_Expires() - { - // B2: DefaultTimeToLive=-1 but per-item _ttl=1 → item expires after 1s - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"WillDie","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ContainerTtlMinusOne_ItemWithTtlMinusOne_NeverExpires() - { - // B3: DefaultTimeToLive=-1 + _ttl=-1 → never expires - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Immortal"); - } + [Fact] + public async Task ContainerTtlMinusOne_ItemsWithoutPerItemTtl_NeverExpire() + { + // B1: DefaultTimeToLive=-1 means TTL is enabled but items don't expire by default. + // Only items with explicit _ttl will expire. + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NoItemTtl" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("NoItemTtl"); + } + + [Fact] + public async Task ContainerTtlMinusOne_ItemWithPerItemTtl_Expires() + { + // B2: DefaultTimeToLive=-1 but per-item _ttl=1 → item expires after 1s + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"WillDie","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ContainerTtlMinusOne_ItemWithTtlMinusOne_NeverExpires() + { + // B3: DefaultTimeToLive=-1 + _ttl=-1 → never expires + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Immortal","_ttl":-1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Immortal"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -366,196 +366,196 @@ await container.CreateItemStreamAsync( public class TtlWriteOperationTests { - [Fact] - public async Task UpsertResetsExpiration() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Updated"); - } - - [Fact] - public async Task ReplaceResetsExpiration() - { - // C1: Replace should reset the TTL clock, just like upsert - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Should still be alive (3s TTL reset 2s ago by replace) - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task PatchResetsExpiration() - { - // C2: Patch should reset the TTL clock - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - await container.PatchItemAsync( - "1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Should still be alive (3s TTL reset 2s ago by patch) - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - } - - [Fact] - public async Task Replace_OnExpiredItem_Returns404() - { - // C3: Replacing an expired item should return 404, not succeed silently - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_OnExpiredItem_Returns404() - { - // C4: Patching an expired item should return 404 - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.PatchItemAsync( - "1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Delete_OnExpiredItem_Returns404() - { - // C5: Deleting an expired item should return 404 - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.DeleteItemAsync("1", new PartitionKey("pk1")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Upsert_OnExpiredItem_Succeeds_CreatesNew() - { - // C6: Upsert on an expired item effectively creates a new item - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Upsert should succeed — the expired item is gone, so this acts as a create - var result = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Recreated"); - } - - [Fact] - public async Task Create_OnExpiredItem_Succeeds() - { - // C7: Creating an item with the same ID as an expired item should succeed - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Create should succeed — the expired item should have been evicted - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NewItem" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("NewItem"); - } + [Fact] + public async Task UpsertResetsExpiration() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Updated" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Updated"); + } + + [Fact] + public async Task ReplaceResetsExpiration() + { + // C1: Replace should reset the TTL clock, just like upsert + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Should still be alive (3s TTL reset 2s ago by replace) + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task PatchResetsExpiration() + { + // C2: Patch should reset the TTL clock + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + await container.PatchItemAsync( + "1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Should still be alive (3s TTL reset 2s ago by patch) + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + } + + [Fact] + public async Task Replace_OnExpiredItem_Returns404() + { + // C3: Replacing an expired item should return 404, not succeed silently + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_OnExpiredItem_Returns404() + { + // C4: Patching an expired item should return 404 + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.PatchItemAsync( + "1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Delete_OnExpiredItem_Returns404() + { + // C5: Deleting an expired item should return 404 + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.DeleteItemAsync("1", new PartitionKey("pk1")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Upsert_OnExpiredItem_Succeeds_CreatesNew() + { + // C6: Upsert on an expired item effectively creates a new item + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Upsert should succeed — the expired item is gone, so this acts as a create + var result = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Recreated"); + } + + [Fact] + public async Task Create_OnExpiredItem_Succeeds() + { + // C7: Creating an item with the same ID as an expired item should succeed + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Create should succeed — the expired item should have been evicted + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NewItem" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("NewItem"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -564,143 +564,143 @@ await container.CreateItemAsync( public class TtlReadPathTests { - [Fact] - public async Task ReadMany_ExcludesExpiredItems() - { - // D1: ReadManyItemsAsync should not return expired items - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expired" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AlsoExpired" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var results = await container.ReadManyItemsAsync( - new List<(string, PartitionKey)> - { - ("1", new PartitionKey("pk1")), - ("2", new PartitionKey("pk1")) - }); - - results.Resource.Should().BeEmpty(); - } - - [Fact] - public async Task ReadManyStream_ExcludesExpiredItems() - { - // D2: ReadManyItemsStreamAsync should not return expired items - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expired" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var response = await container.ReadManyItemsStreamAsync( - new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }); - - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var envelope = JObject.Parse(body); - var docs = envelope["Documents"] as JArray; - docs.Should().BeEmpty(); - } - - [Fact] - public async Task CrossPartitionQuery_ExcludesExpiredItems() - { - // D3: Cross-partition query should exclude expired items - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, - new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, - new PartitionKey("pk2")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task LinqQuery_ExcludesExpiredItems() - { - // D4: LINQ-based queries should exclude expired items - InMemoryFeedIteratorSetup.Register(); - - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemLinqQueryable() - .Where(d => d.Name == "Test") - .ToFeedIteratorOverridable(); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().BeEmpty(); - } - - [Fact] - public async Task CountAggregate_ExcludesExpiredItems() - { - // D5: COUNT aggregate should not include expired items - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Should().Be(0); - } + [Fact] + public async Task ReadMany_ExcludesExpiredItems() + { + // D1: ReadManyItemsAsync should not return expired items + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expired" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "AlsoExpired" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var results = await container.ReadManyItemsAsync( + new List<(string, PartitionKey)> + { + ("1", new PartitionKey("pk1")), + ("2", new PartitionKey("pk1")) + }); + + results.Resource.Should().BeEmpty(); + } + + [Fact] + public async Task ReadManyStream_ExcludesExpiredItems() + { + // D2: ReadManyItemsStreamAsync should not return expired items + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Expired" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var response = await container.ReadManyItemsStreamAsync( + new List<(string, PartitionKey)> { ("1", new PartitionKey("pk1")) }); + + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var envelope = JObject.Parse(body); + var docs = envelope["Documents"] as JArray; + docs.Should().BeEmpty(); + } + + [Fact] + public async Task CrossPartitionQuery_ExcludesExpiredItems() + { + // D3: Cross-partition query should exclude expired items + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "A" }, + new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk2", Name = "B" }, + new PartitionKey("pk2")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task LinqQuery_ExcludesExpiredItems() + { + // D4: LINQ-based queries should exclude expired items + InMemoryFeedIteratorSetup.Register(); + + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemLinqQueryable() + .Where(d => d.Name == "Test") + .ToFeedIteratorOverridable(); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().BeEmpty(); + } + + [Fact] + public async Task CountAggregate_ExcludesExpiredItems() + { + // D5: COUNT aggregate should not include expired items + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT VALUE COUNT(1) FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Should().Be(0); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -709,96 +709,96 @@ await container.CreateItemAsync( public class TtlContainerManagementTests { - [Fact] - public async Task ReplaceContainer_ChangesTtl_AffectsEviction() - { - // E1: Calling ReplaceContainerAsync to set DefaultTimeToLive should actually affect - // item expiration, not just store the property value. - var container = new InMemoryContainer("ttl-test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Enable TTL via ReplaceContainerAsync - await container.ReplaceContainerAsync( - new ContainerProperties("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should now be expired because container TTL was set to 1s - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DisablingTtl_StopsFutureExpiration() - { - // E2: Setting DefaultTimeToLive=null stops expiration; even items with per-item _ttl survive - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - // Disable TTL before the per-item _ttl expires - container.DefaultTimeToLive = null; - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should survive because TTL feature is now disabled - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } - - [Fact] - public async Task EnablingTtl_OnExistingContainer_ExpiresOldItems() - { - // E3: Enabling TTL on a container that already has items should expire items - // whose _ts (creation/update timestamp) is older than the new TTL. - var container = new InMemoryContainer("ttl-test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Now enable TTL with 1 second — item was written 2+ seconds ago - container.DefaultTimeToLive = 1; - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ChangingContainerTtl_AffectsExistingItems() - { - // E4: Changing container TTL from a long value to a short value should - // cause items to expire based on the new value. - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 60 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Reduce TTL to 1 second — item was created >2s ago - container.DefaultTimeToLive = 1; - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task ReplaceContainer_ChangesTtl_AffectsEviction() + { + // E1: Calling ReplaceContainerAsync to set DefaultTimeToLive should actually affect + // item expiration, not just store the property value. + var container = new InMemoryContainer("ttl-test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Enable TTL via ReplaceContainerAsync + await container.ReplaceContainerAsync( + new ContainerProperties("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should now be expired because container TTL was set to 1s + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DisablingTtl_StopsFutureExpiration() + { + // E2: Setting DefaultTimeToLive=null stops expiration; even items with per-item _ttl survive + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + // Disable TTL before the per-item _ttl expires + container.DefaultTimeToLive = null; + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should survive because TTL feature is now disabled + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } + + [Fact] + public async Task EnablingTtl_OnExistingContainer_ExpiresOldItems() + { + // E3: Enabling TTL on a container that already has items should expire items + // whose _ts (creation/update timestamp) is older than the new TTL. + var container = new InMemoryContainer("ttl-test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Now enable TTL with 1 second — item was written 2+ seconds ago + container.DefaultTimeToLive = 1; + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ChangingContainerTtl_AffectsExistingItems() + { + // E4: Changing container TTL from a long value to a short value should + // cause items to expire based on the new value. + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 60 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Reduce TTL to 1 second — item was created >2s ago + container.DefaultTimeToLive = 1; + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -807,46 +807,46 @@ await container.CreateItemAsync( public class TtlEdgeCaseTests { - [Fact] - public async Task VeryLargeTtl_ItemDoesNotExpire() - { - // F2: Max int TTL should not cause overflow or unexpected expiry - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = int.MaxValue - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Forever" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Forever"); - } - - [Fact] - public async Task CreateSameId_AfterExpiry_Succeeds() - { - // F4: After an item expires, creating a new item with the same ID should succeed - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Should not get 409 Conflict — the original is expired/evicted - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NewItem" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("NewItem"); - } + [Fact] + public async Task VeryLargeTtl_ItemDoesNotExpire() + { + // F2: Max int TTL should not cause overflow or unexpected expiry + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = int.MaxValue + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Forever" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Forever"); + } + + [Fact] + public async Task CreateSameId_AfterExpiry_Succeeds() + { + // F4: After an item expires, creating a new item with the same ID should succeed + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Should not get 409 Conflict — the original is expired/evicted + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NewItem" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("NewItem"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -855,87 +855,87 @@ await container.CreateItemAsync( public class TtlStreamVariantTests { - [Fact] - public async Task ReadItemStreamAsync_Returns404_ForExpiredItem() - { - // H1: Stream read should return 404 for expired items - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItemStreamAsync_Returns404_ForExpiredItem() - { - // H2: Stream replace on an expired item should return 404 - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var json = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; - var response = await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItemStreamAsync_Returns404_ForExpiredItem() - { - // H3: Stream delete on an expired item should return 404 - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PatchItemStreamAsync_Returns404_ForExpiredItem() - { - // H4: Stream patch on an expired item should return 404 - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var response = await container.PatchItemStreamAsync( - "1", new PartitionKey("pk1"), - new[] { PatchOperation.Set("/name", "Patched") }); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task ReadItemStreamAsync_Returns404_ForExpiredItem() + { + // H1: Stream read should return 404 for expired items + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var response = await container.ReadItemStreamAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItemStreamAsync_Returns404_ForExpiredItem() + { + // H2: Stream replace on an expired item should return 404 + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var json = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; + var response = await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItemStreamAsync_Returns404_ForExpiredItem() + { + // H3: Stream delete on an expired item should return 404 + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var response = await container.DeleteItemStreamAsync("1", new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PatchItemStreamAsync_Returns404_ForExpiredItem() + { + // H4: Stream patch on an expired item should return 404 + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var response = await container.PatchItemStreamAsync( + "1", new PartitionKey("pk1"), + new[] { PatchOperation.Set("/name", "Patched") }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -944,74 +944,74 @@ await container.CreateItemAsync( public class TtlChangeFeedDivergentTests { - [Fact] - public async Task TtlExpiry_ProducesChangeFeedDeleteEvent() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Trigger lazy eviction by reading the expired item - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) - var iterator = container.GetChangeFeedIterator(0); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page.Resource); - } - - // Create event + delete tombstone from lazy eviction - changes.Should().HaveCount(2); - } - - [Fact] - public async Task TtlExpiry_ChangeFeedDeleteTombstone_HasCorrectShape() - { - // When an item expires via TTL and is lazily evicted, a delete tombstone - // is now recorded in the change feed (matching real Cosmos behavior). - - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Trigger lazy eviction - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - - // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) - var iterator = container.GetChangeFeedIterator(0); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page.Resource); - } - - changes.Should().HaveCount(2); - var tombstone = changes.Last(); - tombstone["id"]!.Value().Should().Be("1"); - tombstone["_deleted"]!.Value().Should().BeTrue(); - } + [Fact] + public async Task TtlExpiry_ProducesChangeFeedDeleteEvent() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Trigger lazy eviction by reading the expired item + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) + var iterator = container.GetChangeFeedIterator(0); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page.Resource); + } + + // Create event + delete tombstone from lazy eviction + changes.Should().HaveCount(2); + } + + [Fact] + public async Task TtlExpiry_ChangeFeedDeleteTombstone_HasCorrectShape() + { + // When an item expires via TTL and is lazily evicted, a delete tombstone + // is now recorded in the change feed (matching real Cosmos behavior). + + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Trigger lazy eviction + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + + // Use checkpoint-based API (which includes deletes, unlike LatestVersion mode) + var iterator = container.GetChangeFeedIterator(0); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page.Resource); + } + + changes.Should().HaveCount(2); + var tombstone = changes.Last(); + tombstone["id"]!.Value().Should().Be("1"); + tombstone["_deleted"]!.Value().Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1020,69 +1020,69 @@ await container.CreateItemAsync( public class TtlBugFixTests { - [Fact] - public async Task Upsert_OnExpiredItem_Returns201Created() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var result = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, - new PartitionKey("pk1")); - - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UpsertStream_OnExpiredItem_Returns201Created() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Temp"}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var newJson = """{"id":"1","partitionKey":"pk1","name":"Reborn"}"""; - var result = await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(newJson)), new PartitionKey("pk1")); - - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task Upsert_OnExpiredItem_EvictsBeforeStatusCheck() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, - new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Reborn"); - } + [Fact] + public async Task Upsert_OnExpiredItem_Returns201Created() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var result = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, + new PartitionKey("pk1")); + + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UpsertStream_OnExpiredItem_Returns201Created() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Temp"}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var newJson = """{"id":"1","partitionKey":"pk1","name":"Reborn"}"""; + var result = await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(newJson)), new PartitionKey("pk1")); + + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Upsert_OnExpiredItem_EvictsBeforeStatusCheck() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, + new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Reborn"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1091,122 +1091,122 @@ await container.UpsertItemAsync( public class TtlPerItemExtendedTests { - public class TtlDocument - { - [JsonProperty("id")] public string Id { get; set; } = default!; - [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = default!; - [JsonProperty("name")] public string Name { get; set; } = default!; - [JsonProperty("_ttl")] public int? Ttl { get; set; } - } - - [Fact] - public async Task PerItemTtl_ViaTypedObject_WithJsonPropertyAttribute() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - await container.CreateItemAsync( - new TtlDocument { Id = "1", PartitionKey = "pk1", Name = "Typed", Ttl = 1 }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_ReplacedWithNewTtl_UsesNewValue() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Original","_ttl":60}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var newJson = """{"id":"1","partitionKey":"pk1","name":"Updated","_ttl":1}"""; - await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(newJson)), "1", new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_RemovedOnReplace_FallsBackToContainerDefault() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - // Create with long per-item TTL - var json = """{"id":"1","partitionKey":"pk1","name":"LongLived","_ttl":600}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - // Replace without _ttl → falls back to container default (1s) - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NowShort" }, - "1", new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_Queryable_InSelectProjection() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"HasTtl","_ttl":300}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var iterator = container.GetItemQueryIterator("SELECT c._ttl FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["_ttl"]!.Value().Should().Be(300); - } - - [Fact] - public async Task PerItemTtl_NonIntegerValue_Ignored() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - // _ttl is a string "abc" — should be ignored (treated as no per-item TTL) - var json = """{"id":"1","partitionKey":"pk1","name":"BadTtl","_ttl":"abc"}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Falls back to container default (1s), should be expired - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + public class TtlDocument + { + [JsonProperty("id")] public string Id { get; set; } = default!; + [JsonProperty("partitionKey")] public string PartitionKey { get; set; } = default!; + [JsonProperty("name")] public string Name { get; set; } = default!; + [JsonProperty("_ttl")] public int? Ttl { get; set; } + } + + [Fact] + public async Task PerItemTtl_ViaTypedObject_WithJsonPropertyAttribute() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + await container.CreateItemAsync( + new TtlDocument { Id = "1", PartitionKey = "pk1", Name = "Typed", Ttl = 1 }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_ReplacedWithNewTtl_UsesNewValue() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Original","_ttl":60}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var newJson = """{"id":"1","partitionKey":"pk1","name":"Updated","_ttl":1}"""; + await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(newJson)), "1", new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_RemovedOnReplace_FallsBackToContainerDefault() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + // Create with long per-item TTL + var json = """{"id":"1","partitionKey":"pk1","name":"LongLived","_ttl":600}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + // Replace without _ttl → falls back to container default (1s) + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "NowShort" }, + "1", new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_Queryable_InSelectProjection() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"HasTtl","_ttl":300}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var iterator = container.GetItemQueryIterator("SELECT c._ttl FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["_ttl"]!.Value().Should().Be(300); + } + + [Fact] + public async Task PerItemTtl_NonIntegerValue_Ignored() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + // _ttl is a string "abc" — should be ignored (treated as no per-item TTL) + var json = """{"id":"1","partitionKey":"pk1","name":"BadTtl","_ttl":"abc"}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Falls back to container default (1s), should be expired + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1215,110 +1215,110 @@ await container.CreateItemStreamAsync( public class TtlTransactionalBatchTests { - [Fact] - public async Task Batch_ReadExpiredItem_Returns404InBatchResult() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("1"); - var response = await batch.ExecuteAsync(); - - response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Batch_ReplaceExpiredItem_Returns404InBatchResult() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); - var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().NotBe(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_DeleteExpiredItem_Returns404InBatchResult() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.DeleteItem("1"); - var response = await batch.ExecuteAsync(); - - response.StatusCode.Should().NotBe(HttpStatusCode.OK); - } - - [Fact] - public async Task Batch_CreateAfterExpiry_Succeeds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }); - var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - } - - [Fact] - public async Task Batch_UpsertExpiredItem_Succeeds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }); - var response = await batch.ExecuteAsync(); - - response.IsSuccessStatusCode.Should().BeTrue(); - } + [Fact] + public async Task Batch_ReadExpiredItem_Returns404InBatchResult() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("1"); + var response = await batch.ExecuteAsync(); + + response[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Batch_ReplaceExpiredItem_Returns404InBatchResult() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReplaceItem("1", new TestDocument { Id = "1", PartitionKey = "pk1", Name = "New" }); + var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().NotBe(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_DeleteExpiredItem_Returns404InBatchResult() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.DeleteItem("1"); + var response = await batch.ExecuteAsync(); + + response.StatusCode.Should().NotBe(HttpStatusCode.OK); + } + + [Fact] + public async Task Batch_CreateAfterExpiry_Succeeds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }); + var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public async Task Batch_UpsertExpiredItem_Succeeds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.UpsertItem(new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }); + var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1327,61 +1327,61 @@ await container.CreateItemAsync( public class TtlContainerPropertiesTests { - [Fact] - public async Task ReadContainerAsync_ReturnsTtlInProperties() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 42 - }; - - var response = await container.ReadContainerAsync(); - response.Resource.DefaultTimeToLive.Should().Be(42); - } - - [Fact] - public async Task ReadContainerStreamAsync_ReturnsTtlInJsonBody() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 42 - }; - - var response = await container.ReadContainerStreamAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - jObj["defaultTtl"]!.Value().Should().Be(42); - } - - [Fact] - public async Task ReplaceContainerStreamAsync_UpdatesTtl() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 10 - }; - - var props = new ContainerProperties("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 99 - }; - await container.ReplaceContainerAsync(props); - - container.DefaultTimeToLive.Should().Be(99); - } - - [Fact] - public void ContainerCreatedViaContainerProperties_HasTtl() - { - var props = new ContainerProperties("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 30 - }; - var container = new InMemoryContainer(props); - - container.DefaultTimeToLive.Should().Be(30); - } + [Fact] + public async Task ReadContainerAsync_ReturnsTtlInProperties() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 42 + }; + + var response = await container.ReadContainerAsync(); + response.Resource.DefaultTimeToLive.Should().Be(42); + } + + [Fact] + public async Task ReadContainerStreamAsync_ReturnsTtlInJsonBody() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 42 + }; + + var response = await container.ReadContainerStreamAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + jObj["defaultTtl"]!.Value().Should().Be(42); + } + + [Fact] + public async Task ReplaceContainerStreamAsync_UpdatesTtl() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 10 + }; + + var props = new ContainerProperties("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 99 + }; + await container.ReplaceContainerAsync(props); + + container.DefaultTimeToLive.Should().Be(99); + } + + [Fact] + public void ContainerCreatedViaContainerProperties_HasTtl() + { + var props = new ContainerProperties("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 30 + }; + var container = new InMemoryContainer(props); + + container.DefaultTimeToLive.Should().Be(30); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1390,93 +1390,93 @@ public void ContainerCreatedViaContainerProperties_HasTtl() public class TtlSystemPropertyTests { - [Fact] - public async Task Ts_SystemProperty_SetOnCreate() - { - var container = new InMemoryContainer("ts-test", "/partitionKey"); + [Fact] + public async Task Ts_SystemProperty_SetOnCreate() + { + var container = new InMemoryContainer("ts-test", "/partitionKey"); - var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var ts = read.Resource["_ts"]!.Value(); - ts.Should().BeGreaterThanOrEqualTo(before); - ts.Should().BeLessThanOrEqualTo(after); - } + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var ts = read.Resource["_ts"]!.Value(); + ts.Should().BeGreaterThanOrEqualTo(before); + ts.Should().BeLessThanOrEqualTo(after); + } - [Fact] - public async Task Ts_SystemProperty_UpdatedOnReplace() - { - var container = new InMemoryContainer("ts-test", "/partitionKey"); + [Fact] + public async Task Ts_SystemProperty_UpdatedOnReplace() + { + var container = new InMemoryContainer("ts-test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = readBefore.Resource["_ts"]!.Value(); + var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = readBefore.Resource["_ts"]!.Value(); - await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); - await container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, - "1", new PartitionKey("pk1")); + await container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, + "1", new PartitionKey("pk1")); - var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = readAfter.Resource["_ts"]!.Value(); + var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = readAfter.Resource["_ts"]!.Value(); - tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); - } + tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); + } - [Fact] - public async Task Ts_SystemProperty_UpdatedOnUpsert() - { - var container = new InMemoryContainer("ts-test", "/partitionKey"); + [Fact] + public async Task Ts_SystemProperty_UpdatedOnUpsert() + { + var container = new InMemoryContainer("ts-test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = readBefore.Resource["_ts"]!.Value(); + var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = readBefore.Resource["_ts"]!.Value(); - await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, - new PartitionKey("pk1")); + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Upserted" }, + new PartitionKey("pk1")); - var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = readAfter.Resource["_ts"]!.Value(); + var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = readAfter.Resource["_ts"]!.Value(); - tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); - } + tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); + } - [Fact] - public async Task Ts_SystemProperty_UpdatedOnPatch() - { - var container = new InMemoryContainer("ts-test", "/partitionKey"); + [Fact] + public async Task Ts_SystemProperty_UpdatedOnPatch() + { + var container = new InMemoryContainer("ts-test", "/partitionKey"); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsBefore = readBefore.Resource["_ts"]!.Value(); + var readBefore = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsBefore = readBefore.Resource["_ts"]!.Value(); - await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Patched") }); + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Patched") }); - var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var tsAfter = readAfter.Resource["_ts"]!.Value(); + var readAfter = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var tsAfter = readAfter.Resource["_ts"]!.Value(); - tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); - } + tsAfter.Should().BeGreaterThanOrEqualTo(tsBefore); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1485,134 +1485,134 @@ await container.CreateItemAsync( public class TtlQueryPathExtendedTests { - private async Task CreateContainerWithExpiredAndLiveItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - // Item that will expire quickly - var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); - - // Item that won't expire - await container.CreateItemAsync( - new TestDocument { Id = "long", PartitionKey = "pk1", Name = "Long", Value = 42 }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - return container; - } - - [Fact] - public async Task QueryStreamIterator_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var arr = JObject.Parse(body)["Documents"]!.ToObject>()!; - results.AddRange(arr); - } - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("long"); - } - - [Fact] - public async Task WhereClauseQuery_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c.partitionKey = 'pk1'"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("long"); - } - - [Fact] - public async Task SumAggregate_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - // SELECT VALUE SUM returns a double (42.0), not int - var sumIterator = container.GetItemQueryIterator("SELECT VALUE SUM(c.value) FROM c"); - var sums = new List(); - while (sumIterator.HasMoreResults) - sums.AddRange(await sumIterator.ReadNextAsync()); - - sums.Should().Contain(42.0); - } - - [Fact] - public async Task DistinctQuery_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryIterator( - "SELECT DISTINCT c.name FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["name"]!.Value().Should().Be("Long"); - } - - [Fact] - public async Task OrderByQuery_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c ORDER BY c.name"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("long"); - } - - [Fact] - public async Task TopQuery_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryIterator("SELECT TOP 10 * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("long"); - } - - [Fact] - public async Task OffsetLimitQuery_ExcludesExpiredItems() - { - var container = await CreateContainerWithExpiredAndLiveItems(); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c OFFSET 0 LIMIT 10"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("long"); - } + private async Task CreateContainerWithExpiredAndLiveItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + // Item that will expire quickly + var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); + + // Item that won't expire + await container.CreateItemAsync( + new TestDocument { Id = "long", PartitionKey = "pk1", Name = "Long", Value = 42 }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + return container; + } + + [Fact] + public async Task QueryStreamIterator_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryStreamIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(); + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var arr = JObject.Parse(body)["Documents"]!.ToObject>()!; + results.AddRange(arr); + } + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("long"); + } + + [Fact] + public async Task WhereClauseQuery_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c.partitionKey = 'pk1'"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("long"); + } + + [Fact] + public async Task SumAggregate_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + // SELECT VALUE SUM returns a double (42.0), not int + var sumIterator = container.GetItemQueryIterator("SELECT VALUE SUM(c.value) FROM c"); + var sums = new List(); + while (sumIterator.HasMoreResults) + sums.AddRange(await sumIterator.ReadNextAsync()); + + sums.Should().Contain(42.0); + } + + [Fact] + public async Task DistinctQuery_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryIterator( + "SELECT DISTINCT c.name FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Long"); + } + + [Fact] + public async Task OrderByQuery_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c ORDER BY c.name"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("long"); + } + + [Fact] + public async Task TopQuery_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryIterator("SELECT TOP 10 * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("long"); + } + + [Fact] + public async Task OffsetLimitQuery_ExcludesExpiredItems() + { + var container = await CreateContainerWithExpiredAndLiveItems(); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c OFFSET 0 LIMIT 10"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("long"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1621,76 +1621,76 @@ public async Task OffsetLimitQuery_ExcludesExpiredItems() public class TtlStreamWriteResetTests { - [Fact] - public async Task UpsertStream_ResetsExpirationClock() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; + [Fact] + public async Task UpsertStream_ResetsExpirationClock() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var newJson = """{"id":"1","partitionKey":"pk1","name":"Upserted"}"""; - await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(newJson)), new PartitionKey("pk1")); + var newJson = """{"id":"1","partitionKey":"pk1","name":"Upserted"}"""; + await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(newJson)), new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Upserted"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Upserted"); + } - [Fact] - public async Task ReplaceStream_ResetsExpirationClock() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; + [Fact] + public async Task ReplaceStream_ResetsExpirationClock() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; - var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + var json = """{"id":"1","partitionKey":"pk1","name":"Original"}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var newJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; - await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(newJson)), "1", new PartitionKey("pk1")); + var newJson = """{"id":"1","partitionKey":"pk1","name":"Replaced"}"""; + await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(newJson)), "1", new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Replaced"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Replaced"); + } - [Fact] - public async Task PatchStream_ResetsExpirationClock() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 3 - }; + [Fact] + public async Task PatchStream_ResetsExpirationClock() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 3 + }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Patched") }); + await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Patched") }); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Patched"); - } + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Patched"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1699,58 +1699,58 @@ await container.CreateItemAsync( public class TtlHierarchicalPartitionKeyTests { - [Fact] - public async Task Ttl_WithHierarchicalPartitionKey_ExpiresCorrectly() - { - var container = new InMemoryContainer(new ContainerProperties("ttl-hpk", "/tenantId") - { - DefaultTimeToLive = 1, - PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/departmentId" } - }); - - var json = """{"id":"1","tenantId":"t1","departmentId":"d1","name":"Temp"}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKeyBuilder().Add("t1").Add("d1").Build()); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", - new PartitionKeyBuilder().Add("t1").Add("d1").Build()); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Ttl_WithHierarchicalPartitionKey_QueryExcludesExpired() - { - var container = new InMemoryContainer(new ContainerProperties("ttl-hpk", "/tenantId") - { - DefaultTimeToLive = -1, - PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/departmentId" } - }); - - // Item with per-item _ttl=1 will expire - var json = """{"id":"1","tenantId":"t1","departmentId":"d1","name":"Temp","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), - new PartitionKeyBuilder().Add("t1").Add("d1").Build()); - - // Item without per-item _ttl won't expire (DefaultTimeToLive=-1) - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenantId = "t1", departmentId = "d1", name = "Long" }), - new PartitionKeyBuilder().Add("t1").Add("d1").Build()); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["id"]!.Value().Should().Be("2"); - } + [Fact] + public async Task Ttl_WithHierarchicalPartitionKey_ExpiresCorrectly() + { + var container = new InMemoryContainer(new ContainerProperties("ttl-hpk", "/tenantId") + { + DefaultTimeToLive = 1, + PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/departmentId" } + }); + + var json = """{"id":"1","tenantId":"t1","departmentId":"d1","name":"Temp"}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKeyBuilder().Add("t1").Add("d1").Build()); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", + new PartitionKeyBuilder().Add("t1").Add("d1").Build()); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Ttl_WithHierarchicalPartitionKey_QueryExcludesExpired() + { + var container = new InMemoryContainer(new ContainerProperties("ttl-hpk", "/tenantId") + { + DefaultTimeToLive = -1, + PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/departmentId" } + }); + + // Item with per-item _ttl=1 will expire + var json = """{"id":"1","tenantId":"t1","departmentId":"d1","name":"Temp","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), + new PartitionKeyBuilder().Add("t1").Add("d1").Build()); + + // Item without per-item _ttl won't expire (DefaultTimeToLive=-1) + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenantId = "t1", departmentId = "d1", name = "Long" }), + new PartitionKeyBuilder().Add("t1").Add("d1").Build()); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1759,47 +1759,47 @@ await container.CreateItemAsync( public class TtlConcurrencyTests { - [Fact] - public async Task IfMatch_OnExpiredItem_Returns404NotPreconditionFailed() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfMatchEtag = etag }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task IfNoneMatch_OnExpiredItem_Returns404() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - var created = await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - var etag = created.ETag; - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1"), - new ItemRequestOptions { IfNoneMatchEtag = etag }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task IfMatch_OnExpiredItem_Returns404NotPreconditionFailed() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfMatchEtag = etag }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task IfNoneMatch_OnExpiredItem_Returns404() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + var created = await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + var etag = created.ETag; + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1"), + new ItemRequestOptions { IfNoneMatchEtag = etag }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1808,63 +1808,63 @@ public async Task IfNoneMatch_OnExpiredItem_Returns404() public class TtlChangeFeedExtendedTests { - [Fact] - public async Task ChangeFeed_ShowsItemWithTtlWhileAlive() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","name":"HasTtl","_ttl":600}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - results.AddRange(page.Resource); - } - - results.Should().HaveCount(1); - results[0]["_ttl"]!.Value().Should().Be(600); - } - - [Fact] - public async Task ChangeFeed_UpsertOnExpiredItem_ProducesNewCreateEvent() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - var checkpoint = container.GetChangeFeedCheckpoint(); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, - new PartitionKey("pk1")); - - var iterator = container.GetChangeFeedIterator(checkpoint); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().HaveCountGreaterThanOrEqualTo(1); - results.Last()["name"]!.Value().Should().Be("Reborn"); - } + [Fact] + public async Task ChangeFeed_ShowsItemWithTtlWhileAlive() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","name":"HasTtl","_ttl":600}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + results.AddRange(page.Resource); + } + + results.Should().HaveCount(1); + results[0]["_ttl"]!.Value().Should().Be(600); + } + + [Fact] + public async Task ChangeFeed_UpsertOnExpiredItem_ProducesNewCreateEvent() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + var checkpoint = container.GetChangeFeedCheckpoint(); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, + new PartitionKey("pk1")); + + var iterator = container.GetChangeFeedIterator(checkpoint); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().HaveCountGreaterThanOrEqualTo(1); + results.Last()["name"]!.Value().Should().Be("Reborn"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1873,57 +1873,57 @@ await container.UpsertItemAsync( public class TtlDivergentBehaviorDeepTests { - [Fact] - public void ContainerTtl_ZeroDefault_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey"); - var act = () => container.DefaultTimeToLive = 0; - act.Should().Throw(); - } - - [Fact] - public async Task PerItemTtl_Zero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = -1 - }; - - var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; - var act = () => container.CreateItemAsync(JObject.Parse(json), new PartitionKey("pk1")); - await act.Should().ThrowAsync(); - } - - [Fact(Skip = "DIVERGENT: Queries filter out expired items but do NOT evict them from memory. " - + "Real Cosmos DB has a background GC process. Only direct CRUD triggers EvictIfExpired().")] - public void Query_ShouldEvictExpiredItemsFromMemory() { } - - [Fact] - public async Task Query_EmulatorFiltersButDoesNotEvictExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") - { - DefaultTimeToLive = 1 - }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Query filters out expired items - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - results.Should().BeEmpty(); - - // But internal item count still includes the expired item (not evicted) - // Trigger eviction via a direct read attempt - container.DefaultTimeToLive = null; // Disable TTL so ItemCount reflects all stored items - container.ItemCount.Should().Be(1, "expired item is still in memory until evicted by direct CRUD"); - } + [Fact] + public void ContainerTtl_ZeroDefault_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey"); + var act = () => container.DefaultTimeToLive = 0; + act.Should().Throw(); + } + + [Fact] + public async Task PerItemTtl_Zero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = -1 + }; + + var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; + var act = () => container.CreateItemAsync(JObject.Parse(json), new PartitionKey("pk1")); + await act.Should().ThrowAsync(); + } + + [Fact(Skip = "DIVERGENT: Queries filter out expired items but do NOT evict them from memory. " + + "Real Cosmos DB has a background GC process. Only direct CRUD triggers EvictIfExpired().")] + public void Query_ShouldEvictExpiredItemsFromMemory() { } + + [Fact] + public async Task Query_EmulatorFiltersButDoesNotEvictExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") + { + DefaultTimeToLive = 1 + }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Query filters out expired items + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + results.Should().BeEmpty(); + + // But internal item count still includes the expired item (not evicted) + // Trigger eviction via a direct read attempt + container.DefaultTimeToLive = null; // Disable TTL so ItemCount reflects all stored items + container.ItemCount.Should().Be(1, "expired item is still in memory until evicted by direct CRUD"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1932,60 +1932,60 @@ await container.CreateItemAsync( public class TtlStreamTtlValidationTests { - [Fact] - public async Task CreateItemStreamAsync_WithTtlZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; - var response = await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task UpsertItemStreamAsync_WithTtlZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; - var response = await container.UpsertItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReplaceItemAsync_WithTtlZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var replacement = JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Replaced", _ttl = 0 }); - var act = () => container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ReplaceItemStreamAsync_WithTtlZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var json = """{"id":"1","partitionKey":"pk1","name":"Replaced","_ttl":0}"""; - var response = await container.ReplaceItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), "1", new PartitionKey("pk1")); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task CreateItemStreamAsync_WithTtlZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; + var response = await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task UpsertItemStreamAsync_WithTtlZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + var json = """{"id":"1","partitionKey":"pk1","_ttl":0}"""; + var response = await container.UpsertItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReplaceItemAsync_WithTtlZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var replacement = JObject.FromObject(new { id = "1", partitionKey = "pk1", name = "Replaced", _ttl = 0 }); + var act = () => container.ReplaceItemAsync(replacement, "1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ReplaceItemStreamAsync_WithTtlZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var json = """{"id":"1","partitionKey":"pk1","name":"Replaced","_ttl":0}"""; + var response = await container.ReplaceItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), "1", new PartitionKey("pk1")); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1994,108 +1994,108 @@ await container.CreateItemAsync( public class TtlPatchTtlValidationTests { - [Fact] - public async Task Patch_SetTtlToZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var act = () => container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/_ttl", 0) }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task PatchStream_SetTtlToZero_ShouldReturn400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - var response = await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/_ttl", 0) }); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Patch_SetTtlToMinusOne_Succeeds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/_ttl", -1) }); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should still be alive because per-item _ttl=-1 overrides container TTL - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_ttl"]!.Value().Should().Be(-1); - } - - [Fact] - public async Task Patch_SetTtlToPositive_Succeeds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/_ttl", 60) }); - - result.StatusCode.Should().Be(HttpStatusCode.OK); - result.Resource["_ttl"]!.Value().Should().Be(60); - } - - [Fact] - public async Task Patch_RemoveTtl_FallsBackToContainerDefault() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - // Create item with per-item _ttl=-1 (never expires) - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":-1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - // Remove per-item _ttl — item should fall back to container's DefaultTimeToLive=1 - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Remove("/_ttl") }); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task Patch_IncrementTtl_ToZero_Returns400() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":5}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var act = () => container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Increment("/_ttl", -5) }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + [Fact] + public async Task Patch_SetTtlToZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/_ttl", 0) }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PatchStream_SetTtlToZero_ShouldReturn400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + var response = await container.PatchItemStreamAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/_ttl", 0) }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_SetTtlToMinusOne_Succeeds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/_ttl", -1) }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should still be alive because per-item _ttl=-1 overrides container TTL + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_ttl"]!.Value().Should().Be(-1); + } + + [Fact] + public async Task Patch_SetTtlToPositive_Succeeds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var result = await container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/_ttl", 60) }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Resource["_ttl"]!.Value().Should().Be(60); + } + + [Fact] + public async Task Patch_RemoveTtl_FallsBackToContainerDefault() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + // Create item with per-item _ttl=-1 (never expires) + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":-1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + // Remove per-item _ttl — item should fall back to container's DefaultTimeToLive=1 + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Remove("/_ttl") }); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Patch_IncrementTtl_ToZero_Returns400() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":5}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var act = () => container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Increment("/_ttl", -5) }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2104,91 +2104,91 @@ await container.CreateItemStreamAsync( public class TtlStatePersistenceTests { - [Fact] - public async Task ExportState_ShouldExcludeExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var state = container.ExportState(); - var parsed = JObject.Parse(state); - var items = parsed["items"] as JArray; - items.Should().BeEmpty("expired items should not be exported"); - } - - [Fact] - public async Task ExportState_AfterEviction_ExcludesEvictedItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Trigger eviction via a read attempt - try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } catch { } - - var state = container.ExportState(); - var parsed = JObject.Parse(state); - var items = parsed["items"] as JArray; - items.Should().BeEmpty("evicted items should not appear in export"); - } - - [Fact] - public async Task ImportState_ResetsTimestamps_ItemsGetNewTtlCountdown() - { - // Create container with TTL=60 and export state - var source = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; - await source.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var state = source.ExportState(); - - // Wait a bit, then import into another container with TTL=1 - await Task.Delay(TimeSpan.FromSeconds(1)); - var target = new InMemoryContainer("ttl-test2", "/partitionKey") { DefaultTimeToLive = 1 }; - target.ImportState(state); - - // Item should be readable immediately after import (timestamps reset to now) - var read = await target.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - - // But should expire after TTL - await Task.Delay(TimeSpan.FromSeconds(2)); - var act = () => target.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ImportState_WithTtlZeroItem_ImportsButExpiresImmediately() - { - // ImportState does NOT call ValidatePerItemTtl. - // Items with _ttl:0 are imported but expire immediately on read (elapsed >= 0 is always true). - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - var stateJson = """{"items":[{"id":"1","partitionKey":"pk1","name":"Test","_ttl":0}]}"""; - // ImportState succeeds (no validation) - container.ImportState(stateJson); - - // Item is stored but _ttl:0 means immediate expiry - container.DefaultTimeToLive = null; // Disable TTL to check raw storage - container.ItemCount.Should().Be(1, "import stored the item"); - - // Re-enable TTL — item now expires immediately - container.DefaultTimeToLive = -1; - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task ExportState_ShouldExcludeExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var state = container.ExportState(); + var parsed = JObject.Parse(state); + var items = parsed["items"] as JArray; + items.Should().BeEmpty("expired items should not be exported"); + } + + [Fact] + public async Task ExportState_AfterEviction_ExcludesEvictedItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Trigger eviction via a read attempt + try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } catch { } + + var state = container.ExportState(); + var parsed = JObject.Parse(state); + var items = parsed["items"] as JArray; + items.Should().BeEmpty("evicted items should not appear in export"); + } + + [Fact] + public async Task ImportState_ResetsTimestamps_ItemsGetNewTtlCountdown() + { + // Create container with TTL=60 and export state + var source = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; + await source.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var state = source.ExportState(); + + // Wait a bit, then import into another container with TTL=1 + await Task.Delay(TimeSpan.FromSeconds(1)); + var target = new InMemoryContainer("ttl-test2", "/partitionKey") { DefaultTimeToLive = 1 }; + target.ImportState(state); + + // Item should be readable immediately after import (timestamps reset to now) + var read = await target.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + + // But should expire after TTL + await Task.Delay(TimeSpan.FromSeconds(2)); + var act = () => target.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ImportState_WithTtlZeroItem_ImportsButExpiresImmediately() + { + // ImportState does NOT call ValidatePerItemTtl. + // Items with _ttl:0 are imported but expire immediately on read (elapsed >= 0 is always true). + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + var stateJson = """{"items":[{"id":"1","partitionKey":"pk1","name":"Test","_ttl":0}]}"""; + // ImportState succeeds (no validation) + container.ImportState(stateJson); + + // Item is stored but _ttl:0 means immediate expiry + container.DefaultTimeToLive = null; // Disable TTL to check raw storage + container.ItemCount.Should().Be(1, "import stored the item"); + + // Re-enable TTL — item now expires immediately + container.DefaultTimeToLive = -1; + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2197,62 +2197,62 @@ public async Task ImportState_WithTtlZeroItem_ImportsButExpiresImmediately() public class TtlUniqueKeyTests { - [Fact] - public async Task UniqueKey_ExpiredItem_AllowsNewItemWithSameUniqueKey() - { - var properties = new ContainerProperties("ttl-uk", "/partitionKey") - { - DefaultTimeToLive = 1, - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Expired item should not block new item with same unique key - // Trigger eviction first with a read - try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } catch { } - - var result = await container.CreateItemAsync( - new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - result.StatusCode.Should().Be(HttpStatusCode.Created); - } - - [Fact] - public async Task UniqueKey_UnevictedExpiredItem_UpsertSucceedsWithSameKey() - { - var properties = new ContainerProperties("ttl-uk", "/partitionKey") - { - DefaultTimeToLive = 1, - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/name" } } } - } - }; - var container = new InMemoryContainer(properties); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Upsert with same id — should succeed because item is expired - var result = await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, - new PartitionKey("pk1")); - - result.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.OK); - } + [Fact] + public async Task UniqueKey_ExpiredItem_AllowsNewItemWithSameUniqueKey() + { + var properties = new ContainerProperties("ttl-uk", "/partitionKey") + { + DefaultTimeToLive = 1, + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Expired item should not block new item with same unique key + // Trigger eviction first with a read + try { await container.ReadItemAsync("1", new PartitionKey("pk1")); } catch { } + + var result = await container.CreateItemAsync( + new TestDocument { Id = "2", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + result.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task UniqueKey_UnevictedExpiredItem_UpsertSucceedsWithSameKey() + { + var properties = new ContainerProperties("ttl-uk", "/partitionKey") + { + DefaultTimeToLive = 1, + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Upsert with same id — should succeed because item is expired + var result = await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Unique" }, + new PartitionKey("pk1")); + + result.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2261,77 +2261,77 @@ await container.CreateItemAsync( public class TtlBulkOperationTests { - [Fact] - public async Task BulkCreate_WithTtl_ItemsExpireCorrectly() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - var tasks = Enumerable.Range(0, 10).Select(i => - container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, - new PartitionKey("pk1"))); - - await Task.WhenAll(tasks); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task BulkUpsert_OnExpiredItems_Succeeds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - // Create items - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Old{i}" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Bulk upsert on expired items - var tasks = Enumerable.Range(0, 5).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - } - - [Fact] - public async Task BulkRead_ExcludesExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - // Create mix: items with _ttl=1 and items with _ttl=-1 - var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); - - await container.CreateItemAsync( - new TestDocument { Id = "long", PartitionKey = "pk1", Name = "Long" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // ReadMany should only return the live item - var items = new List<(string, PartitionKey)> - { - ("short", new PartitionKey("pk1")), - ("long", new PartitionKey("pk1")) - }; - var readMany = await container.ReadManyItemsAsync(items); - readMany.Resource.Should().HaveCount(1); - readMany.Resource.First().Id.Should().Be("long"); - } + [Fact] + public async Task BulkCreate_WithTtl_ItemsExpireCorrectly() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + var tasks = Enumerable.Range(0, 10).Select(i => + container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Item{i}" }, + new PartitionKey("pk1"))); + + await Task.WhenAll(tasks); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task BulkUpsert_OnExpiredItems_Succeeds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + // Create items + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"Old{i}" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Bulk upsert on expired items + var tasks = Enumerable.Range(0, 5).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = "pk1", Name = $"New{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + } + + [Fact] + public async Task BulkRead_ExcludesExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + // Create mix: items with _ttl=1 and items with _ttl=-1 + var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); + + await container.CreateItemAsync( + new TestDocument { Id = "long", PartitionKey = "pk1", Name = "Long" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // ReadMany should only return the live item + var items = new List<(string, PartitionKey)> + { + ("short", new PartitionKey("pk1")), + ("long", new PartitionKey("pk1")) + }; + var readMany = await container.ReadManyItemsAsync(items); + readMany.Resource.Should().HaveCount(1); + readMany.Resource.First().Id.Should().Be("long"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2340,52 +2340,52 @@ await container.CreateItemAsync( public class TtlDeleteAllByPartitionKeyTests { - [Fact] - public async Task DeleteAllByPK_IncludesExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task DeleteAllByPK_IncludesExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - // Delete all items in partition — should work even though items are expired - var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - response.StatusCode.Should().Be(HttpStatusCode.OK); + // Delete all items in partition — should work even though items are expired + var response = await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + response.StatusCode.Should().Be(HttpStatusCode.OK); - // Disable TTL to check internal state - container.DefaultTimeToLive = null; - container.ItemCount.Should().Be(0, "all items including expired ones should be removed"); - } + // Disable TTL to check internal state + container.DefaultTimeToLive = null; + container.ItemCount.Should().Be(0, "all items including expired ones should be removed"); + } - [Fact] - public async Task DeleteAllByPK_ExpiredItems_ProduceTombstones() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task DeleteAllByPK_ExpiredItems_ProduceTombstones() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - var checkpoint = container.GetChangeFeedCheckpoint(); + var checkpoint = container.GetChangeFeedCheckpoint(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); + await container.DeleteAllItemsByPartitionKeyStreamAsync(new PartitionKey("pk1")); - var iterator = container.GetChangeFeedIterator(checkpoint); - var events = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - events.AddRange(page); - } + var iterator = container.GetChangeFeedIterator(checkpoint); + var events = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + events.AddRange(page); + } - // Should have at least a create event and a delete tombstone - events.Should().HaveCountGreaterThanOrEqualTo(2); - } + // Should have at least a create event and a delete tombstone + events.Should().HaveCountGreaterThanOrEqualTo(2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2394,58 +2394,58 @@ await container.CreateItemAsync( public class TtlComputedPropertyTests { - [Fact] - public async Task ComputedProperty_OnExpiredItem_NotReturnedInQuery() - { - var props = new ContainerProperties("ttl-cp", "/partitionKey") - { - DefaultTimeToLive = 1, - ComputedProperties = new System.Collections.ObjectModel.Collection - { - new ComputedProperty { Name = "upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } - } - }; - var container = new InMemoryContainer(props); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var iterator = container.GetItemQueryIterator("SELECT c.upperName FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task ComputedProperty_BasedOnTtl_EvaluatedCorrectly() - { - var props = new ContainerProperties("ttl-cp", "/partitionKey") - { - DefaultTimeToLive = -1, - ComputedProperties = new System.Collections.ObjectModel.Collection - { - new ComputedProperty { Name = "hasTtl", Query = "SELECT VALUE IS_DEFINED(c._ttl) FROM c" } - } - }; - var container = new InMemoryContainer(props); - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":30}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var iterator = container.GetItemQueryIterator("SELECT c.hasTtl FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["hasTtl"]!.Value().Should().BeTrue(); - } + [Fact] + public async Task ComputedProperty_OnExpiredItem_NotReturnedInQuery() + { + var props = new ContainerProperties("ttl-cp", "/partitionKey") + { + DefaultTimeToLive = 1, + ComputedProperties = new System.Collections.ObjectModel.Collection + { + new ComputedProperty { Name = "upperName", Query = "SELECT VALUE UPPER(c.name) FROM c" } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "hello" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var iterator = container.GetItemQueryIterator("SELECT c.upperName FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ComputedProperty_BasedOnTtl_EvaluatedCorrectly() + { + var props = new ContainerProperties("ttl-cp", "/partitionKey") + { + DefaultTimeToLive = -1, + ComputedProperties = new System.Collections.ObjectModel.Collection + { + new ComputedProperty { Name = "hasTtl", Query = "SELECT VALUE IS_DEFINED(c._ttl) FROM c" } + } + }; + var container = new InMemoryContainer(props); + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":30}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var iterator = container.GetItemQueryIterator("SELECT c.hasTtl FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["hasTtl"]!.Value().Should().BeTrue(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2454,64 +2454,64 @@ await container.CreateItemStreamAsync( public class TtlFeedRangeTests { - [Fact] - public async Task FeedRange_Query_ExcludesExpiredItems() - { - var container = new InMemoryContainer("ttl-fr", "/partitionKey") - { - DefaultTimeToLive = 1, - FeedRangeCount = 4 - }; - - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, - new PartitionKey($"pk-{i}")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT * FROM c")); - while (iterator.HasMoreResults) - allResults.AddRange(await iterator.ReadNextAsync()); - } - - allResults.Should().BeEmpty(); - } - - [Fact] - public async Task ChangeFeed_WithFeedRange_ShowsExpiredItemHistory() - { - var container = new InMemoryContainer("ttl-fr", "/partitionKey") - { - DefaultTimeToLive = 1, - FeedRangeCount = 2 - }; - - var checkpoint = container.GetChangeFeedCheckpoint(); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Change feed should still show the creation event even though item expired - var iterator = container.GetChangeFeedIterator(checkpoint); - var events = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - events.AddRange(page); - } - - events.Should().HaveCountGreaterThanOrEqualTo(1); - events[0]["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task FeedRange_Query_ExcludesExpiredItems() + { + var container = new InMemoryContainer("ttl-fr", "/partitionKey") + { + DefaultTimeToLive = 1, + FeedRangeCount = 4 + }; + + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + new TestDocument { Id = $"{i}", PartitionKey = $"pk-{i}", Name = $"Item{i}" }, + new PartitionKey($"pk-{i}")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT * FROM c")); + while (iterator.HasMoreResults) + allResults.AddRange(await iterator.ReadNextAsync()); + } + + allResults.Should().BeEmpty(); + } + + [Fact] + public async Task ChangeFeed_WithFeedRange_ShowsExpiredItemHistory() + { + var container = new InMemoryContainer("ttl-fr", "/partitionKey") + { + DefaultTimeToLive = 1, + FeedRangeCount = 2 + }; + + var checkpoint = container.GetChangeFeedCheckpoint(); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Change feed should still show the creation event even though item expired + var iterator = container.GetChangeFeedIterator(checkpoint); + var events = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + events.AddRange(page); + } + + events.Should().HaveCountGreaterThanOrEqualTo(1); + events[0]["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2520,79 +2520,79 @@ await container.CreateItemAsync( public class TtlPerItemEdgeCaseTests { - [Fact] - public async Task PerItemTtl_FloatingPointValue_TreatedAsContainerDefault() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; - - // _ttl=1.5 is not a valid integer — int.TryParse fails, so no per-item override - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":1.5}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Item should still be alive (container TTL=60, float _ttl treated as non-integer) - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task PerItemTtl_NegativeNotMinusOne_ExpiresImmediately() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; - - // _ttl=-2: Only -1 is "never expire". Other negatives cause elapsed >= -2 which - // is always true, so the item expires immediately in the emulator. - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":-2}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PerItemTtl_VeryLargeValue_NoOverflow() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":2147483647}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["_ttl"]!.Value().Should().Be(int.MaxValue); - } - - [Fact] - public async Task PerItemTtl_Boolean_TreatedAsNonInteger() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":true}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - // Boolean _ttl: int.TryParse("True") fails, so no per-item override, uses container TTL - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["id"]!.Value().Should().Be("1"); - } - - [Fact] - public async Task PerItemTtl_Null_TreatedAsNoPerItemOverride() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; - - var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":null}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - - // null _ttl: ttlToken is not null but ToString() gives "" which fails int.TryParse → uses container TTL - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource["id"]!.Value().Should().Be("1"); - } + [Fact] + public async Task PerItemTtl_FloatingPointValue_TreatedAsContainerDefault() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; + + // _ttl=1.5 is not a valid integer — int.TryParse fails, so no per-item override + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":1.5}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Item should still be alive (container TTL=60, float _ttl treated as non-integer) + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task PerItemTtl_NegativeNotMinusOne_ExpiresImmediately() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; + + // _ttl=-2: Only -1 is "never expire". Other negatives cause elapsed >= -2 which + // is always true, so the item expires immediately in the emulator. + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":-2}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PerItemTtl_VeryLargeValue_NoOverflow() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":2147483647}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["_ttl"]!.Value().Should().Be(int.MaxValue); + } + + [Fact] + public async Task PerItemTtl_Boolean_TreatedAsNonInteger() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":true}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + // Boolean _ttl: int.TryParse("True") fails, so no per-item override, uses container TTL + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["id"]!.Value().Should().Be("1"); + } + + [Fact] + public async Task PerItemTtl_Null_TreatedAsNoPerItemOverride() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 60 }; + + var json = """{"id":"1","partitionKey":"pk1","name":"Test","_ttl":null}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + + // null _ttl: ttlToken is not null but ToString() gives "" which fails int.TryParse → uses container TTL + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource["id"]!.Value().Should().Be("1"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2601,55 +2601,55 @@ await container.CreateItemStreamAsync( public class TtlTriggerTests { - [Fact] - public async Task PreTrigger_OnExpiredItem_NotFired_Returns404() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - var triggerFired = false; - container.RegisterTrigger("preTrigger", TriggerType.Pre, TriggerOperation.Replace, - doc => { triggerFired = true; return doc; }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var act = () => container.ReplaceItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", - new PartitionKey("pk1"), - new ItemRequestOptions { PreTriggers = new List { "preTrigger" } }); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - // ETag/existence check now happens before pre-triggers, so expired item → 404 without firing trigger - triggerFired.Should().BeFalse(); - } - - [Fact] - public async Task PostTrigger_AfterUpsertOnExpired_IsFired() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - JObject? captured = null; - container.RegisterTrigger("postTrigger", TriggerType.Post, TriggerOperation.All, - doc => { captured = doc; }); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - // Upsert on expired item creates a new item - await container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, - new PartitionKey("pk1"), - new ItemRequestOptions { PostTriggers = new List { "postTrigger" } }); - - captured.Should().NotBeNull(); - captured["name"]!.Value().Should().Be("Reborn"); - } + [Fact] + public async Task PreTrigger_OnExpiredItem_NotFired_Returns404() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + var triggerFired = false; + container.RegisterTrigger("preTrigger", TriggerType.Pre, TriggerOperation.Replace, + doc => { triggerFired = true; return doc; }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var act = () => container.ReplaceItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Replaced" }, "1", + new PartitionKey("pk1"), + new ItemRequestOptions { PreTriggers = new List { "preTrigger" } }); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + // ETag/existence check now happens before pre-triggers, so expired item → 404 without firing trigger + triggerFired.Should().BeFalse(); + } + + [Fact] + public async Task PostTrigger_AfterUpsertOnExpired_IsFired() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + JObject? captured = null; + container.RegisterTrigger("postTrigger", TriggerType.Post, TriggerOperation.All, + doc => { captured = doc; }); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Original" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Upsert on expired item creates a new item + await container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Reborn" }, + new PartitionKey("pk1"), + new ItemRequestOptions { PostTriggers = new List { "postTrigger" } }); + + captured.Should().NotBeNull(); + captured["name"]!.Value().Should().Be("Reborn"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2658,69 +2658,69 @@ await container.UpsertItemAsync( public class TtlTsDeepTests { - [Fact] - public async Task Ts_IsUnixTimestamp_InSeconds() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey"); - - var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - var ts = read.Resource["_ts"]!.Value(); - - ts.Should().BeGreaterThanOrEqualTo(before); - ts.Should().BeLessThanOrEqualTo(after); - } - - [Fact] - public async Task Ts_PreservedInQuery_SelectProjection() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey"); - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - var iterator = container.GetItemQueryIterator("SELECT c._ts FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - results[0]["_ts"]!.Value().Should().BeGreaterThan(0); - } - - [Fact] - public async Task Ts_UsedForTtlCalculation_InternalTimestampDrivesTtl() - { - // The emulator tracks timestamps internally in _timestamps dictionary. - // Patching a document resets the internal timestamp (TTL clock reset). - // This test verifies that TTL is driven by the internal timestamp, not _ts in the doc. - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 3 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Patch something non-TTL related — this resets the internal timestamp - await container.PatchItemAsync("1", new PartitionKey("pk1"), - new List { PatchOperation.Set("/name", "Updated") }); - - // Item should still be alive because the patch reset the TTL clock - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Updated"); - - await Task.Delay(TimeSpan.FromSeconds(4)); - - // Now it should be expired - var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + [Fact] + public async Task Ts_IsUnixTimestamp_InSeconds() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey"); + + var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + var ts = read.Resource["_ts"]!.Value(); + + ts.Should().BeGreaterThanOrEqualTo(before); + ts.Should().BeLessThanOrEqualTo(after); + } + + [Fact] + public async Task Ts_PreservedInQuery_SelectProjection() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey"); + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + var iterator = container.GetItemQueryIterator("SELECT c._ts FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["_ts"]!.Value().Should().BeGreaterThan(0); + } + + [Fact] + public async Task Ts_UsedForTtlCalculation_InternalTimestampDrivesTtl() + { + // The emulator tracks timestamps internally in _timestamps dictionary. + // Patching a document resets the internal timestamp (TTL clock reset). + // This test verifies that TTL is driven by the internal timestamp, not _ts in the doc. + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 3 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Patch something non-TTL related — this resets the internal timestamp + await container.PatchItemAsync("1", new PartitionKey("pk1"), + new List { PatchOperation.Set("/name", "Updated") }); + + // Item should still be alive because the patch reset the TTL clock + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Updated"); + + await Task.Delay(TimeSpan.FromSeconds(4)); + + // Now it should be expired + var act = () => container.ReadItemAsync("1", new PartitionKey("pk1")); + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2729,61 +2729,61 @@ await container.CreateItemAsync( public class TtlConcurrencyDeepTests { - [Fact] - public async Task ConcurrentReads_OnExpiringItem_AllGet404AfterExpiry() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); - - // Wait long enough for TTL=1s to definitely expire, even under load - await Task.Delay(TimeSpan.FromSeconds(3)); - - var tasks = Enumerable.Range(0, 20).Select(_ => Task.Run(async () => - { - try - { - await container.ReadItemAsync("1", new PartitionKey("pk1")); - return false; // Should not reach here - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return true; - } - })); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r, "all concurrent reads on expired item should get 404"); - } - - [Fact] - public async Task ConcurrentUpsert_OnExpiredItem_OneWins_NoDataCorruption() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, - new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(2)); - - var tasks = Enumerable.Range(0, 10).Select(i => - container.UpsertItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Upsert{i}" }, - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - - // All should succeed (either as create or update) - results.Should().OnlyContain(r => - r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); - - // Final state should be one of the upserted values - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().StartWith("Upsert"); - } + [Fact] + public async Task ConcurrentReads_OnExpiringItem_AllGet404AfterExpiry() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); + + // Wait long enough for TTL=1s to definitely expire, even under load + await Task.Delay(TimeSpan.FromSeconds(3)); + + var tasks = Enumerable.Range(0, 20).Select(_ => Task.Run(async () => + { + try + { + await container.ReadItemAsync("1", new PartitionKey("pk1")); + return false; // Should not reach here + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return true; + } + })); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r, "all concurrent reads on expired item should get 404"); + } + + [Fact] + public async Task ConcurrentUpsert_OnExpiredItem_OneWins_NoDataCorruption() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Old" }, + new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(2)); + + var tasks = Enumerable.Range(0, 10).Select(i => + container.UpsertItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = $"Upsert{i}" }, + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + + // All should succeed (either as create or update) + results.Should().OnlyContain(r => + r.StatusCode == HttpStatusCode.Created || r.StatusCode == HttpStatusCode.OK); + + // Final state should be one of the upserted values + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().StartWith("Upsert"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2792,64 +2792,64 @@ await container.CreateItemAsync( public class TtlContainerManagementDeepTests { - [Fact] - public async Task ReplaceContainer_SetTtlToMinusOne_ExistingItemsStopExpiring() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 2 }; + [Fact] + public async Task ReplaceContainer_SetTtlToMinusOne_ExistingItemsStopExpiring() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 2 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - // Change TTL to -1 before item expires - container.DefaultTimeToLive = -1; + // Change TTL to -1 before item expires + container.DefaultTimeToLive = -1; - await Task.Delay(TimeSpan.FromSeconds(3)); + await Task.Delay(TimeSpan.FromSeconds(3)); - // Item should still be alive because container TTL is now -1 (no expiration) - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } + // Item should still be alive because container TTL is now -1 (no expiration) + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task ReplaceContainer_SetTtlToNull_DisablesTtlCompletely() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task ReplaceContainer_SetTtlToNull_DisablesTtlCompletely() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - // Disable TTL entirely - container.DefaultTimeToLive = null; + // Disable TTL entirely + container.DefaultTimeToLive = null; - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - // Item should still be alive because TTL is disabled - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } + // Item should still be alive because TTL is disabled + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } - [Fact] - public async Task ReplaceContainer_IncreaseTtl_ExtendsExistingItemLifetimes() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 2 }; + [Fact] + public async Task ReplaceContainer_IncreaseTtl_ExtendsExistingItemLifetimes() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 2 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(1)); + await Task.Delay(TimeSpan.FromSeconds(1)); - // Extend TTL to 60 seconds - container.DefaultTimeToLive = 60; + // Extend TTL to 60 seconds + container.DefaultTimeToLive = 60; - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - // Item should still be alive because TTL was extended - var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Test"); - } + // Item should still be alive because TTL was extended + var read = await container.ReadItemAsync("1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Test"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2858,65 +2858,65 @@ await container.CreateItemAsync( public class TtlBatchExtendedTests { - [Fact] - public async Task Batch_PatchExpiredItem_Returns404InBatchResult() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task Batch_PatchExpiredItem_Returns404InBatchResult() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Test" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.PatchItem("1", new List { PatchOperation.Set("/name", "Patched") }); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.PatchItem("1", new List { PatchOperation.Set("/name", "Patched") }); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [Fact] - public async Task Batch_CreateWithTtlZero_ShouldFail() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + [Fact] + public async Task Batch_CreateWithTtlZero_ShouldFail() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - var item = JObject.FromObject(new { id = "1", partitionKey = "pk1", _ttl = 0 }); - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(item); + var item = JObject.FromObject(new { id = "1", partitionKey = "pk1", _ttl = 0 }); + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(item); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } - [Fact] - public async Task Batch_MixedExpiredAndLive_CorrectResults() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + [Fact] + public async Task Batch_MixedExpiredAndLive_CorrectResults() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - // Create one item with short TTL and one without - var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); + // Create one item with short TTL and one without + var shortJson = """{"id":"short","partitionKey":"pk1","name":"Short","_ttl":1}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(shortJson)), new PartitionKey("pk1")); - await container.CreateItemAsync( - new TestDocument { Id = "live", PartitionKey = "pk1", Name = "Long" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "live", PartitionKey = "pk1", Name = "Long" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - // Batch read on both items — short is expired, live is not - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.ReadItem("short"); - batch.ReadItem("live"); + // Batch read on both items — short is expired, live is not + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.ReadItem("short"); + batch.ReadItem("live"); - using var response = await batch.ExecuteAsync(); + using var response = await batch.ExecuteAsync(); - // Batch should fail since one item returns 404 - response.StatusCode.Should().NotBe(HttpStatusCode.OK); - } + // Batch should fail since one item returns 404 + response.StatusCode.Should().NotBe(HttpStatusCode.OK); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2925,71 +2925,71 @@ await container.CreateItemAsync( public class TtlQuerySqlExpressionsTests { - [Fact] - public async Task Query_WhereOnTtlField_FiltersByPerItemTtl() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - for (var i = 1; i <= 5; i++) - { - var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","name":"Item{{{i}}}","_ttl":{{{i * 100}}}}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - } - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c WHERE c._ttl > 200"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); // _ttl = 300, 400, 500 - } - - [Fact] - public async Task Query_OrderByTtl_SortsCorrectly() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - for (var i = 1; i <= 3; i++) - { - var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","_ttl":{{{(4 - i) * 10}}}}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - } - - var iterator = container.GetItemQueryIterator( - "SELECT c._ttl FROM c ORDER BY c._ttl"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - var ttls = results.Select(r => r["_ttl"]!.Value()).ToList(); - ttls.Should().BeInAscendingOrder(); - } - - [Fact] - public async Task GroupBy_TtlField_GroupsCorrectly() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; - - for (var i = 0; i < 6; i++) - { - var ttl = (i % 3 + 1) * 10; // ttl values: 10, 20, 30, 10, 20, 30 - var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","_ttl":{{{ttl}}}}"""; - await container.CreateItemStreamAsync( - new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); - } - - var iterator = container.GetItemQueryIterator( - "SELECT c._ttl, COUNT(1) as cnt FROM c GROUP BY c._ttl"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - results.Should().OnlyContain(r => r["cnt"]!.Value() == 2); - } + [Fact] + public async Task Query_WhereOnTtlField_FiltersByPerItemTtl() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + for (var i = 1; i <= 5; i++) + { + var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","name":"Item{{{i}}}","_ttl":{{{i * 100}}}}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + } + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c WHERE c._ttl > 200"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); // _ttl = 300, 400, 500 + } + + [Fact] + public async Task Query_OrderByTtl_SortsCorrectly() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + for (var i = 1; i <= 3; i++) + { + var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","_ttl":{{{(4 - i) * 10}}}}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + } + + var iterator = container.GetItemQueryIterator( + "SELECT c._ttl FROM c ORDER BY c._ttl"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + var ttls = results.Select(r => r["_ttl"]!.Value()).ToList(); + ttls.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task GroupBy_TtlField_GroupsCorrectly() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = -1 }; + + for (var i = 0; i < 6; i++) + { + var ttl = (i % 3 + 1) * 10; // ttl values: 10, 20, 30, 10, 20, 30 + var json = $$$"""{"id":"{{{i}}}","partitionKey":"pk1","_ttl":{{{ttl}}}}"""; + await container.CreateItemStreamAsync( + new MemoryStream(Encoding.UTF8.GetBytes(json)), new PartitionKey("pk1")); + } + + var iterator = container.GetItemQueryIterator( + "SELECT c._ttl, COUNT(1) as cnt FROM c GROUP BY c._ttl"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + results.Should().OnlyContain(r => r["cnt"]!.Value() == 2); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2998,32 +2998,32 @@ await container.CreateItemStreamAsync( public class TtlItemCountDivergentTests { - [Fact] - public async Task ItemCount_ShouldExcludeExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task ItemCount_ShouldExcludeExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - container.ItemCount.Should().Be(0, "expired items should not be counted"); - } + container.ItemCount.Should().Be(0, "expired items should not be counted"); + } - [Fact] - public async Task ItemCount_IncludesNonExpiredItems() - { - var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; + [Fact] + public async Task ItemCount_IncludesNonExpiredItems() + { + var container = new InMemoryContainer("ttl-test", "/partitionKey") { DefaultTimeToLive = 1 }; - await container.CreateItemAsync( - new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, - new PartitionKey("pk1")); + await container.CreateItemAsync( + new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Temp" }, + new PartitionKey("pk1")); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)); - container.ItemCount.Should().Be(0, "expired items should not be counted"); - } + container.ItemCount.Should().Be(0, "expired items should not be counted"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/VectorSearchTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/VectorSearchTests.cs index e38958d..0f90ad0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/VectorSearchTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/VectorSearchTests.cs @@ -14,1863 +14,1871 @@ namespace CosmosDB.InMemoryEmulator.Tests; /// public class VectorSearchTests { - private static async Task CreateContainerWithVectors() - { - var container = new InMemoryContainer("vector-test", "/pk"); - - // Unit vectors along each axis — easy to reason about cosine similarity - await container.CreateItemAsync( - JObject.FromObject(new { id = "x", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "y", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "z", pk = "a", embedding = new[] { 0.0, 0.0, 1.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "neg-x", pk = "a", embedding = new[] { -1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - return container; - } - - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - // ─── Cosine Similarity ─────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_Cosine_IdenticalVectors_ReturnsOne() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "cosine similarity of identical vectors should be 1.0"); - } - - [Fact] - public async Task VectorDistance_Cosine_OrthogonalVectors_ReturnsZero() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'y'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, - "cosine similarity of orthogonal vectors should be 0.0"); - } - - [Fact] - public async Task VectorDistance_Cosine_OppositeVectors_ReturnsNegativeOne() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'neg-x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9, - "cosine similarity of opposite vectors should be -1.0"); - } - - // ─── Dot Product ───────────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_DotProduct_ReturnsCorrectValue() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.0, 3.0, 4.0 } }), - new PartitionKey("a")); - - // dot([2,3,4], [1,5,2]) = 2*1 + 3*5 + 4*2 = 2 + 15 + 8 = 25 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 5.0, 2.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(25.0, 1e-9); - } - - // ─── Euclidean Distance ────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_Euclidean_ReturnsCorrectValue() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - // euclidean([1,0,0], [0,1,0]) = sqrt((1-0)^2 + (0-1)^2 + (0-0)^2) = sqrt(2) - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 1.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(Math.Sqrt(2), 1e-9); - } - - // ─── SELECT Projection ─────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_InSelectProjection_ReturnsSimilarityScore() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().HaveCount(4, "all 4 documents should have a score computed"); - results.Should().Contain(r => r["id"]!.ToString() == "x" && Math.Abs(r["score"]!.Value() - 1.0) < 1e-9); - results.Should().Contain(r => r["id"]!.ToString() == "y" && Math.Abs(r["score"]!.Value() - 0.0) < 1e-9); - } - - // ─── ORDER BY ──────────────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_InOrderBy_SortsByScore() - { - var container = await CreateContainerWithVectors(); - - // ORDER BY VectorDistance DESC — most similar first (highest cosine similarity) - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(4); - results[0]["id"]!.ToString().Should().Be("x", "identical vector should be first (score 1.0)"); - results[^1]["id"]!.ToString().Should().Be("neg-x", "opposite vector should be last (score -1.0)"); - } - - [Fact] - public async Task VectorDistance_TopN_WithOrderBy_ReturnsClosest() - { - var container = await CreateContainerWithVectors(); - - var results = await RunQuery(container, - "SELECT TOP 2 c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(2); - results[0]["id"]!.ToString().Should().Be("x"); - } - - // ─── Combined with WHERE ───────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_WithWhereClause_FiltersAndScores() - { - var container = await CreateContainerWithVectors(); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id != 'neg-x' ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(3); - results.Should().NotContain(r => r["id"]!.ToString() == "neg-x"); - } - - // ─── Edge Cases ────────────────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_MismatchedDimensions_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Document has 2D vector, query has 3D vector — should return null - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "mismatched dimensions should return null, not throw"); - } - - [Fact] - public async Task VectorDistance_MissingEmbedding_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "no-embedding" }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "missing vector property should return null"); - } - - // ─── Optional Parameters ───────────────────────────────────────────── - - [Fact] - public async Task VectorDistance_DistanceFunctionOverride_UsesEuclidean() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - // Euclidean distance between [3,0,0] and [0,0,0] = 3.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(3.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_BoolBruteForceParam_AcceptedAndIgnored() - { - var container = await CreateContainerWithVectors(); - - // 3rd argument (true = brute force) should be accepted but has no effect in emulator - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0], true) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - // ─── High-Dimensional Vectors ──────────────────────────────────────── - - [Fact] - public async Task VectorDistance_HighDimensional_1536Dimensions() - { - var container = new InMemoryContainer("vector-test", "/pk"); - - // Create a 1536-dim unit vector along first axis - var vec1 = new double[1536]; - vec1[0] = 1.0; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = vec1 }), - new PartitionKey("a")); - - // Query with same vector — cosine similarity should be 1.0 - var queryVec = string.Join(",", vec1.Select(v => v.ToString("F1"))); - var results = await RunQuery(container, - $"SELECT VectorDistance(c.embedding, [{queryVec}]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - // ─── Default Distance Function (Cosine) ────────────────────────────── - - [Fact] - public async Task VectorDistance_DefaultDistanceFunction_IsCosine() - { - var container = await CreateContainerWithVectors(); - - // No 4th argument — should default to cosine - // VectorDistance([1,0,0], [1,0,0]) with cosine = 1.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "default distance function should be cosine similarity"); - } - - // ═════════════════════════════════════════════════════════════════════ - // A: Mathematical Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_Cosine_ZeroVector_ReturnsNull() - { - // BUG-1: zero-magnitude vector → cosine is undefined. - // Real Cosmos DB returns no SimilarityScore (null). Emulator should match. - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "cosine similarity is undefined for zero-magnitude vectors — should return null"); - } - - [Fact] - public async Task VectorDistance_Cosine_NonUnitVectors_NormalizesCorrectly() - { - // [3,4] and [6,8] are parallel (same direction) — cosine = 1.0 regardless of magnitude - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [6.0, 8.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "parallel non-unit vectors should have cosine similarity 1.0"); - } - - [Fact] - public async Task VectorDistance_Cosine_NegativeComponents_ComputesCorrectly() - { - // [1, -1] vs [-1, 1] → dot = -2, |a|=√2, |b|=√2 → cosine = -2/2 = -1.0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, -1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [-1.0, 1.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_DotProduct_OrthogonalVectors_ReturnsZero() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 1.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_DotProduct_OppositeVectors_ReturnsNegative() - { - // [1,0] · [-1,0] = -1 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [-1.0, 0.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_Euclidean_IdenticalVectors_ReturnsZero() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 5.0, 3.0, 1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [5.0, 3.0, 1.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, - "euclidean distance from a vector to itself should be 0"); - } - - [Fact] - public async Task VectorDistance_Euclidean_KnownTriangle_345() - { - // [0,0] vs [3,4] → √(9+16) = 5.0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(5.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_Cosine_SingleDimension_HandlesCorrectly() - { - // 1D vectors: [5] vs [3] → both positive, cosine = 1.0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 5.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [3.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "1D positive vectors are parallel → cosine = 1.0"); - } - - [Fact] - public async Task VectorDistance_DotProduct_ZeroVector_ReturnsZero() - { - // dot([0,0,0], [1,2,3]) = 0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 2.0, 3.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_Euclidean_ZeroVector_ReturnsVectorMagnitude() - { - // euclidean([3,4,0], [0,0,0]) = √(9+16+0) = 5.0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(5.0, 1e-9); - } - - // ═════════════════════════════════════════════════════════════════════ - // B: Parameter Handling & Overloads - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_TwoArgsOnly_DefaultsToCosine() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_ThreeArgs_BruteForce_False() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_FourArgs_DotProduct() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.0, 3.0 } }), - new PartitionKey("a")); - - // dot([2,3], [4,5]) = 8 + 15 = 23 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [4.0, 5.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(23.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_FourArgs_Cosine_Explicit() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine'}) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "explicit cosine should produce same result as default"); - } - - [Fact] - public async Task VectorDistance_OptionsWithDataType_AcceptedSilently() - { - // dataType is an index-level concern — emulator accepts but ignores it - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', dataType:'Float32'}) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_OptionsWithSearchListSizeMultiplier_AcceptedSilently() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', searchListSizeMultiplier:10}) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_OptionsWithFilterPriority_AcceptedSilently() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', filterPriority:0.5}) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Theory] - [InlineData("cosine")] - [InlineData("COSINE")] - [InlineData("Cosine")] - [InlineData("CoSiNe")] - public async Task VectorDistance_CaseInsensitiveDistanceFunction(string distFn) - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - $"SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {{distanceFunction:'{distFn}'}}) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - $"distanceFunction '{distFn}' should be case-insensitive"); - } - - [Fact] - public async Task VectorDistance_OneArg_ReturnsNull() - { - // Only 1 vector arg — less than minimum 2 args, should return null - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null); - } - - // ═════════════════════════════════════════════════════════════════════ - // C: Data Type & Input Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_IntegerVectorValues_WorkCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1, 0, 0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1, 0, 0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_MixedIntAndFloatValues_WorkCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 2, 3.5 } }), - new PartitionKey("a")); - - // dot([1.0, 2, 3.5], [1, 2.0, 3.5]) = 1 + 4 + 12.25 = 17.25 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1, 2.0, 3.5], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(17.25, 1e-9); - } - - [Fact] - public async Task VectorDistance_EmptyVector_ReturnsNull() - { - // Both vectors empty [] — length == 0, should return null - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[]}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, []) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "empty vectors (length 0) should return null"); - } - - [Fact] - public async Task VectorDistance_VeryLargeValues_NoOverflow() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e38, 1e38 } }), - new PartitionKey("a")); - - // Should not throw — cosine of parallel vectors is still 1.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1e38, 1e38]) AS score FROM c"); - - results.Should().ContainSingle(); - var score = results[0]["score"]; - // Either a valid double or null — must not throw - (score!.Type == JTokenType.Float || score.Type == JTokenType.Integer || score.Type == JTokenType.Null) - .Should().BeTrue("very large values should not cause an exception"); - } - - [Fact] - public async Task VectorDistance_VerySmallValues_NoPrecisionLoss() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e-10, 1e-10, 0.0 } }), - new PartitionKey("a")); - - // [1e-10, 1e-10, 0] vs [1e-10, 1e-10, 0] — identical vectors, cosine = 1.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1e-10, 1e-10, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-6, - "very small but identical vectors should still have cosine ≈ 1.0"); - } - - [Fact] - public async Task VectorDistance_NullVectorProperty_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":null}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task VectorDistance_NonArrayVectorProperty_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = "not-a-vector" }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null); - } - - [Fact] - public async Task VectorDistance_NestedProperty_WorksCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"metadata\":{\"embedding\":[1.0,0.0,0.0]}}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.metadata.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_VectorWithNonNumericElement_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[1.0,\"abc\",3.0]}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "non-numeric elements in the vector should return null"); - } - - [Fact] - public async Task VectorDistance_TwoDimensional_WorksCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // cosine([1,0], [0,1]) = 0.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 1.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); - } - - // ═════════════════════════════════════════════════════════════════════ - // D: SQL Integration (SELECT, WHERE, ORDER BY, GROUP BY) - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_InWhereClause_FiltersBySimilarityThreshold() - { - var container = await CreateContainerWithVectors(); - - // Cosine scores: x=1.0, y=0.0, z=0.0, neg-x=-1.0 - // WHERE score > 0.5 should only return x - var results = await RunQuery(container, - "SELECT c.id FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("x"); - } - - [Fact] - public async Task VectorDistance_InWhereAndSelect_DifferentVectors() - { - var container = await CreateContainerWithVectors(); - - // WHERE uses [1,0,0] to filter (score > 0.5 → only 'x'), but SELECT computes score against [0,1,0] - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS score FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("x"); - // x=[1,0,0] vs [0,1,0] → cosine = 0.0 - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_OrderByAsc_LeastSimilarFirst() - { - var container = await CreateContainerWithVectors(); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) ASC"); - - results.Should().HaveCount(4); - results[0]["id"]!.ToString().Should().Be("neg-x", "score -1.0 should be first in ASC"); - results[^1]["id"]!.ToString().Should().Be("x", "score 1.0 should be last in ASC"); - } - - [Fact] - public async Task VectorDistance_WithOffsetLimit_Paginated() - { - var container = await CreateContainerWithVectors(); - - // ORDER BY DESC: x(1.0), y(0.0), z(0.0), neg-x(-1.0) - // OFFSET 1 LIMIT 2 → should skip x, return y and z - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC OFFSET 1 LIMIT 2"); - - results.Should().HaveCount(2); - results.Should().NotContain(r => r["id"]!.ToString() == "x"); - results.Should().NotContain(r => r["id"]!.ToString() == "neg-x"); - } - - [Fact] - public async Task VectorDistance_AliasedInOrderBy_Works() - { - var container = await CreateContainerWithVectors(); - - // Use the alias 'score' in ORDER BY — some SQL engines support this - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(4); - results[0]["id"]!.ToString().Should().Be("x"); - } - - [Fact] - public async Task VectorDistance_MultipleCallsInSameQuery_Works() - { - var container = await CreateContainerWithVectors(); - - // Two VectorDistance calls against different query vectors in the same SELECT - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS scoreX, VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS scoreY FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["scoreX"]!.Value().Should().BeApproximately(1.0, 1e-9); - results[0]["scoreY"]!.Value().Should().BeApproximately(0.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_WithDistinct_WorksCorrectly() - { - var container = await CreateContainerWithVectors(); - - // y and z both have cosine 0.0 against [1,0,0], so DISTINCT should collapse them - var results = await RunQuery(container, - "SELECT DISTINCT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - // Scores: 1.0, 0.0, 0.0, -1.0 → distinct: 1.0, 0.0, -1.0 - results.Should().HaveCount(3); - } - - [Fact] - public async Task VectorDistance_CrossPartition_WithoutPartitionKey() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("b")); - - // No partition key specified — cross-partition query - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - } - - // D10: GROUP BY with AVG(VectorDistance(...)) — the GROUP BY aggregate handler - // uses ExtractNumericValues which calls SelectToken with the inner argument as a - // JSON path. Function calls like VectorDistance(...) are not valid JSON paths, so - // this throws. Fixing requires the GROUP BY aggregate pipeline to evaluate arbitrary - // SQL expressions (not just property paths) before aggregating. This is a general - // limitation of GROUP BY + aggregate(functionCall), not specific to vectors. - [Fact] - public async Task VectorDistance_WithGroupBy_AggregatesCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", category = "A", embedding = new[] { 0.8, 0.6 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.category, AVG(VectorDistance(c.embedding, [1.0, 0.0])) AS avgScore FROM c GROUP BY c.category"); - - results.Should().HaveCount(2); - var catA = results.First(r => r["category"]!.ToString() == "A"); - var catB = results.First(r => r["category"]!.ToString() == "B"); - catA["avgScore"]!.Value().Should().BeGreaterThan(catB["avgScore"]!.Value(), - "category A vectors are closer to [1,0] than category B"); - } - - [Fact] - public async Task VectorDistance_WithGroupBy_MinMax_AggregatesCorrectly() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", category = "A", embedding = new[] { 0.8, 0.6 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "3", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.category, MIN(VectorDistance(c.embedding, [1.0, 0.0])) AS minDist, " + - "MAX(VectorDistance(c.embedding, [1.0, 0.0])) AS maxDist FROM c GROUP BY c.category"); - - results.Should().HaveCount(2); - var catA = results.First(r => r["category"]!.ToString() == "A"); - catA["minDist"]!.Value().Should().BeLessThan(catA["maxDist"]!.Value(), - "category A should have distinct MIN and MAX distances"); - } - - // Sister test: GROUP BY works fine with VectorDistance when used outside aggregates - [Fact] - public async Task VectorDistance_WithGroupBy_NonAggregated_WorksInSelect() - { - // GROUP BY c.category works; the VectorDistance call is in a non-aggregated - // position (it just reappears in the grouping key projection). This shows - // that VectorDistance itself works fine — it's only the combination of - // aggregate(functionCall) that's unsupported. - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); - - results.Should().HaveCount(2); - } - - // ═════════════════════════════════════════════════════════════════════ - // E: Multi-Document / Ranking Scenarios - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_KNN_Top5_ReturnsCorrectNearest() - { - var container = new InMemoryContainer("vector-test", "/pk"); - // Insert 20 docs with embeddings at increasing angles from [1,0] - for (var i = 0; i < 20; i++) - { - var angle = i * Math.PI / 20; // 0 to ~π - await container.CreateItemAsync( - JObject.FromObject(new { id = $"d{i}", pk = "a", - embedding = new[] { Math.Cos(angle), Math.Sin(angle) } }), - new PartitionKey("a")); - } - - var results = await RunQuery(container, - "SELECT TOP 5 c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0]) DESC"); - - results.Should().HaveCount(5); - // First result should be d0 (angle=0, closest to [1,0]) - results[0]["id"]!.ToString().Should().Be("d0"); - // All scores should be in descending order - for (var i = 0; i < results.Count - 1; i++) - results[i]["score"]!.Value().Should() - .BeGreaterThanOrEqualTo(results[i + 1]["score"]!.Value()); - } - - [Fact] - public async Task VectorDistance_TiedScores_ReturnedStably() - { - var container = await CreateContainerWithVectors(); - - // y=[0,1,0] and z=[0,0,1] both have cosine 0.0 with [1,0,0] - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(4); - // Both y and z should appear (tied at 0.0), between x (1.0) and neg-x (-1.0) - var middleIds = results.Skip(1).Take(2).Select(r => r["id"]!.ToString()).ToList(); - middleIds.Should().Contain("y"); - middleIds.Should().Contain("z"); - } - - [Fact] - public async Task VectorDistance_AllDocsHaveSameEmbedding_SameScore() - { - var container = new InMemoryContainer("vector-test", "/pk"); - for (var i = 0; i < 5; i++) - { - await container.CreateItemAsync( - JObject.FromObject(new { id = $"d{i}", pk = "a", embedding = new[] { 0.6, 0.8 } }), - new PartitionKey("a")); - } - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().HaveCount(5); - var scores = results.Select(r => r["score"]!.Value()).Distinct().ToList(); - scores.Should().ContainSingle("all identical embeddings should produce the same score"); - } - - [Fact] - public async Task VectorDistance_LargeDataset_100Docs_OrderByCorrect() - { - var container = new InMemoryContainer("vector-test", "/pk"); - var rng = new Random(42); // deterministic seed - for (var i = 0; i < 100; i++) - { - var vec = new[] { rng.NextDouble(), rng.NextDouble(), rng.NextDouble() }; - await container.CreateItemAsync( - JObject.FromObject(new { id = $"d{i}", pk = "a", embedding = vec }), - new PartitionKey("a")); - } - - var results = await RunQuery(container, - "SELECT TOP 10 c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(10); - for (var i = 0; i < results.Count - 1; i++) - results[i]["score"]!.Value().Should() - .BeGreaterThanOrEqualTo(results[i + 1]["score"]!.Value(), - "results should be in descending score order"); - } - - [Fact] - public async Task VectorDistance_MixOfValidAndMissingEmbeddings_NullsHandled() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "with-vec", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "no-vec", pk = "a", name = "missing" }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().HaveCount(2); - var withVec = results.First(r => r["id"]!.ToString() == "with-vec"); - var noVec = results.First(r => r["id"]!.ToString() == "no-vec"); - withVec["score"]!.Value().Should().BeApproximately(1.0, 1e-9); - noVec["score"]!.Type.Should().Be(JTokenType.Null); - } - - // ═════════════════════════════════════════════════════════════════════ - // F: Divergent Behaviour Tests - // Skipped tests document real Cosmos DB behaviour that is too complex - // or not meaningful to implement. Sister tests show emulator behaviour. - // ═════════════════════════════════════════════════════════════════════ - - // ─── F1: Multi-dimensional arrays ──────────────────────────────────── - // Real Cosmos DB: "If a multi-dimensional array is provided, the function - // doesn't return a SimilarityScore value and doesn't return an error." - // This means the property is omitted entirely from the result object. - // The emulator returns null for the property instead, which is close - // but not identical (property present with null vs property absent). - - [Fact(Skip = "F1: Real Cosmos DB omits the SimilarityScore property entirely for multi-dimensional " + - "array inputs rather than returning null. The emulator returns the property with a null " + - "value. Fixing this would require the query engine to distinguish between 'return null' " + - "and 'omit property from output', which is a fundamental change to projection handling. " + - "Impact: Very low — user code checking for null handles both cases.")] - public void VectorDistance_MultiDimensionalArray_RealCosmosReturnsNoScore() - { - // Expected real Cosmos DB behavior: - // Given a document with embedding: [[1,2],[3,4]] (nested array), - // SELECT VectorDistance(c.embedding, [1,0]) AS score FROM c - // returns {"id": "1"} with NO "score" property (not null, absent). - } - - [Fact] - public async Task VectorDistance_MultiDimensionalArray_EmulatorReturnsNull() - { - // Emulator behaviour: multi-dimensional array → ToDoubleArray returns null - // because inner elements are JArray not JTokenType.Float/Integer → returns null - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[[1,2],[3,4]]}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - // Emulator returns the property with null value (not omitted) - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "emulator returns null for multi-dimensional arrays (real Cosmos omits the property entirely)"); - } - - // ─── F2: Vector embedding policy required in real Cosmos ───────────── - // Real Cosmos DB requires a vectorEmbeddings container policy defining - // path, dataType, dimensions, and distanceFunction. Without it, vector - // search fails. The emulator has no such requirement. - - [Fact(Skip = "F2: Real Cosmos DB requires a vectorEmbeddings container policy (path, dataType, " + - "dimensions, distanceFunction) for VECTORDISTANCE to work. Without it, the query fails. " + - "The emulator intentionally skips this requirement — always brute-force exact computation " + - "without any policy needed. Implementing this would add complexity with no testing value " + - "since the policy is an infrastructure concern, not a logic concern.")] - public void VectorDistance_RequiresVectorPolicy_InRealCosmos() - { - // Expected real Cosmos DB behavior: - // Without vectorEmbeddings policy, VECTORDISTANCE queries fail with an error. - } - - [Fact] - public async Task VectorDistance_EmulatorDoesNotRequireVectorPolicy() - { - // Emulator behaviour: no vector policy needed, just use the function - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "emulator works without any vector embedding policy configuration"); - } - - // ─── F3: Flat index dimension limit ────────────────────────────────── - // Real Cosmos DB flat index supports max 505 dimensions. - // quantizedFlat and diskANN support up to 4096. - // The emulator has no limit (tested up to 1536). - - [Fact(Skip = "F3: Real Cosmos DB flat index limits vectors to 505 dimensions; quantizedFlat and " + - "diskANN support up to 4096. The emulator has no dimensionality limit because it " + - "doesn't simulate vector indexing — it always does brute-force linear scan. Imposing " + - "artificial limits would reduce testing flexibility without adding correctness value.")] - public void VectorDistance_FlatIndexMax505Dimensions_InRealCosmos() - { - // Expected real Cosmos DB behavior: - // Vectors with >505 dimensions fail with flat index type. - // Vectors with >4096 dimensions fail with quantizedFlat/diskANN. - } - - [Fact] - public async Task VectorDistance_EmulatorSupportsAnyDimensionality() - { - var container = new InMemoryContainer("vector-test", "/pk"); - var vec = new double[2000]; // Exceeds all real Cosmos limits - vec[0] = 1.0; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = vec }), - new PartitionKey("a")); - - var queryVec = string.Join(",", vec.Select(v => v.ToString("F1"))); - var results = await RunQuery(container, - $"SELECT VectorDistance(c.embedding, [{queryVec}]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "emulator supports vectors of any dimensionality (no index limits)"); - } - - // ─── F4: TOP N required with ORDER BY in real Cosmos ───────────────── - // Microsoft docs: "Always use a TOP N clause in the SELECT statement of a query. - // Otherwise the vector search tries to return many more results and the query - // costs more RUs and have higher latency than necessary." - // This is a performance guidance, not a hard error in all cases. - - [Fact(Skip = "F4: Real Cosmos DB strongly recommends TOP N with ORDER BY VectorDistance — without " + - "it, queries may time out or consume excessive RUs on large datasets. The emulator " + - "has no RU model and performs instant brute-force computation, so this limitation " + - "doesn't apply. Enforcing it would break valid test patterns.")] - public void VectorDistance_RequiresTopNWithOrderBy_InRealCosmos() - { - // Expected real Cosmos DB behavior: - // ORDER BY VectorDistance without TOP N may time out. - } - - [Fact] - public async Task VectorDistance_EmulatorAllowsOrderByWithoutTopN() - { - var container = await CreateContainerWithVectors(); - - // No TOP N — emulator returns all results, no issue - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); - - results.Should().HaveCount(4, "emulator returns all results without requiring TOP N"); - } - - // ═════════════════════════════════════════════════════════════════════ - // G: Numerical Robustness & IEEE 754 Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_Cosine_BothZeroVectors_ReturnsNull() - { - // Both document and query vectors are zero — cosine is undefined for both - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "cosine similarity is undefined when both vectors are zero-magnitude"); - } - - [Fact] - public async Task VectorDistance_Cosine_QueryVectorZero_ReturnsNull() - { - // Document has a valid vector but query vector is zero - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "cosine similarity is undefined when the query vector is zero-magnitude"); - } - - [Fact] - public async Task VectorDistance_DotProduct_LargeValues_NoOverflow() - { - // Dot product with very large components — could overflow to Infinity - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e308, 1e308 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1e308, 1e308], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - var score = results[0]["score"]; - // Must not throw, and should return null for overflow (Infinity) rather than a non-finite value - score!.Type.Should().Be(JTokenType.Null, - "dot product overflow to Infinity should return null rather than a non-finite value"); - } - - [Fact] - public async Task VectorDistance_Euclidean_LargeValues_NoOverflow() - { - // Euclidean squaring doubles the exponent — overflow risk - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e308, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); - - results.Should().ContainSingle(); - var score = results[0]["score"]; - // Euclidean: sqrt((1e308)^2 + 0) = sqrt(Infinity) = Infinity → should return null - score!.Type.Should().Be(JTokenType.Null, - "euclidean distance overflow to Infinity should return null rather than a non-finite value"); - } - - [Fact] - public async Task VectorDistance_Cosine_NearlyParallelVectors_HighPrecision() - { - // Vectors differing by a tiny amount — cosine should be very close to 1.0 but not exactly - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - // 1e-7 is large enough to be distinguishable in double precision - // cos([1,0,0], [1,1e-7,0]) = 1/sqrt(1 + 1e-14) ≈ 0.999999999999995 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0000001, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - var score = results[0]["score"]!.Value(); - score.Should().BeGreaterThan(0.99999999, - "nearly parallel vectors should have cosine very close to 1.0"); - score.Should().BeLessThan(1.0, - "slightly different vectors should not have cosine exactly 1.0"); - } - - [Fact] - public async Task VectorDistance_Cosine_NearlyAntiparallel_HighPrecision() - { - // Vectors nearly opposite — cosine should be very close to -1.0 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - // 1e-7 is large enough to be distinguishable in double precision - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [-1.0, 0.0000001, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - var score = results[0]["score"]!.Value(); - score.Should().BeLessThan(-0.99999999, - "nearly anti-parallel vectors should have cosine very close to -1.0"); - score.Should().BeGreaterThan(-1.0, - "slightly off-axis vectors should not have cosine exactly -1.0"); - } - - [Fact] - public async Task VectorDistance_DotProduct_IdenticalVectors_ReturnsSumOfSquares() - { - // [3,4] · [3,4] = 9 + 16 = 25 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(25.0, 1e-9); - } - - [Fact] - public async Task VectorDistance_Euclidean_SymmetricDistance() - { - // distance(a, b) should equal distance(b, a) — commutative - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "ab", pk = "a", embedding = new[] { 1.0, 2.0, 3.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "ba", pk = "a", embedding = new[] { 4.0, 5.0, 6.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [4.0, 5.0, 6.0], false, {distanceFunction:'euclidean'}) AS scoreForward FROM c WHERE c.id = 'ab'"); - var results2 = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 2.0, 3.0], false, {distanceFunction:'euclidean'}) AS scoreReverse FROM c WHERE c.id = 'ba'"); - - var forward = results[0]["scoreForward"]!.Value(); - var reverse = results2[0]["scoreReverse"]!.Value(); - forward.Should().BeApproximately(reverse, 1e-9, - "euclidean distance should be symmetric: d(a,b) == d(b,a)"); - } - - [Fact] - public async Task VectorDistance_Cosine_SymmetricSimilarity() - { - // cosine(a, b) should equal cosine(b, a) — verify by swapping args - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "ab", pk = "a", embedding = new[] { 1.0, 2.0, 3.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "ba", pk = "a", embedding = new[] { 4.0, 5.0, 6.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [4.0, 5.0, 6.0]) AS scoreForward FROM c WHERE c.id = 'ab'"); - var results2 = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 2.0, 3.0]) AS scoreReverse FROM c WHERE c.id = 'ba'"); - - var forward = results[0]["scoreForward"]!.Value(); - var reverse = results2[0]["scoreReverse"]!.Value(); - forward.Should().BeApproximately(reverse, 1e-9, - "cosine similarity should be symmetric: cos(a,b) == cos(b,a)"); - } - - [Fact] - public async Task VectorDistance_DotProduct_NegativeValues_ComputesCorrectly() - { - // [-2,-3] · [4,5] = -8 + -15 = -23 - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { -2.0, -3.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [4.0, 5.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(-23.0, 1e-9); - } - - // ═════════════════════════════════════════════════════════════════════ - // H: Query Literal & Parser Edge Cases - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_LiteralVectorWithSpaces_Works() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [ 1.0 , 0.0 , 0.0 ]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "whitespace inside array literal should be handled by parser"); - } - - [Fact] - public async Task VectorDistance_LiteralVectorWithNegativeValues_Works() - { - var container = await CreateContainerWithVectors(); - // cosine([1,0,0], [-1,-2,-3]) — negative values in query literal - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [-1.0, -2.0, -3.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - // cos([1,0,0], [-1,-2,-3]) = -1 / (1 * sqrt(14)) ≈ -0.2673 - results[0]["score"]!.Value().Should().BeApproximately(-1.0 / Math.Sqrt(14), 1e-6); - } - - [Theory] - [InlineData("vectordistance")] - [InlineData("VECTORDISTANCE")] - [InlineData("VectorDistance")] - [InlineData("vectorDistance")] - public async Task VectorDistance_FunctionNameCaseInsensitive(string funcName) - { - // Parser uppercases all function names — all case variants should work - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - $"SELECT {funcName}(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - $"function name '{funcName}' should be case-insensitive"); - } - - [Fact] - public async Task VectorDistance_NoArgs_ReturnsNull() - { - // Zero args: VectorDistance() — guard returns null for < 2 args - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance() AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "zero args should return null"); - } - - [Fact] - public async Task VectorDistance_VectorWithSingleZeroElement_ReturnsNull() - { - // [0.0] vs [0.0] — single zero-element, cosine = undefined → null - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "single zero-element vectors → cosine undefined → null"); - } - - [Fact] - public async Task VectorDistance_WithArithmeticOnResult_InSelect() - { - // VectorDistance(...) * 100 — arithmetic on the function result in SELECT - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) * 100 AS pctScore FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["pctScore"]!.Value().Should().BeApproximately(100.0, 1e-6, - "arithmetic (score * 100) should work on VectorDistance results"); - } - - [Fact] - public async Task VectorDistance_InValueExpression_Addition() - { - // VectorDistance(...) + 5 — addition on the function result - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) + 5 AS adjustedScore FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["adjustedScore"]!.Value().Should().BeApproximately(6.0, 1e-6, - "VectorDistance result (1.0) + 5 should equal 6.0"); - } - - [Fact] - public async Task VectorDistance_WithIIF_ConditionalLabel() - { - // Use VectorDistance result in IIF to produce a label - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, IIF(VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5, 'similar', 'different') AS label FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["label"]!.ToString().Should().Be("similar", - "x has cosine 1.0 which is > 0.5, so IIF should return 'similar'"); - } - - [Fact] - public async Task VectorDistance_WithAbsArithmetic_Works() - { - // ABS(VectorDistance(...)) — function composition - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id, ABS(VectorDistance(c.embedding, [1.0, 0.0, 0.0])) AS absScore FROM c WHERE c.id = 'neg-x'"); - - results.Should().ContainSingle(); - results[0]["absScore"]!.Value().Should().BeApproximately(1.0, 1e-9, - "ABS of cosine -1.0 should be 1.0"); - } - - // ═════════════════════════════════════════════════════════════════════ - // I: CRUD + Mutation Interaction - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_AfterUpsert_UsesUpdatedVector() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Upsert with a completely different embedding - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - // After upsert, embedding is [0,1], cosine([0,1], [1,0]) = 0.0 - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, - "query should reflect the updated embedding after upsert"); - } - - [Fact] - public async Task VectorDistance_AfterPatch_UsesUpdatedVector() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Patch the embedding via Set operation - await container.PatchItemAsync("1", new PartitionKey("a"), - new List { PatchOperation.Set("/embedding", new[] { 0.0, 1.0 }) }); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - // After patch, embedding is [0,1], cosine([0,1], [1,0]) = 0.0 - results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, - "query should reflect the patched embedding"); - } - - [Fact] - public async Task VectorDistance_AfterDelete_ExcludesDeletedDoc() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - await container.DeleteItemAsync("1", new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("2", - "deleted document should not appear in vector search results"); - } - - [Fact] - public async Task VectorDistance_WithTTLExpiredDoc_ExcludesExpired() - { - var container = new InMemoryContainer("vector-test", "/pk") - { - DefaultTimeToLive = 1 // 1 second TTL - }; - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Wait for TTL to expire - await Task.Delay(TimeSpan.FromSeconds(2)); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().BeEmpty("TTL-expired documents should not appear in vector search results"); - } - - [Fact] - public async Task VectorDistance_ConcurrentReads_ThreadSafe() - { - var container = new InMemoryContainer("vector-test", "/pk"); - for (var i = 0; i < 10; i++) - { - await container.CreateItemAsync( - JObject.FromObject(new { id = $"d{i}", pk = "a", - embedding = new[] { Math.Cos(i * 0.3), Math.Sin(i * 0.3) } }), - new PartitionKey("a")); - } - - // Run 20 concurrent vector searches - var tasks = Enumerable.Range(0, 20).Select(_ => - RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); - - var allResults = await Task.WhenAll(tasks); - - foreach (var results in allResults) - { - results.Should().HaveCount(10, "each concurrent query should return all 10 documents"); - } - } - - // ═════════════════════════════════════════════════════════════════════ - // J: Change Feed + Vector Search Integration - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_ChangeFeed_CapturesVectorUpdates() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Update the embedding via upsert - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - // Should capture both the create and the upsert - changes.Should().HaveCountGreaterThanOrEqualTo(1); - var latest = changes.Last(); - // The latest version should have the updated embedding [0,1] - var embedding = latest["embedding"]!.ToObject()!; - embedding.Should().BeEquivalentTo(new[] { 0.0, 1.0 }, - "change feed should capture the updated vector embedding"); - } - - [Fact] - public async Task VectorDistance_VectorOnlyUpdate_AppearsInChangeFeed() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "stable", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Only change the embedding, nothing else - await container.UpsertItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "stable", embedding = new[] { 0.5, 0.5 } }), - new PartitionKey("a")); - - var iterator = container.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page); - } - - // The change feed should have the latest version with the updated vector - // (Incremental mode returns latest version per document, not every intermediate state) - changes.Should().HaveCountGreaterThanOrEqualTo(1, - "the vector-only update should appear in the change feed"); - var latest = changes.Last(); - latest["embedding"]!.ToObject().Should().BeEquivalentTo(new[] { 0.5, 0.5 }, - "change feed should reflect the updated vector embedding"); - } - - // ═════════════════════════════════════════════════════════════════════ - // K: Partition Key Interaction - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_HierarchicalPartitionKey_CrossPartition() - { - var container = new InMemoryContainer("vector-test", new List { "/tenant", "/region" }); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", tenant = "A", region = "US", embedding = new[] { 1.0, 0.0 } }), - new PartitionKeyBuilder().Add("A").Add("US").Build()); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", tenant = "B", region = "EU", embedding = new[] { 0.0, 1.0 } }), - new PartitionKeyBuilder().Add("B").Add("EU").Build()); - - // Cross-partition query (no PK filter) - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2, - "cross-partition vector search should return docs from all partitions"); - } - - [Fact] - public async Task VectorDistance_SinglePartitionScopedSearch_OnlyReturnsPartitionDocs() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "b", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("b")); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - // RunQuery uses PartitionKey("a") — should only return the "a" partition doc - results.Should().ContainSingle(); - results[0]["id"]!.ToString().Should().Be("1"); - } - - // ═════════════════════════════════════════════════════════════════════ - // L: Stream API Integration - // ═════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task VectorDistance_ViaStreamIterator_ReturnsValidJson() - { - var container = await CreateContainerWithVectors(); - - var query = new QueryDefinition( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - var iterator = container.GetItemQueryStreamIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - - using var response = await iterator.ReadNextAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var reader = new StreamReader(response.Content); - var body = await reader.ReadToEndAsync(); - var jObj = JObject.Parse(body); - - var docs = jObj["Documents"]!.ToObject>()!; - docs.Should().ContainSingle(); - docs[0]["id"]!.ToString().Should().Be("x"); - docs[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, - "stream iterator should return valid JSON with vector scores"); - } - - // ═════════════════════════════════════════════════════════════════════ - // M: Additional Divergent Behaviour (Skip + Sister) - // ═════════════════════════════════════════════════════════════════════ - - // ─── M1: Extra args (>4) ───────────────────────────────────────────── - // Real Cosmos DB rejects queries with more than 4 args to VECTORDISTANCE - // at the query compilation layer. The emulator's generic function dispatcher - // accepts arbitrary arg counts and the 5th+ args are silently ignored. - - [Fact] - public async Task VectorDistance_FiveArgs_RealCosmosRejectsExtraArgs() - { - var container = await CreateContainerWithVectors(); - var act = () => RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine'}, 'extra') AS score FROM c WHERE c.id = 'x'"); - - await act.Should().ThrowAsync(); - } - - // Sister test already exists as VectorDistance_FiveArgs_EmulatorIgnoresExtraArgs in H category - - // ─── M2: Unknown distance function ──────────────────────────────────── - // Real Cosmos DB rejects unknown distance function values with an error. - // The emulator silently falls back to cosine similarity. - - [Fact] - public async Task VectorDistance_UnknownDistanceFunction_RealCosmosRejects() - { - var container = await CreateContainerWithVectors(); - var act = () => RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'manhattan'}) AS score FROM c WHERE c.id = 'x'"); - - await act.Should().ThrowAsync(); - } - - // Sister test already exists as VectorDistance_UnknownDistanceFunction_FallsToCosine in B category - - // ─── M3: Parameterized query vectors ───────────────────────────────── - // Real Cosmos DB supports parameterized query vectors: - // new QueryDefinition("SELECT VectorDistance(c.vec, @qv) AS s FROM c") - // .WithParameter("@qv", new[] { 1.0, 0.0, 0.0 }) - // The emulator may or may not support this depending on how parameters - // are resolved for function arguments. - - [Fact] - public async Task VectorDistance_ParameterizedQuery_ReturnsCorrectScore() - { - var container = await CreateContainerWithVectors(); - - var query = new QueryDefinition( - "SELECT VectorDistance(c.embedding, @qv) AS score FROM c WHERE c.id = 'x'") - .WithParameter("@qv", new[] { 1.0, 0.0, 0.0 }); - - var iterator = container.GetItemQueryIterator(query, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.001); - } - - // ─── M4: Return type ───────────────────────────────────────────────── - // Real Cosmos DB returns VectorDistance as a JSON number (float64). - // The emulator returns a .NET double boxed as object, which serializes - // as a JSON number. Behaviour is functionally identical. - - [Fact] - public async Task VectorDistance_ReturnType_EmulatorReturnsDouble() - { - // Verify the emulator returns a numeric type (float/integer in JToken terms) - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Float, - "VectorDistance should return a float/double value, matching real Cosmos DB's JSON number"); - } - - // ─── M5: NaN in vector ─────────────────────────────────────────────── - // Real Cosmos DB likely rejects NaN values during document insertion - // since NaN is not a valid JSON number. The emulator's Newtonsoft.Json - // parser might accept NaN depending on serialization settings. - // This is a data quality issue, not a query engine issue. - - [Fact(Skip = "Real Cosmos DB rejects NaN in vector fields at insert time (invalid JSON). " + - "Implementing this requires intercepting all document writes to validate vector " + - "field values, which is architecturally invasive. The emulator's NaN/Infinity guard " + - "in VectorDistanceFunc returns null for NaN results, providing partial safety.")] - public void VectorDistance_WithNaNInVector_RealCosmosBehaviour() - { - // Expected real Cosmos DB behavior: - // Inserting a document with embedding: [NaN, 1.0] → rejected at insert time - } - - [Fact] - public async Task VectorDistance_WithNaNInVector_EmulatorBehaviour() - { - // Emulator behaviour: if NaN somehow gets stored, the Infinity/NaN guard - // in VectorDistanceFunc catches it and returns null - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[\"NaN\",1.0]}"), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - - results.Should().ContainSingle(); - // "NaN" is a string, not a number — ToDoubleArray rejects non-numeric elements → null - results[0]["score"]!.Type.Should().Be(JTokenType.Null, - "NaN string in vector should be handled gracefully (ToDoubleArray rejects non-numeric elements)"); - } + private static async Task CreateContainerWithVectors() + { + var container = new InMemoryContainer("vector-test", "/pk"); + + // Unit vectors along each axis — easy to reason about cosine similarity + await container.CreateItemAsync( + JObject.FromObject(new { id = "x", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "y", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "z", pk = "a", embedding = new[] { 0.0, 0.0, 1.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "neg-x", pk = "a", embedding = new[] { -1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + return container; + } + + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + // ─── Cosine Similarity ─────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_Cosine_IdenticalVectors_ReturnsOne() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "cosine similarity of identical vectors should be 1.0"); + } + + [Fact] + public async Task VectorDistance_Cosine_OrthogonalVectors_ReturnsZero() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'y'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, + "cosine similarity of orthogonal vectors should be 0.0"); + } + + [Fact] + public async Task VectorDistance_Cosine_OppositeVectors_ReturnsNegativeOne() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'neg-x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9, + "cosine similarity of opposite vectors should be -1.0"); + } + + // ─── Dot Product ───────────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_DotProduct_ReturnsCorrectValue() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.0, 3.0, 4.0 } }), + new PartitionKey("a")); + + // dot([2,3,4], [1,5,2]) = 2*1 + 3*5 + 4*2 = 2 + 15 + 8 = 25 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 5.0, 2.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(25.0, 1e-9); + } + + // ─── Euclidean Distance ────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_Euclidean_ReturnsCorrectValue() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + // euclidean([1,0,0], [0,1,0]) = sqrt((1-0)^2 + (0-1)^2 + (0-0)^2) = sqrt(2) + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 1.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(Math.Sqrt(2), 1e-9); + } + + // ─── SELECT Projection ─────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_InSelectProjection_ReturnsSimilarityScore() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().HaveCount(4, "all 4 documents should have a score computed"); + results.Should().Contain(r => r["id"]!.ToString() == "x" && Math.Abs(r["score"]!.Value() - 1.0) < 1e-9); + results.Should().Contain(r => r["id"]!.ToString() == "y" && Math.Abs(r["score"]!.Value() - 0.0) < 1e-9); + } + + // ─── ORDER BY ──────────────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_InOrderBy_SortsByScore() + { + var container = await CreateContainerWithVectors(); + + // ORDER BY VectorDistance DESC — most similar first (highest cosine similarity) + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(4); + results[0]["id"]!.ToString().Should().Be("x", "identical vector should be first (score 1.0)"); + results[^1]["id"]!.ToString().Should().Be("neg-x", "opposite vector should be last (score -1.0)"); + } + + [Fact] + public async Task VectorDistance_TopN_WithOrderBy_ReturnsClosest() + { + var container = await CreateContainerWithVectors(); + + var results = await RunQuery(container, + "SELECT TOP 2 c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(2); + results[0]["id"]!.ToString().Should().Be("x"); + } + + // ─── Combined with WHERE ───────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_WithWhereClause_FiltersAndScores() + { + var container = await CreateContainerWithVectors(); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id != 'neg-x' ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(3); + results.Should().NotContain(r => r["id"]!.ToString() == "neg-x"); + } + + // ─── Edge Cases ────────────────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_MismatchedDimensions_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Document has 2D vector, query has 3D vector — should return null + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "mismatched dimensions should return null, not throw"); + } + + [Fact] + public async Task VectorDistance_MissingEmbedding_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "no-embedding" }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "missing vector property should return null"); + } + + // ─── Optional Parameters ───────────────────────────────────────────── + + [Fact] + public async Task VectorDistance_DistanceFunctionOverride_UsesEuclidean() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + // Euclidean distance between [3,0,0] and [0,0,0] = 3.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(3.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_BoolBruteForceParam_AcceptedAndIgnored() + { + var container = await CreateContainerWithVectors(); + + // 3rd argument (true = brute force) should be accepted but has no effect in emulator + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0], true) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + // ─── High-Dimensional Vectors ──────────────────────────────────────── + + [Fact] + public async Task VectorDistance_HighDimensional_1536Dimensions() + { + var container = new InMemoryContainer("vector-test", "/pk"); + + // Create a 1536-dim unit vector along first axis + var vec1 = new double[1536]; + vec1[0] = 1.0; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = vec1 }), + new PartitionKey("a")); + + // Query with same vector — cosine similarity should be 1.0 + var queryVec = string.Join(",", vec1.Select(v => v.ToString("F1"))); + var results = await RunQuery(container, + $"SELECT VectorDistance(c.embedding, [{queryVec}]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + // ─── Default Distance Function (Cosine) ────────────────────────────── + + [Fact] + public async Task VectorDistance_DefaultDistanceFunction_IsCosine() + { + var container = await CreateContainerWithVectors(); + + // No 4th argument — should default to cosine + // VectorDistance([1,0,0], [1,0,0]) with cosine = 1.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "default distance function should be cosine similarity"); + } + + // ═════════════════════════════════════════════════════════════════════ + // A: Mathematical Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_Cosine_ZeroVector_ReturnsNull() + { + // BUG-1: zero-magnitude vector → cosine is undefined. + // Real Cosmos DB returns no SimilarityScore (null). Emulator should match. + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "cosine similarity is undefined for zero-magnitude vectors — should return null"); + } + + [Fact] + public async Task VectorDistance_Cosine_NonUnitVectors_NormalizesCorrectly() + { + // [3,4] and [6,8] are parallel (same direction) — cosine = 1.0 regardless of magnitude + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [6.0, 8.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "parallel non-unit vectors should have cosine similarity 1.0"); + } + + [Fact] + public async Task VectorDistance_Cosine_NegativeComponents_ComputesCorrectly() + { + // [1, -1] vs [-1, 1] → dot = -2, |a|=√2, |b|=√2 → cosine = -2/2 = -1.0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, -1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [-1.0, 1.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_DotProduct_OrthogonalVectors_ReturnsZero() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 1.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_DotProduct_OppositeVectors_ReturnsNegative() + { + // [1,0] · [-1,0] = -1 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [-1.0, 0.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(-1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_Euclidean_IdenticalVectors_ReturnsZero() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 5.0, 3.0, 1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [5.0, 3.0, 1.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, + "euclidean distance from a vector to itself should be 0"); + } + + [Fact] + public async Task VectorDistance_Euclidean_KnownTriangle_345() + { + // [0,0] vs [3,4] → √(9+16) = 5.0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(5.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_Cosine_SingleDimension_HandlesCorrectly() + { + // 1D vectors: [5] vs [3] → both positive, cosine = 1.0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 5.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [3.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "1D positive vectors are parallel → cosine = 1.0"); + } + + [Fact] + public async Task VectorDistance_DotProduct_ZeroVector_ReturnsZero() + { + // dot([0,0,0], [1,2,3]) = 0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 2.0, 3.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_Euclidean_ZeroVector_ReturnsVectorMagnitude() + { + // euclidean([3,4,0], [0,0,0]) = √(9+16+0) = 5.0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(5.0, 1e-9); + } + + // ═════════════════════════════════════════════════════════════════════ + // B: Parameter Handling & Overloads + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_TwoArgsOnly_DefaultsToCosine() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_ThreeArgs_BruteForce_False() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_FourArgs_DotProduct() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.0, 3.0 } }), + new PartitionKey("a")); + + // dot([2,3], [4,5]) = 8 + 15 = 23 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [4.0, 5.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(23.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_FourArgs_Cosine_Explicit() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine'}) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "explicit cosine should produce same result as default"); + } + + [Fact] + public async Task VectorDistance_OptionsWithDataType_AcceptedSilently() + { + // dataType is an index-level concern — emulator accepts but ignores it + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', dataType:'Float32'}) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_OptionsWithSearchListSizeMultiplier_AcceptedSilently() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', searchListSizeMultiplier:10}) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_OptionsWithFilterPriority_AcceptedSilently() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine', filterPriority:0.5}) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Theory] + [InlineData("cosine")] + [InlineData("COSINE")] + [InlineData("Cosine")] + [InlineData("CoSiNe")] + public async Task VectorDistance_CaseInsensitiveDistanceFunction(string distFn) + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + $"SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {{distanceFunction:'{distFn}'}}) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + $"distanceFunction '{distFn}' should be case-insensitive"); + } + + [Fact] + public async Task VectorDistance_OneArg_ReturnsNull() + { + // Only 1 vector arg — less than minimum 2 args, should return null + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null); + } + + // ═════════════════════════════════════════════════════════════════════ + // C: Data Type & Input Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_IntegerVectorValues_WorkCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1, 0, 0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1, 0, 0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_MixedIntAndFloatValues_WorkCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 2, 3.5 } }), + new PartitionKey("a")); + + // dot([1.0, 2, 3.5], [1, 2.0, 3.5]) = 1 + 4 + 12.25 = 17.25 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1, 2.0, 3.5], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(17.25, 1e-9); + } + + [Fact] + public async Task VectorDistance_EmptyVector_ReturnsNull() + { + // Both vectors empty [] — length == 0, should return null + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[]}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, []) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "empty vectors (length 0) should return null"); + } + + [Fact] + public async Task VectorDistance_VeryLargeValues_NoOverflow() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e38, 1e38 } }), + new PartitionKey("a")); + + // Should not throw — cosine of parallel vectors is still 1.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1e38, 1e38]) AS score FROM c"); + + results.Should().ContainSingle(); + var score = results[0]["score"]; + // Either a valid double or null — must not throw + (score!.Type == JTokenType.Float || score.Type == JTokenType.Integer || score.Type == JTokenType.Null) + .Should().BeTrue("very large values should not cause an exception"); + } + + [Fact] + public async Task VectorDistance_VerySmallValues_NoPrecisionLoss() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e-10, 1e-10, 0.0 } }), + new PartitionKey("a")); + + // [1e-10, 1e-10, 0] vs [1e-10, 1e-10, 0] — identical vectors, cosine = 1.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1e-10, 1e-10, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-6, + "very small but identical vectors should still have cosine ≈ 1.0"); + } + + [Fact] + public async Task VectorDistance_NullVectorProperty_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":null}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task VectorDistance_NonArrayVectorProperty_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = "not-a-vector" }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public async Task VectorDistance_NestedProperty_WorksCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"metadata\":{\"embedding\":[1.0,0.0,0.0]}}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.metadata.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_VectorWithNonNumericElement_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[1.0,\"abc\",3.0]}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "non-numeric elements in the vector should return null"); + } + + [Fact] + public async Task VectorDistance_TwoDimensional_WorksCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // cosine([1,0], [0,1]) = 0.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 1.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); + } + + // ═════════════════════════════════════════════════════════════════════ + // D: SQL Integration (SELECT, WHERE, ORDER BY, GROUP BY) + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_InWhereClause_FiltersBySimilarityThreshold() + { + var container = await CreateContainerWithVectors(); + + // Cosine scores: x=1.0, y=0.0, z=0.0, neg-x=-1.0 + // WHERE score > 0.5 should only return x + var results = await RunQuery(container, + "SELECT c.id FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("x"); + } + + [Fact] + public async Task VectorDistance_InWhereAndSelect_DifferentVectors() + { + var container = await CreateContainerWithVectors(); + + // WHERE uses [1,0,0] to filter (score > 0.5 → only 'x'), but SELECT computes score against [0,1,0] + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS score FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("x"); + // x=[1,0,0] vs [0,1,0] → cosine = 0.0 + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_OrderByAsc_LeastSimilarFirst() + { + var container = await CreateContainerWithVectors(); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) ASC"); + + results.Should().HaveCount(4); + results[0]["id"]!.ToString().Should().Be("neg-x", "score -1.0 should be first in ASC"); + results[^1]["id"]!.ToString().Should().Be("x", "score 1.0 should be last in ASC"); + } + + [Fact] + public async Task VectorDistance_WithOffsetLimit_Paginated() + { + var container = await CreateContainerWithVectors(); + + // ORDER BY DESC: x(1.0), y(0.0), z(0.0), neg-x(-1.0) + // OFFSET 1 LIMIT 2 → should skip x, return y and z + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC OFFSET 1 LIMIT 2"); + + results.Should().HaveCount(2); + results.Should().NotContain(r => r["id"]!.ToString() == "x"); + results.Should().NotContain(r => r["id"]!.ToString() == "neg-x"); + } + + [Fact] + public async Task VectorDistance_AliasedInOrderBy_Works() + { + var container = await CreateContainerWithVectors(); + + // Use the alias 'score' in ORDER BY — some SQL engines support this + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(4); + results[0]["id"]!.ToString().Should().Be("x"); + } + + [Fact] + public async Task VectorDistance_MultipleCallsInSameQuery_Works() + { + var container = await CreateContainerWithVectors(); + + // Two VectorDistance calls against different query vectors in the same SELECT + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS scoreX, VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS scoreY FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["scoreX"]!.Value().Should().BeApproximately(1.0, 1e-9); + results[0]["scoreY"]!.Value().Should().BeApproximately(0.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_WithDistinct_WorksCorrectly() + { + var container = await CreateContainerWithVectors(); + + // y and z both have cosine 0.0 against [1,0,0], so DISTINCT should collapse them + var results = await RunQuery(container, + "SELECT DISTINCT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + // Scores: 1.0, 0.0, 0.0, -1.0 → distinct: 1.0, 0.0, -1.0 + results.Should().HaveCount(3); + } + + [Fact] + public async Task VectorDistance_CrossPartition_WithoutPartitionKey() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("b")); + + // No partition key specified — cross-partition query + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + } + + // D10: GROUP BY with AVG(VectorDistance(...)) — the GROUP BY aggregate handler + // uses ExtractNumericValues which calls SelectToken with the inner argument as a + // JSON path. Function calls like VectorDistance(...) are not valid JSON paths, so + // this throws. Fixing requires the GROUP BY aggregate pipeline to evaluate arbitrary + // SQL expressions (not just property paths) before aggregating. This is a general + // limitation of GROUP BY + aggregate(functionCall), not specific to vectors. + [Fact] + public async Task VectorDistance_WithGroupBy_AggregatesCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", category = "A", embedding = new[] { 0.8, 0.6 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.category, AVG(VectorDistance(c.embedding, [1.0, 0.0])) AS avgScore FROM c GROUP BY c.category"); + + results.Should().HaveCount(2); + var catA = results.First(r => r["category"]!.ToString() == "A"); + var catB = results.First(r => r["category"]!.ToString() == "B"); + catA["avgScore"]!.Value().Should().BeGreaterThan(catB["avgScore"]!.Value(), + "category A vectors are closer to [1,0] than category B"); + } + + [Fact] + public async Task VectorDistance_WithGroupBy_MinMax_AggregatesCorrectly() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", category = "A", embedding = new[] { 0.8, 0.6 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "3", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.category, MIN(VectorDistance(c.embedding, [1.0, 0.0])) AS minDist, " + + "MAX(VectorDistance(c.embedding, [1.0, 0.0])) AS maxDist FROM c GROUP BY c.category"); + + results.Should().HaveCount(2); + var catA = results.First(r => r["category"]!.ToString() == "A"); + catA["minDist"]!.Value().Should().BeLessThan(catA["maxDist"]!.Value(), + "category A should have distinct MIN and MAX distances"); + } + + // Sister test: GROUP BY works fine with VectorDistance when used outside aggregates + [Fact] + public async Task VectorDistance_WithGroupBy_NonAggregated_WorksInSelect() + { + // GROUP BY c.category works; the VectorDistance call is in a non-aggregated + // position (it just reappears in the grouping key projection). This shows + // that VectorDistance itself works fine — it's only the combination of + // aggregate(functionCall) that's unsupported. + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", category = "A", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", category = "B", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.category, COUNT(1) AS cnt FROM c GROUP BY c.category"); + + results.Should().HaveCount(2); + } + + // ═════════════════════════════════════════════════════════════════════ + // E: Multi-Document / Ranking Scenarios + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_KNN_Top5_ReturnsCorrectNearest() + { + var container = new InMemoryContainer("vector-test", "/pk"); + // Insert 20 docs with embeddings at increasing angles from [1,0] + for (var i = 0; i < 20; i++) + { + var angle = i * Math.PI / 20; // 0 to ~π + await container.CreateItemAsync( + JObject.FromObject(new + { + id = $"d{i}", + pk = "a", + embedding = new[] { Math.Cos(angle), Math.Sin(angle) } + }), + new PartitionKey("a")); + } + + var results = await RunQuery(container, + "SELECT TOP 5 c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0]) DESC"); + + results.Should().HaveCount(5); + // First result should be d0 (angle=0, closest to [1,0]) + results[0]["id"]!.ToString().Should().Be("d0"); + // All scores should be in descending order + for (var i = 0; i < results.Count - 1; i++) + results[i]["score"]!.Value().Should() + .BeGreaterThanOrEqualTo(results[i + 1]["score"]!.Value()); + } + + [Fact] + public async Task VectorDistance_TiedScores_ReturnedStably() + { + var container = await CreateContainerWithVectors(); + + // y=[0,1,0] and z=[0,0,1] both have cosine 0.0 with [1,0,0] + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(4); + // Both y and z should appear (tied at 0.0), between x (1.0) and neg-x (-1.0) + var middleIds = results.Skip(1).Take(2).Select(r => r["id"]!.ToString()).ToList(); + middleIds.Should().Contain("y"); + middleIds.Should().Contain("z"); + } + + [Fact] + public async Task VectorDistance_AllDocsHaveSameEmbedding_SameScore() + { + var container = new InMemoryContainer("vector-test", "/pk"); + for (var i = 0; i < 5; i++) + { + await container.CreateItemAsync( + JObject.FromObject(new { id = $"d{i}", pk = "a", embedding = new[] { 0.6, 0.8 } }), + new PartitionKey("a")); + } + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().HaveCount(5); + var scores = results.Select(r => r["score"]!.Value()).Distinct().ToList(); + scores.Should().ContainSingle("all identical embeddings should produce the same score"); + } + + [Fact] + public async Task VectorDistance_LargeDataset_100Docs_OrderByCorrect() + { + var container = new InMemoryContainer("vector-test", "/pk"); + var rng = new Random(42); // deterministic seed + for (var i = 0; i < 100; i++) + { + var vec = new[] { rng.NextDouble(), rng.NextDouble(), rng.NextDouble() }; + await container.CreateItemAsync( + JObject.FromObject(new { id = $"d{i}", pk = "a", embedding = vec }), + new PartitionKey("a")); + } + + var results = await RunQuery(container, + "SELECT TOP 10 c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(10); + for (var i = 0; i < results.Count - 1; i++) + results[i]["score"]!.Value().Should() + .BeGreaterThanOrEqualTo(results[i + 1]["score"]!.Value(), + "results should be in descending score order"); + } + + [Fact] + public async Task VectorDistance_MixOfValidAndMissingEmbeddings_NullsHandled() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "with-vec", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "no-vec", pk = "a", name = "missing" }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().HaveCount(2); + var withVec = results.First(r => r["id"]!.ToString() == "with-vec"); + var noVec = results.First(r => r["id"]!.ToString() == "no-vec"); + withVec["score"]!.Value().Should().BeApproximately(1.0, 1e-9); + noVec["score"]!.Type.Should().Be(JTokenType.Null); + } + + // ═════════════════════════════════════════════════════════════════════ + // F: Divergent Behaviour Tests + // Skipped tests document real Cosmos DB behaviour that is too complex + // or not meaningful to implement. Sister tests show emulator behaviour. + // ═════════════════════════════════════════════════════════════════════ + + // ─── F1: Multi-dimensional arrays ──────────────────────────────────── + // Real Cosmos DB: "If a multi-dimensional array is provided, the function + // doesn't return a SimilarityScore value and doesn't return an error." + // This means the property is omitted entirely from the result object. + // The emulator returns null for the property instead, which is close + // but not identical (property present with null vs property absent). + + [Fact(Skip = "F1: Real Cosmos DB omits the SimilarityScore property entirely for multi-dimensional " + + "array inputs rather than returning null. The emulator returns the property with a null " + + "value. Fixing this would require the query engine to distinguish between 'return null' " + + "and 'omit property from output', which is a fundamental change to projection handling. " + + "Impact: Very low — user code checking for null handles both cases.")] + public void VectorDistance_MultiDimensionalArray_RealCosmosReturnsNoScore() + { + // Expected real Cosmos DB behavior: + // Given a document with embedding: [[1,2],[3,4]] (nested array), + // SELECT VectorDistance(c.embedding, [1,0]) AS score FROM c + // returns {"id": "1"} with NO "score" property (not null, absent). + } + + [Fact] + public async Task VectorDistance_MultiDimensionalArray_EmulatorReturnsNull() + { + // Emulator behaviour: multi-dimensional array → ToDoubleArray returns null + // because inner elements are JArray not JTokenType.Float/Integer → returns null + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[[1,2],[3,4]]}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + // Emulator returns the property with null value (not omitted) + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "emulator returns null for multi-dimensional arrays (real Cosmos omits the property entirely)"); + } + + // ─── F2: Vector embedding policy required in real Cosmos ───────────── + // Real Cosmos DB requires a vectorEmbeddings container policy defining + // path, dataType, dimensions, and distanceFunction. Without it, vector + // search fails. The emulator has no such requirement. + + [Fact(Skip = "F2: Real Cosmos DB requires a vectorEmbeddings container policy (path, dataType, " + + "dimensions, distanceFunction) for VECTORDISTANCE to work. Without it, the query fails. " + + "The emulator intentionally skips this requirement — always brute-force exact computation " + + "without any policy needed. Implementing this would add complexity with no testing value " + + "since the policy is an infrastructure concern, not a logic concern.")] + public void VectorDistance_RequiresVectorPolicy_InRealCosmos() + { + // Expected real Cosmos DB behavior: + // Without vectorEmbeddings policy, VECTORDISTANCE queries fail with an error. + } + + [Fact] + public async Task VectorDistance_EmulatorDoesNotRequireVectorPolicy() + { + // Emulator behaviour: no vector policy needed, just use the function + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "emulator works without any vector embedding policy configuration"); + } + + // ─── F3: Flat index dimension limit ────────────────────────────────── + // Real Cosmos DB flat index supports max 505 dimensions. + // quantizedFlat and diskANN support up to 4096. + // The emulator has no limit (tested up to 1536). + + [Fact(Skip = "F3: Real Cosmos DB flat index limits vectors to 505 dimensions; quantizedFlat and " + + "diskANN support up to 4096. The emulator has no dimensionality limit because it " + + "doesn't simulate vector indexing — it always does brute-force linear scan. Imposing " + + "artificial limits would reduce testing flexibility without adding correctness value.")] + public void VectorDistance_FlatIndexMax505Dimensions_InRealCosmos() + { + // Expected real Cosmos DB behavior: + // Vectors with >505 dimensions fail with flat index type. + // Vectors with >4096 dimensions fail with quantizedFlat/diskANN. + } + + [Fact] + public async Task VectorDistance_EmulatorSupportsAnyDimensionality() + { + var container = new InMemoryContainer("vector-test", "/pk"); + var vec = new double[2000]; // Exceeds all real Cosmos limits + vec[0] = 1.0; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = vec }), + new PartitionKey("a")); + + var queryVec = string.Join(",", vec.Select(v => v.ToString("F1"))); + var results = await RunQuery(container, + $"SELECT VectorDistance(c.embedding, [{queryVec}]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "emulator supports vectors of any dimensionality (no index limits)"); + } + + // ─── F4: TOP N required with ORDER BY in real Cosmos ───────────────── + // Microsoft docs: "Always use a TOP N clause in the SELECT statement of a query. + // Otherwise the vector search tries to return many more results and the query + // costs more RUs and have higher latency than necessary." + // This is a performance guidance, not a hard error in all cases. + + [Fact(Skip = "F4: Real Cosmos DB strongly recommends TOP N with ORDER BY VectorDistance — without " + + "it, queries may time out or consume excessive RUs on large datasets. The emulator " + + "has no RU model and performs instant brute-force computation, so this limitation " + + "doesn't apply. Enforcing it would break valid test patterns.")] + public void VectorDistance_RequiresTopNWithOrderBy_InRealCosmos() + { + // Expected real Cosmos DB behavior: + // ORDER BY VectorDistance without TOP N may time out. + } + + [Fact] + public async Task VectorDistance_EmulatorAllowsOrderByWithoutTopN() + { + var container = await CreateContainerWithVectors(); + + // No TOP N — emulator returns all results, no issue + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0, 0.0]) DESC"); + + results.Should().HaveCount(4, "emulator returns all results without requiring TOP N"); + } + + // ═════════════════════════════════════════════════════════════════════ + // G: Numerical Robustness & IEEE 754 Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_Cosine_BothZeroVectors_ReturnsNull() + { + // Both document and query vectors are zero — cosine is undefined for both + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "cosine similarity is undefined when both vectors are zero-magnitude"); + } + + [Fact] + public async Task VectorDistance_Cosine_QueryVectorZero_ReturnsNull() + { + // Document has a valid vector but query vector is zero + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "cosine similarity is undefined when the query vector is zero-magnitude"); + } + + [Fact] + public async Task VectorDistance_DotProduct_LargeValues_NoOverflow() + { + // Dot product with very large components — could overflow to Infinity + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e308, 1e308 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1e308, 1e308], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + var score = results[0]["score"]; + // Must not throw, and should return null for overflow (Infinity) rather than a non-finite value + score!.Type.Should().Be(JTokenType.Null, + "dot product overflow to Infinity should return null rather than a non-finite value"); + } + + [Fact] + public async Task VectorDistance_Euclidean_LargeValues_NoOverflow() + { + // Euclidean squaring doubles the exponent — overflow risk + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e308, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, {distanceFunction:'euclidean'}) AS score FROM c"); + + results.Should().ContainSingle(); + var score = results[0]["score"]; + // Euclidean: sqrt((1e308)^2 + 0) = sqrt(Infinity) = Infinity → should return null + score!.Type.Should().Be(JTokenType.Null, + "euclidean distance overflow to Infinity should return null rather than a non-finite value"); + } + + [Fact] + public async Task VectorDistance_Cosine_NearlyParallelVectors_HighPrecision() + { + // Vectors differing by a tiny amount — cosine should be very close to 1.0 but not exactly + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + // 1e-7 is large enough to be distinguishable in double precision + // cos([1,0,0], [1,1e-7,0]) = 1/sqrt(1 + 1e-14) ≈ 0.999999999999995 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0000001, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + var score = results[0]["score"]!.Value(); + score.Should().BeGreaterThan(0.99999999, + "nearly parallel vectors should have cosine very close to 1.0"); + score.Should().BeLessThan(1.0, + "slightly different vectors should not have cosine exactly 1.0"); + } + + [Fact] + public async Task VectorDistance_Cosine_NearlyAntiparallel_HighPrecision() + { + // Vectors nearly opposite — cosine should be very close to -1.0 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + // 1e-7 is large enough to be distinguishable in double precision + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [-1.0, 0.0000001, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + var score = results[0]["score"]!.Value(); + score.Should().BeLessThan(-0.99999999, + "nearly anti-parallel vectors should have cosine very close to -1.0"); + score.Should().BeGreaterThan(-1.0, + "slightly off-axis vectors should not have cosine exactly -1.0"); + } + + [Fact] + public async Task VectorDistance_DotProduct_IdenticalVectors_ReturnsSumOfSquares() + { + // [3,4] · [3,4] = 9 + 16 = 25 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(25.0, 1e-9); + } + + [Fact] + public async Task VectorDistance_Euclidean_SymmetricDistance() + { + // distance(a, b) should equal distance(b, a) — commutative + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "ab", pk = "a", embedding = new[] { 1.0, 2.0, 3.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "ba", pk = "a", embedding = new[] { 4.0, 5.0, 6.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [4.0, 5.0, 6.0], false, {distanceFunction:'euclidean'}) AS scoreForward FROM c WHERE c.id = 'ab'"); + var results2 = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 2.0, 3.0], false, {distanceFunction:'euclidean'}) AS scoreReverse FROM c WHERE c.id = 'ba'"); + + var forward = results[0]["scoreForward"]!.Value(); + var reverse = results2[0]["scoreReverse"]!.Value(); + forward.Should().BeApproximately(reverse, 1e-9, + "euclidean distance should be symmetric: d(a,b) == d(b,a)"); + } + + [Fact] + public async Task VectorDistance_Cosine_SymmetricSimilarity() + { + // cosine(a, b) should equal cosine(b, a) — verify by swapping args + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "ab", pk = "a", embedding = new[] { 1.0, 2.0, 3.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "ba", pk = "a", embedding = new[] { 4.0, 5.0, 6.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [4.0, 5.0, 6.0]) AS scoreForward FROM c WHERE c.id = 'ab'"); + var results2 = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 2.0, 3.0]) AS scoreReverse FROM c WHERE c.id = 'ba'"); + + var forward = results[0]["scoreForward"]!.Value(); + var reverse = results2[0]["scoreReverse"]!.Value(); + forward.Should().BeApproximately(reverse, 1e-9, + "cosine similarity should be symmetric: cos(a,b) == cos(b,a)"); + } + + [Fact] + public async Task VectorDistance_DotProduct_NegativeValues_ComputesCorrectly() + { + // [-2,-3] · [4,5] = -8 + -15 = -23 + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { -2.0, -3.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [4.0, 5.0], false, {distanceFunction:'dotproduct'}) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(-23.0, 1e-9); + } + + // ═════════════════════════════════════════════════════════════════════ + // H: Query Literal & Parser Edge Cases + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_LiteralVectorWithSpaces_Works() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [ 1.0 , 0.0 , 0.0 ]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "whitespace inside array literal should be handled by parser"); + } + + [Fact] + public async Task VectorDistance_LiteralVectorWithNegativeValues_Works() + { + var container = await CreateContainerWithVectors(); + // cosine([1,0,0], [-1,-2,-3]) — negative values in query literal + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [-1.0, -2.0, -3.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + // cos([1,0,0], [-1,-2,-3]) = -1 / (1 * sqrt(14)) ≈ -0.2673 + results[0]["score"]!.Value().Should().BeApproximately(-1.0 / Math.Sqrt(14), 1e-6); + } + + [Theory] + [InlineData("vectordistance")] + [InlineData("VECTORDISTANCE")] + [InlineData("VectorDistance")] + [InlineData("vectorDistance")] + public async Task VectorDistance_FunctionNameCaseInsensitive(string funcName) + { + // Parser uppercases all function names — all case variants should work + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + $"SELECT {funcName}(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + $"function name '{funcName}' should be case-insensitive"); + } + + [Fact] + public async Task VectorDistance_NoArgs_ReturnsNull() + { + // Zero args: VectorDistance() — guard returns null for < 2 args + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance() AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "zero args should return null"); + } + + [Fact] + public async Task VectorDistance_VectorWithSingleZeroElement_ReturnsNull() + { + // [0.0] vs [0.0] — single zero-element, cosine = undefined → null + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "single zero-element vectors → cosine undefined → null"); + } + + [Fact] + public async Task VectorDistance_WithArithmeticOnResult_InSelect() + { + // VectorDistance(...) * 100 — arithmetic on the function result in SELECT + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) * 100 AS pctScore FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["pctScore"]!.Value().Should().BeApproximately(100.0, 1e-6, + "arithmetic (score * 100) should work on VectorDistance results"); + } + + [Fact] + public async Task VectorDistance_InValueExpression_Addition() + { + // VectorDistance(...) + 5 — addition on the function result + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) + 5 AS adjustedScore FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["adjustedScore"]!.Value().Should().BeApproximately(6.0, 1e-6, + "VectorDistance result (1.0) + 5 should equal 6.0"); + } + + [Fact] + public async Task VectorDistance_WithIIF_ConditionalLabel() + { + // Use VectorDistance result in IIF to produce a label + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, IIF(VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5, 'similar', 'different') AS label FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["label"]!.ToString().Should().Be("similar", + "x has cosine 1.0 which is > 0.5, so IIF should return 'similar'"); + } + + [Fact] + public async Task VectorDistance_WithAbsArithmetic_Works() + { + // ABS(VectorDistance(...)) — function composition + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id, ABS(VectorDistance(c.embedding, [1.0, 0.0, 0.0])) AS absScore FROM c WHERE c.id = 'neg-x'"); + + results.Should().ContainSingle(); + results[0]["absScore"]!.Value().Should().BeApproximately(1.0, 1e-9, + "ABS of cosine -1.0 should be 1.0"); + } + + // ═════════════════════════════════════════════════════════════════════ + // I: CRUD + Mutation Interaction + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_AfterUpsert_UsesUpdatedVector() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Upsert with a completely different embedding + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + // After upsert, embedding is [0,1], cosine([0,1], [1,0]) = 0.0 + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, + "query should reflect the updated embedding after upsert"); + } + + [Fact] + public async Task VectorDistance_AfterPatch_UsesUpdatedVector() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Patch the embedding via Set operation + await container.PatchItemAsync("1", new PartitionKey("a"), + new List { PatchOperation.Set("/embedding", new[] { 0.0, 1.0 }) }); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + // After patch, embedding is [0,1], cosine([0,1], [1,0]) = 0.0 + results[0]["score"]!.Value().Should().BeApproximately(0.0, 1e-9, + "query should reflect the patched embedding"); + } + + [Fact] + public async Task VectorDistance_AfterDelete_ExcludesDeletedDoc() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + await container.DeleteItemAsync("1", new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("2", + "deleted document should not appear in vector search results"); + } + + [Fact] + public async Task VectorDistance_WithTTLExpiredDoc_ExcludesExpired() + { + var container = new InMemoryContainer("vector-test", "/pk") + { + DefaultTimeToLive = 1 // 1 second TTL + }; + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Wait for TTL to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().BeEmpty("TTL-expired documents should not appear in vector search results"); + } + + [Fact] + public async Task VectorDistance_ConcurrentReads_ThreadSafe() + { + var container = new InMemoryContainer("vector-test", "/pk"); + for (var i = 0; i < 10; i++) + { + await container.CreateItemAsync( + JObject.FromObject(new + { + id = $"d{i}", + pk = "a", + embedding = new[] { Math.Cos(i * 0.3), Math.Sin(i * 0.3) } + }), + new PartitionKey("a")); + } + + // Run 20 concurrent vector searches + var tasks = Enumerable.Range(0, 20).Select(_ => + RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); + + var allResults = await Task.WhenAll(tasks); + + foreach (var results in allResults) + { + results.Should().HaveCount(10, "each concurrent query should return all 10 documents"); + } + } + + // ═════════════════════════════════════════════════════════════════════ + // J: Change Feed + Vector Search Integration + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_ChangeFeed_CapturesVectorUpdates() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Update the embedding via upsert + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + // Should capture both the create and the upsert + changes.Should().HaveCountGreaterThanOrEqualTo(1); + var latest = changes.Last(); + // The latest version should have the updated embedding [0,1] + var embedding = latest["embedding"]!.ToObject()!; + embedding.Should().BeEquivalentTo(new[] { 0.0, 1.0 }, + "change feed should capture the updated vector embedding"); + } + + [Fact] + public async Task VectorDistance_VectorOnlyUpdate_AppearsInChangeFeed() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "stable", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Only change the embedding, nothing else + await container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "stable", embedding = new[] { 0.5, 0.5 } }), + new PartitionKey("a")); + + var iterator = container.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.Incremental); + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page); + } + + // The change feed should have the latest version with the updated vector + // (Incremental mode returns latest version per document, not every intermediate state) + changes.Should().HaveCountGreaterThanOrEqualTo(1, + "the vector-only update should appear in the change feed"); + var latest = changes.Last(); + latest["embedding"]!.ToObject().Should().BeEquivalentTo(new[] { 0.5, 0.5 }, + "change feed should reflect the updated vector embedding"); + } + + // ═════════════════════════════════════════════════════════════════════ + // K: Partition Key Interaction + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_HierarchicalPartitionKey_CrossPartition() + { + var container = new InMemoryContainer("vector-test", new List { "/tenant", "/region" }); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenant = "A", region = "US", embedding = new[] { 1.0, 0.0 } }), + new PartitionKeyBuilder().Add("A").Add("US").Build()); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenant = "B", region = "EU", embedding = new[] { 0.0, 1.0 } }), + new PartitionKeyBuilder().Add("B").Add("EU").Build()); + + // Cross-partition query (no PK filter) + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2, + "cross-partition vector search should return docs from all partitions"); + } + + [Fact] + public async Task VectorDistance_SinglePartitionScopedSearch_OnlyReturnsPartitionDocs() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "b", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("b")); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + // RunQuery uses PartitionKey("a") — should only return the "a" partition doc + results.Should().ContainSingle(); + results[0]["id"]!.ToString().Should().Be("1"); + } + + // ═════════════════════════════════════════════════════════════════════ + // L: Stream API Integration + // ═════════════════════════════════════════════════════════════════════ + + [Fact] + public async Task VectorDistance_ViaStreamIterator_ReturnsValidJson() + { + var container = await CreateContainerWithVectors(); + + var query = new QueryDefinition( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + var iterator = container.GetItemQueryStreamIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + + using var response = await iterator.ReadNextAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var reader = new StreamReader(response.Content); + var body = await reader.ReadToEndAsync(); + var jObj = JObject.Parse(body); + + var docs = jObj["Documents"]!.ToObject>()!; + docs.Should().ContainSingle(); + docs[0]["id"]!.ToString().Should().Be("x"); + docs[0]["score"]!.Value().Should().BeApproximately(1.0, 1e-9, + "stream iterator should return valid JSON with vector scores"); + } + + // ═════════════════════════════════════════════════════════════════════ + // M: Additional Divergent Behaviour (Skip + Sister) + // ═════════════════════════════════════════════════════════════════════ + + // ─── M1: Extra args (>4) ───────────────────────────────────────────── + // Real Cosmos DB rejects queries with more than 4 args to VECTORDISTANCE + // at the query compilation layer. The emulator's generic function dispatcher + // accepts arbitrary arg counts and the 5th+ args are silently ignored. + + [Fact] + public async Task VectorDistance_FiveArgs_RealCosmosRejectsExtraArgs() + { + var container = await CreateContainerWithVectors(); + var act = () => RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'cosine'}, 'extra') AS score FROM c WHERE c.id = 'x'"); + + await act.Should().ThrowAsync(); + } + + // Sister test already exists as VectorDistance_FiveArgs_EmulatorIgnoresExtraArgs in H category + + // ─── M2: Unknown distance function ──────────────────────────────────── + // Real Cosmos DB rejects unknown distance function values with an error. + // The emulator silently falls back to cosine similarity. + + [Fact] + public async Task VectorDistance_UnknownDistanceFunction_RealCosmosRejects() + { + var container = await CreateContainerWithVectors(); + var act = () => RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0], false, {distanceFunction:'manhattan'}) AS score FROM c WHERE c.id = 'x'"); + + await act.Should().ThrowAsync(); + } + + // Sister test already exists as VectorDistance_UnknownDistanceFunction_FallsToCosine in B category + + // ─── M3: Parameterized query vectors ───────────────────────────────── + // Real Cosmos DB supports parameterized query vectors: + // new QueryDefinition("SELECT VectorDistance(c.vec, @qv) AS s FROM c") + // .WithParameter("@qv", new[] { 1.0, 0.0, 0.0 }) + // The emulator may or may not support this depending on how parameters + // are resolved for function arguments. + + [Fact] + public async Task VectorDistance_ParameterizedQuery_ReturnsCorrectScore() + { + var container = await CreateContainerWithVectors(); + + var query = new QueryDefinition( + "SELECT VectorDistance(c.embedding, @qv) AS score FROM c WHERE c.id = 'x'") + .WithParameter("@qv", new[] { 1.0, 0.0, 0.0 }); + + var iterator = container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.001); + } + + // ─── M4: Return type ───────────────────────────────────────────────── + // Real Cosmos DB returns VectorDistance as a JSON number (float64). + // The emulator returns a .NET double boxed as object, which serializes + // as a JSON number. Behaviour is functionally identical. + + [Fact] + public async Task VectorDistance_ReturnType_EmulatorReturnsDouble() + { + // Verify the emulator returns a numeric type (float/integer in JToken terms) + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'x'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Float, + "VectorDistance should return a float/double value, matching real Cosmos DB's JSON number"); + } + + // ─── M5: NaN in vector ─────────────────────────────────────────────── + // Real Cosmos DB likely rejects NaN values during document insertion + // since NaN is not a valid JSON number. The emulator's Newtonsoft.Json + // parser might accept NaN depending on serialization settings. + // This is a data quality issue, not a query engine issue. + + [Fact(Skip = "Real Cosmos DB rejects NaN in vector fields at insert time (invalid JSON). " + + "Implementing this requires intercepting all document writes to validate vector " + + "field values, which is architecturally invasive. The emulator's NaN/Infinity guard " + + "in VectorDistanceFunc returns null for NaN results, providing partial safety.")] + public void VectorDistance_WithNaNInVector_RealCosmosBehaviour() + { + // Expected real Cosmos DB behavior: + // Inserting a document with embedding: [NaN, 1.0] → rejected at insert time + } + + [Fact] + public async Task VectorDistance_WithNaNInVector_EmulatorBehaviour() + { + // Emulator behaviour: if NaN somehow gets stored, the Infinity/NaN guard + // in VectorDistanceFunc catches it and returns null + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("{\"id\":\"1\",\"pk\":\"a\",\"embedding\":[\"NaN\",1.0]}"), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + + results.Should().ContainSingle(); + // "NaN" is a string, not a number — ToDoubleArray rejects non-numeric elements → null + results[0]["score"]!.Type.Should().Be(JTokenType.Null, + "NaN string in vector should be handled gracefully (ToDoubleArray rejects non-numeric elements)"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1879,61 +1887,61 @@ await container.CreateItemAsync( public class VectorDistanceBareStringTests { - private static async Task CreateContainer() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - return container; - } - - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_BareStringEuclidean_WorksCorrectly() - { - var container = await CreateContainer(); - // [3,4] vs [0,0] = sqrt(9+16) = 5.0 - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 0.0 } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, 'euclidean') AS dist FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["dist"]!.Value().Should().BeApproximately(5.0, 0.001); - } - - [Fact] - public async Task VectorDistance_BareStringDotProduct_WorksCorrectly() - { - var container = await CreateContainer(); - // dot([3,4], [2,5]) = 3*2 + 4*5 = 6+20 = 26 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [2.0, 5.0], false, 'dotproduct') AS dp FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["dp"]!.Value().Should().BeApproximately(26.0, 0.001); - } - - [Fact] - public async Task VectorDistance_BareStringCosine_WorksCorrectly() - { - var container = await CreateContainer(); - // cosine([3,4], [3,4]) = 1.0 (identical vectors) - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, 'cosine') AS score FROM c WHERE c.id = '1'"); - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.001); - } + private static async Task CreateContainer() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + return container; + } + + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_BareStringEuclidean_WorksCorrectly() + { + var container = await CreateContainer(); + // [3,4] vs [0,0] = sqrt(9+16) = 5.0 + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 0.0 } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, 'euclidean') AS dist FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["dist"]!.Value().Should().BeApproximately(5.0, 0.001); + } + + [Fact] + public async Task VectorDistance_BareStringDotProduct_WorksCorrectly() + { + var container = await CreateContainer(); + // dot([3,4], [2,5]) = 3*2 + 4*5 = 6+20 = 26 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [2.0, 5.0], false, 'dotproduct') AS dp FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["dp"]!.Value().Should().BeApproximately(26.0, 0.001); + } + + [Fact] + public async Task VectorDistance_BareStringCosine_WorksCorrectly() + { + var container = await CreateContainer(); + // cosine([3,4], [3,4]) = 1.0 (identical vectors) + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, 'cosine') AS score FROM c WHERE c.id = '1'"); + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.001); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1942,65 +1950,65 @@ public async Task VectorDistance_BareStringCosine_WorksCorrectly() public class VectorDistanceSubqueryTests { - private static async Task CreateContainerWithVectors() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "a", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "b", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "c", pk = "a", embedding = new[] { 0.707, 0.707, 0.0 } }), - new PartitionKey("a")); - return container; - } - - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_InExistsSubquery_FiltersDocuments() - { - var container = await CreateContainerWithVectors(); - // Filter documents where cosine to [1,0,0] > 0.9 — only doc "a" (cosine=1.0) - var results = await RunQuery(container, - "SELECT * FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.9"); - - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("a"); - } - - [Fact] - public async Task VectorDistance_InScalarSubquery_ReturnsScore() - { - var container = await CreateContainerWithVectors(); - // Use VectorDistance directly in SELECT with alias instead of scalar subquery syntax - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'a'"); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_WhereWithExistsAndVector_CombinesFilters() - { - var container = await CreateContainerWithVectors(); - var results = await RunQuery(container, - "SELECT c.id FROM c WHERE c.id != 'b' AND VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); - - results.Should().HaveCount(2); // 'a' (score=1.0) and 'c' (score~0.707) - results.Select(r => r["id"]!.Value()).Should().Contain("a").And.Contain("c"); - } + private static async Task CreateContainerWithVectors() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "a", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "b", pk = "a", embedding = new[] { 0.0, 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "c", pk = "a", embedding = new[] { 0.707, 0.707, 0.0 } }), + new PartitionKey("a")); + return container; + } + + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_InExistsSubquery_FiltersDocuments() + { + var container = await CreateContainerWithVectors(); + // Filter documents where cosine to [1,0,0] > 0.9 — only doc "a" (cosine=1.0) + var results = await RunQuery(container, + "SELECT * FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.9"); + + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("a"); + } + + [Fact] + public async Task VectorDistance_InScalarSubquery_ReturnsScore() + { + var container = await CreateContainerWithVectors(); + // Use VectorDistance directly in SELECT with alias instead of scalar subquery syntax + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c WHERE c.id = 'a'"); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_WhereWithExistsAndVector_CombinesFilters() + { + var container = await CreateContainerWithVectors(); + var results = await RunQuery(container, + "SELECT c.id FROM c WHERE c.id != 'b' AND VectorDistance(c.embedding, [1.0, 0.0, 0.0]) > 0.5"); + + results.Should().HaveCount(2); // 'a' (score=1.0) and 'c' (score~0.707) + results.Select(r => r["id"]!.Value()).Should().Contain("a").And.Contain("c"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2009,64 +2017,64 @@ public async Task VectorDistance_WhereWithExistsAndVector_CombinesFilters() public class VectorDistanceProjectionTests { - private static async Task CreateSingleDoc() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - return container; - } - - [Fact] - public async Task VectorDistance_SelectValue_ReturnsRawScores() - { - var container = await CreateSingleDoc(); - var iterator = container.GetItemQueryIterator( - "SELECT VALUE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle().Which.Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_SelectStar_DoesNotIncludeComputedScore() - { - var container = await CreateSingleDoc(); - var iterator = container.GetItemQueryIterator("SELECT * FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["embedding"].Should().NotBeNull(); - results[0]["score"].Should().BeNull("SELECT * should not include computed scores"); - } - - [Fact] - public async Task VectorDistance_SelectValueTopN_ReturnsTopScores() - { - var container = new InMemoryContainer("vector-test", "/pk"); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)(i + 1), 0.0, 0.0 } }), - new PartitionKey("a")); - - var iterator = container.GetItemQueryIterator( - "SELECT TOP 3 VectorDistance(c.embedding, [5.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [5.0, 0.0, 0.0]) DESC", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(3); - var scores = results.Select(r => r["score"]!.Value()).ToList(); - scores.Should().BeInDescendingOrder(); - } + private static async Task CreateSingleDoc() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + return container; + } + + [Fact] + public async Task VectorDistance_SelectValue_ReturnsRawScores() + { + var container = await CreateSingleDoc(); + var iterator = container.GetItemQueryIterator( + "SELECT VALUE VectorDistance(c.embedding, [1.0, 0.0, 0.0]) FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle().Which.Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_SelectStar_DoesNotIncludeComputedScore() + { + var container = await CreateSingleDoc(); + var iterator = container.GetItemQueryIterator("SELECT * FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["embedding"].Should().NotBeNull(); + results[0]["score"].Should().BeNull("SELECT * should not include computed scores"); + } + + [Fact] + public async Task VectorDistance_SelectValueTopN_ReturnsTopScores() + { + var container = new InMemoryContainer("vector-test", "/pk"); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)(i + 1), 0.0, 0.0 } }), + new PartitionKey("a")); + + var iterator = container.GetItemQueryIterator( + "SELECT TOP 3 VectorDistance(c.embedding, [5.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [5.0, 0.0, 0.0]) DESC", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(3); + var scores = results.Select(r => r["score"]!.Value()).ToList(); + scores.Should().BeInDescendingOrder(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2075,59 +2083,59 @@ await container.CreateItemAsync( public class VectorDistancePaginationTests { - [Fact] - public async Task VectorDistance_PaginatedResults_AllPagesReturnCorrectScores() - { - var container = new InMemoryContainer("vector-test", "/pk"); - for (var i = 0; i < 6; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)i, 0.0 } }), - new PartitionKey("a")); - - var allResults = new List(); - string? continuationToken = null; - do - { - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c", - continuationToken: continuationToken, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); - - var page = await iterator.ReadNextAsync(); - allResults.AddRange(page); - continuationToken = page.ContinuationToken; - } while (continuationToken != null); - - allResults.Should().HaveCount(6); - allResults.Should().OnlyContain(r => r["score"] != null); - } - - [Fact] - public async Task VectorDistance_PaginatedOrderBy_MaintainsOrderAcrossPages() - { - var container = new InMemoryContainer("vector-test", "/pk"); - for (var i = 0; i < 6; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)(i + 1), 0.0, 0.0 } }), - new PartitionKey("a")); - - var allScores = new List(); - string? continuationToken = null; - do - { - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [6.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [6.0, 0.0, 0.0]) DESC", - continuationToken: continuationToken, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); - - var page = await iterator.ReadNextAsync(); - allScores.AddRange(page.Select(r => r["score"]!.Value())); - continuationToken = page.ContinuationToken; - } while (continuationToken != null); - - allScores.Should().HaveCount(6); - allScores.Should().BeInDescendingOrder(); - } + [Fact] + public async Task VectorDistance_PaginatedResults_AllPagesReturnCorrectScores() + { + var container = new InMemoryContainer("vector-test", "/pk"); + for (var i = 0; i < 6; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)i, 0.0 } }), + new PartitionKey("a")); + + var allResults = new List(); + string? continuationToken = null; + do + { + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c", + continuationToken: continuationToken, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); + + var page = await iterator.ReadNextAsync(); + allResults.AddRange(page); + continuationToken = page.ContinuationToken; + } while (continuationToken != null); + + allResults.Should().HaveCount(6); + allResults.Should().OnlyContain(r => r["score"] != null); + } + + [Fact] + public async Task VectorDistance_PaginatedOrderBy_MaintainsOrderAcrossPages() + { + var container = new InMemoryContainer("vector-test", "/pk"); + for (var i = 0; i < 6; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = "a", embedding = new[] { (double)(i + 1), 0.0, 0.0 } }), + new PartitionKey("a")); + + var allScores = new List(); + string? continuationToken = null; + do + { + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [6.0, 0.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [6.0, 0.0, 0.0]) DESC", + continuationToken: continuationToken, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a"), MaxItemCount = 2 }); + + var page = await iterator.ReadNextAsync(); + allScores.AddRange(page.Select(r => r["score"]!.Value())); + continuationToken = page.ContinuationToken; + } while (continuationToken != null); + + allScores.Should().HaveCount(6); + allScores.Should().BeInDescendingOrder(); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2136,74 +2144,74 @@ await container.CreateItemAsync( public class VectorDistanceMutationTests { - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_AfterReplace_UsesUpdatedVector() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - // Replace with new vector - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), - "1", new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 1.0]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_InTransactionalBatch_AfterBatchUpsert() - { - var container = new InMemoryContainer("vector-test", "/pk"); - - var batch = container.CreateTransactionalBatch(new PartitionKey("a")); - batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } })); - batch.CreateItem(JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } })); - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY c.id"); - results.Should().HaveCount(2); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); // id=1, identical - results[1]["score"]!.Value().Should().BeApproximately(0.0, 0.01); // id=2, orthogonal - } - - [Fact] - public async Task VectorDistance_AfterBatchDelete_ExcludesDeleted() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } }), - new PartitionKey("a")); - - var batch = container.CreateTransactionalBatch(new PartitionKey("a")); - batch.DeleteItem("1"); - using var response = await batch.ExecuteAsync(); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var results = await RunQuery(container, - "SELECT c.id FROM c"); - results.Should().ContainSingle(); - results[0]["id"]!.Value().Should().Be("2"); - } + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_AfterReplace_UsesUpdatedVector() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + // Replace with new vector + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), + "1", new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 1.0]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_InTransactionalBatch_AfterBatchUpsert() + { + var container = new InMemoryContainer("vector-test", "/pk"); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } })); + batch.CreateItem(JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } })); + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY c.id"); + results.Should().HaveCount(2); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); // id=1, identical + results[1]["score"]!.Value().Should().BeApproximately(0.0, 0.01); // id=2, orthogonal + } + + [Fact] + public async Task VectorDistance_AfterBatchDelete_ExcludesDeleted() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", embedding = new[] { 0.0, 1.0 } }), + new PartitionKey("a")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.DeleteItem("1"); + using var response = await batch.ExecuteAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var results = await RunQuery(container, + "SELECT c.id FROM c"); + results.Should().ContainSingle(); + results[0]["id"]!.Value().Should().Be("2"); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2212,50 +2220,50 @@ await container.CreateItemAsync( public class VectorDistanceFeedRangeTests { - [Fact] - public async Task VectorDistance_WithFeedRange_ScopedToRange() - { - var container = new InMemoryContainer("vector-test", "/pk") { FeedRangeCount = 4 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"pk-{i}", embedding = new[] { (double)i, 0.0 } }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var rangeResults = new List(); - var range = ranges.First(); - - var iterator = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); - while (iterator.HasMoreResults) - rangeResults.AddRange(await iterator.ReadNextAsync()); - - // Should be a subset of all items (scoped to one feed range) - rangeResults.Count.Should().BeLessThanOrEqualTo(10); - rangeResults.Should().OnlyContain(r => r["score"] != null); - } - - [Fact] - public async Task VectorDistance_AllFeedRanges_ReturnCompleteResults() - { - var container = new InMemoryContainer("vector-test", "/pk") { FeedRangeCount = 4 }; - for (var i = 0; i < 10; i++) - await container.CreateItemAsync( - JObject.FromObject(new { id = $"{i}", pk = $"pk-{i}", embedding = new[] { (double)i, 0.0 } }), - new PartitionKey($"pk-{i}")); - - var ranges = await container.GetFeedRangesAsync(); - var allResults = new List(); - foreach (var range in ranges) - { - var iterator = container.GetItemQueryIterator(range, - new QueryDefinition("SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); - while (iterator.HasMoreResults) - allResults.AddRange(await iterator.ReadNextAsync()); - } - - allResults.Should().HaveCount(10); - } + [Fact] + public async Task VectorDistance_WithFeedRange_ScopedToRange() + { + var container = new InMemoryContainer("vector-test", "/pk") { FeedRangeCount = 4 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"pk-{i}", embedding = new[] { (double)i, 0.0 } }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var rangeResults = new List(); + var range = ranges.First(); + + var iterator = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); + while (iterator.HasMoreResults) + rangeResults.AddRange(await iterator.ReadNextAsync()); + + // Should be a subset of all items (scoped to one feed range) + rangeResults.Count.Should().BeLessThanOrEqualTo(10); + rangeResults.Should().OnlyContain(r => r["score"] != null); + } + + [Fact] + public async Task VectorDistance_AllFeedRanges_ReturnCompleteResults() + { + var container = new InMemoryContainer("vector-test", "/pk") { FeedRangeCount = 4 }; + for (var i = 0; i < 10; i++) + await container.CreateItemAsync( + JObject.FromObject(new { id = $"{i}", pk = $"pk-{i}", embedding = new[] { (double)i, 0.0 } }), + new PartitionKey($"pk-{i}")); + + var ranges = await container.GetFeedRangesAsync(); + var allResults = new List(); + foreach (var range in ranges) + { + var iterator = container.GetItemQueryIterator(range, + new QueryDefinition("SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c")); + while (iterator.HasMoreResults) + allResults.AddRange(await iterator.ReadNextAsync()); + } + + allResults.Should().HaveCount(10); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2264,50 +2272,50 @@ await container.CreateItemAsync( public class VectorDistanceEdgeCaseExtendedTests { - [Fact] - public async Task VectorDistance_EmptyContainer_ReturnsNoResults() - { - var container = new InMemoryContainer("vector-test", "/pk"); - var iterator = container.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task VectorDistance_SingleDoc_AllMetricsReturnValue() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - - var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a") }; - - // Cosine - var cosIter = container.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, 'cosine') AS score FROM c", requestOptions: opts); - var cosRes = new List(); - while (cosIter.HasMoreResults) cosRes.AddRange(await cosIter.ReadNextAsync()); - cosRes[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - - // Dot product - var dotIter = container.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [1.0, 0.0], false, 'dotproduct') AS score FROM c", requestOptions: opts); - var dotRes = new List(); - while (dotIter.HasMoreResults) dotRes.AddRange(await dotIter.ReadNextAsync()); - dotRes[0]["score"]!.Value().Should().BeApproximately(3.0, 0.01); - - // Euclidean - var eucIter = container.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, 'euclidean') AS score FROM c", requestOptions: opts); - var eucRes = new List(); - while (eucIter.HasMoreResults) eucRes.AddRange(await eucIter.ReadNextAsync()); - eucRes[0]["score"]!.Value().Should().BeApproximately(5.0, 0.01); - } + [Fact] + public async Task VectorDistance_EmptyContainer_ReturnsNoResults() + { + var container = new InMemoryContainer("vector-test", "/pk"); + var iterator = container.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task VectorDistance_SingleDoc_AllMetricsReturnValue() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + + var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("a") }; + + // Cosine + var cosIter = container.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [3.0, 4.0], false, 'cosine') AS score FROM c", requestOptions: opts); + var cosRes = new List(); + while (cosIter.HasMoreResults) cosRes.AddRange(await cosIter.ReadNextAsync()); + cosRes[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + + // Dot product + var dotIter = container.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [1.0, 0.0], false, 'dotproduct') AS score FROM c", requestOptions: opts); + var dotRes = new List(); + while (dotIter.HasMoreResults) dotRes.AddRange(await dotIter.ReadNextAsync()); + dotRes[0]["score"]!.Value().Should().BeApproximately(3.0, 0.01); + + // Euclidean + var eucIter = container.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [0.0, 0.0], false, 'euclidean') AS score FROM c", requestOptions: opts); + var eucRes = new List(); + while (eucIter.HasMoreResults) eucRes.AddRange(await eucIter.ReadNextAsync()); + eucRes[0]["score"]!.Value().Should().BeApproximately(5.0, 0.01); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2316,80 +2324,80 @@ await container.CreateItemAsync( public class VectorDistanceAdvancedSqlTests { - private static async Task CreateMultiDocContainer() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "a", pk = "a", embedding = new[] { 1.0, 0.0 }, name = "Alpha" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "b", pk = "a", embedding = new[] { 0.0, 1.0 }, name = "Beta" }), - new PartitionKey("a")); - await container.CreateItemAsync( - JObject.FromObject(new { id = "c", pk = "a", embedding = new[] { 0.707, 0.707 }, name = "Charlie" }), - new PartitionKey("a")); - return container; - } - - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_OrderByWithSecondarySort() - { - var container = await CreateMultiDocContainer(); - var results = await RunQuery(container, - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0]) DESC, c.id ASC"); - - results.Should().HaveCount(3); - results[0]["id"]!.Value().Should().Be("a"); // cosine to [1,0] = 1.0 - } - - [Fact] - public async Task VectorDistance_CountWithVectorWhere() - { - var container = await CreateMultiDocContainer(); - var results = await RunQuery(container, - "SELECT VALUE COUNT(1) FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0]) > 0.5"); - - results.Should().ContainSingle().Which.Should().Be(2); // 'a' (1.0) and 'c' (~0.707) - } - - [Fact] - public async Task VectorDistance_InIifExpression() - { - var container = await CreateMultiDocContainer(); - var results = await RunQuery(container, - "SELECT c.id, IIF(VectorDistance(c.embedding, [1.0, 0.0]) > 0.9, 'similar', 'different') AS label FROM c ORDER BY c.id"); - - results.Should().HaveCount(3); - results.First(r => r["id"]!.Value() == "a")["label"]!.Value().Should().Be("similar"); - results.First(r => r["id"]!.Value() == "b")["label"]!.Value().Should().Be("different"); - } - - [Fact] - public async Task VectorDistance_WithJoin_CrossApply() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embeddings = new[] { new[] { 1.0, 0.0 }, new[] { 0.0, 1.0 } } }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(e, [1.0, 0.0]) AS score FROM c JOIN e IN c.embeddings"); - - results.Should().HaveCount(2); - var scores = results.Select(r => r["score"]!.Value()).OrderDescending().ToList(); - scores[0].Should().BeApproximately(1.0, 0.01); // [1,0] vs [1,0] - scores[1].Should().BeApproximately(0.0, 0.01); // [0,1] vs [1,0] - } + private static async Task CreateMultiDocContainer() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "a", pk = "a", embedding = new[] { 1.0, 0.0 }, name = "Alpha" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "b", pk = "a", embedding = new[] { 0.0, 1.0 }, name = "Beta" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "c", pk = "a", embedding = new[] { 0.707, 0.707 }, name = "Charlie" }), + new PartitionKey("a")); + return container; + } + + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_OrderByWithSecondarySort() + { + var container = await CreateMultiDocContainer(); + var results = await RunQuery(container, + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c ORDER BY VectorDistance(c.embedding, [1.0, 0.0]) DESC, c.id ASC"); + + results.Should().HaveCount(3); + results[0]["id"]!.Value().Should().Be("a"); // cosine to [1,0] = 1.0 + } + + [Fact] + public async Task VectorDistance_CountWithVectorWhere() + { + var container = await CreateMultiDocContainer(); + var results = await RunQuery(container, + "SELECT VALUE COUNT(1) FROM c WHERE VectorDistance(c.embedding, [1.0, 0.0]) > 0.5"); + + results.Should().ContainSingle().Which.Should().Be(2); // 'a' (1.0) and 'c' (~0.707) + } + + [Fact] + public async Task VectorDistance_InIifExpression() + { + var container = await CreateMultiDocContainer(); + var results = await RunQuery(container, + "SELECT c.id, IIF(VectorDistance(c.embedding, [1.0, 0.0]) > 0.9, 'similar', 'different') AS label FROM c ORDER BY c.id"); + + results.Should().HaveCount(3); + results.First(r => r["id"]!.Value() == "a")["label"]!.Value().Should().Be("similar"); + results.First(r => r["id"]!.Value() == "b")["label"]!.Value().Should().Be("different"); + } + + [Fact] + public async Task VectorDistance_WithJoin_CrossApply() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embeddings = new[] { new[] { 1.0, 0.0 }, new[] { 0.0, 1.0 } } }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(e, [1.0, 0.0]) AS score FROM c JOIN e IN c.embeddings"); + + results.Should().HaveCount(2); + var scores = results.Select(r => r["score"]!.Value()).OrderDescending().ToList(); + scores[0].Should().BeApproximately(1.0, 0.01); // [1,0] vs [1,0] + scores[1].Should().BeApproximately(0.0, 0.01); // [0,1] vs [1,0] + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2398,57 +2406,57 @@ await container.CreateItemAsync( public class VectorDistanceDeepPathTests { - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_DeeplyNestedVector_ThreeLevels() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"a","a":{"b":{"embedding":[1.0,0.0,0.0]}}}"""), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.a.b.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_BooleanInVector_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.Parse("""{"id":"1","pk":"a","embedding":[true,false,1.0]}"""), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null, "boolean elements in vector should cause null result"); - } - - [Fact] - public async Task VectorDistance_MissingVectorField_ReturnsNull() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", name = "No vector" }), - new PartitionKey("a")); - - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Type.Should().Be(JTokenType.Null); - } + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_DeeplyNestedVector_ThreeLevels() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"a","a":{"b":{"embedding":[1.0,0.0,0.0]}}}"""), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.a.b.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_BooleanInVector_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.Parse("""{"id":"1","pk":"a","embedding":[true,false,1.0]}"""), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null, "boolean elements in vector should cause null result"); + } + + [Fact] + public async Task VectorDistance_MissingVectorField_ReturnsNull() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", name = "No vector" }), + new PartitionKey("a")); + + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Type.Should().Be(JTokenType.Null); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2457,61 +2465,61 @@ await container.CreateItemAsync( public class VectorDistanceStatePersistenceTests { - [Fact] - public async Task VectorDistance_AfterExportImport_PreservesVectorData() - { - var source = new InMemoryContainer("vector-test", "/pk"); - await source.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), - new PartitionKey("a")); - - var state = source.ExportState(); - - var target = new InMemoryContainer("vector-test2", "/pk"); - target.ImportState(state); - - var iterator = target.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [3.0, 4.0]) AS score FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_PointInTimeRestore_VectorDataCorrect() - { - var container = new InMemoryContainer("vector-test", "/pk"); - - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - var restorePoint = DateTimeOffset.UtcNow; - await Task.Delay(TimeSpan.FromMilliseconds(100)); - - // Replace vector after restore point - await container.ReplaceItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), - "1", new PartitionKey("a")); - - // Restore to point before replacement - container.RestoreToPointInTime(restorePoint); - - var iterator = container.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c", - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().ContainSingle(); - // After restore, embedding should be [1,0] again, so cosine with [1,0] = 1.0 - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } + [Fact] + public async Task VectorDistance_AfterExportImport_PreservesVectorData() + { + var source = new InMemoryContainer("vector-test", "/pk"); + await source.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 3.0, 4.0 } }), + new PartitionKey("a")); + + var state = source.ExportState(); + + var target = new InMemoryContainer("vector-test2", "/pk"); + target.ImportState(state); + + var iterator = target.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [3.0, 4.0]) AS score FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_PointInTimeRestore_VectorDataCorrect() + { + var container = new InMemoryContainer("vector-test", "/pk"); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + var restorePoint = DateTimeOffset.UtcNow; + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + // Replace vector after restore point + await container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 0.0, 1.0 } }), + "1", new PartitionKey("a")); + + // Restore to point before replacement + container.RestoreToPointInTime(restorePoint); + + var iterator = container.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c", + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().ContainSingle(); + // After restore, embedding should be [1,0] again, so cosine with [1,0] = 1.0 + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2520,62 +2528,62 @@ await container.ReplaceItemAsync( public class VectorDistanceIeee754ExtendedTests { - private static async Task> RunQuery(InMemoryContainer container, string sql) - { - var iterator = container.GetItemQueryIterator(sql, - requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - return results; - } - - [Fact] - public async Task VectorDistance_NegativeZero_TreatedAsZero() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { -0.0, 1.0, 0.0 } }), - new PartitionKey("a")); - - // -0.0 should be treated same as 0.0. Cosine of [-0,1,0] vs [0,1,0] = 1.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_SubnormalFloats_PreservePrecision() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e-10, 1e-10 } }), - new PartitionKey("a")); - - // Cosine of identical small vectors should still be ~1.0 - var results = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [1e-10, 1e-10]) AS score FROM c"); - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_DotProduct_Symmetry_Verified() - { - var container = new InMemoryContainer("vector-test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.5, 3.7, 1.2 } }), - new PartitionKey("a")); - - // dot(a,b) should equal dot(b,a) - var results1 = await RunQuery(container, - "SELECT VectorDistance(c.embedding, [4.1, 0.8, 5.3], false, 'dotproduct') AS dp FROM c"); - // dot([2.5,3.7,1.2], [4.1,0.8,5.3]) = 10.25 + 2.96 + 6.36 = 19.57 - results1.Should().ContainSingle(); - var dp = results1[0]["dp"]!.Value(); - dp.Should().BeApproximately(19.57, 0.01); - } + private static async Task> RunQuery(InMemoryContainer container, string sql) + { + var iterator = container.GetItemQueryIterator(sql, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey("a") }); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + return results; + } + + [Fact] + public async Task VectorDistance_NegativeZero_TreatedAsZero() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { -0.0, 1.0, 0.0 } }), + new PartitionKey("a")); + + // -0.0 should be treated same as 0.0. Cosine of [-0,1,0] vs [0,1,0] = 1.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [0.0, 1.0, 0.0]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_SubnormalFloats_PreservePrecision() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1e-10, 1e-10 } }), + new PartitionKey("a")); + + // Cosine of identical small vectors should still be ~1.0 + var results = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [1e-10, 1e-10]) AS score FROM c"); + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_DotProduct_Symmetry_Verified() + { + var container = new InMemoryContainer("vector-test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 2.5, 3.7, 1.2 } }), + new PartitionKey("a")); + + // dot(a,b) should equal dot(b,a) + var results1 = await RunQuery(container, + "SELECT VectorDistance(c.embedding, [4.1, 0.8, 5.3], false, 'dotproduct') AS dp FROM c"); + // dot([2.5,3.7,1.2], [4.1,0.8,5.3]) = 10.25 + 2.96 + 6.36 = 19.57 + results1.Should().ContainSingle(); + var dp = results1[0]["dp"]!.Value(); + dp.Should().BeApproximately(19.57, 0.01); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -2584,65 +2592,65 @@ await container.CreateItemAsync( public class VectorDistanceFakeHandlerTests { - private static CosmosClient CreateClient(HttpMessageHandler handler) => - new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", - new CosmosClientOptions - { - ConnectionMode = ConnectionMode.Gateway, - LimitToEndpoint = true, - MaxRetryAttemptsOnRateLimitedRequests = 0, - HttpClientFactory = () => new HttpClient(handler) - }); - - [Fact] - public async Task VectorDistance_ViaFakeCosmosHandler_ReturnsCorrectScore() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), - new PartitionKey("a")); - - using var handler = new FakeCosmosHandler(container); - using var client = CreateClient(handler); - - var sdkContainer = client.GetContainer("db", "test"); - var iterator = sdkContainer.GetItemQueryIterator( - "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle(); - results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); - } - - [Fact] - public async Task VectorDistance_ViaFakeCosmosHandler_WithFaultInjection() - { - var container = new InMemoryContainer("test", "/pk"); - await container.CreateItemAsync( - JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), - new PartitionKey("a")); - - using var handler = new FakeCosmosHandler(container) - { - // Always return 429 — this ensures both query plan and query requests are faulted - FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) - { Content = new StringContent("{}") } - }; - using var client = CreateClient(handler); - - var sdkContainer = client.GetContainer("db", "test"); - // The SDK should get a 429 on every attempt - var act = () => sdkContainer.GetItemQueryIterator( - "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c") - .ReadNextAsync(); - - // MaxRetryAttemptsOnRateLimitedRequests = 0, so 429 is not retried - var ex = await act.Should().ThrowAsync(); - ex.Which.StatusCode.Should().Be((HttpStatusCode)429); - } + private static CosmosClient CreateClient(HttpMessageHandler handler) => + new("AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;", + new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + MaxRetryAttemptsOnRateLimitedRequests = 0, + HttpClientFactory = () => new HttpClient(handler) + }); + + [Fact] + public async Task VectorDistance_ViaFakeCosmosHandler_ReturnsCorrectScore() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0, 0.0 } }), + new PartitionKey("a")); + + using var handler = new FakeCosmosHandler(container); + using var client = CreateClient(handler); + + var sdkContainer = client.GetContainer("db", "test"); + var iterator = sdkContainer.GetItemQueryIterator( + "SELECT c.id, VectorDistance(c.embedding, [1.0, 0.0, 0.0]) AS score FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle(); + results[0]["score"]!.Value().Should().BeApproximately(1.0, 0.01); + } + + [Fact] + public async Task VectorDistance_ViaFakeCosmosHandler_WithFaultInjection() + { + var container = new InMemoryContainer("test", "/pk"); + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", embedding = new[] { 1.0, 0.0 } }), + new PartitionKey("a")); + + using var handler = new FakeCosmosHandler(container) + { + // Always return 429 — this ensures both query plan and query requests are faulted + FaultInjector = _ => new HttpResponseMessage((HttpStatusCode)429) + { Content = new StringContent("{}") } + }; + using var client = CreateClient(handler); + + var sdkContainer = client.GetContainer("db", "test"); + // The SDK should get a 429 on every attempt + var act = () => sdkContainer.GetItemQueryIterator( + "SELECT VectorDistance(c.embedding, [1.0, 0.0]) AS score FROM c") + .ReadNextAsync(); + + // MaxRetryAttemptsOnRateLimitedRequests = 0, so 429 is not retried + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be((HttpStatusCode)429); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/WebApplicationFactoryIntegrationTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/WebApplicationFactoryIntegrationTests.cs index bfb41c3..a0a9947 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/WebApplicationFactoryIntegrationTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/WebApplicationFactoryIntegrationTests.cs @@ -22,113 +22,113 @@ namespace CosmosDB.InMemoryEmulator.Tests; // ════════════════════════════════════════════════════════════════════════════════ public record CosmosTestItem( - [property: JsonPropertyName("id")] - [property: Newtonsoft.Json.JsonProperty("id")] - string Id, - [property: JsonPropertyName("partitionKey")] - [property: Newtonsoft.Json.JsonProperty("partitionKey")] - string PartitionKey, - [property: JsonPropertyName("name")] - [property: Newtonsoft.Json.JsonProperty("name")] - string Name); + [property: JsonPropertyName("id")] + [property: Newtonsoft.Json.JsonProperty("id")] + string Id, + [property: JsonPropertyName("partitionKey")] + [property: Newtonsoft.Json.JsonProperty("partitionKey")] + string PartitionKey, + [property: JsonPropertyName("name")] + [property: Newtonsoft.Json.JsonProperty("name")] + string Name); /// /// A repository that resolves Container from DI — simulates the BreakfastProvider pattern. /// public class TestRepository { - private readonly Container _container; - - public TestRepository(Container container) - { - _container = container; - } - - public async Task CreateAsync(CosmosTestItem item) - { - var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - return response.Resource; - } - - public async Task GetByIdAsync(string id, string partitionKey) - { - try - { - var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); - return response.Resource; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return null; - } - } - - public async Task> GetAllAsync() - { - var iterator = _container.GetItemLinqQueryable(true) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return results; - } - - public async Task UpsertAsync(CosmosTestItem item) - { - var response = await _container.UpsertItemAsync(item, new PartitionKey(item.PartitionKey)); - return response.Resource; - } - - public async Task DeleteAsync(string id, string partitionKey) - { - await _container.DeleteItemAsync(id, new PartitionKey(partitionKey)); - } - - public async Task ReplaceAsync(CosmosTestItem item) - { - var response = await _container.ReplaceItemAsync(item, item.Id, new PartitionKey(item.PartitionKey)); - return response.Resource; - } - - public async Task PatchNameAsync(string id, string partitionKey, string newName) - { - await _container.PatchItemAsync(id, new PartitionKey(partitionKey), - new[] { PatchOperation.Set("/name", newName) }); - } - - public async Task> GetFilteredByNameAsync(string name) - { - var iterator = _container.GetItemLinqQueryable(true) - .Where(x => x.Name == name) - .ToFeedIteratorOverridable(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return results; - } - - public async Task> QuerySqlAsync(string sql) - { - var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return results; - } + private readonly Container _container; + + public TestRepository(Container container) + { + _container = container; + } + + public async Task CreateAsync(CosmosTestItem item) + { + var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + return response.Resource; + } + + public async Task GetByIdAsync(string id, string partitionKey) + { + try + { + var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); + return response.Resource; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + public async Task> GetAllAsync() + { + var iterator = _container.GetItemLinqQueryable(true) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return results; + } + + public async Task UpsertAsync(CosmosTestItem item) + { + var response = await _container.UpsertItemAsync(item, new PartitionKey(item.PartitionKey)); + return response.Resource; + } + + public async Task DeleteAsync(string id, string partitionKey) + { + await _container.DeleteItemAsync(id, new PartitionKey(partitionKey)); + } + + public async Task ReplaceAsync(CosmosTestItem item) + { + var response = await _container.ReplaceItemAsync(item, item.Id, new PartitionKey(item.PartitionKey)); + return response.Resource; + } + + public async Task PatchNameAsync(string id, string partitionKey, string newName) + { + await _container.PatchItemAsync(id, new PartitionKey(partitionKey), + new[] { PatchOperation.Set("/name", newName) }); + } + + public async Task> GetFilteredByNameAsync(string name) + { + var iterator = _container.GetItemLinqQueryable(true) + .Where(x => x.Name == name) + .ToFeedIteratorOverridable(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return results; + } + + public async Task> QuerySqlAsync(string sql) + { + var iterator = _container.GetItemQueryIterator(new QueryDefinition(sql)); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return results; + } } /// @@ -136,31 +136,31 @@ public async Task> QuerySqlAsync(string sql) /// public class ClientResolvedRepository { - private readonly Container _container; - - public ClientResolvedRepository(CosmosClient client) - { - _container = client.GetContainer("TestDb", "items"); - } - - public async Task CreateAsync(CosmosTestItem item) - { - var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - return response.Resource; - } - - public async Task GetByIdAsync(string id, string partitionKey) - { - try - { - var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); - return response.Resource; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return null; - } - } + private readonly Container _container; + + public ClientResolvedRepository(CosmosClient client) + { + _container = client.GetContainer("TestDb", "items"); + } + + public async Task CreateAsync(CosmosTestItem item) + { + var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + return response.Resource; + } + + public async Task GetByIdAsync(string id, string partitionKey) + { + try + { + var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); + return response.Resource; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -169,8 +169,8 @@ public async Task CreateAsync(CosmosTestItem item) public class TestCosmosClient : CosmosClient { - public TestCosmosClient(string connectionString, CosmosClientOptions? options = null) - : base(connectionString, options) { } + public TestCosmosClient(string connectionString, CosmosClientOptions? options = null) + : base(connectionString, options) { } } /// @@ -178,31 +178,31 @@ public TestCosmosClient(string connectionString, CosmosClientOptions? options = /// public class TypedClientRepository { - private readonly Container _container; - - public TypedClientRepository(TestCosmosClient client) - { - _container = client.GetContainer("TestDb", "items"); - } - - public async Task CreateAsync(CosmosTestItem item) - { - var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - return response.Resource; - } - - public async Task GetByIdAsync(string id, string partitionKey) - { - try - { - var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); - return response.Resource; - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return null; - } - } + private readonly Container _container; + + public TypedClientRepository(TestCosmosClient client) + { + _container = client.GetContainer("TestDb", "items"); + } + + public async Task CreateAsync(CosmosTestItem item) + { + var response = await _container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + return response.Resource; + } + + public async Task GetByIdAsync(string id, string partitionKey) + { + try + { + var response = await _container.ReadItemAsync(id, new PartitionKey(partitionKey)); + return response.Resource; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -211,171 +211,171 @@ public async Task CreateAsync(CosmosTestItem item) public class TestAppHost : IAsyncDisposable { - private IHost? _host; - private HttpClient? _httpClient; - - public IServiceProvider Services => _host!.Services; - public HttpClient HttpClient => _httpClient!; - - private TestAppHost() { } - - public static async Task CreateAsync( - Action? configureTestServices = null, - Action? configureBaseServices = null, - Action? configureEndpoints = null) - { - var instance = new TestAppHost(); - - var builder = new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder.UseTestServer(); - webBuilder.ConfigureServices(services => - { - services.AddRouting(); - - if (configureBaseServices != null) - { - configureBaseServices(services); - } - else - { - // Default: simulate a real app registering CosmosClient + Container - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(sp => - { - var client = sp.GetRequiredService(); - return client.GetContainer("ProductionDb", "items"); - }); - services.AddSingleton(); - } - }); - - // Test services override production registrations (same as ConfigureTestServices) - if (configureTestServices != null) - { - webBuilder.ConfigureServices(configureTestServices); - } - - webBuilder.Configure(app => - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - if (configureEndpoints != null) - { - configureEndpoints(endpoints); - } - else - { - endpoints.MapGet("/items/{id}", async (string id, TestRepository repo) => - { - var item = await repo.GetByIdAsync(id, id); - return item is not null ? Results.Ok(item) : Results.NotFound(); - }); - - endpoints.MapPost("/items", async (CosmosTestItem item, TestRepository repo) => - { - try - { - var created = await repo.CreateAsync(item); - return Results.Created($"/items/{created.Id}", created); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - return Results.Conflict(); - } - }); - - endpoints.MapGet("/items", async (TestRepository repo) => - { - var items = await repo.GetAllAsync(); - return Results.Ok(items); - }); - - endpoints.MapDelete("/items/{id}", async (string id, TestRepository repo) => - { - try - { - await repo.DeleteAsync(id, id); - return Results.NoContent(); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return Results.NotFound(); - } - }); - - endpoints.MapPut("/items/{id}", async (string id, CosmosTestItem item, TestRepository repo) => - { - try - { - var replaced = await repo.ReplaceAsync(item); - return Results.Ok(replaced); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return Results.NotFound(); - } - }); - - endpoints.MapPut("/items", async (CosmosTestItem item, TestRepository repo) => - { - var upserted = await repo.UpsertAsync(item); - return Results.Ok(upserted); - }); - - endpoints.MapMethods("/items/{id}/name", new[] { "PATCH" }, - async (string id, HttpContext ctx) => - { - var body = await ctx.Request.ReadFromJsonAsync(); - var newName = body.GetProperty("name").GetString()!; - var repo = ctx.RequestServices.GetRequiredService(); - try - { - await repo.PatchNameAsync(id, id, newName); - return Results.Ok(); - } - catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - return Results.NotFound(); - } - }); - - endpoints.MapGet("/items/search", async (string name, TestRepository repo) => - { - var items = await repo.GetFilteredByNameAsync(name); - return Results.Ok(items); - }); - - endpoints.MapPost("/items/query", async (HttpContext ctx) => - { - var body = await ctx.Request.ReadFromJsonAsync(); - var sql = body.GetProperty("sql").GetString()!; - var repo = ctx.RequestServices.GetRequiredService(); - var items = await repo.QuerySqlAsync(sql); - return Results.Ok(items); - }); - } - }); - }); - }); - - instance._host = await builder.StartAsync(); - instance._httpClient = instance._host.GetTestClient(); - return instance; - } - - public async ValueTask DisposeAsync() - { - _httpClient?.Dispose(); - if (_host != null) - { - await _host.StopAsync(); - _host.Dispose(); - } - } + private IHost? _host; + private HttpClient? _httpClient; + + public IServiceProvider Services => _host!.Services; + public HttpClient HttpClient => _httpClient!; + + private TestAppHost() { } + + public static async Task CreateAsync( + Action? configureTestServices = null, + Action? configureBaseServices = null, + Action? configureEndpoints = null) + { + var instance = new TestAppHost(); + + var builder = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + + if (configureBaseServices != null) + { + configureBaseServices(services); + } + else + { + // Default: simulate a real app registering CosmosClient + Container + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return client.GetContainer("ProductionDb", "items"); + }); + services.AddSingleton(); + } + }); + + // Test services override production registrations (same as ConfigureTestServices) + if (configureTestServices != null) + { + webBuilder.ConfigureServices(configureTestServices); + } + + webBuilder.Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + if (configureEndpoints != null) + { + configureEndpoints(endpoints); + } + else + { + endpoints.MapGet("/items/{id}", async (string id, TestRepository repo) => + { + var item = await repo.GetByIdAsync(id, id); + return item is not null ? Results.Ok(item) : Results.NotFound(); + }); + + endpoints.MapPost("/items", async (CosmosTestItem item, TestRepository repo) => + { + try + { + var created = await repo.CreateAsync(item); + return Results.Created($"/items/{created.Id}", created); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + return Results.Conflict(); + } + }); + + endpoints.MapGet("/items", async (TestRepository repo) => + { + var items = await repo.GetAllAsync(); + return Results.Ok(items); + }); + + endpoints.MapDelete("/items/{id}", async (string id, TestRepository repo) => + { + try + { + await repo.DeleteAsync(id, id); + return Results.NoContent(); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return Results.NotFound(); + } + }); + + endpoints.MapPut("/items/{id}", async (string id, CosmosTestItem item, TestRepository repo) => + { + try + { + var replaced = await repo.ReplaceAsync(item); + return Results.Ok(replaced); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return Results.NotFound(); + } + }); + + endpoints.MapPut("/items", async (CosmosTestItem item, TestRepository repo) => + { + var upserted = await repo.UpsertAsync(item); + return Results.Ok(upserted); + }); + + endpoints.MapMethods("/items/{id}/name", new[] { "PATCH" }, + async (string id, HttpContext ctx) => + { + var body = await ctx.Request.ReadFromJsonAsync(); + var newName = body.GetProperty("name").GetString()!; + var repo = ctx.RequestServices.GetRequiredService(); + try + { + await repo.PatchNameAsync(id, id, newName); + return Results.Ok(); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return Results.NotFound(); + } + }); + + endpoints.MapGet("/items/search", async (string name, TestRepository repo) => + { + var items = await repo.GetFilteredByNameAsync(name); + return Results.Ok(items); + }); + + endpoints.MapPost("/items/query", async (HttpContext ctx) => + { + var body = await ctx.Request.ReadFromJsonAsync(); + var sql = body.GetProperty("sql").GetString()!; + var repo = ctx.RequestServices.GetRequiredService(); + var items = await repo.QuerySqlAsync(sql); + return Results.Ok(items); + }); + } + }); + }); + }); + + instance._host = await builder.StartAsync(); + instance._httpClient = instance._host.GetTestClient(); + return instance; + } + + public async ValueTask DisposeAsync() + { + _httpClient?.Dispose(); + if (_host != null) + { + await _host.StopAsync(); + _host.Dispose(); + } + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -385,246 +385,246 @@ public async ValueTask DisposeAsync() [Collection("FeedIteratorSetup")] public class WebApplicationFactoryIntegrationTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task CreateAndReadItem() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var item = new CosmosTestItem("1", "1", "Test"); - var postResponse = await client.PostAsJsonAsync("/items", item); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var getResponse = await client.GetAsync("/items/1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("Test"); - } - - [Fact] - public async Task ReadNotFound() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var response = await client.GetAsync("/items/nonexistent"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ListItems() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("3", "3", "Charlie")); - - var response = await client.GetAsync("/items"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(3); - } - - [Fact] - public async Task LinqQueryWorks() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); - - var response = await client.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(2); - } - - [Fact] - public async Task ClientIsAccessible() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var cosmosClient = app.Services.GetRequiredService(); - cosmosClient.Should().NotBeNull(); - - var container = cosmosClient.GetContainer("in-memory-db", "items"); - container.Should().NotBeNull(); - container.Id.Should().Be("items"); - } - - [Fact] - public async Task IsolatedPerFactory() - { - await using var app1 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - await using var app2 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var client1 = app1.HttpClient; - var client2 = app2.HttpClient; - - await client1.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Factory1Item")); - - var response1 = await client1.GetAsync("/items/1"); - response1.StatusCode.Should().Be(HttpStatusCode.OK); - - var response2 = await client2.GetAsync("/items/1"); - response2.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task CalledFromConfigureTestServices_FullRoundTrip() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - // Create - var postResponse = await client.PostAsJsonAsync("/items", - new CosmosTestItem("rt1", "rt1", "RoundTrip")); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - var created = await postResponse.Content.ReadFromJsonAsync(JsonOptions); - created!.Id.Should().Be("rt1"); - created.Name.Should().Be("RoundTrip"); - - // Read - var getResponse = await client.GetAsync("/items/rt1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var read = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - read!.Name.Should().Be("RoundTrip"); - - // List - var listResponse = await client.GetAsync("/items"); - var items = await listResponse.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().ContainSingle().Which.Name.Should().Be("RoundTrip"); - } - - [Fact] - public async Task ConstructorResolvedContainer_Pattern3() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(); - }, - configureTestServices: services => - { - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "TestDb"; - o.AddContainer("items", "/partitionKey"); - }); - }, - configureEndpoints: endpoints => - { - endpoints.MapPost("/client-items", async (CosmosTestItem item, ClientResolvedRepository repo) => - { - var created = await repo.CreateAsync(item); - return Results.Created($"/client-items/{created.Id}", created); - }); - endpoints.MapGet("/client-items/{id}", async (string id, ClientResolvedRepository repo) => - { - var result = await repo.GetByIdAsync(id, id); - return result is not null ? Results.Ok(result) : Results.NotFound(); - }); - }); - - var client = app.HttpClient; - - var postResponse = await client.PostAsJsonAsync("/client-items", - new CosmosTestItem("p3-1", "p3-1", "Pattern3")); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var getResponse = await client.GetAsync("/client-items/p3-1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("Pattern3"); - } - - [Fact] - public async Task ZeroConfig_JustWorks() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB()); - var client = app.HttpClient; - - var postResponse = await client.PostAsJsonAsync("/items", - new CosmosTestItem("z1", "z1", "ZeroConfig")); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var getResponse = await client.GetAsync("/items/z1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ZeroConfig_AutoDetect_ContainerNameMatchesProduction() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB()); - - // The auto-detected container should be the one from the production factory: - // client.GetContainer("ProductionDb", "items") - var container = app.Services.GetRequiredService(); - container.Id.Should().Be("items"); - - // Verify the client also returns a container with the same name - var cosmosClient = app.Services.GetRequiredService(); - var clientContainer = cosmosClient.GetContainer("ProductionDb", "items"); - clientContainer.Id.Should().Be("items"); - } - - [Fact] - public async Task UpsertAndQuery() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - - await container.UpsertItemAsync( - new CosmosTestItem("u1", "u1", "Original"), - new PartitionKey("u1")); - await container.UpsertItemAsync( - new CosmosTestItem("u1", "u1", "Updated"), - new PartitionKey("u1")); - await container.UpsertItemAsync( - new CosmosTestItem("u2", "u2", "Second"), - new PartitionKey("u2")); - - var httpClient = app.HttpClient; - var response = await httpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(2); - items.Should().Contain(i => i.Id == "u1" && i.Name == "Updated"); - items.Should().Contain(i => i.Id == "u2" && i.Name == "Second"); - } + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task CreateAndReadItem() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var item = new CosmosTestItem("1", "1", "Test"); + var postResponse = await client.PostAsJsonAsync("/items", item); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var getResponse = await client.GetAsync("/items/1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("Test"); + } + + [Fact] + public async Task ReadNotFound() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var response = await client.GetAsync("/items/nonexistent"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ListItems() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("3", "3", "Charlie")); + + var response = await client.GetAsync("/items"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(3); + } + + [Fact] + public async Task LinqQueryWorks() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); + + var response = await client.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(2); + } + + [Fact] + public async Task ClientIsAccessible() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var cosmosClient = app.Services.GetRequiredService(); + cosmosClient.Should().NotBeNull(); + + var container = cosmosClient.GetContainer("in-memory-db", "items"); + container.Should().NotBeNull(); + container.Id.Should().Be("items"); + } + + [Fact] + public async Task IsolatedPerFactory() + { + await using var app1 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + await using var app2 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var client1 = app1.HttpClient; + var client2 = app2.HttpClient; + + await client1.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Factory1Item")); + + var response1 = await client1.GetAsync("/items/1"); + response1.StatusCode.Should().Be(HttpStatusCode.OK); + + var response2 = await client2.GetAsync("/items/1"); + response2.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CalledFromConfigureTestServices_FullRoundTrip() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + // Create + var postResponse = await client.PostAsJsonAsync("/items", + new CosmosTestItem("rt1", "rt1", "RoundTrip")); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await postResponse.Content.ReadFromJsonAsync(JsonOptions); + created!.Id.Should().Be("rt1"); + created.Name.Should().Be("RoundTrip"); + + // Read + var getResponse = await client.GetAsync("/items/rt1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var read = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + read!.Name.Should().Be("RoundTrip"); + + // List + var listResponse = await client.GetAsync("/items"); + var items = await listResponse.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().ContainSingle().Which.Name.Should().Be("RoundTrip"); + } + + [Fact] + public async Task ConstructorResolvedContainer_Pattern3() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(); + }, + configureTestServices: services => + { + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "TestDb"; + o.AddContainer("items", "/partitionKey"); + }); + }, + configureEndpoints: endpoints => + { + endpoints.MapPost("/client-items", async (CosmosTestItem item, ClientResolvedRepository repo) => + { + var created = await repo.CreateAsync(item); + return Results.Created($"/client-items/{created.Id}", created); + }); + endpoints.MapGet("/client-items/{id}", async (string id, ClientResolvedRepository repo) => + { + var result = await repo.GetByIdAsync(id, id); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }); + }); + + var client = app.HttpClient; + + var postResponse = await client.PostAsJsonAsync("/client-items", + new CosmosTestItem("p3-1", "p3-1", "Pattern3")); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var getResponse = await client.GetAsync("/client-items/p3-1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("Pattern3"); + } + + [Fact] + public async Task ZeroConfig_JustWorks() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB()); + var client = app.HttpClient; + + var postResponse = await client.PostAsJsonAsync("/items", + new CosmosTestItem("z1", "z1", "ZeroConfig")); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var getResponse = await client.GetAsync("/items/z1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ZeroConfig_AutoDetect_ContainerNameMatchesProduction() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB()); + + // The auto-detected container should be the one from the production factory: + // client.GetContainer("ProductionDb", "items") + var container = app.Services.GetRequiredService(); + container.Id.Should().Be("items"); + + // Verify the client also returns a container with the same name + var cosmosClient = app.Services.GetRequiredService(); + var clientContainer = cosmosClient.GetContainer("ProductionDb", "items"); + clientContainer.Id.Should().Be("items"); + } + + [Fact] + public async Task UpsertAndQuery() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + + await container.UpsertItemAsync( + new CosmosTestItem("u1", "u1", "Original"), + new PartitionKey("u1")); + await container.UpsertItemAsync( + new CosmosTestItem("u1", "u1", "Updated"), + new PartitionKey("u1")); + await container.UpsertItemAsync( + new CosmosTestItem("u2", "u2", "Second"), + new PartitionKey("u2")); + + var httpClient = app.HttpClient; + var response = await httpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(2); + items.Should().Contain(i => i.Id == "u1" && i.Name == "Updated"); + items.Should().Contain(i => i.Id == "u2" && i.Name == "Second"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -634,53 +634,53 @@ await container.UpsertItemAsync( [Collection("FeedIteratorSetup")] public class WafLinqFilterTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task LinqQueryWithFilter_ReturnsMatchingItems() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); - - var response = await client.GetAsync("/items/search?name=Alice"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } - - [Fact] - public async Task LinqWithNativeToFeedIterator_WorksWithUseInMemoryCosmosDB() - { - // UseInMemoryCosmosDB uses FakeCosmosHandler, so native .ToFeedIterator() works - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - - await container.CreateItemAsync(new CosmosTestItem("1", "1", "Alice"), new PartitionKey("1")); - await container.CreateItemAsync(new CosmosTestItem("2", "2", "Bob"), new PartitionKey("2")); - - // Use native .ToFeedIterator() — NOT .ToFeedIteratorOverridable() - var iterator = container.GetItemLinqQueryable(true) - .Where(x => x.Name == "Alice") - .ToFeedIterator(); - - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - results.Should().ContainSingle().Which.Name.Should().Be("Alice"); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task LinqQueryWithFilter_ReturnsMatchingItems() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); + + var response = await client.GetAsync("/items/search?name=Alice"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } + + [Fact] + public async Task LinqWithNativeToFeedIterator_WorksWithUseInMemoryCosmosDB() + { + // UseInMemoryCosmosDB uses FakeCosmosHandler, so native .ToFeedIterator() works + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + + await container.CreateItemAsync(new CosmosTestItem("1", "1", "Alice"), new PartitionKey("1")); + await container.CreateItemAsync(new CosmosTestItem("2", "2", "Bob"), new PartitionKey("2")); + + // Use native .ToFeedIterator() — NOT .ToFeedIteratorOverridable() + var iterator = container.GetItemLinqQueryable(true) + .Where(x => x.Name == "Alice") + .ToFeedIterator(); + + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + results.Should().ContainSingle().Which.Name.Should().Be("Alice"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -690,117 +690,117 @@ public async Task LinqWithNativeToFeedIterator_WorksWithUseInMemoryCosmosDB() [Collection("FeedIteratorSetup")] public class WafCrudViaHttpTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task DeleteItem_ViaHttp_RemovesItem() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("d1", "d1", "ToDelete")); - - var deleteResponse = await client.DeleteAsync("/items/d1"); - deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - - var getResponse = await client.GetAsync("/items/d1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteItem_ViaHttp_NotFound_Returns404() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var response = await client.DeleteAsync("/items/nonexistent"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task ReplaceItem_ViaHttp_UpdatesItem() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("r1", "r1", "Original")); - - var putResponse = await client.PutAsJsonAsync("/items/r1", - new CosmosTestItem("r1", "r1", "Replaced")); - putResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - var getResponse = await client.GetAsync("/items/r1"); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("Replaced"); - } - - [Fact] - public async Task UpsertItem_ViaHttp_CreatesOrUpdates() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - // Upsert new - var upsertResponse = await client.PutAsJsonAsync("/items", - new CosmosTestItem("up1", "up1", "New")); - upsertResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - // Upsert existing - var upsertResponse2 = await client.PutAsJsonAsync("/items", - new CosmosTestItem("up1", "up1", "Updated")); - upsertResponse2.StatusCode.Should().Be(HttpStatusCode.OK); - - var getResponse = await client.GetAsync("/items/up1"); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("Updated"); - } - - [Fact] - public async Task PatchItem_ViaHttp_PartialUpdate() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("p1", "p1", "Original")); - - var patchContent = JsonContent.Create(new { name = "Patched" }); - var patchRequest = new HttpRequestMessage(new HttpMethod("PATCH"), "/items/p1/name") - { - Content = patchContent - }; - var patchResponse = await client.SendAsync(patchRequest); - patchResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - var getResponse = await client.GetAsync("/items/p1"); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("Patched"); - } - - [Fact] - public async Task CreateDuplicateItem_ViaHttp_Returns409() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var item = new CosmosTestItem("dup1", "dup1", "First"); - await client.PostAsJsonAsync("/items", item); - - var duplicateResponse = await client.PostAsJsonAsync("/items", item); - duplicateResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task DeleteItem_ViaHttp_RemovesItem() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("d1", "d1", "ToDelete")); + + var deleteResponse = await client.DeleteAsync("/items/d1"); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var getResponse = await client.GetAsync("/items/d1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteItem_ViaHttp_NotFound_Returns404() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var response = await client.DeleteAsync("/items/nonexistent"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ReplaceItem_ViaHttp_UpdatesItem() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("r1", "r1", "Original")); + + var putResponse = await client.PutAsJsonAsync("/items/r1", + new CosmosTestItem("r1", "r1", "Replaced")); + putResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getResponse = await client.GetAsync("/items/r1"); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("Replaced"); + } + + [Fact] + public async Task UpsertItem_ViaHttp_CreatesOrUpdates() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + // Upsert new + var upsertResponse = await client.PutAsJsonAsync("/items", + new CosmosTestItem("up1", "up1", "New")); + upsertResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Upsert existing + var upsertResponse2 = await client.PutAsJsonAsync("/items", + new CosmosTestItem("up1", "up1", "Updated")); + upsertResponse2.StatusCode.Should().Be(HttpStatusCode.OK); + + var getResponse = await client.GetAsync("/items/up1"); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("Updated"); + } + + [Fact] + public async Task PatchItem_ViaHttp_PartialUpdate() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("p1", "p1", "Original")); + + var patchContent = JsonContent.Create(new { name = "Patched" }); + var patchRequest = new HttpRequestMessage(new HttpMethod("PATCH"), "/items/p1/name") + { + Content = patchContent + }; + var patchResponse = await client.SendAsync(patchRequest); + patchResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var getResponse = await client.GetAsync("/items/p1"); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("Patched"); + } + + [Fact] + public async Task CreateDuplicateItem_ViaHttp_Returns409() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var item = new CosmosTestItem("dup1", "dup1", "First"); + await client.PostAsJsonAsync("/items", item); + + var duplicateResponse = await client.PostAsJsonAsync("/items", item); + duplicateResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -810,195 +810,195 @@ public async Task CreateDuplicateItem_ViaHttp_Returns409() [Collection("FeedIteratorSetup")] public class WafDiPatternTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task UseInMemoryCosmosContainers_WorksThroughWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db", "items")); - services.AddSingleton(); - }, - configureTestServices: services => - { - services.UseInMemoryCosmosContainers(o => o.AddContainer("items", "/partitionKey")); - }); - var client = app.HttpClient; - - var postResponse = await client.PostAsJsonAsync("/items", - new CosmosTestItem("c1", "c1", "ContainerPattern")); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var getResponse = await client.GetAsync("/items/c1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("ContainerPattern"); - } - - [Fact] - public async Task TypedClient_WorksThroughWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(); - }, - configureTestServices: services => - { - services.UseInMemoryCosmosDB(o => - { - o.DatabaseName = "TestDb"; - o.AddContainer("items", "/partitionKey"); - }); - }, - configureEndpoints: endpoints => - { - endpoints.MapPost("/typed-items", async (CosmosTestItem item, TypedClientRepository repo) => - { - var created = await repo.CreateAsync(item); - return Results.Created($"/typed-items/{created.Id}", created); - }); - endpoints.MapGet("/typed-items/{id}", async (string id, TypedClientRepository repo) => - { - var result = await repo.GetByIdAsync(id, id); - return result is not null ? Results.Ok(result) : Results.NotFound(); - }); - }); - var client = app.HttpClient; - - var postResponse = await client.PostAsJsonAsync("/typed-items", - new CosmosTestItem("t1", "t1", "TypedClient")); - postResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var getResponse = await client.GetAsync("/typed-items/t1"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); - result!.Name.Should().Be("TypedClient"); - } - - [Fact] - public async Task MultipleContainers_SameApp_IsolatedData() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddRouting(); - }, - configureTestServices: services => - { - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey"); - o.AddContainer("products", "/partitionKey"); - }); - }, - configureEndpoints: endpoints => - { - endpoints.MapPost("/orders", async (CosmosTestItem item, CosmosClient cosmosClient) => - { - var container = cosmosClient.GetContainer("in-memory-db", "orders"); - var response = await container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); - return Results.Created($"/orders/{response.Resource.Id}", response.Resource); - }); - endpoints.MapGet("/orders", async (CosmosClient cosmosClient) => - { - var container = cosmosClient.GetContainer("in-memory-db", "orders"); - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return Results.Ok(results); - }); - endpoints.MapGet("/products", async (CosmosClient cosmosClient) => - { - var container = cosmosClient.GetContainer("in-memory-db", "products"); - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - results.AddRange(page); - } - - return Results.Ok(results); - }); - }); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/orders", new CosmosTestItem("o1", "o1", "Order1")); - - var ordersResponse = await client.GetAsync("/orders"); - var orders = await ordersResponse.Content.ReadFromJsonAsync>(JsonOptions); - orders.Should().ContainSingle(); - - var productsResponse = await client.GetAsync("/products"); - var products = await productsResponse.Content.ReadFromJsonAsync>(JsonOptions); - products.Should().BeEmpty(); - } - - [Fact] - public async Task OnHandlerCreated_FaultInjection_ViaWaf() - { - FakeCosmosHandler? capturedHandler = null; - - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => capturedHandler = handler; - })); - - capturedHandler.Should().NotBeNull(); - - var client = app.HttpClient; - - // Seed an item first - await client.PostAsJsonAsync("/items", new CosmosTestItem("f1", "f1", "Faulted")); - - // Now inject a fault — all reads return 503 - capturedHandler!.FaultInjector = _ => - new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - - // TestServer propagates unhandled exceptions rather than returning 500, - // so the CosmosException surfaces directly to the caller - Func act = async () => await client.GetAsync("/items/f1"); - await act.Should().ThrowAsync() - .Where(ex => ex.StatusCode == HttpStatusCode.ServiceUnavailable); - } - - [Fact] - public async Task HttpMessageHandlerWrapper_RequestCounting_ViaWaf() - { - var requestCount = 0; - - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.WithHttpMessageHandlerWrapper(innerHandler => - { - var countingHandler = new CountingDelegatingHandler(innerHandler, () => Interlocked.Increment(ref requestCount)); - return countingHandler; - }); - })); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("c1", "c1", "Counted")); - await client.GetAsync("/items/c1"); - - requestCount.Should().BeGreaterThan(0); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task UseInMemoryCosmosContainers_WorksThroughWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db", "items")); + services.AddSingleton(); + }, + configureTestServices: services => + { + services.UseInMemoryCosmosContainers(o => o.AddContainer("items", "/partitionKey")); + }); + var client = app.HttpClient; + + var postResponse = await client.PostAsJsonAsync("/items", + new CosmosTestItem("c1", "c1", "ContainerPattern")); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var getResponse = await client.GetAsync("/items/c1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("ContainerPattern"); + } + + [Fact] + public async Task TypedClient_WorksThroughWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(); + }, + configureTestServices: services => + { + services.UseInMemoryCosmosDB(o => + { + o.DatabaseName = "TestDb"; + o.AddContainer("items", "/partitionKey"); + }); + }, + configureEndpoints: endpoints => + { + endpoints.MapPost("/typed-items", async (CosmosTestItem item, TypedClientRepository repo) => + { + var created = await repo.CreateAsync(item); + return Results.Created($"/typed-items/{created.Id}", created); + }); + endpoints.MapGet("/typed-items/{id}", async (string id, TypedClientRepository repo) => + { + var result = await repo.GetByIdAsync(id, id); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }); + }); + var client = app.HttpClient; + + var postResponse = await client.PostAsJsonAsync("/typed-items", + new CosmosTestItem("t1", "t1", "TypedClient")); + postResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var getResponse = await client.GetAsync("/typed-items/t1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + result!.Name.Should().Be("TypedClient"); + } + + [Fact] + public async Task MultipleContainers_SameApp_IsolatedData() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddRouting(); + }, + configureTestServices: services => + { + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey"); + o.AddContainer("products", "/partitionKey"); + }); + }, + configureEndpoints: endpoints => + { + endpoints.MapPost("/orders", async (CosmosTestItem item, CosmosClient cosmosClient) => + { + var container = cosmosClient.GetContainer("in-memory-db", "orders"); + var response = await container.CreateItemAsync(item, new PartitionKey(item.PartitionKey)); + return Results.Created($"/orders/{response.Resource.Id}", response.Resource); + }); + endpoints.MapGet("/orders", async (CosmosClient cosmosClient) => + { + var container = cosmosClient.GetContainer("in-memory-db", "orders"); + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return Results.Ok(results); + }); + endpoints.MapGet("/products", async (CosmosClient cosmosClient) => + { + var container = cosmosClient.GetContainer("in-memory-db", "products"); + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + + return Results.Ok(results); + }); + }); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/orders", new CosmosTestItem("o1", "o1", "Order1")); + + var ordersResponse = await client.GetAsync("/orders"); + var orders = await ordersResponse.Content.ReadFromJsonAsync>(JsonOptions); + orders.Should().ContainSingle(); + + var productsResponse = await client.GetAsync("/products"); + var products = await productsResponse.Content.ReadFromJsonAsync>(JsonOptions); + products.Should().BeEmpty(); + } + + [Fact] + public async Task OnHandlerCreated_FaultInjection_ViaWaf() + { + FakeCosmosHandler? capturedHandler = null; + + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => capturedHandler = handler; + })); + + capturedHandler.Should().NotBeNull(); + + var client = app.HttpClient; + + // Seed an item first + await client.PostAsJsonAsync("/items", new CosmosTestItem("f1", "f1", "Faulted")); + + // Now inject a fault — all reads return 503 + capturedHandler!.FaultInjector = _ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + + // TestServer propagates unhandled exceptions rather than returning 500, + // so the CosmosException surfaces directly to the caller + Func act = async () => await client.GetAsync("/items/f1"); + await act.Should().ThrowAsync() + .Where(ex => ex.StatusCode == HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public async Task HttpMessageHandlerWrapper_RequestCounting_ViaWaf() + { + var requestCount = 0; + + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.WithHttpMessageHandlerWrapper(innerHandler => + { + var countingHandler = new CountingDelegatingHandler(innerHandler, () => Interlocked.Increment(ref requestCount)); + return countingHandler; + }); + })); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("c1", "c1", "Counted")); + await client.GetAsync("/items/c1"); + + requestCount.Should().BeGreaterThan(0); + } } /// @@ -1006,19 +1006,19 @@ public async Task HttpMessageHandlerWrapper_RequestCounting_ViaWaf() /// public class CountingDelegatingHandler : DelegatingHandler { - private readonly Action _onRequest; - - public CountingDelegatingHandler(HttpMessageHandler inner, Action onRequest) : base(inner) - { - _onRequest = onRequest; - } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - _onRequest(); - return base.SendAsync(request, cancellationToken); - } + private readonly Action _onRequest; + + public CountingDelegatingHandler(HttpMessageHandler inner, Action onRequest) : base(inner) + { + _onRequest = onRequest; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + _onRequest(); + return base.SendAsync(request, cancellationToken); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1028,42 +1028,42 @@ protected override Task SendAsync( [Collection("FeedIteratorSetup")] public class WafQueryPatternTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task SqlQuery_ViaHttpEndpoint_ReturnsFilteredResults() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - await client.PostAsJsonAsync("/items", new CosmosTestItem("q1", "q1", "Alice")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("q2", "q2", "Bob")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("q3", "q3", "Alice")); - - var queryResponse = await client.PostAsJsonAsync("/items/query", - new { sql = "SELECT * FROM c WHERE c.name = 'Alice'" }); - queryResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var items = await queryResponse.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(2); - items.Should().OnlyContain(i => i.Name == "Alice"); - } - - [Fact] - public async Task EmptyContainer_ListReturnsEmptyArray() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var response = await client.GetAsync("/items"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().BeEmpty(); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task SqlQuery_ViaHttpEndpoint_ReturnsFilteredResults() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + await client.PostAsJsonAsync("/items", new CosmosTestItem("q1", "q1", "Alice")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("q2", "q2", "Bob")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("q3", "q3", "Alice")); + + var queryResponse = await client.PostAsJsonAsync("/items/query", + new { sql = "SELECT * FROM c WHERE c.name = 'Alice'" }); + queryResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await queryResponse.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(2); + items.Should().OnlyContain(i => i.Name == "Alice"); + } + + [Fact] + public async Task EmptyContainer_ListReturnsEmptyArray() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var response = await client.GetAsync("/items"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().BeEmpty(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1073,96 +1073,96 @@ public async Task EmptyContainer_ListReturnsEmptyArray() [Collection("FeedIteratorSetup")] public class WafEdgeCaseTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task ConcurrentRequests_AllSucceed() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - var tasks = Enumerable.Range(1, 10).Select(i => - client.PostAsJsonAsync("/items", - new CosmosTestItem($"c{i}", $"c{i}", $"Item{i}"))); - var responses = await Task.WhenAll(tasks); - - responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - var listResponse = await client.GetAsync("/items"); - var items = await listResponse.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(10); - } - - [Fact] - public async Task ItemWithSpecialCharactersInId_RoundTrips() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - // Use direct container access to avoid HTTP URL encoding issues with the test endpoint - var container = app.Services.GetRequiredService(); - var id = "item+special chars&more"; - var item = new CosmosTestItem(id, id, "Special"); - await container.CreateItemAsync(item, new PartitionKey(id)); - - var read = await container.ReadItemAsync(id, new PartitionKey(id)); - read.Resource.Name.Should().Be("Special"); - read.Resource.Id.Should().Be(id); - } - - [Fact] - public async Task ETagConcurrency_ViaDirectContainer_InWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - - // Create and capture ETag - var createResponse = await container.CreateItemAsync( - new CosmosTestItem("e1", "e1", "Original"), new PartitionKey("e1")); - var originalETag = createResponse.ETag; - - // Update the item, which changes the ETag - await container.ReplaceItemAsync( - new CosmosTestItem("e1", "e1", "Updated"), "e1", new PartitionKey("e1")); - - // Try to replace with stale ETag — should fail - var act = () => container.ReplaceItemAsync( - new CosmosTestItem("e1", "e1", "StaleUpdate"), "e1", new PartitionKey("e1"), - new ItemRequestOptions { IfMatchEtag = originalETag }); - await act.Should().ThrowAsync() - .Where(ex => ex.StatusCode == HttpStatusCode.PreconditionFailed); - } - - [Fact] - public async Task ItemWithDifferentIdAndPartitionKey_ViaHttp() - { - // Demonstrates that GET /items/{id} assumes PK == ID — items with different PK won't be found - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - - // Create an item where PK differs from ID - await container.CreateItemAsync( - new CosmosTestItem("abc", "xyz", "DiffPk"), new PartitionKey("xyz")); - - var client = app.HttpClient; - // The HTTP endpoint hardcodes PK=ID, so this returns 404 - var response = await client.GetAsync("/items/abc"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - - // But direct container access with correct PK works - var read = await container.ReadItemAsync("abc", new PartitionKey("xyz")); - read.Resource.Name.Should().Be("DiffPk"); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task ConcurrentRequests_AllSucceed() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + var tasks = Enumerable.Range(1, 10).Select(i => + client.PostAsJsonAsync("/items", + new CosmosTestItem($"c{i}", $"c{i}", $"Item{i}"))); + var responses = await Task.WhenAll(tasks); + + responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + var listResponse = await client.GetAsync("/items"); + var items = await listResponse.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(10); + } + + [Fact] + public async Task ItemWithSpecialCharactersInId_RoundTrips() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + // Use direct container access to avoid HTTP URL encoding issues with the test endpoint + var container = app.Services.GetRequiredService(); + var id = "item+special chars&more"; + var item = new CosmosTestItem(id, id, "Special"); + await container.CreateItemAsync(item, new PartitionKey(id)); + + var read = await container.ReadItemAsync(id, new PartitionKey(id)); + read.Resource.Name.Should().Be("Special"); + read.Resource.Id.Should().Be(id); + } + + [Fact] + public async Task ETagConcurrency_ViaDirectContainer_InWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + + // Create and capture ETag + var createResponse = await container.CreateItemAsync( + new CosmosTestItem("e1", "e1", "Original"), new PartitionKey("e1")); + var originalETag = createResponse.ETag; + + // Update the item, which changes the ETag + await container.ReplaceItemAsync( + new CosmosTestItem("e1", "e1", "Updated"), "e1", new PartitionKey("e1")); + + // Try to replace with stale ETag — should fail + var act = () => container.ReplaceItemAsync( + new CosmosTestItem("e1", "e1", "StaleUpdate"), "e1", new PartitionKey("e1"), + new ItemRequestOptions { IfMatchEtag = originalETag }); + await act.Should().ThrowAsync() + .Where(ex => ex.StatusCode == HttpStatusCode.PreconditionFailed); + } + + [Fact] + public async Task ItemWithDifferentIdAndPartitionKey_ViaHttp() + { + // Demonstrates that GET /items/{id} assumes PK == ID — items with different PK won't be found + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + + // Create an item where PK differs from ID + await container.CreateItemAsync( + new CosmosTestItem("abc", "xyz", "DiffPk"), new PartitionKey("xyz")); + + var client = app.HttpClient; + // The HTTP endpoint hardcodes PK=ID, so this returns 404 + var response = await client.GetAsync("/items/abc"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // But direct container access with correct PK works + var read = await container.ReadItemAsync("abc", new PartitionKey("xyz")); + read.Resource.Name.Should().Be("DiffPk"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1172,114 +1172,114 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class WafDivergentBehaviorTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact(Skip = "UseInMemoryCosmosDB creates a single FakeCosmosHandler and CosmosClient at registration time. " - + "The Container resolved from client.GetContainer() returns the same in-memory backing store regardless of scope. " - + "Per-request data isolation would require a scoped FakeCosmosHandler factory. " - + "See sister test: ScopedLifetime_ActualBehavior_ReturnsSameDataAcrossScopes")] - public async Task ScopedLifetime_PreservesPerRequestIsolation() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddScoped(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddScoped(sp => sp.GetRequiredService().GetContainer("Db", "items")); - services.AddScoped(); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var client = app.HttpClient; - // If scoped, data written in one request should be isolated from another - await client.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "ScopedItem")); - var response = await client.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().BeEmpty(); // Would need true scoped isolation - } - - [Fact] - public async Task ScopedLifetime_ActualBehavior_ReturnsSameDataAcrossScopes() - { - // DIVERGENT BEHAVIOR: Even when the base service registers Container as Scoped, - // UseInMemoryCosmosDB replaces it with a Singleton CosmosClient. The Container - // resolved from client.GetContainer() shares the same in-memory store. Data written - // in one scope is visible in all other scopes. This matches real CosmosClient behavior - // (the SDK client is typically a singleton), but differs from what "Scoped" lifetime - // might imply for DI-resolved Container instances. - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddScoped(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddScoped(sp => sp.GetRequiredService().GetContainer("Db", "items")); - services.AddScoped(); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var client = app.HttpClient; - await client.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "ScopedItem")); - - var response = await client.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().ContainSingle().Which.Name.Should().Be("ScopedItem"); - } - - [Fact(Skip = "HTTP pagination requires exposing continuation tokens through the HTTP API layer. " - + "The FakeCosmosHandler supports pagination internally, but surfacing this through a minimal API " - + "endpoint would require custom response headers and request parameters beyond the scope of the " - + "integration pattern being tested. See sister test: Pagination_WorksViaDirectContainerAccess_InWafContext")] - public async Task Pagination_ContinuationToken_ViaHttp() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - var client = app.HttpClient; - - for (var i = 0; i < 100; i++) - await client.PostAsJsonAsync("/items", new CosmosTestItem($"p{i}", $"p{i}", $"Item{i}")); - - // Would need continuation token header support in the endpoint - var response = await client.GetAsync("/items?pageSize=10"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task Pagination_WorksViaDirectContainerAccess_InWafContext() - { - // DIVERGENT BEHAVIOR: Pagination via FeedIterator works correctly at the Container - // level. However, when accessed through HTTP endpoints in a WAF context, pagination - // requires the API layer to thread continuation tokens through request/response headers — - // this is an application concern, not an emulator concern. - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new CosmosTestItem($"p{i}", $"p{i}", $"Item{i}"), new PartitionKey($"p{i}")); - - var iterator = container.GetItemQueryIterator( - "SELECT * FROM c", - requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); - - var allItems = new List(); - var pageCount = 0; - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - allItems.AddRange(page); - pageCount++; - } - - allItems.Should().HaveCount(5); - pageCount.Should().BeGreaterThan(1); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact(Skip = "UseInMemoryCosmosDB creates a single FakeCosmosHandler and CosmosClient at registration time. " + + "The Container resolved from client.GetContainer() returns the same in-memory backing store regardless of scope. " + + "Per-request data isolation would require a scoped FakeCosmosHandler factory. " + + "See sister test: ScopedLifetime_ActualBehavior_ReturnsSameDataAcrossScopes")] + public async Task ScopedLifetime_PreservesPerRequestIsolation() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddScoped(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddScoped(sp => sp.GetRequiredService().GetContainer("Db", "items")); + services.AddScoped(); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var client = app.HttpClient; + // If scoped, data written in one request should be isolated from another + await client.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "ScopedItem")); + var response = await client.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().BeEmpty(); // Would need true scoped isolation + } + + [Fact] + public async Task ScopedLifetime_ActualBehavior_ReturnsSameDataAcrossScopes() + { + // DIVERGENT BEHAVIOR: Even when the base service registers Container as Scoped, + // UseInMemoryCosmosDB replaces it with a Singleton CosmosClient. The Container + // resolved from client.GetContainer() shares the same in-memory store. Data written + // in one scope is visible in all other scopes. This matches real CosmosClient behavior + // (the SDK client is typically a singleton), but differs from what "Scoped" lifetime + // might imply for DI-resolved Container instances. + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddScoped(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddScoped(sp => sp.GetRequiredService().GetContainer("Db", "items")); + services.AddScoped(); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var client = app.HttpClient; + await client.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "ScopedItem")); + + var response = await client.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().ContainSingle().Which.Name.Should().Be("ScopedItem"); + } + + [Fact(Skip = "HTTP pagination requires exposing continuation tokens through the HTTP API layer. " + + "The FakeCosmosHandler supports pagination internally, but surfacing this through a minimal API " + + "endpoint would require custom response headers and request parameters beyond the scope of the " + + "integration pattern being tested. See sister test: Pagination_WorksViaDirectContainerAccess_InWafContext")] + public async Task Pagination_ContinuationToken_ViaHttp() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + var client = app.HttpClient; + + for (var i = 0; i < 100; i++) + await client.PostAsJsonAsync("/items", new CosmosTestItem($"p{i}", $"p{i}", $"Item{i}")); + + // Would need continuation token header support in the endpoint + var response = await client.GetAsync("/items?pageSize=10"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Pagination_WorksViaDirectContainerAccess_InWafContext() + { + // DIVERGENT BEHAVIOR: Pagination via FeedIterator works correctly at the Container + // level. However, when accessed through HTTP endpoints in a WAF context, pagination + // requires the API layer to thread continuation tokens through request/response headers — + // this is an application concern, not an emulator concern. + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new CosmosTestItem($"p{i}", $"p{i}", $"Item{i}"), new PartitionKey($"p{i}")); + + var iterator = container.GetItemQueryIterator( + "SELECT * FROM c", + requestOptions: new QueryRequestOptions { MaxItemCount = 2 }); + + var allItems = new List(); + var pageCount = 0; + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + allItems.AddRange(page); + pageCount++; + } + + allItems.Should().HaveCount(5); + pageCount.Should().BeGreaterThan(1); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1289,78 +1289,78 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class WafCallbackTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task OnClientCreated_CapturesCosmosClient_ViaWaf() - { - CosmosClient? captured = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnClientCreated = client => captured = client; - })); - - captured.Should().NotBeNull(); - var resolved = app.Services.GetRequiredService(); - captured.Should().BeSameAs(resolved); - } - - [Fact] - public async Task OnContainerCreated_CapturesInMemoryContainer_ViaWaf() - { - InMemoryContainer? captured = null; - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnContainerCreated = c => captured = (InMemoryContainer)c; - })); - - captured.Should().NotBeNull(); - - await captured!.CreateItemAsync( - new CosmosTestItem("seed1", "seed1", "Seeded"), - new PartitionKey("seed1")); - - var container = app.Services.GetRequiredService(); - var read = await container.ReadItemAsync("seed1", new PartitionKey("seed1")); - read.Resource.Name.Should().Be("Seeded"); - } - - [Fact] - public async Task RegisterFeedIteratorSetup_False_NativeToFeedIteratorStillWorks() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.RegisterFeedIteratorSetup = false; - })); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("1", "1", "Test"), - new PartitionKey("1")); - - // Native query iterator works because UseInMemoryCosmosDB uses FakeCosmosHandler - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task OnClientCreated_CapturesCosmosClient_ViaWaf() + { + CosmosClient? captured = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnClientCreated = client => captured = client; + })); + + captured.Should().NotBeNull(); + var resolved = app.Services.GetRequiredService(); + captured.Should().BeSameAs(resolved); + } + + [Fact] + public async Task OnContainerCreated_CapturesInMemoryContainer_ViaWaf() + { + InMemoryContainer? captured = null; + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnContainerCreated = c => captured = (InMemoryContainer)c; + })); + + captured.Should().NotBeNull(); + + await captured!.CreateItemAsync( + new CosmosTestItem("seed1", "seed1", "Seeded"), + new PartitionKey("seed1")); + + var container = app.Services.GetRequiredService(); + var read = await container.ReadItemAsync("seed1", new PartitionKey("seed1")); + read.Resource.Name.Should().Be("Seeded"); + } + + [Fact] + public async Task RegisterFeedIteratorSetup_False_NativeToFeedIteratorStillWorks() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.RegisterFeedIteratorSetup = false; + })); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("1", "1", "Test"), + new PartitionKey("1")); + + // Native query iterator works because UseInMemoryCosmosDB uses FakeCosmosHandler + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1370,135 +1370,135 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class WafSeedingPatternTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task SeedingViaOnHandlerCreated_BackingContainer_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => - { - handler.BackingContainer.CreateItemAsync( - new CosmosTestItem("seed1", "seed1", "SeededViaHandler"), - new PartitionKey("seed1")).GetAwaiter().GetResult(); - }; - })); - - var response = await app.HttpClient.GetAsync("/items/seed1"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var item = await response.Content.ReadFromJsonAsync(JsonOptions); - item!.Name.Should().Be("SeededViaHandler"); - } - - [Fact] - public async Task SeedingViaOnContainerCreated_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnContainerCreated = container => - { - ((InMemoryContainer)container).CreateItemAsync( - new CosmosTestItem("seed1", "seed1", "SeededViaContainer"), - new PartitionKey("seed1")).GetAwaiter().GetResult(); - }; - })); - - var container = app.Services.GetRequiredService(); - var read = await container.ReadItemAsync("seed1", new PartitionKey("seed1")); - read.Resource.Name.Should().Be("SeededViaContainer"); - } - - [Fact] - public async Task SeedingViaOnClientCreated_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnClientCreated = client => - { - var container = client.GetContainer("in-memory-db", "items"); - container.CreateItemAsync( - new CosmosTestItem("seed1", "seed1", "SeededViaClient"), - new PartitionKey("seed1")).GetAwaiter().GetResult(); - }; - })); - - var response = await app.HttpClient.GetAsync("/items/seed1"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var item = await response.Content.ReadFromJsonAsync(JsonOptions); - item!.Name.Should().Be("SeededViaClient"); - } - - [Fact] - public async Task SeedingViaImportState_InDiCallback_ViaWaf() - { - // Pre-generate state by exporting from a seeded container - var source = new InMemoryContainer("items", "/partitionKey"); - await source.CreateItemAsync( - new CosmosTestItem("imp1", "imp1", "Imported"), - new PartitionKey("imp1")); - var state = source.ExportState(); - - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => - { - handler.BackingInMemoryContainer.ImportState(state); - }; - })); - - var response = await app.HttpClient.GetAsync("/items/imp1"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var item = await response.Content.ReadFromJsonAsync(JsonOptions); - item!.Name.Should().Be("Imported"); - } - - [Fact] - public async Task ClearItems_ResetBetweenTests_ViaWaf() - { - FakeCosmosHandler? capturedHandler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, handler) => capturedHandler = handler; - })); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("1", "1", "ToBeCleared"), - new PartitionKey("1")); - - capturedHandler!.BackingInMemoryContainer.ClearItems(); - - var response = await app.HttpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().BeEmpty(); - } + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task SeedingViaOnHandlerCreated_BackingContainer_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => + { + handler.BackingContainer.CreateItemAsync( + new CosmosTestItem("seed1", "seed1", "SeededViaHandler"), + new PartitionKey("seed1")).GetAwaiter().GetResult(); + }; + })); + + var response = await app.HttpClient.GetAsync("/items/seed1"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await response.Content.ReadFromJsonAsync(JsonOptions); + item!.Name.Should().Be("SeededViaHandler"); + } + + [Fact] + public async Task SeedingViaOnContainerCreated_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnContainerCreated = container => + { + ((InMemoryContainer)container).CreateItemAsync( + new CosmosTestItem("seed1", "seed1", "SeededViaContainer"), + new PartitionKey("seed1")).GetAwaiter().GetResult(); + }; + })); + + var container = app.Services.GetRequiredService(); + var read = await container.ReadItemAsync("seed1", new PartitionKey("seed1")); + read.Resource.Name.Should().Be("SeededViaContainer"); + } + + [Fact] + public async Task SeedingViaOnClientCreated_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnClientCreated = client => + { + var container = client.GetContainer("in-memory-db", "items"); + container.CreateItemAsync( + new CosmosTestItem("seed1", "seed1", "SeededViaClient"), + new PartitionKey("seed1")).GetAwaiter().GetResult(); + }; + })); + + var response = await app.HttpClient.GetAsync("/items/seed1"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await response.Content.ReadFromJsonAsync(JsonOptions); + item!.Name.Should().Be("SeededViaClient"); + } + + [Fact] + public async Task SeedingViaImportState_InDiCallback_ViaWaf() + { + // Pre-generate state by exporting from a seeded container + var source = new InMemoryContainer("items", "/partitionKey"); + await source.CreateItemAsync( + new CosmosTestItem("imp1", "imp1", "Imported"), + new PartitionKey("imp1")); + var state = source.ExportState(); + + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => + { + handler.BackingInMemoryContainer.ImportState(state); + }; + })); + + var response = await app.HttpClient.GetAsync("/items/imp1"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await response.Content.ReadFromJsonAsync(JsonOptions); + item!.Name.Should().Be("Imported"); + } + + [Fact] + public async Task ClearItems_ResetBetweenTests_ViaWaf() + { + FakeCosmosHandler? capturedHandler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, handler) => capturedHandler = handler; + })); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("1", "1", "ToBeCleared"), + new PartitionKey("1")); + + capturedHandler!.BackingInMemoryContainer.ClearItems(); + + var response = await app.HttpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().BeEmpty(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1508,154 +1508,154 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class WafAdvancedFeatureTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task TransactionalBatch_ViaWaf_AtomicCreateAndRead() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => handler = h; - })); - - // Use backing container for batch (HTTP-proxied Container doesn't support batch) - var container = handler!.BackingContainer; - var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); - batch.CreateItem(new CosmosTestItem("b1", "pk1", "Batch1")); - batch.CreateItem(new CosmosTestItem("b2", "pk1", "Batch2")); - batch.CreateItem(new CosmosTestItem("b3", "pk1", "Batch3")); - var batchResult = await batch.ExecuteAsync(); - batchResult.IsSuccessStatusCode.Should().BeTrue(); - - var response = await app.HttpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(3); - } - - [Fact] - public async Task ChangeFeed_ViaWaf_ReadsChangesAfterHttpMutations() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => handler = h; - })); - - var client = app.HttpClient; - await client.PostAsJsonAsync("/items", new CosmosTestItem("cf1", "cf1", "CF1")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("cf2", "cf2", "CF2")); - await client.PostAsJsonAsync("/items", new CosmosTestItem("cf3", "cf3", "CF3")); - - var backingContainer = handler!.BackingContainer; - var iterator = backingContainer.GetChangeFeedIterator( - ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); - - var changes = new List(); - while (iterator.HasMoreResults) - { - var page = await iterator.ReadNextAsync(); - if (page.StatusCode == HttpStatusCode.NotModified) break; - changes.AddRange(page.Resource); - } - - changes.Should().HaveCount(3); - } - - [Fact] - public async Task Ttl_ViaWaf_ExpiredItemsNotReturned() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => - { - handler = h; - h.BackingInMemoryContainer.DefaultTimeToLive = 1; - }; - })); - - var client = app.HttpClient; - await client.PostAsJsonAsync("/items", new CosmosTestItem("ttl1", "ttl1", "Temporary")); - - await Task.Delay(TimeSpan.FromSeconds(3)); - - var response = await app.HttpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().BeEmpty(); - } - - [Fact] - public async Task ReadMany_ViaWaf_ReturnsRequestedItems() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - for (var i = 1; i <= 5; i++) - await container.CreateItemAsync( - new CosmosTestItem($"rm{i}", $"rm{i}", $"Item{i}"), - new PartitionKey($"rm{i}")); - - var readManyResult = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> - { - ("rm1", new PartitionKey("rm1")), - ("rm3", new PartitionKey("rm3")), - ("rm5", new PartitionKey("rm5")) - }); - - readManyResult.Resource.Should().HaveCount(3); - } - - [Fact] - public async Task StatePersistence_ExportImport_ViaWaf() - { - FakeCosmosHandler? handler1 = null; - await using var app1 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => handler1 = h; - })); - - await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "State1")); - await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("s2", "s2", "State2")); - - var exportedState = handler1!.BackingInMemoryContainer.ExportState(); - - FakeCosmosHandler? handler2 = null; - await using var app2 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => - { - handler2 = h; - h.BackingInMemoryContainer.ImportState(exportedState); - }; - })); - - var response = await app2.HttpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(2); - } + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task TransactionalBatch_ViaWaf_AtomicCreateAndRead() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => handler = h; + })); + + // Use backing container for batch (HTTP-proxied Container doesn't support batch) + var container = handler!.BackingContainer; + var batch = container.CreateTransactionalBatch(new PartitionKey("pk1")); + batch.CreateItem(new CosmosTestItem("b1", "pk1", "Batch1")); + batch.CreateItem(new CosmosTestItem("b2", "pk1", "Batch2")); + batch.CreateItem(new CosmosTestItem("b3", "pk1", "Batch3")); + var batchResult = await batch.ExecuteAsync(); + batchResult.IsSuccessStatusCode.Should().BeTrue(); + + var response = await app.HttpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(3); + } + + [Fact] + public async Task ChangeFeed_ViaWaf_ReadsChangesAfterHttpMutations() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => handler = h; + })); + + var client = app.HttpClient; + await client.PostAsJsonAsync("/items", new CosmosTestItem("cf1", "cf1", "CF1")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("cf2", "cf2", "CF2")); + await client.PostAsJsonAsync("/items", new CosmosTestItem("cf3", "cf3", "CF3")); + + var backingContainer = handler!.BackingContainer; + var iterator = backingContainer.GetChangeFeedIterator( + ChangeFeedStartFrom.Beginning(), ChangeFeedMode.LatestVersion); + + var changes = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + if (page.StatusCode == HttpStatusCode.NotModified) break; + changes.AddRange(page.Resource); + } + + changes.Should().HaveCount(3); + } + + [Fact] + public async Task Ttl_ViaWaf_ExpiredItemsNotReturned() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => + { + handler = h; + h.BackingInMemoryContainer.DefaultTimeToLive = 1; + }; + })); + + var client = app.HttpClient; + await client.PostAsJsonAsync("/items", new CosmosTestItem("ttl1", "ttl1", "Temporary")); + + await Task.Delay(TimeSpan.FromSeconds(3)); + + var response = await app.HttpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().BeEmpty(); + } + + [Fact] + public async Task ReadMany_ViaWaf_ReturnsRequestedItems() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + for (var i = 1; i <= 5; i++) + await container.CreateItemAsync( + new CosmosTestItem($"rm{i}", $"rm{i}", $"Item{i}"), + new PartitionKey($"rm{i}")); + + var readManyResult = await container.ReadManyItemsAsync(new List<(string, PartitionKey)> + { + ("rm1", new PartitionKey("rm1")), + ("rm3", new PartitionKey("rm3")), + ("rm5", new PartitionKey("rm5")) + }); + + readManyResult.Resource.Should().HaveCount(3); + } + + [Fact] + public async Task StatePersistence_ExportImport_ViaWaf() + { + FakeCosmosHandler? handler1 = null; + await using var app1 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => handler1 = h; + })); + + await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("s1", "s1", "State1")); + await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("s2", "s2", "State2")); + + var exportedState = handler1!.BackingInMemoryContainer.ExportState(); + + FakeCosmosHandler? handler2 = null; + await using var app2 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => + { + handler2 = h; + h.BackingInMemoryContainer.ImportState(exportedState); + }; + })); + + var response = await app2.HttpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(2); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1665,51 +1665,51 @@ public async Task StatePersistence_ExportImport_ViaWaf() [Collection("FeedIteratorSetup")] public class WafContainerManagementTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task DynamicContainerCreation_ViaWaf_CreateThenUse() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var client = app.Services.GetRequiredService(); - var db = client.GetDatabase("in-memory-db"); - var dynamicResponse = await db.CreateContainerIfNotExistsAsync("dynamic", "/partitionKey"); - var dynamicContainer = dynamicResponse.Container; - - await dynamicContainer.CreateItemAsync( - new CosmosTestItem("d1", "a", "Dynamic"), - new PartitionKey("a")); - - var read = await dynamicContainer.ReadItemAsync("d1", new PartitionKey("a")); - read.Resource.Name.Should().Be("Dynamic"); - } - - [Fact] - public async Task ReadContainerProperties_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - var props = await container.ReadContainerAsync(); - props.Resource.Id.Should().Be("items"); - } - - [Fact] - public async Task ReadAccountProperties_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var client = app.Services.GetRequiredService(); - var account = await client.ReadAccountAsync(); - account.Should().NotBeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task DynamicContainerCreation_ViaWaf_CreateThenUse() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var client = app.Services.GetRequiredService(); + var db = client.GetDatabase("in-memory-db"); + var dynamicResponse = await db.CreateContainerIfNotExistsAsync("dynamic", "/partitionKey"); + var dynamicContainer = dynamicResponse.Container; + + await dynamicContainer.CreateItemAsync( + new CosmosTestItem("d1", "a", "Dynamic"), + new PartitionKey("a")); + + var read = await dynamicContainer.ReadItemAsync("d1", new PartitionKey("a")); + read.Resource.Name.Should().Be("Dynamic"); + } + + [Fact] + public async Task ReadContainerProperties_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + var props = await container.ReadContainerAsync(); + props.Resource.Id.Should().Be("items"); + } + + [Fact] + public async Task ReadAccountProperties_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var client = app.Services.GetRequiredService(); + var account = await client.ReadAccountAsync(); + account.Should().NotBeNull(); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1719,50 +1719,50 @@ public async Task ReadAccountProperties_ViaWaf() [Collection("FeedIteratorSetup")] public class WafPatternValidationTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task ProductionClientNotReachable_ReplacedByEmulator() - { - // Base services register a CosmosClient pointing to an unreachable endpoint. - // UseInMemoryCosmosDB replaces it. If replacement failed, CreateItemAsync would - // throw a network error. - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - var act = () => container.CreateItemAsync( - new CosmosTestItem("1", "1", "Works"), - new PartitionKey("1")); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task RegistrationOrder_DoesNotMatter() - { - // Register services before UseInMemoryCosmosDB - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(sp => - sp.GetRequiredService().GetContainer("Db", "items")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("1", "1", "OrderTest"), - new PartitionKey("1")); - - var read = await container.ReadItemAsync("1", new PartitionKey("1")); - read.Resource.Name.Should().Be("OrderTest"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task ProductionClientNotReachable_ReplacedByEmulator() + { + // Base services register a CosmosClient pointing to an unreachable endpoint. + // UseInMemoryCosmosDB replaces it. If replacement failed, CreateItemAsync would + // throw a network error. + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + var act = () => container.CreateItemAsync( + new CosmosTestItem("1", "1", "Works"), + new PartitionKey("1")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RegistrationOrder_DoesNotMatter() + { + // Register services before UseInMemoryCosmosDB + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(sp => + sp.GetRequiredService().GetContainer("Db", "items")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("1", "1", "OrderTest"), + new PartitionKey("1")); + + var read = await container.ReadItemAsync("1", new PartitionKey("1")); + read.Resource.Name.Should().Be("OrderTest"); + } } // ════════════════════════════════════════════════════════════════════════════════ @@ -1772,207 +1772,207 @@ await container.CreateItemAsync( [Collection("FeedIteratorSetup")] public class WafDivergentBehaviorDeepTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact(Skip = "DIVERGENT: When RegisterFeedIteratorSetup=false with UseInMemoryCosmosContainers, " - + ".ToFeedIteratorOverridable() falls back to native .ToFeedIterator() which requires " - + "the Cosmos LINQ provider. InMemoryContainer's IOrderedQueryable doesn't implement " - + "this provider, so it throws.")] - public void RegisterFeedIteratorSetup_False_WithContainers_BreaksOverridable() { } - - [Fact] - public async Task RegisterFeedIteratorSetup_False_WithContainers_NativeQueryStillWorks() - { - // With UseInMemoryCosmosContainers + RegisterFeedIteratorSetup=false, - // SQL queries still work; only LINQ .ToFeedIteratorOverridable() breaks. - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("items", "/partitionKey"); - })); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("1", "1", "Test"), - new PartitionKey("1")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(1); - } - - [Fact] - public async Task PerContainerDatabaseName_MultipleDbsSameContainerName_ShouldIsolate() - { - // Two containers with the same name in different databases should be isolated - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db1", "items")); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey", databaseName: "Db1"); - o.AddContainer("items", "/partitionKey", databaseName: "Db2"); - })); - - var client = app.Services.GetRequiredService(); - var db1Items = client.GetContainer("Db1", "items"); - var db2Items = client.GetContainer("Db2", "items"); - - await db1Items.CreateItemAsync( - new CosmosTestItem("i1", "i1", "FromDb1"), - new PartitionKey("i1")); - await db2Items.CreateItemAsync( - new CosmosTestItem("i2", "i2", "FromDb2"), - new PartitionKey("i2")); - - var db1Iterator = db1Items.GetItemQueryIterator("SELECT * FROM c"); - var db1Results = new List(); - while (db1Iterator.HasMoreResults) - db1Results.AddRange(await db1Iterator.ReadNextAsync()); - - var db2Iterator = db2Items.GetItemQueryIterator("SELECT * FROM c"); - var db2Results = new List(); - while (db2Iterator.HasMoreResults) - db2Results.AddRange(await db2Iterator.ReadNextAsync()); - - db1Results.Should().HaveCount(1); - db1Results[0].Name.Should().Be("FromDb1"); - db2Results.Should().HaveCount(1); - db2Results[0].Name.Should().Be("FromDb2"); - } - - [Fact] - public async Task PerContainerDatabaseName_DifferentContainerNames_IsolatesCorrectly() - { - // Different container names in different databases work because routing is by container name - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db", "items")); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey", databaseName: "Db1"); - o.AddContainer("products", "/partitionKey", databaseName: "Db2"); - })); - - var client = app.Services.GetRequiredService(); - var items = client.GetContainer("Db1", "items"); - var products = client.GetContainer("Db2", "products"); - - await items.CreateItemAsync( - new CosmosTestItem("i1", "i1", "Item"), - new PartitionKey("i1")); - await products.CreateItemAsync( - new CosmosTestItem("p1", "p1", "Product"), - new PartitionKey("p1")); - - var itemsIterator = items.GetItemQueryIterator("SELECT * FROM c"); - var itemResults = new List(); - while (itemsIterator.HasMoreResults) - itemResults.AddRange(await itemsIterator.ReadNextAsync()); - - var productsIterator = products.GetItemQueryIterator("SELECT * FROM c"); - var productResults = new List(); - while (productsIterator.HasMoreResults) - productResults.AddRange(await productsIterator.ReadNextAsync()); - - itemResults.Should().HaveCount(1); - itemResults[0].Name.Should().Be("Item"); - productResults.Should().HaveCount(1); - productResults[0].Name.Should().Be("Product"); - } - - [Fact(Skip = "DIVERGENT: Stored procedure registration requires InMemoryContainer.RegisterStoredProcedure() " - + "which is not available on the abstract Container class resolved from DI. " - + "Use OnHandlerCreated to capture the FakeCosmosHandler.BackingContainer.")] - public void StoredProcedure_ViaWaf_RequiresBackingContainerAccess() { } - - [Fact(Skip = "DIVERGENT: Trigger registration requires InMemoryContainer.RegisterTrigger() " - + "which is not part of the Container abstract class. Additionally, " - + "pre-trigger headers must be threaded through ItemRequestOptions, " - + "which HTTP endpoints don't expose.")] - public void Trigger_ViaWaf_RequiresBackingContainerAndHeaders() { } - - [Fact] - public async Task UniqueKeyPolicy_ViaWaf_AddContainerWithContainerProperties() - { - var containerProps = new ContainerProperties("unique-test", "/partitionKey") - { - UniqueKeyPolicy = new UniqueKeyPolicy - { - UniqueKeys = { new UniqueKey { Paths = { "/Email" } } } - } - }; - - using var host = new HostBuilder() - .ConfigureWebHost(web => - { - web.UseTestServer(); - web.ConfigureServices(services => - { - services.UseInMemoryCosmosDB(opts => - opts.AddContainer(containerProps)); - }); - web.Configure(_ => { }); - }) - .Build(); - - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - var container = client.GetContainer("in-memory-db", "unique-test"); - - // First insert succeeds - await container.CreateItemAsync( - new { id = "1", partitionKey = "pk", Email = "a@b.com" }, - new PartitionKey("pk")); - - // Duplicate unique key should fail with Conflict - var ex = await Assert.ThrowsAnyAsync(() => - container.CreateItemAsync( - new { id = "2", partitionKey = "pk", Email = "a@b.com" }, - new PartitionKey("pk"))); - Assert.Equal(HttpStatusCode.Conflict, ex.StatusCode); - } - - [Fact(Skip = "DIVERGENT: Deleting a container via DeleteContainerAsync() removes it from " - + "the in-memory database, but the DI container retains its reference to the " - + "now-deleted singleton. This is a DI lifetime concern.")] - public void DeleteContainer_ViaWaf_DiStillHoldsReference() { } - - [Fact(Skip = "DIVERGENT: After DisposeAsync(), the IHost and TestServer are stopped. " - + "Subsequent HttpClient calls throw, but the exact exception type is " - + "framework-dependent (ObjectDisposedException, HttpRequestException, etc.).")] - public void Dispose_ThenAttemptUse_FrameworkDependentException() { } - - [Fact(Skip = "DIVERGENT: Change feed continuation tokens via HTTP require threading tokens " - + "through request/response headers — an application concern, not an emulator concern.")] - public void ChangeFeed_IncrementalReads_ViaHttp_RequiresCustomHeaders() { } - - [Fact(Skip = "DIVERGENT: Item-level TTL requires _ttl property on the document. " - + "CosmosTestItem record doesn't include this field. Testing through HTTP " - + "requires a dedicated record type or dynamic JSON endpoints.")] - public void Ttl_ItemLevelOverride_ViaHttp_RequiresCustomRecordType() { } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact(Skip = "DIVERGENT: When RegisterFeedIteratorSetup=false with UseInMemoryCosmosContainers, " + + ".ToFeedIteratorOverridable() falls back to native .ToFeedIterator() which requires " + + "the Cosmos LINQ provider. InMemoryContainer's IOrderedQueryable doesn't implement " + + "this provider, so it throws.")] + public void RegisterFeedIteratorSetup_False_WithContainers_BreaksOverridable() { } + + [Fact] + public async Task RegisterFeedIteratorSetup_False_WithContainers_NativeQueryStillWorks() + { + // With UseInMemoryCosmosContainers + RegisterFeedIteratorSetup=false, + // SQL queries still work; only LINQ .ToFeedIteratorOverridable() breaks. + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("items", "/partitionKey"); + })); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("1", "1", "Test"), + new PartitionKey("1")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(1); + } + + [Fact] + public async Task PerContainerDatabaseName_MultipleDbsSameContainerName_ShouldIsolate() + { + // Two containers with the same name in different databases should be isolated + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db1", "items")); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey", databaseName: "Db1"); + o.AddContainer("items", "/partitionKey", databaseName: "Db2"); + })); + + var client = app.Services.GetRequiredService(); + var db1Items = client.GetContainer("Db1", "items"); + var db2Items = client.GetContainer("Db2", "items"); + + await db1Items.CreateItemAsync( + new CosmosTestItem("i1", "i1", "FromDb1"), + new PartitionKey("i1")); + await db2Items.CreateItemAsync( + new CosmosTestItem("i2", "i2", "FromDb2"), + new PartitionKey("i2")); + + var db1Iterator = db1Items.GetItemQueryIterator("SELECT * FROM c"); + var db1Results = new List(); + while (db1Iterator.HasMoreResults) + db1Results.AddRange(await db1Iterator.ReadNextAsync()); + + var db2Iterator = db2Items.GetItemQueryIterator("SELECT * FROM c"); + var db2Results = new List(); + while (db2Iterator.HasMoreResults) + db2Results.AddRange(await db2Iterator.ReadNextAsync()); + + db1Results.Should().HaveCount(1); + db1Results[0].Name.Should().Be("FromDb1"); + db2Results.Should().HaveCount(1); + db2Results[0].Name.Should().Be("FromDb2"); + } + + [Fact] + public async Task PerContainerDatabaseName_DifferentContainerNames_IsolatesCorrectly() + { + // Different container names in different databases work because routing is by container name + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService().GetContainer("Db", "items")); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey", databaseName: "Db1"); + o.AddContainer("products", "/partitionKey", databaseName: "Db2"); + })); + + var client = app.Services.GetRequiredService(); + var items = client.GetContainer("Db1", "items"); + var products = client.GetContainer("Db2", "products"); + + await items.CreateItemAsync( + new CosmosTestItem("i1", "i1", "Item"), + new PartitionKey("i1")); + await products.CreateItemAsync( + new CosmosTestItem("p1", "p1", "Product"), + new PartitionKey("p1")); + + var itemsIterator = items.GetItemQueryIterator("SELECT * FROM c"); + var itemResults = new List(); + while (itemsIterator.HasMoreResults) + itemResults.AddRange(await itemsIterator.ReadNextAsync()); + + var productsIterator = products.GetItemQueryIterator("SELECT * FROM c"); + var productResults = new List(); + while (productsIterator.HasMoreResults) + productResults.AddRange(await productsIterator.ReadNextAsync()); + + itemResults.Should().HaveCount(1); + itemResults[0].Name.Should().Be("Item"); + productResults.Should().HaveCount(1); + productResults[0].Name.Should().Be("Product"); + } + + [Fact(Skip = "DIVERGENT: Stored procedure registration requires InMemoryContainer.RegisterStoredProcedure() " + + "which is not available on the abstract Container class resolved from DI. " + + "Use OnHandlerCreated to capture the FakeCosmosHandler.BackingContainer.")] + public void StoredProcedure_ViaWaf_RequiresBackingContainerAccess() { } + + [Fact(Skip = "DIVERGENT: Trigger registration requires InMemoryContainer.RegisterTrigger() " + + "which is not part of the Container abstract class. Additionally, " + + "pre-trigger headers must be threaded through ItemRequestOptions, " + + "which HTTP endpoints don't expose.")] + public void Trigger_ViaWaf_RequiresBackingContainerAndHeaders() { } + + [Fact] + public async Task UniqueKeyPolicy_ViaWaf_AddContainerWithContainerProperties() + { + var containerProps = new ContainerProperties("unique-test", "/partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/Email" } } } + } + }; + + using var host = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.UseInMemoryCosmosDB(opts => + opts.AddContainer(containerProps)); + }); + web.Configure(_ => { }); + }) + .Build(); + + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var container = client.GetContainer("in-memory-db", "unique-test"); + + // First insert succeeds + await container.CreateItemAsync( + new { id = "1", partitionKey = "pk", Email = "a@b.com" }, + new PartitionKey("pk")); + + // Duplicate unique key should fail with Conflict + var ex = await Assert.ThrowsAnyAsync(() => + container.CreateItemAsync( + new { id = "2", partitionKey = "pk", Email = "a@b.com" }, + new PartitionKey("pk"))); + Assert.Equal(HttpStatusCode.Conflict, ex.StatusCode); + } + + [Fact(Skip = "DIVERGENT: Deleting a container via DeleteContainerAsync() removes it from " + + "the in-memory database, but the DI container retains its reference to the " + + "now-deleted singleton. This is a DI lifetime concern.")] + public void DeleteContainer_ViaWaf_DiStillHoldsReference() { } + + [Fact(Skip = "DIVERGENT: After DisposeAsync(), the IHost and TestServer are stopped. " + + "Subsequent HttpClient calls throw, but the exact exception type is " + + "framework-dependent (ObjectDisposedException, HttpRequestException, etc.).")] + public void Dispose_ThenAttemptUse_FrameworkDependentException() { } + + [Fact(Skip = "DIVERGENT: Change feed continuation tokens via HTTP require threading tokens " + + "through request/response headers — an application concern, not an emulator concern.")] + public void ChangeFeed_IncrementalReads_ViaHttp_RequiresCustomHeaders() { } + + [Fact(Skip = "DIVERGENT: Item-level TTL requires _ttl property on the document. " + + "CosmosTestItem record doesn't include this field. Testing through HTTP " + + "requires a dedicated record type or dynamic JSON endpoints.")] + public void Ttl_ItemLevelOverride_ViaHttp_RequiresCustomRecordType() { } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -1982,711 +1982,711 @@ public void Ttl_ItemLevelOverride_ViaHttp_RequiresCustomRecordType() { } [Collection("FeedIteratorSetup")] public class WafCrudEdgeCaseTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task ReplaceNonExistent_ViaHttp_Returns404() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var response = await app.HttpClient.PutAsJsonAsync("/items/nonexistent", - new CosmosTestItem("nonexistent", "nonexistent", "Ghost")); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task PatchNonExistent_ViaHttp_Returns404() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var response = await app.HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/items/nonexistent/name") - { - Content = JsonContent.Create(new { name = "NewName" }) - }); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task ReplaceNonExistent_ViaHttp_Returns404() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var response = await app.HttpClient.PutAsJsonAsync("/items/nonexistent", + new CosmosTestItem("nonexistent", "nonexistent", "Ghost")); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task PatchNonExistent_ViaHttp_Returns404() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var response = await app.HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Patch, "/items/nonexistent/name") + { + Content = JsonContent.Create(new { name = "NewName" }) + }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } [Collection("FeedIteratorSetup")] public class WafAdvancedDataPatternTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task CrossPartitionQuery_ViaWaf_ReturnsAllPartitions() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - for (var i = 0; i < 5; i++) - await container.CreateItemAsync( - new CosmosTestItem($"item-{i}", $"pk-{i}", $"Item{i}"), - new PartitionKey($"pk-{i}")); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(5); - } - - [Fact] - public async Task StreamApi_CreateAndRead_ViaWaf() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - var json = """{"id":"stream1","partitionKey":"pk1","name":"StreamItem"}"""; - var createResponse = await container.CreateItemStreamAsync( - new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), - new PartitionKey("pk1")); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResponse = await container.ReadItemStreamAsync("stream1", new PartitionKey("pk1")); - readResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var reader = new System.IO.StreamReader(readResponse.Content); - var body = await reader.ReadToEndAsync(); - body.Should().Contain("StreamItem"); - } - - [Fact] - public async Task ParameterizedQuery_ViaWaf_ReturnsFilteredResults() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync(new CosmosTestItem("1", "pk1", "Alice"), new PartitionKey("pk1")); - await container.CreateItemAsync(new CosmosTestItem("2", "pk1", "Bob"), new PartitionKey("pk1")); - await container.CreateItemAsync(new CosmosTestItem("3", "pk1", "Alice"), new PartitionKey("pk1")); - - var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") - .WithParameter("@name", "Alice"); - - var iterator = container.GetItemQueryIterator(query); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().HaveCount(2); - results.Should().OnlyContain(r => r.Name == "Alice"); - } - - [Fact] - public async Task BulkOperations_ViaWaf_AllSucceed() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - var tasks = Enumerable.Range(0, 50).Select(i => - container.CreateItemAsync( - new CosmosTestItem($"bulk-{i}", "pk1", $"Bulk{i}"), - new PartitionKey("pk1"))); - - var results = await Task.WhenAll(tasks); - results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var all = new List(); - while (iterator.HasMoreResults) - all.AddRange(await iterator.ReadNextAsync()); - all.Should().HaveCount(50); - } - - [Fact] - public async Task EmptyStringPartitionKey_ViaWaf_ItemAccessible() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("none1", "", "NoPartition"), - new PartitionKey("")); - - var read = await container.ReadItemAsync("none1", new PartitionKey("")); - read.Resource.Name.Should().Be("NoPartition"); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task CrossPartitionQuery_ViaWaf_ReturnsAllPartitions() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + for (var i = 0; i < 5; i++) + await container.CreateItemAsync( + new CosmosTestItem($"item-{i}", $"pk-{i}", $"Item{i}"), + new PartitionKey($"pk-{i}")); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(5); + } + + [Fact] + public async Task StreamApi_CreateAndRead_ViaWaf() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + var json = """{"id":"stream1","partitionKey":"pk1","name":"StreamItem"}"""; + var createResponse = await container.CreateItemStreamAsync( + new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)), + new PartitionKey("pk1")); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResponse = await container.ReadItemStreamAsync("stream1", new PartitionKey("pk1")); + readResponse.StatusCode.Should().Be(HttpStatusCode.OK); + using var reader = new System.IO.StreamReader(readResponse.Content); + var body = await reader.ReadToEndAsync(); + body.Should().Contain("StreamItem"); + } + + [Fact] + public async Task ParameterizedQuery_ViaWaf_ReturnsFilteredResults() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync(new CosmosTestItem("1", "pk1", "Alice"), new PartitionKey("pk1")); + await container.CreateItemAsync(new CosmosTestItem("2", "pk1", "Bob"), new PartitionKey("pk1")); + await container.CreateItemAsync(new CosmosTestItem("3", "pk1", "Alice"), new PartitionKey("pk1")); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name") + .WithParameter("@name", "Alice"); + + var iterator = container.GetItemQueryIterator(query); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().HaveCount(2); + results.Should().OnlyContain(r => r.Name == "Alice"); + } + + [Fact] + public async Task BulkOperations_ViaWaf_AllSucceed() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + var tasks = Enumerable.Range(0, 50).Select(i => + container.CreateItemAsync( + new CosmosTestItem($"bulk-{i}", "pk1", $"Bulk{i}"), + new PartitionKey("pk1"))); + + var results = await Task.WhenAll(tasks); + results.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Created); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var all = new List(); + while (iterator.HasMoreResults) + all.AddRange(await iterator.ReadNextAsync()); + all.Should().HaveCount(50); + } + + [Fact] + public async Task EmptyStringPartitionKey_ViaWaf_ItemAccessible() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("none1", "", "NoPartition"), + new PartitionKey("")); + + var read = await container.ReadItemAsync("none1", new PartitionKey("")); + read.Resource.Name.Should().Be("NoPartition"); + } } [Collection("FeedIteratorSetup")] public class WafLoggingDiagnosticsTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task RequestLog_CapturesHttpOperations_ViaWaf() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => handler = h; - })); - - // Perform CRUD via HTTP - await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("log1", "log1", "LogTest")); - await app.HttpClient.GetAsync("/items/log1"); - await app.HttpClient.DeleteAsync("/items/log1"); - - handler.Should().NotBeNull(); - handler!.RequestLog.Should().NotBeEmpty(); - handler.RequestLog.Count.Should().BeGreaterThanOrEqualTo(3); - } - - [Fact] - public async Task QueryLog_CapturesSqlQueries_ViaWaf() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => handler = h; - })); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync(new CosmosTestItem("q1", "q1", "Test"), new PartitionKey("q1")); - - // Execute SQL query - var iterator = container.GetItemQueryIterator( - new QueryDefinition("SELECT * FROM c WHERE c.name = 'Test'")); - while (iterator.HasMoreResults) - await iterator.ReadNextAsync(); - - handler.Should().NotBeNull(); - handler!.QueryLog.Should().NotBeEmpty(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task RequestLog_CapturesHttpOperations_ViaWaf() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => handler = h; + })); + + // Perform CRUD via HTTP + await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("log1", "log1", "LogTest")); + await app.HttpClient.GetAsync("/items/log1"); + await app.HttpClient.DeleteAsync("/items/log1"); + + handler.Should().NotBeNull(); + handler!.RequestLog.Should().NotBeEmpty(); + handler.RequestLog.Count.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task QueryLog_CapturesSqlQueries_ViaWaf() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => handler = h; + })); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync(new CosmosTestItem("q1", "q1", "Test"), new PartitionKey("q1")); + + // Execute SQL query + var iterator = container.GetItemQueryIterator( + new QueryDefinition("SELECT * FROM c WHERE c.name = 'Test'")); + while (iterator.HasMoreResults) + await iterator.ReadNextAsync(); + + handler.Should().NotBeNull(); + handler!.QueryLog.Should().NotBeEmpty(); + } } [Collection("FeedIteratorSetup")] public class WafStateLifecycleTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task NewHost_AfterDispose_StartsEmpty() - { - // First host: create an item - await using (var app1 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey")))) - { - await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("x", "x", "Temp")); - var resp = await app1.HttpClient.GetAsync("/items"); - var items = await resp.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(1); - } - - // Second host: should start empty (no persistence) - await using var app2 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var response = await app2.HttpClient.GetAsync("/items"); - var items2 = await response.Content.ReadFromJsonAsync>(JsonOptions); - items2.Should().BeEmpty(); - } - - [Fact] - public async Task DisposeAsync_IsIdempotent() - { - var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - await app.DisposeAsync(); - // Second dispose should not throw - await app.DisposeAsync(); - } - - [Fact] - public async Task ContainerProperties_WithDefaultTtl_ViaWaf_ItemsExpire() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => - { - handler = h; - h.BackingInMemoryContainer.DefaultTimeToLive = 1; - }; - })); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync(new CosmosTestItem("ttl1", "pk1", "Temp"), new PartitionKey("pk1")); - - await Task.Delay(TimeSpan.FromSeconds(3)); - - var iterator = container.GetItemQueryIterator("SELECT * FROM c"); - var results = new List(); - while (iterator.HasMoreResults) - results.AddRange(await iterator.ReadNextAsync()); - - results.Should().BeEmpty(); - } - - [Fact] - public async Task StatePersistenceDirectory_AutoSavesOnDispose_ReloadsOnNewHost() - { - var tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-test-{Guid.NewGuid():N}"); - try - { - // Host 1: create items, explicitly persist, then dispose - FakeCosmosHandler? handler1 = null; - await using (var app1 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.StatePersistenceDirectory = tempDir; - o.OnHandlerCreated = (_, h) => handler1 = h; - }))) - { - var container = app1.Services.GetRequiredService(); - await container.CreateItemAsync(new CosmosTestItem("persist1", "pk1", "Persisted"), new PartitionKey("pk1")); - // Explicitly persist — host disposal may not reliably trigger InMemoryContainer.Dispose() - handler1!.BackingInMemoryContainer.Dispose(); - } - - // Host 2: should auto-load from same directory - await using var app2 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.StatePersistenceDirectory = tempDir; - })); - - var container2 = app2.Services.GetRequiredService(); - var read = await container2.ReadItemAsync("persist1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("Persisted"); - } - finally - { - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, recursive: true); - } - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task NewHost_AfterDispose_StartsEmpty() + { + // First host: create an item + await using (var app1 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey")))) + { + await app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("x", "x", "Temp")); + var resp = await app1.HttpClient.GetAsync("/items"); + var items = await resp.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(1); + } + + // Second host: should start empty (no persistence) + await using var app2 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var response = await app2.HttpClient.GetAsync("/items"); + var items2 = await response.Content.ReadFromJsonAsync>(JsonOptions); + items2.Should().BeEmpty(); + } + + [Fact] + public async Task DisposeAsync_IsIdempotent() + { + var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + await app.DisposeAsync(); + // Second dispose should not throw + await app.DisposeAsync(); + } + + [Fact] + public async Task ContainerProperties_WithDefaultTtl_ViaWaf_ItemsExpire() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => + { + handler = h; + h.BackingInMemoryContainer.DefaultTimeToLive = 1; + }; + })); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync(new CosmosTestItem("ttl1", "pk1", "Temp"), new PartitionKey("pk1")); + + await Task.Delay(TimeSpan.FromSeconds(3)); + + var iterator = container.GetItemQueryIterator("SELECT * FROM c"); + var results = new List(); + while (iterator.HasMoreResults) + results.AddRange(await iterator.ReadNextAsync()); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task StatePersistenceDirectory_AutoSavesOnDispose_ReloadsOnNewHost() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-test-{Guid.NewGuid():N}"); + try + { + // Host 1: create items, explicitly persist, then dispose + FakeCosmosHandler? handler1 = null; + await using (var app1 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.StatePersistenceDirectory = tempDir; + o.OnHandlerCreated = (_, h) => handler1 = h; + }))) + { + var container = app1.Services.GetRequiredService(); + await container.CreateItemAsync(new CosmosTestItem("persist1", "pk1", "Persisted"), new PartitionKey("pk1")); + // Explicitly persist — host disposal may not reliably trigger InMemoryContainer.Dispose() + handler1!.BackingInMemoryContainer.Dispose(); + } + + // Host 2: should auto-load from same directory + await using var app2 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.StatePersistenceDirectory = tempDir; + })); + + var container2 = app2.Services.GetRequiredService(); + var read = await container2.ReadItemAsync("persist1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("Persisted"); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } } [Collection("FeedIteratorSetup")] public class WafConcurrencyIsolationTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task MultipleHosts_ConcurrentOperations_FullyIsolated() - { - await using var app1 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - await using var app2 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - await using var app3 = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - // Each host creates different items concurrently - var t1 = app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("a", "a", "Host1")); - var t2 = app2.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("b", "b", "Host2")); - var t3 = app3.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("c", "c", "Host3")); - await Task.WhenAll(t1, t2, t3); - - // Each host should only see its own data - var r1 = await (await app1.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); - var r2 = await (await app2.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); - var r3 = await (await app3.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); - - r1.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host1"); - r2.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host2"); - r3.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host3"); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task MultipleHosts_ConcurrentOperations_FullyIsolated() + { + await using var app1 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + await using var app2 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + await using var app3 = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + // Each host creates different items concurrently + var t1 = app1.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("a", "a", "Host1")); + var t2 = app2.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("b", "b", "Host2")); + var t3 = app3.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("c", "c", "Host3")); + await Task.WhenAll(t1, t2, t3); + + // Each host should only see its own data + var r1 = await (await app1.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); + var r2 = await (await app2.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); + var r3 = await (await app3.HttpClient.GetAsync("/items")).Content.ReadFromJsonAsync>(JsonOptions); + + r1.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host1"); + r2.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host2"); + r3.Should().HaveCount(1).And.ContainSingle(i => i.Name == "Host3"); + } } [Collection("FeedIteratorSetup")] public class WafDiPatternDeepTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task OnHandlerCreated_MultiContainer_ReceivesCorrectContainerName() - { - var capturedNames = new List(); - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(sp => sp.GetRequiredService().GetContainer("db", "orders")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("orders", "/partitionKey"); - o.AddContainer("products", "/partitionKey"); - o.AddContainer("users", "/partitionKey"); - o.OnHandlerCreated = (name, _) => capturedNames.Add(name); - })); - - capturedNames.Should().HaveCount(3); - capturedNames.Should().Contain("orders"); - capturedNames.Should().Contain("products"); - capturedNames.Should().Contain("users"); - } - - [Fact] - public async Task FeedIteratorSetup_Deregister_WhenNotRegistered_DoesNotThrow() - { - // Make sure deregister is safe even without prior registration - InMemoryFeedIteratorSetup.Deregister(); - InMemoryFeedIteratorSetup.Deregister(); // double deregister should also be safe - - // Now register and use normally - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - await container.CreateItemAsync(new CosmosTestItem("1", "1", "Test"), new PartitionKey("1")); - var read = await container.ReadItemAsync("1", new PartitionKey("1")); - read.Resource.Name.Should().Be("Test"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task OnHandlerCreated_MultiContainer_ReceivesCorrectContainerName() + { + var capturedNames = new List(); + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(sp => sp.GetRequiredService().GetContainer("db", "orders")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("orders", "/partitionKey"); + o.AddContainer("products", "/partitionKey"); + o.AddContainer("users", "/partitionKey"); + o.OnHandlerCreated = (name, _) => capturedNames.Add(name); + })); + + capturedNames.Should().HaveCount(3); + capturedNames.Should().Contain("orders"); + capturedNames.Should().Contain("products"); + capturedNames.Should().Contain("users"); + } + + [Fact] + public async Task FeedIteratorSetup_Deregister_WhenNotRegistered_DoesNotThrow() + { + // Make sure deregister is safe even without prior registration + InMemoryFeedIteratorSetup.Deregister(); + InMemoryFeedIteratorSetup.Deregister(); // double deregister should also be safe + + // Now register and use normally + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + await container.CreateItemAsync(new CosmosTestItem("1", "1", "Test"), new PartitionKey("1")); + var read = await container.ReadItemAsync("1", new PartitionKey("1")); + read.Resource.Name.Should().Be("Test"); + } } [Collection("FeedIteratorSetup")] public class WafMiscEdgeCaseDeepTests : IDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task ContainerPattern_LinqViaOverridable_WorksEndToEnd_ViaHttp() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - o.AddContainer("items", "/partitionKey"))); - - // Create items via HTTP - await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); - await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); - - // List via HTTP (uses ToFeedIteratorOverridable in TestRepository) - var response = await app.HttpClient.GetAsync("/items"); - var items = await response.Content.ReadFromJsonAsync>(JsonOptions); - items.Should().HaveCount(2); - } - - [Fact(Skip = "DIVERGENT: When AddContainer is called twice with the same name, the router dictionary " - + "overwrites the first handler. This is Dictionary semantics — " - + "the second registration wins.")] - public void DuplicateContainerNames_AmbiguousRouting() { } - - [Fact] - public async Task DuplicateContainerNames_SecondRegistrationActive() - { - // When two containers are registered with the same name, the second one wins - FakeCosmosHandler? capturedHandler = null; - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - services.AddSingleton(sp => sp.GetRequiredService().GetContainer("db", "items")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/pk1"); - o.AddContainer("items", "/pk2"); // overwrites the first - o.OnHandlerCreated = (_, h) => capturedHandler = h; - })); - - // The effective partition key path should be from the second registration - capturedHandler.Should().NotBeNull(); - capturedHandler!.BackingContainer.Should().NotBeNull(); - } - - [Fact] - public async Task CompositePartitionKey_ViaWaf_CrudWorksEndToEnd() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - var props = new ContainerProperties("items", "/tenantId") - { - PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/userId" } - }; - o.AddContainer(props); - }), - configureEndpoints: endpoints => - { - endpoints.MapPost("/composite", async (Container container) => - { - var item = Newtonsoft.Json.Linq.JObject.FromObject(new { id = "c1", tenantId = "t1", userId = "u1", name = "Composite" }); - await container.CreateItemAsync(item, new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - return Results.Created("/composite/c1", null); - }); - - endpoints.MapGet("/composite/{id}", async (string id, Container container) => - { - var read = await container.ReadItemAsync(id, - new PartitionKeyBuilder().Add("t1").Add("u1").Build()); - return Results.Ok(read.Resource); - }); - }); - - var createResp = await app.HttpClient.PostAsync("/composite", null); - createResp.StatusCode.Should().Be(HttpStatusCode.Created); - - var readResp = await app.HttpClient.GetAsync("/composite/c1"); - readResp.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact(Skip = "DIVERGENT: Empty SQL query never reaches the parser — the SDK's QueryDefinition " - + "constructor throws ArgumentNullException for null/empty query strings. " - + "Real Cosmos returns 400 BadRequest with syntax error.")] - public void EmptySqlQuery_ViaWaf_ErrorBehavior() { } - - [Fact] - public async Task EmptySqlQuery_ViaWaf_ThrowsArgumentNullException() - { - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); - - var container = app.Services.GetRequiredService(); - // SDK's QueryDefinition constructor rejects null/empty query with ArgumentNullException - var act = () => container.GetItemQueryIterator( - new QueryDefinition("")).ReadNextAsync(); - await act.Should().ThrowAsync(); - } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task ContainerPattern_LinqViaOverridable_WorksEndToEnd_ViaHttp() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + o.AddContainer("items", "/partitionKey"))); + + // Create items via HTTP + await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("1", "1", "Alice")); + await app.HttpClient.PostAsJsonAsync("/items", new CosmosTestItem("2", "2", "Bob")); + + // List via HTTP (uses ToFeedIteratorOverridable in TestRepository) + var response = await app.HttpClient.GetAsync("/items"); + var items = await response.Content.ReadFromJsonAsync>(JsonOptions); + items.Should().HaveCount(2); + } + + [Fact(Skip = "DIVERGENT: When AddContainer is called twice with the same name, the router dictionary " + + "overwrites the first handler. This is Dictionary semantics — " + + "the second registration wins.")] + public void DuplicateContainerNames_AmbiguousRouting() { } + + [Fact] + public async Task DuplicateContainerNames_SecondRegistrationActive() + { + // When two containers are registered with the same name, the second one wins + FakeCosmosHandler? capturedHandler = null; + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + services.AddSingleton(sp => sp.GetRequiredService().GetContainer("db", "items")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/pk1"); + o.AddContainer("items", "/pk2"); // overwrites the first + o.OnHandlerCreated = (_, h) => capturedHandler = h; + })); + + // The effective partition key path should be from the second registration + capturedHandler.Should().NotBeNull(); + capturedHandler!.BackingContainer.Should().NotBeNull(); + } + + [Fact] + public async Task CompositePartitionKey_ViaWaf_CrudWorksEndToEnd() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + var props = new ContainerProperties("items", "/tenantId") + { + PartitionKeyPaths = new System.Collections.ObjectModel.Collection { "/tenantId", "/userId" } + }; + o.AddContainer(props); + }), + configureEndpoints: endpoints => + { + endpoints.MapPost("/composite", async (Container container) => + { + var item = Newtonsoft.Json.Linq.JObject.FromObject(new { id = "c1", tenantId = "t1", userId = "u1", name = "Composite" }); + await container.CreateItemAsync(item, new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + return Results.Created("/composite/c1", null); + }); + + endpoints.MapGet("/composite/{id}", async (string id, Container container) => + { + var read = await container.ReadItemAsync(id, + new PartitionKeyBuilder().Add("t1").Add("u1").Build()); + return Results.Ok(read.Resource); + }); + }); + + var createResp = await app.HttpClient.PostAsync("/composite", null); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + + var readResp = await app.HttpClient.GetAsync("/composite/c1"); + readResp.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact(Skip = "DIVERGENT: Empty SQL query never reaches the parser — the SDK's QueryDefinition " + + "constructor throws ArgumentNullException for null/empty query strings. " + + "Real Cosmos returns 400 BadRequest with syntax error.")] + public void EmptySqlQuery_ViaWaf_ErrorBehavior() { } + + [Fact] + public async Task EmptySqlQuery_ViaWaf_ThrowsArgumentNullException() + { + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => o.AddContainer("items", "/partitionKey"))); + + var container = app.Services.GetRequiredService(); + // SDK's QueryDefinition constructor rejects null/empty query with ArgumentNullException + var act = () => container.GetItemQueryIterator( + new QueryDefinition("")).ReadNextAsync(); + await act.Should().ThrowAsync(); + } } [Collection("FeedIteratorSetup")] public class WafStatePersistenceContainerPatternTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task StatePersistenceDirectory_WithContainerPattern_AutoPersists() - { - var tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-cont-{Guid.NewGuid():N}"); - try - { - // Host 1: create items via container-only pattern, explicitly persist - IContainerTestSetup? capturedContainer = null; - await using (var app1 = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("items", "/partitionKey"); - o.StatePersistenceDirectory = tempDir; - o.OnContainerCreated = c => capturedContainer = c; - }))) - { - var container = app1.Services.GetRequiredService(); - await container.CreateItemAsync( - new CosmosTestItem("p1", "pk1", "ContainerPersisted"), - new PartitionKey("pk1")); - // Explicitly persist — container pattern may not auto-dispose - ((InMemoryContainer)capturedContainer!).Dispose(); - } - - // Host 2: should auto-load - await using var app2 = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new InMemoryContainer("items", "/partitionKey")); - services.AddSingleton(); - }, - configureTestServices: services => - services.UseInMemoryCosmosContainers(o => - { - o.AddContainer("items", "/partitionKey"); - o.StatePersistenceDirectory = tempDir; - })); - - var container2 = app2.Services.GetRequiredService(); - var read = await container2.ReadItemAsync("p1", new PartitionKey("pk1")); - read.Resource.Name.Should().Be("ContainerPersisted"); - } - finally - { - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, recursive: true); - } - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task StatePersistenceDirectory_WithContainerPattern_AutoPersists() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cosmos-cont-{Guid.NewGuid():N}"); + try + { + // Host 1: create items via container-only pattern, explicitly persist + IContainerTestSetup? capturedContainer = null; + await using (var app1 = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("items", "/partitionKey"); + o.StatePersistenceDirectory = tempDir; + o.OnContainerCreated = c => capturedContainer = c; + }))) + { + var container = app1.Services.GetRequiredService(); + await container.CreateItemAsync( + new CosmosTestItem("p1", "pk1", "ContainerPersisted"), + new PartitionKey("pk1")); + // Explicitly persist — container pattern may not auto-dispose + ((InMemoryContainer)capturedContainer!).Dispose(); + } + + // Host 2: should auto-load + await using var app2 = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new InMemoryContainer("items", "/partitionKey")); + services.AddSingleton(); + }, + configureTestServices: services => + services.UseInMemoryCosmosContainers(o => + { + o.AddContainer("items", "/partitionKey"); + o.StatePersistenceDirectory = tempDir; + })); + + var container2 = app2.Services.GetRequiredService(); + var read = await container2.ReadItemAsync("p1", new PartitionKey("pk1")); + read.Resource.Name.Should().Be("ContainerPersisted"); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } } [Collection("FeedIteratorSetup")] public class WafFaultInjectorMetadataTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task FaultInjectorIncludesMetadata_False_DataFaulted_MetadataUnaffected() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => - { - handler = h; - h.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - h.FaultInjectorIncludesMetadata = false; - }; - })); - - var container = app.Services.GetRequiredService(); - - // Data operations should be faulted - var act = () => container.CreateItemAsync( - new CosmosTestItem("f1", "pk1", "Fail"), - new PartitionKey("pk1")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); - - // Metadata operations should still work (container read) - handler!.FaultInjector = null; - } - - [Fact] - public async Task FaultInjectorIncludesMetadata_True_AlsoFaultsMetadataRoutes() - { - FakeCosmosHandler? handler = null; - await using var app = await TestAppHost.CreateAsync( - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - { - o.AddContainer("items", "/partitionKey"); - o.OnHandlerCreated = (_, h) => - { - handler = h; - h.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); - h.FaultInjectorIncludesMetadata = true; - }; - })); - - var container = app.Services.GetRequiredService(); - - // Even creating an item (which involves metadata) should be faulted - var act = () => container.CreateItemAsync( - new CosmosTestItem("f2", "pk1", "Fail"), - new PartitionKey("pk1")); - await act.Should().ThrowAsync() - .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task FaultInjectorIncludesMetadata_False_DataFaulted_MetadataUnaffected() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => + { + handler = h; + h.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + h.FaultInjectorIncludesMetadata = false; + }; + })); + + var container = app.Services.GetRequiredService(); + + // Data operations should be faulted + var act = () => container.CreateItemAsync( + new CosmosTestItem("f1", "pk1", "Fail"), + new PartitionKey("pk1")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); + + // Metadata operations should still work (container read) + handler!.FaultInjector = null; + } + + [Fact] + public async Task FaultInjectorIncludesMetadata_True_AlsoFaultsMetadataRoutes() + { + FakeCosmosHandler? handler = null; + await using var app = await TestAppHost.CreateAsync( + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + { + o.AddContainer("items", "/partitionKey"); + o.OnHandlerCreated = (_, h) => + { + handler = h; + h.FaultInjector = _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + h.FaultInjectorIncludesMetadata = true; + }; + })); + + var container = app.Services.GetRequiredService(); + + // Even creating an item (which involves metadata) should be faulted + var act = () => container.CreateItemAsync( + new CosmosTestItem("f2", "pk1", "Fail"), + new PartitionKey("pk1")); + await act.Should().ThrowAsync() + .Where(e => e.StatusCode == HttpStatusCode.ServiceUnavailable); + } } [Collection("FeedIteratorSetup")] public class WafZeroConfigDeepTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact] - public async Task ZeroConfig_NoBaseContainerRegistration_UsesDefaultName() - { - // When UseInMemoryCosmosDB() is called with no base Container registration - // and no explicit AddContainer, it creates a default "in-memory-container" - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - // Register only CosmosClient (no Container) - services.AddSingleton(_ => - new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB()); - - var container = app.Services.GetRequiredService(); - container.Should().NotBeNull(); - - // Should be able to create items in the default container - await container.CreateItemAsync( - new { id = "z1", partitionKey = "z1" }, - new PartitionKey("z1")); - - var read = await container.ReadItemAsync("z1", new PartitionKey("z1")); - read.Resource["id"]!.ToString().Should().Be("z1"); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact] + public async Task ZeroConfig_NoBaseContainerRegistration_UsesDefaultName() + { + // When UseInMemoryCosmosDB() is called with no base Container registration + // and no explicit AddContainer, it creates a default "in-memory-container" + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + // Register only CosmosClient (no Container) + services.AddSingleton(_ => + new CosmosClient("AccountEndpoint=https://fail.example.com:9999/;AccountKey=dGVzdA==;")); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB()); + + var container = app.Services.GetRequiredService(); + container.Should().NotBeNull(); + + // Should be able to create items in the default container + await container.CreateItemAsync( + new { id = "z1", partitionKey = "z1" }, + new PartitionKey("z1")); + + var read = await container.ReadItemAsync("z1", new PartitionKey("z1")); + read.Resource["id"]!.ToString().Should().Be("z1"); + } } [Collection("FeedIteratorSetup")] public class WafTypedClientDeepTests : IDisposable { - public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); - - [Fact(Skip = "DIVERGENT: UseInMemoryCosmosDB() registers only as TClient (e.g., TestCosmosClient), " - + "not as base CosmosClient. This is by design — typed clients are independent registrations. " - + "Services.GetService() returns null unless separately registered.")] - public void TypedClient_NotResolvableAsBaseCosmosClient() { } - - [Fact] - public async Task TypedClient_ResolvableOnlyAsTClient_NotBaseCosmosClient() - { - await using var app = await TestAppHost.CreateAsync( - configureBaseServices: services => - { - services.AddSingleton(_ => - new TestCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); - }, - configureTestServices: services => - services.UseInMemoryCosmosDB(o => - o.AddContainer("items", "/partitionKey"))); - - // TestCosmosClient should be resolvable - var typedClient = app.Services.GetService(); - typedClient.Should().NotBeNull(); - - // Base CosmosClient should NOT be registered by UseInMemoryCosmosDB - var baseClient = app.Services.GetService(); - baseClient.Should().BeNull(); - } + public void Dispose() => InMemoryFeedIteratorSetup.Deregister(); + + [Fact(Skip = "DIVERGENT: UseInMemoryCosmosDB() registers only as TClient (e.g., TestCosmosClient), " + + "not as base CosmosClient. This is by design — typed clients are independent registrations. " + + "Services.GetService() returns null unless separately registered.")] + public void TypedClient_NotResolvableAsBaseCosmosClient() { } + + [Fact] + public async Task TypedClient_ResolvableOnlyAsTClient_NotBaseCosmosClient() + { + await using var app = await TestAppHost.CreateAsync( + configureBaseServices: services => + { + services.AddSingleton(_ => + new TestCosmosClient("AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=dGVzdA==")); + }, + configureTestServices: services => + services.UseInMemoryCosmosDB(o => + o.AddContainer("items", "/partitionKey"))); + + // TestCosmosClient should be resolvable + var typedClient = app.Services.GetService(); + typedClient.Should().NotBeNull(); + + // Base CosmosClient should NOT be registered by UseInMemoryCosmosDB + var baseClient = app.Services.GetService(); + baseClient.Should().BeNull(); + } } diff --git a/tests/EmulatorWarmup/Program.cs b/tests/EmulatorWarmup/Program.cs index c3be686..770cf51 100644 --- a/tests/EmulatorWarmup/Program.cs +++ b/tests/EmulatorWarmup/Program.cs @@ -239,6 +239,10 @@ private static async Task RetryAsync( // 404/1013 — "Collection is not yet available for read". CosmosException ce when ce.StatusCode == HttpStatusCode.NotFound && ce.SubStatusCode == 1013 => true, + // 404/0 — Windows emulator intermediate startup state: HTTP server is + // reachable but internal metadata services haven't fully materialised. + CosmosException ce when ce.StatusCode == HttpStatusCode.NotFound + && ce.SubStatusCode == 0 => true, CosmosException ce => ce.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.InternalServerError or